8da50dbcf8
## 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>
232 lines
7.0 KiB
TypeScript
232 lines
7.0 KiB
TypeScript
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");
|
|
});
|
|
});
|