Files
paperclip/server/src/__tests__/health-dev-server-token.test.ts
T
Dotta 8da50dbcf8 [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>
2026-05-27 21:15:01 -10:00

207 lines
6.6 KiB
TypeScript

import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import express from "express";
import request from "supertest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Db } from "@paperclipai/db";
import { healthRoutes } from "../routes/health.js";
const tempDirs: string[] = [];
function createDevServerStatusFile(payload: unknown) {
const dir = mkdtempSync(path.join(os.tmpdir(), "paperclip-health-dev-server-"));
tempDirs.push(dir);
const filePath = path.join(dir, "dev-server-status.json");
writeFileSync(filePath, `${JSON.stringify(payload)}\n`, "utf8");
return filePath;
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe("GET /health dev-server supervisor access", () => {
it("exposes dev-server metadata to the supervising dev runner in authenticated mode", async () => {
const previousFile = process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE;
const previousToken = process.env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN;
process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE = createDevServerStatusFile({
dirty: true,
lastChangedAt: "2026-03-20T12:00:00.000Z",
changedPathCount: 1,
changedPathsSample: ["server/src/routes/health.ts"],
pendingMigrations: [],
lastRestartAt: "2026-03-20T11:30:00.000Z",
});
process.env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN = "dev-runner-token";
let selectCall = 0;
const db = {
execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]),
select: vi.fn(() => {
selectCall += 1;
if (selectCall === 1) {
return {
from: vi.fn(() => ({
where: vi.fn().mockResolvedValue([{ count: 1 }]),
})),
};
}
if (selectCall === 2) {
return {
from: vi.fn(() => ({
where: vi.fn().mockResolvedValue([
{
id: "settings-1",
general: {},
experimental: { autoRestartDevServerWhenIdle: true },
createdAt: new Date("2026-03-20T11:00:00.000Z"),
updatedAt: new Date("2026-03-20T11:00:00.000Z"),
},
]),
})),
};
}
return {
from: vi.fn(() => ({
where: vi.fn().mockResolvedValue([{ count: 0 }]),
})),
};
}),
} as unknown as Db;
try {
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: "private",
authReady: true,
companyDeletionEnabled: true,
}),
);
const res = await request(app)
.get("/health")
.set("X-Paperclip-Dev-Server-Status-Token", "dev-runner-token");
expect(res.status).toBe(200);
expect(res.body).toEqual({
status: "ok",
deploymentMode: "authenticated",
deploymentExposure: "private",
bootstrapStatus: "ready",
bootstrapInviteActive: false,
devServer: {
enabled: true,
restartRequired: true,
reason: "backend_changes",
lastChangedAt: "2026-03-20T12:00:00.000Z",
changedPathCount: 1,
changedPathsSample: ["server/src/routes/health.ts"],
pendingMigrations: [],
autoRestartEnabled: true,
activeRunCount: 0,
waitingForIdle: false,
lastRestartAt: "2026-03-20T11:30:00.000Z",
},
});
} finally {
if (previousFile === undefined) {
delete process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE;
} else {
process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE = previousFile;
}
if (previousToken === undefined) {
delete process.env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN;
} else {
process.env.PAPERCLIP_DEV_SERVER_STATUS_TOKEN = previousToken;
}
}
});
});
describe("POST /health/dev-server/restart", () => {
it("records a manual restart request for the dev runner", async () => {
const previousFile = process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE;
process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE = createDevServerStatusFile({
dirty: true,
lastChangedAt: "2026-03-20T12:00:00.000Z",
changedPathCount: 1,
changedPathsSample: ["server/src/routes/health.ts"],
pendingMigrations: [],
lastRestartAt: "2026-03-20T11:30:00.000Z",
});
try {
const app = express();
app.use("/health", healthRoutes(undefined));
const res = await request(app).post("/health/dev-server/restart");
expect(res.status).toBe(202);
expect(res.body).toEqual({ status: "restart_requested" });
const requestPath = path.join(
path.dirname(process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE),
"dev-server-restart-request.json",
);
expect(existsSync(requestPath)).toBe(true);
expect(JSON.parse(readFileSync(requestPath, "utf8"))).toMatchObject({
reason: "manual_restart_now",
});
} finally {
if (previousFile === undefined) {
delete process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE;
} else {
process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE = previousFile;
}
}
});
it("rejects unauthenticated manual restarts in authenticated mode", async () => {
const previousFile = process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE;
process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE = createDevServerStatusFile({
dirty: true,
changedPathCount: 1,
changedPathsSample: ["server/src/routes/health.ts"],
pendingMigrations: [],
});
try {
const app = express();
app.use((req, _res, next) => {
(req as any).actor = { type: "none", source: "none" };
next();
});
app.use(
"/health",
healthRoutes(undefined, {
deploymentMode: "authenticated",
deploymentExposure: "private",
authReady: true,
companyDeletionEnabled: true,
}),
);
const res = await request(app).post("/health/dev-server/restart");
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: "board_access_required" });
} finally {
if (previousFile === undefined) {
delete process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE;
} else {
process.env.PAPERCLIP_DEV_SERVER_STATUS_FILE = previousFile;
}
}
});
});