From 8da50dbcf8f98e82fb313c1fb023c13047d44bb6 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 27 May 2026 21:15:01 -1000 Subject: [PATCH] [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 --- doc/DEPLOYMENT-MODES.md | 37 ++- doc/DEVELOPING.md | 7 + doc/DOCKER.md | 10 + scripts/run-vitest-stable.mjs | 13 +- .../__tests__/bootstrap-claim-routes.test.ts | 231 ++++++++++++++++ .../src/__tests__/first-admin-claim.test.ts | 56 ++++ .../__tests__/health-dev-server-token.test.ts | 1 + server/src/__tests__/health.test.ts | 2 + .../heartbeat-comment-wake-batching.test.ts | 10 +- server/src/first-admin-claim.ts | 55 ++++ server/src/routes/access.ts | 59 ++++- server/src/routes/health.ts | 1 + ui/src/App.test.tsx | 167 +++++++++--- ui/src/App.tsx | 2 + ui/src/api/access.ts | 3 + ui/src/bootstrapSetup.ts | 1 + ui/src/components/BootstrapPendingPage.tsx | 176 +++++++++++++ ui/src/components/CloudAccessGate.tsx | 60 +++-- ui/src/pages/BootstrapSetupUxLab.tsx | 247 ++++++++++++++++++ 19 files changed, 1058 insertions(+), 80 deletions(-) create mode 100644 server/src/__tests__/bootstrap-claim-routes.test.ts create mode 100644 server/src/__tests__/first-admin-claim.test.ts create mode 100644 server/src/first-admin-claim.ts create mode 100644 ui/src/bootstrapSetup.ts create mode 100644 ui/src/components/BootstrapPendingPage.tsx create mode 100644 ui/src/pages/BootstrapSetupUxLab.tsx diff --git a/doc/DEPLOYMENT-MODES.md b/doc/DEPLOYMENT-MODES.md index bf8dbdd4..96c78a43 100644 --- a/doc/DEPLOYMENT-MODES.md +++ b/doc/DEPLOYMENT-MODES.md @@ -125,19 +125,50 @@ When running `authenticated` mode, if the only instance admin is `local-board`, This prevents lockout when a user migrates from long-running local trusted usage to authenticated mode. -## 8. Current Code Reality (As Of 2026-02-23) +## 8. First Admin Setup For Fresh Authenticated Installs + +Fresh authenticated installs start in `bootstrap_pending` until the first +`instance_admin` exists. + +For `authenticated/private`, Paperclip supports a browser-first setup path: + +1. open the Paperclip URL from the private network or appliance UI +2. sign in or create a Paperclip account +3. choose `Claim this instance` on the setup screen + +That browser claim promotes the signed-in session user to the first instance +admin and then falls through to normal onboarding. The endpoint is available +only to real browser session actors in `authenticated/private`; unauthenticated +requests, agent keys, board API keys, and local implicit board actors are +rejected. + +The CLI fallback remains supported in all authenticated setup states: + +```sh +pnpm paperclipai auth bootstrap-ceo +``` + +That command prints a one-time first-admin invite URL. Browser claim and +bootstrap invite acceptance share the same first-admin transaction, so whichever +path wins first makes later attempts return a conflict. + +For `authenticated/public`, browser first-admin claim is intentionally disabled. +Public deployments must use the high-entropy bootstrap invite path unless a +future public-hosted setup design explicitly changes this policy. + +## 9. Current Code Reality (As Of 2026-02-23) - runtime values are `local_trusted | authenticated` - `authenticated` uses Better Auth sessions and bootstrap invite flow - `local_trusted` ensures a real local Board user principal in `authUsers` with `instance_user_roles` admin access - company creation ensures creator membership in `company_memberships` so user assignment/access flows remain consistent -## 9. Naming and Compatibility Policy +## 10. Naming and Compatibility Policy - canonical naming is `local_trusted` and `authenticated` with `private/public` exposure - no long-term compatibility alias layer for discarded naming variants -## 10. Relationship to Other Docs +## 11. Relationship to Other Docs - implementation plan: `doc/plans/deployment-auth-mode-consolidation.md` - V1 contract: `doc/SPEC-implementation.md` diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 341f3b11..ec1789a2 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -72,6 +72,13 @@ pnpm dev --bind lan ``` This runs dev as `authenticated/private` with a private-network bind preset. +On a fresh authenticated/private instance, open the app, sign in or create an +account, and use the setup screen to claim the first instance admin from the +browser. The CLI fallback remains: + +```sh +pnpm paperclipai auth bootstrap-ceo +``` For Tailscale-only reachability on a detected tailnet address: diff --git a/doc/DOCKER.md b/doc/DOCKER.md index 056a7bdf..bc1fae8c 100644 --- a/doc/DOCKER.md +++ b/doc/DOCKER.md @@ -117,6 +117,16 @@ services: - bootstrap invite URL defaults - hostname allowlist defaults (hostname extracted from URL) +For fresh `authenticated/private` Docker or appliance-style installs, the first +admin can now be claimed entirely from the browser after sign-in. Open the +Paperclip URL, sign in or create an account, then choose `Claim this instance` +on the setup screen. This browser claim is disabled for `authenticated/public`; +public deployments should run the high-entropy CLI invite fallback instead: + +```sh +pnpm paperclipai auth bootstrap-ceo +``` + Granular overrides remain available if needed (`PAPERCLIP_AUTH_PUBLIC_BASE_URL`, `BETTER_AUTH_URL`, `BETTER_AUTH_TRUSTED_ORIGINS`, `PAPERCLIP_ALLOWED_HOSTNAMES`). Set `PAPERCLIP_ALLOWED_HOSTNAMES` explicitly only when you need additional hostnames beyond the public URL host (for example Tailscale/LAN aliases or multiple private hostnames). diff --git a/scripts/run-vitest-stable.mjs b/scripts/run-vitest-stable.mjs index a3ef4ba4..42f13cdd 100644 --- a/scripts/run-vitest-stable.mjs +++ b/scripts/run-vitest-stable.mjs @@ -55,6 +55,11 @@ const generalWorkspacesBGroupName = "general-workspaces-b"; const generalWorkspacesAProjects = ["@paperclipai/ui", "paperclipai"]; const generalWorkspacesBProjects = nonServerProjects.filter((project) => !generalWorkspacesAProjects.includes(project)); const generalGroupNames = [generalServerGroupName, generalWorkspacesAGroupName, generalWorkspacesBGroupName]; +const serializedServerVitestArgs = [ + "--no-file-parallelism", + "--maxWorkers=1", + "--minWorkers=1", +]; function walk(dir) { const entries = readdirSync(dir); @@ -241,6 +246,7 @@ function runVitest(args, label) { // Keep per-run paths compact so Unix socket fixtures stay under macOS path limits. const env = { ...process.env, + NODE_ENV: "test", PAPERCLIP_HOME: path.join(testRoot, "h"), PAPERCLIP_INSTANCE_ID: `vt-${process.pid}-${invocationIndex}`, TMPDIR: path.join(testRoot, "t"), @@ -277,7 +283,12 @@ function runGeneralGroup(routeTests, groupName) { if (groupName === generalServerGroupName) { const excludeRouteArgs = routeTests.flatMap((file) => ["--exclude", file.serverPath]); runVitest( - ["--project", "@paperclipai/server", ...excludeRouteArgs], + [ + "--project", + "@paperclipai/server", + ...serializedServerVitestArgs, + ...excludeRouteArgs, + ], `${groupName} server suites excluding ${routeTests.length} serialized suites`, ); return; diff --git a/server/src/__tests__/bootstrap-claim-routes.test.ts b/server/src/__tests__/bootstrap-claim-routes.test.ts new file mode 100644 index 00000000..d58daf42 --- /dev/null +++ b/server/src/__tests__/bootstrap-claim-routes.test.ts @@ -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) { + return { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => Promise.resolve(invite ? [invite] : [])), + })), + })), + } as any; +} + +function createApp(input: { + actor?: Record; + deploymentMode?: "authenticated" | "local_trusted"; + deploymentExposure?: "private" | "public"; + guardMutations?: boolean; + db?: Record; +}) { + 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"); + }); +}); diff --git a/server/src/__tests__/first-admin-claim.test.ts b/server/src/__tests__/first-admin-claim.test.ts new file mode 100644 index 00000000..a0461a89 --- /dev/null +++ b/server/src/__tests__/first-admin-claim.test.ts @@ -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; + let tempDb: Awaited> | 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] }); + }); +}); diff --git a/server/src/__tests__/health-dev-server-token.test.ts b/server/src/__tests__/health-dev-server-token.test.ts index 536ef66c..e50b0930 100644 --- a/server/src/__tests__/health-dev-server-token.test.ts +++ b/server/src/__tests__/health-dev-server-token.test.ts @@ -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: { diff --git a/server/src/__tests__/health.test.ts b/server/src/__tests__/health.test.ts index 2d294c0e..66bf9a77 100644 --- a/server/src/__tests__/health.test.ts +++ b/server/src/__tests__/health.test.ts @@ -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, }); diff --git a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts index 606f69bc..117ae4bb 100644 --- a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts +++ b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts @@ -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], diff --git a/server/src/first-admin-claim.ts b/server/src/first-admin-claim.ts new file mode 100644 index 00000000..b134348f --- /dev/null +++ b/server/src/first-admin-claim.ts @@ -0,0 +1,55 @@ +import { eq, sql } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { instanceUserRoles } from "@paperclipai/db"; + +type FirstAdminTransaction = Pick; + +export type FirstAdminClaimResult = + | { + status: "claimed"; + userId: string; + value: T | null; + } + | { + status: "already_claimed"; + existingUserId: string | null; + value: null; + }; + +export async function claimFirstInstanceAdmin( + db: Db, + input: { + userId: string; + onClaim?: (tx: FirstAdminTransaction) => Promise; + }, +): Promise> { + 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, + }; + }); +} diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 44b1fbaf..0478f60e 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -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, diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index 80a2dd70..f5a99ce3 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -157,6 +157,7 @@ export function healthRoutes( res.json({ status: "ok", deploymentMode: opts.deploymentMode, + deploymentExposure: opts.deploymentExposure, bootstrapStatus, bootstrapInviteActive, ...(devServer ? { devServer } : {}), diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx index bec9c199..ed6bba5d 100644 --- a/ui/src/App.test.tsx +++ b/ui/src/App.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom -import { act, type ReactNode } from "react"; +import type { ReactNode } from "react"; +import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -16,6 +17,7 @@ const mockAuthApi = vi.hoisted(() => ({ const mockAccessApi = vi.hoisted(() => ({ getCurrentBoardAccess: vi.fn(), + claimBootstrapAdmin: vi.fn(), })); vi.mock("./api/health", () => ({ @@ -31,6 +33,7 @@ vi.mock("./api/access", () => ({ })); vi.mock("@/lib/router", () => ({ + Link: ({ to, children }: { to: string; children?: ReactNode }) => {children}, Navigate: ({ to }: { to: string }) =>
Navigate:{to}
, Outlet: () =>
Outlet content
, Route: ({ children }: { children?: ReactNode }) => <>{children}, @@ -39,13 +42,39 @@ vi.mock("@/lib/router", () => ({ useParams: () => ({}), })); -// eslint-disable-next-line @typescript-eslint/no-explicit-any -(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; - async function flushReact() { - await act(async () => { - await Promise.resolve(); - await new Promise((resolve) => window.setTimeout(resolve, 0)); + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); +} + +async function waitForText(container: HTMLElement, text: string) { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (container.textContent?.includes(text)) return; + await flushReact(); + } + expect(container.textContent).toContain(text); +} + +function renderGate(container: HTMLElement) { + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + flushSync(() => { + root.render( + + + , + ); + }); + + return root; +} + +function unmountRoot(root: ReturnType) { + flushSync(() => { + root.unmount(); }); } @@ -58,6 +87,7 @@ describe("CloudAccessGate", () => { mockHealthApi.get.mockResolvedValue({ status: "ok", deploymentMode: "authenticated", + deploymentExposure: "private", bootstrapStatus: "ready", }); }); @@ -82,28 +112,13 @@ describe("CloudAccessGate", () => { keyId: null, }); - const root = createRoot(container); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - - await act(async () => { - root.render( - - - , - ); - }); - await flushReact(); - await flushReact(); - await flushReact(); + const root = renderGate(container); + await waitForText(container, "No company access"); expect(container.textContent).toContain("No company access"); expect(container.textContent).not.toContain("Outlet content"); - await act(async () => { - root.unmount(); - }); + unmountRoot(root); }); it("allows authenticated users with company access through to the board", async () => { @@ -120,27 +135,95 @@ describe("CloudAccessGate", () => { keyId: null, }); - const root = createRoot(container); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); - - await act(async () => { - root.render( - - - , - ); - }); - await flushReact(); - await flushReact(); - await flushReact(); + const root = renderGate(container); + await waitForText(container, "Outlet content"); expect(container.textContent).toContain("Outlet content"); expect(container.textContent).not.toContain("No company access"); - await act(async () => { - root.unmount(); + unmountRoot(root); + }); + + it("shows browser sign-in setup for signed-out private bootstrap-pending instances", async () => { + mockHealthApi.get.mockResolvedValue({ + status: "ok", + deploymentMode: "authenticated", + deploymentExposure: "private", + bootstrapStatus: "bootstrap_pending", + bootstrapInviteActive: false, }); + mockAuthApi.getSession.mockResolvedValue(null); + + const root = renderGate(container); + await waitForText(container, "Finish setting up this Paperclip"); + + expect(container.textContent).toContain("Finish setting up this Paperclip"); + expect(container.textContent).toContain("Sign in / Create account"); + expect(container.textContent).toContain("pnpm paperclipai auth bootstrap-ceo"); + expect(mockAccessApi.getCurrentBoardAccess).not.toHaveBeenCalled(); + + unmountRoot(root); + }); + + it("shows the claim action for signed-in private bootstrap-pending instances", async () => { + mockHealthApi.get.mockResolvedValue({ + status: "ok", + deploymentMode: "authenticated", + deploymentExposure: "private", + bootstrapStatus: "bootstrap_pending", + bootstrapInviteActive: false, + }); + mockAuthApi.getSession.mockResolvedValue({ + session: { id: "session-1", userId: "user-1" }, + user: { id: "user-1", email: "user@example.com", name: "User", image: null }, + }); + mockAccessApi.claimBootstrapAdmin.mockResolvedValue({ claimed: true, userId: "user-1" }); + + const root = renderGate(container); + await waitForText(container, "Claim this instance"); + + expect(container.textContent).toContain("Claim this instance"); + expect(container.textContent).toContain("Signed in as user@example.com"); + expect(mockAccessApi.getCurrentBoardAccess).not.toHaveBeenCalled(); + + const button = Array.from(container.querySelectorAll("button")).find((candidate) => + candidate.textContent?.includes("Claim this instance"), + ); + expect(button).toBeTruthy(); + flushSync(() => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await waitForText(container, "You're the instance admin"); + + expect(mockAccessApi.claimBootstrapAdmin).toHaveBeenCalledTimes(1); + expect(container.textContent).toContain("You're the instance admin"); + expect(container.textContent).toContain("Continue to dashboard"); + + unmountRoot(root); + }); + + it("keeps public bootstrap-pending instances invite-only", async () => { + mockHealthApi.get.mockResolvedValue({ + status: "ok", + deploymentMode: "authenticated", + deploymentExposure: "public", + bootstrapStatus: "bootstrap_pending", + bootstrapInviteActive: true, + }); + mockAuthApi.getSession.mockResolvedValue({ + session: { id: "session-1", userId: "user-1" }, + user: { id: "user-1", email: "user@example.com", name: "User", image: null }, + }); + + const root = renderGate(container); + await waitForText(container, "This Paperclip is waiting on its first admin"); + + expect(container.textContent).toContain("This Paperclip is waiting on its first admin"); + expect(container.textContent).toContain("invite-only mode"); + expect(container.textContent).not.toContain("Claim this instance"); + expect(container.textContent).not.toContain("Sign in / Create account"); + expect(mockAccessApi.claimBootstrapAdmin).not.toHaveBeenCalled(); + + unmountRoot(root); }); }); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index f6d81f2b..94522182 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -32,6 +32,7 @@ import { CompanySettings } from "./pages/CompanySettings"; import { CompanyEnvironments } from "./pages/CompanyEnvironments"; import { CloudUpstream } from "./pages/CloudUpstream"; import { CloudUpstreamUxLab } from "./pages/CloudUpstreamUxLab"; +import { BootstrapSetupUxLab } from "./pages/BootstrapSetupUxLab"; import { CompanySettingsPluginPage } from "./pages/CompanySettingsPluginPage"; import { CompanyAccess, CompanyAccessLegacyRoute } from "./pages/CompanyAccess"; import { CompanyInvites } from "./pages/CompanyInvites"; @@ -284,6 +285,7 @@ export function App() { } /> } /> } /> + } /> }> } /> diff --git a/ui/src/api/access.ts b/ui/src/api/access.ts index b5b68946..dc304757 100644 --- a/ui/src/api/access.ts +++ b/ui/src/api/access.ts @@ -384,6 +384,9 @@ export const accessApi = { claimBoard: (token: string, code: string) => api.post<{ claimed: true; userId: string }>(`/board-claim/${token}/claim`, { code }), + claimBootstrapAdmin: () => + api.post<{ claimed: true; userId: string }>("/bootstrap/claim", {}), + getCliAuthChallenge: (id: string, token: string) => api.get(`/cli-auth/challenges/${id}?token=${encodeURIComponent(token)}`), diff --git a/ui/src/bootstrapSetup.ts b/ui/src/bootstrapSetup.ts new file mode 100644 index 00000000..83ebeba8 --- /dev/null +++ b/ui/src/bootstrapSetup.ts @@ -0,0 +1 @@ +export const BOOTSTRAP_FALLBACK_COMMAND = "pnpm paperclipai auth bootstrap-ceo"; diff --git a/ui/src/components/BootstrapPendingPage.tsx b/ui/src/components/BootstrapPendingPage.tsx new file mode 100644 index 00000000..659ad572 --- /dev/null +++ b/ui/src/components/BootstrapPendingPage.tsx @@ -0,0 +1,176 @@ +import type { ReactNode } from "react"; +import { Loader2, ShieldCheck, Terminal, TriangleAlert } from "lucide-react"; +import { Link } from "@/lib/router"; +import { Button } from "@/components/ui/button"; +import { BOOTSTRAP_FALLBACK_COMMAND } from "@/bootstrapSetup"; +import type { AuthSession } from "@paperclipai/shared"; + +type BootstrapPendingPageProps = { + claimAvailable: boolean; + hasActiveInvite?: boolean; + session: AuthSession | null | undefined; + claimState: "idle" | "claiming" | "success"; + claimError?: { status?: number; message?: string } | null; + onClaim: () => void; +}; + +function CliFallback({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) { + return ( +
+
+ + Prefer to finish setup from the host? +
+

+ {hasActiveInvite + ? "A bootstrap invite is already active. Check your Paperclip startup logs for the first-admin URL, or run this command on the host to rotate it:" + : "Run this command on the host that runs Paperclip to print a one-time first-admin invite URL:"} +

+
+{BOOTSTRAP_FALLBACK_COMMAND}
+      
+
+ ); +} + +function StateChrome({ children }: { children: ReactNode }) { + return ( +
+
{children}
+
+ ); +} + +function displayIdentity(session: AuthSession) { + return session.user.email || session.user.name || session.user.id; +} + +function claimErrorCopy(error: BootstrapPendingPageProps["claimError"]) { + if (error?.status === 409) { + return { + title: "Someone else has already claimed this instance.", + body: "Refresh to sign in, or ask the existing admin to invite you from Instance settings -> Access.", + }; + } + if (error?.status === 401) { + return { + title: "Your session expired. Sign in again to claim this instance.", + body: "", + }; + } + return { + title: "We couldn't reach the server. Try again in a moment.", + body: "", + }; +} + +export function BootstrapPendingPage({ + claimAvailable, + hasActiveInvite = false, + session, + claimState, + claimError, + onClaim, +}: BootstrapPendingPageProps) { + if (!claimAvailable) { + return ( + +

This Paperclip is waiting on its first admin

+

+ This instance runs in invite-only mode. The operator must generate a one-time first-admin invite URL + from the host. Once you have the link, open it from this browser to finish setup. +

+ +

+ Browser-based claim is intentionally disabled in public mode so anyone on the network can't promote + themselves. +

+
+ ); + } + + if (claimState === "success") { + return ( + +
+
+ +
+
+

You're the instance admin

+

+ Setup is complete. Taking you to onboarding to create your first company... +

+
+
+
+ + Redirecting... +
+ +
+ ); + } + + if (!session) { + return ( + +

Finish setting up this Paperclip

+

+ No admin has claimed this instance yet. Sign in or create your Paperclip account to become the first + admin from this browser. +

+
+ +
+ +
+ ); + } + + const errorCopy = claimErrorCopy(claimError); + const isClaiming = claimState === "claiming"; + return ( + +

Finish setting up this Paperclip

+

+ No admin has claimed this instance yet. Claim it now to become the first admin and start onboarding. +

+
+ + + Signed in as {displayIdentity(session)} + +
+

+ Wrong account?{" "} + + Switch account + + . +

+ {claimError && ( +
+ +
+

{errorCopy.title}

+ {errorCopy.body &&

{errorCopy.body}

} +
+
+ )} + +
+ ); +} diff --git a/ui/src/components/CloudAccessGate.tsx b/ui/src/components/CloudAccessGate.tsx index eaea736b..0ec45926 100644 --- a/ui/src/components/CloudAccessGate.tsx +++ b/ui/src/components/CloudAccessGate.tsx @@ -1,27 +1,11 @@ import { Navigate, Outlet, useLocation } from "@/lib/router"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { accessApi } from "@/api/access"; +import { ApiError } from "@/api/client"; import { authApi } from "@/api/auth"; import { healthApi } from "@/api/health"; import { queryKeys } from "@/lib/queryKeys"; - -function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) { - return ( -
-
-

Instance setup required

-

- {hasActiveInvite - ? "No instance admin exists yet. A bootstrap invite is already active. Check your Paperclip startup logs for the first admin invite URL, or run this command to rotate it:" - : "No instance admin exists yet. Run this command in your Paperclip environment to generate the first admin invite URL:"} -

-
-{`pnpm paperclipai auth bootstrap-ceo`}
-        
-
-
- ); -} +import { BootstrapPendingPage } from "@/components/BootstrapPendingPage"; function NoBoardAccessPage() { return ( @@ -42,6 +26,7 @@ function NoBoardAccessPage() { export function CloudAccessGate() { const location = useLocation(); + const queryClient = useQueryClient(); const healthQuery = useQuery({ queryKey: queryKeys.health, queryFn: () => healthApi.get(), @@ -58,6 +43,7 @@ export function CloudAccessGate() { }); const isAuthenticatedMode = healthQuery.data?.deploymentMode === "authenticated"; + const isBootstrapPending = isAuthenticatedMode && healthQuery.data?.bootstrapStatus === "bootstrap_pending"; const sessionQuery = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), @@ -68,14 +54,24 @@ export function CloudAccessGate() { const boardAccessQuery = useQuery({ queryKey: queryKeys.access.currentBoardAccess, queryFn: () => accessApi.getCurrentBoardAccess(), - enabled: isAuthenticatedMode && !!sessionQuery.data, + enabled: isAuthenticatedMode && !isBootstrapPending && !!sessionQuery.data, retry: false, }); + const claimMutation = useMutation({ + mutationFn: () => accessApi.claimBootstrapAdmin(), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session }); + await queryClient.invalidateQueries({ queryKey: queryKeys.health }); + await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); + await queryClient.invalidateQueries({ queryKey: queryKeys.companies.stats }); + await queryClient.invalidateQueries({ queryKey: queryKeys.access.currentBoardAccess }); + }, + }); if ( healthQuery.isLoading || (isAuthenticatedMode && sessionQuery.isLoading) || - (isAuthenticatedMode && !!sessionQuery.data && boardAccessQuery.isLoading) + (isAuthenticatedMode && !isBootstrapPending && !!sessionQuery.data && boardAccessQuery.isLoading) ) { return
Loading...
; } @@ -92,8 +88,26 @@ export function CloudAccessGate() { ); } - if (isAuthenticatedMode && healthQuery.data?.bootstrapStatus === "bootstrap_pending") { - return ; + if (isBootstrapPending) { + const health = healthQuery.data; + if (!health) { + return
Loading...
; + } + const claimError = claimMutation.error instanceof ApiError + ? { status: claimMutation.error.status, message: claimMutation.error.message } + : claimMutation.error instanceof Error + ? { message: claimMutation.error.message } + : null; + return ( + claimMutation.mutate()} + /> + ); } if (isAuthenticatedMode && !sessionQuery.data) { diff --git a/ui/src/pages/BootstrapSetupUxLab.tsx b/ui/src/pages/BootstrapSetupUxLab.tsx new file mode 100644 index 00000000..35d49644 --- /dev/null +++ b/ui/src/pages/BootstrapSetupUxLab.tsx @@ -0,0 +1,247 @@ +import type { ReactElement, ReactNode } from "react"; +import { Loader2, ShieldCheck, Terminal, TriangleAlert } from "lucide-react"; +import { BOOTSTRAP_FALLBACK_COMMAND } from "@/bootstrapSetup"; +import { Button } from "@/components/ui/button"; + +type LabFixtureKey = + | "signed-out-private" + | "signed-in-private" + | "claiming" + | "claim-error" + | "claim-success" + | "public-invite-only"; + +const FIXTURE_LABELS: Record = { + "signed-out-private": "1 · authenticated/private — signed out (browser claim available)", + "signed-in-private": "2 · authenticated/private — signed in (claim CTA primary)", + claiming: "3 · authenticated/private — claim in flight", + "claim-error": "4 · authenticated/private — claim error (e.g. 409 already claimed)", + "claim-success": "5 · authenticated/private — claim succeeded, redirect pending", + "public-invite-only": "6 · authenticated/public — invite-only (no browser claim)", +}; + +const FIXTURE_ORDER: LabFixtureKey[] = [ + "signed-out-private", + "signed-in-private", + "claiming", + "claim-error", + "claim-success", + "public-invite-only", +]; + +function CliFallback({ hasActiveInvite }: { hasActiveInvite: boolean }) { + return ( +
+
+ + Prefer to finish setup from the host? +
+

+ {hasActiveInvite + ? "A bootstrap invite is already active. Check your Paperclip startup logs for the first‑admin URL, or run this command on the host to rotate it:" + : "Run this command on the host that runs Paperclip to print a one‑time first‑admin invite URL:"} +

+
+{BOOTSTRAP_FALLBACK_COMMAND}
+      
+
+ ); +} + +function StateChrome({ children }: { children: ReactNode }) { + return ( +
+
{children}
+
+ ); +} + +function SignedOutPrivate() { + return ( + +

Finish setting up this Paperclip

+

+ No admin has claimed this instance yet. Sign in or create your Paperclip account to become the first + admin from this browser. +

+ + +
+ ); +} + +function SignedInPrivate() { + return ( + +

Finish setting up this Paperclip

+

+ No admin has claimed this instance yet. Claim it now to become the first admin and start onboarding. +

+
+ + + Signed in as jane@appliance.local + +
+

+ Wrong account?{" "} + + Switch account + + . +

+ +
+ ); +} + +function ClaimingPrivate() { + return ( + +

Finish setting up this Paperclip

+

+ No admin has claimed this instance yet. Claim it now to become the first admin and start onboarding. +

+
+ + + Signed in as jane@appliance.local + +
+ +
+ ); +} + +function ClaimErrorPrivate() { + return ( + +

Finish setting up this Paperclip

+

+ No admin has claimed this instance yet. Claim it now to become the first admin and start onboarding. +

+
+ + + Signed in as jane@appliance.local + +
+
+ +
+

Someone else has already claimed this instance.

+

+ Refresh to sign in, or ask the existing admin to invite you from{" "} + Instance settings → Access. +

+
+
+ +
+ ); +} + +function ClaimSuccess() { + return ( + +
+
+ +
+
+

You’re the instance admin

+

+ Setup is complete. Taking you to onboarding to create your first company… +

+
+
+
+ + Redirecting… +
+ +
+ ); +} + +function PublicInviteOnly() { + return ( + +

This Paperclip is waiting on its first admin

+

+ This instance runs in invite‑only mode. The operator must generate a one‑time first‑admin invite URL + from the host. Once you have the link, open it from this browser to finish setup. +

+ +

+ Browser‑based claim is intentionally disabled in public mode so anyone on the network can’t + promote themselves. +

+
+ ); +} + +const FIXTURE_BODIES: Record = { + "signed-out-private": , + "signed-in-private": , + claiming: , + "claim-error": , + "claim-success": , + "public-invite-only": , +}; + +export function BootstrapSetupUxLab() { + return ( +
+
+
+

UX Lab

+

Bootstrap-pending setup states

+

+ Fixtures for the bootstrap-pending screen in CloudAccessGate. Used + as the UX spec for{" "} + + PAP-10113 + {" "} + and the implementation reference for{" "} + + PAP-10114 + + . The browser claim CTA only appears when{" "} + deploymentMode === "authenticated" and{" "} + deploymentExposure === "private". +

+
+
+
+ {FIXTURE_ORDER.map((key) => ( +
+

+ {FIXTURE_LABELS[key]} +

+
+ {FIXTURE_BODIES[key]} +
+
+ ))} +
+
+ ); +}