Harden Board CLI approval and native verification fixtures (#16482)

Preserve governed Board CLI authority and strict verification fixtures on the Muse stack.

Keep approval/cancel ownership, current membership/key authority and durable first-change audit atomic. Bound Retry-After scheduling within the original challenge; never automatically resubmit consent. Retain descriptor ownership until identity-based cleanup and close it on success and error. Keep strict Git-origin and synchronized provider-entry fixture assertions.

Related public work: LukeAbell's existing PR #13919 proposes the same production descriptor-lifetime fix. This independently measured local integration retains the original tests and adds real-descriptor coverage. Do not treat this local integration as ownership of that contribution.

Carry actual merged UI source 896f3c7f3b through a normal forward merge, preserving canonical workspace recovery, Muse authority and existing migration bytes. Current-head CI and substantive review remain merge gates. Complete qualification of the final immutable canonical source and actual provider acceptance remain separate.

Refs #16482, #13919.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-17 23:22:57 -05:00
1 parent 63306f6660
commit 2befcd9a0a
13 files changed
+1153 -253

No files matched your search

+85 -1
View File
@@ -1,9 +1,10 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
getStoredBoardCredential,
loginBoardCli,
readBoardAuthStore,
removeStoredBoardCredential,
setStoredBoardCredential,
@@ -51,3 +52,86 @@ describe("board auth store", () => {
expect(getStoredBoardCredential("http://localhost:3100", authPath)).toBeNull();
});
});
describe("board CLI polling backoff", () => {
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); });
function harness(statuses: Array<{ status: number; body: unknown; retryAfter?: string }>, expiresIn = 10_000) {
vi.useFakeTimers(); vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const authPath = createTempAuthPath();
const created = { id: "12345678-1234-4123-8123-123456789abc", token: "synthetic-challenge-capability",
boardApiToken: "synthetic-board-token", approvalPath: "/cli-auth/approve", approvalUrl: null,
pollPath: "/cli-auth/challenges/12345678-1234-4123-8123-123456789abc",
expiresAt: new Date(Date.now() + expiresIn).toISOString(), suggestedPollIntervalMs: 1000 };
let creates = 0, polls = 0;
const fetcher = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
if (init?.method === "POST") { creates++; return new Response(JSON.stringify(created), { status: 201 }); }
if (String(url).endsWith("/cli-auth/me")) return new Response(JSON.stringify({ userId: "synthetic-user" }));
polls++;
const next = statuses.shift();
if (!next) throw new Error("Unexpected additional poll");
return new Response(JSON.stringify(next.body), { status: next.status,
headers: next.retryAfter === undefined ? undefined : { "Retry-After": next.retryAfter } });
});
vi.stubGlobal("fetch", fetcher);
const outcome = loginBoardCli({ apiBase: "https://cli.example.invalid", requestedAccess: "board", storePath: authPath,
openBrowser: false, print: false }).then(value => ({ value }), error => ({ error }));
return { authPath, created, outcome, counts: () => ({ creates, polls }), cleanup: () => fs.rmSync(path.dirname(authPath), { recursive: true, force: true }) };
}
it("waits for numeric Retry-After and persists the original approved key", async () => {
const f = harness([{ status: 429, body: { error: "Rate limited" }, retryAfter: "2" }, { status: 200, body: { status: "approved" } }]);
try {
await vi.advanceTimersByTimeAsync(1999);
expect(f.counts()).toEqual({ creates: 1, polls: 1 });
expect(getStoredBoardCredential("https://cli.example.invalid", f.authPath)).toBeNull();
await vi.advanceTimersByTimeAsync(1);
expect(await f.outcome).toHaveProperty("value.token", f.created.boardApiToken);
expect(getStoredBoardCredential("https://cli.example.invalid", f.authPath)?.token).toBe(f.created.boardApiToken);
expect(f.counts()).toEqual({ creates: 1, polls: 2 });
} finally { f.cleanup(); }
});
it.each([undefined, "not-a-time", "-1", "0"])("uses normal polling when Retry-After is %s", async retryAfter => {
const f = harness([{ status: 429, body: { error: "Rate limited" }, retryAfter }, { status: 200, body: { status: "approved" } }]);
try {
await vi.advanceTimersByTimeAsync(999); expect(f.counts()).toEqual({ creates: 1, polls: 1 });
await vi.advanceTimersByTimeAsync(1); expect(await f.outcome).toHaveProperty("value.token", f.created.boardApiToken);
expect(f.counts()).toEqual({ creates: 1, polls: 2 });
} finally { f.cleanup(); }
});
it("honors an HTTP-date Retry-After", async () => {
const f = harness([{ status: 429, body: { error: "Rate limited" }, retryAfter: "Thu, 01 Jan 2026 00:00:02 GMT" },
{ status: 200, body: { status: "approved" } }]);
try { await vi.advanceTimersByTimeAsync(2000); expect(await f.outcome).toHaveProperty("value.token", f.created.boardApiToken); }
finally { f.cleanup(); }
});
it("never extends the original challenge deadline for a long Retry-After", async () => {
const f = harness([{ status: 429, body: { error: "Rate limited" }, retryAfter: "3600" }], 1500);
try {
await vi.advanceTimersByTimeAsync(1500);
expect(await f.outcome).toHaveProperty("error.message", "CLI auth challenge expired before approval.");
expect(f.counts()).toEqual({ creates: 1, polls: 1 });
expect(getStoredBoardCredential("https://cli.example.invalid", f.authPath)).toBeNull();
} finally { f.cleanup(); }
});
it.each(["cancelled", "expired"])("preserves %s after a rate-limit wait", async status => {
const f = harness([{ status: 429, body: { error: "Rate limited" }, retryAfter: "1" }, { status: 200, body: { status } }]);
try {
await vi.advanceTimersByTimeAsync(1000);
expect(await f.outcome).toHaveProperty("error.message", status === "cancelled" ? "CLI auth challenge was cancelled." : "CLI auth challenge expired before approval.");
expect(f.counts()).toEqual({ creates: 1, polls: 2 });
expect(getStoredBoardCredential("https://cli.example.invalid", f.authPath)).toBeNull();
} finally { f.cleanup(); }
});
it("does not retry other HTTP failures", async () => {
const f = harness([{ status: 503, body: { error: "Unavailable" } }]);
try { expect(await f.outcome).toHaveProperty("error.message", "Unavailable"); expect(f.counts()).toEqual({ creates: 1, polls: 1 }); }
finally { f.cleanup(); }
});
});
+22 -5
View File
@@ -148,6 +148,10 @@ function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
class BoardAuthRequestError extends Error {
constructor(message: string, readonly status: number, readonly retryAfterMs: number | null) { super(message); }
}
async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
const headers = new Headers(init?.headers ?? undefined);
if (init?.body !== undefined && !headers.has("content-type")) {
@@ -168,7 +172,10 @@ async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
body && typeof body === "object" && typeof (body as { error?: unknown }).error === "string"
? (body as { error: string }).error
: `Request failed: ${response.status}`;
throw new Error(message);
const retryAfter = response.headers.get("retry-after")?.trim();
const seconds = retryAfter && /^\d+(?:\.\d+)?$/.test(retryAfter) ? Number(retryAfter) : null;
const delay = seconds === null ? Date.parse(retryAfter ?? "") - Date.now() : seconds * 1000;
throw new BoardAuthRequestError(message, response.status, Number.isFinite(delay) && delay > 0 ? delay : null);
}
return response.json() as Promise<T>;
@@ -246,11 +253,21 @@ export async function loginBoardCli(params: {
const expiresAtMs = Date.parse(challenge.expiresAt);
const pollMs = Math.max(500, challenge.suggestedPollIntervalMs || 1000);
if (!Number.isFinite(expiresAtMs)) throw new Error("CLI auth challenge returned an invalid expiry.");
while (Number.isFinite(expiresAtMs) ? Date.now() < expiresAtMs : true) {
const status = await requestJson<ChallengeStatusResponse>(
`${apiBase}/api${challenge.pollPath}?token=${encodeURIComponent(challenge.token)}`,
);
while (Date.now() < expiresAtMs) {
let status: ChallengeStatusResponse;
try {
status = await requestJson<ChallengeStatusResponse>(
`${apiBase}/api${challenge.pollPath}?token=${encodeURIComponent(challenge.token)}`,
);
} catch (error) {
if (!(error instanceof BoardAuthRequestError) || error.status !== 429) throw error;
const remainingMs = expiresAtMs - Date.now();
if (remainingMs <= 0) break;
await sleep(Math.min(Math.max(pollMs, error.retryAfterMs ?? pollMs), remainingMs));
continue;
}
if (status.status === "approved") {
const me = await requestJson<{ userId: string; user?: { id: string } | null }>(
+49
View File
@@ -6,6 +6,40 @@ Paperclip CLI now supports both:
- instance setup/diagnostics (`onboard`, `doctor`, `configure`, `env`, `allowed-hostname`, `env-lab`)
- control-plane client operations (issues, approvals, agents, activity, dashboard)
## Browser-approved Board authentication
`npx paperclipai auth login --api-base <instance-url>` creates a ten-minute
challenge and prints a browser approval link. Sign in to the instance, review
the requesting command and full Board access, then approve. The pending token
cannot authenticate before approval. The CLI verifies its resulting identity
before storing its credential in its private auth store. The default Board key
expires after thirty days; `npx paperclipai auth logout` attempts server-side revocation and removes the
local credential; check its `revoked` result.
A requested company supplies audit context, not a restriction on the key's
access. Approval requires current access to that company (active membership or
instance-admin authority). A different person cannot take over an approved
challenge. Cancellation and approval serialize on the same challenge; cancelling
an already approved challenge does not revoke its key.
The instance limits challenge creation to twenty requests per minute per client
IP, and polling to 120, using separate process-local budgets. The normal
one-second poll interval remains supported. HTTP 429 includes `Retry-After`.
Operators behind a proxy must configure `TRUST_PROXY` for their actual trusted
proxy boundary; client-supplied forwarding headers alone never mint a budget.
These limits are per instance process, not a distributed denial-of-service or
historical-record retention guarantee.
The browser approval page retries only rate-limited challenge reads, up to
three times. It honors the server delay, or waits sixty seconds when no valid
delay is provided. Scheduling stops at ten minutes or a known earlier
challenge expiry. Browser suspension can delay a queued read. The server still
enforces the original expiry. Approval is never resubmitted automatically.
Cloud front doors must preserve only anonymous challenge creation and
capability-bound status polling. Approval uses the normal signed-in browser;
no Cloud user identity is synthesized for the CLI's unauthenticated requests.
## Security: safe invocation for content-bearing arguments
Use `npx paperclipai` for any command whose argument can hold untrusted or
@@ -1172,3 +1206,18 @@ Supported providers:
- `local_disk` (default; local single-user installs)
- `s3` (S3-compatible object storage)
When a challenge poll receives HTTP 429, the CLI waits for `Retry-After` and
continues the same challenge. A missing or invalid value uses the normal poll
interval. The wait never extends the original challenge deadline and never
creates another challenge or key. This allows concurrent logins on a shared IP
to complete after the process-local limit clears.
Revocation records use the current key owner and trusted original creation or
approved-challenge company context. Removing a company membership does not
remove that audit history. Revocation and its audit records commit together;
an audit failure leaves the key unchanged so the operation can be retried.
Historical creation rows with a NULL responsible user do not provide trusted
revocation context. The original approved challenge or current authorized
company context can still provide that destination. Client-supplied activity
rows cannot establish key ownership or a revocation audit destination.
@@ -988,6 +988,66 @@ describe("deterministic remote process-session wrapper shutdown (PAP-5316)", ()
return fakeBirthtimePreloadPath;
}
let probeLifetimePreloadDir: string | null = null;
afterAll(async () => {
if (probeLifetimePreloadDir) await rm(probeLifetimePreloadDir, { recursive: true, force: true }).catch(() => undefined);
});
let probeLifetimePreloadPath: Promise<string> | null = null;
async function getProbeLifetimePreloadPath(): Promise<string> {
probeLifetimePreloadPath ??= (async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "paperclip-probe-lifetime-preload-"));
probeLifetimePreloadDir = dir;
const preloadPath = path.join(dir, "probe-lifetime-preload.cjs");
await writeFile(preloadPath, [
`const fs = require("fs");`,
`const path = require("path");`,
`const mode = process.env.PAPERCLIP_TEST_PROBE_LIFETIME;`,
`const sessionDir = process.env.PAPERCLIP_PROCESS_SESSION_DIR;`,
`const probePath = path.join(sessionDir, ".paperclip-birthtime-probe-" + process.pid + "-1");`,
`const tracePath = path.join(sessionDir, ".probe-lifetime.json");`,
`const events = [];`,
`let probeHandle;`,
`const save = () => { fs.writeFileSync(tracePath + ".tmp", JSON.stringify(events)); fs.renameSync(tracePath + ".tmp", tracePath); };`,
`const originalOpen = fs.promises.open.bind(fs.promises);`,
`fs.promises.open = async (candidatePath, ...args) => {`,
` const handle = await originalOpen(candidatePath, ...args);`,
` if (String(candidatePath) === probePath) {`,
` probeHandle = handle;`,
` const originalClose = handle.close.bind(handle);`,
` handle.close = async () => {`,
` const fd = handle.fd;`,
` await originalClose();`,
` let descriptorClosed = false;`,
` try { fs.fstatSync(fd); } catch (error) { descriptorClosed = error.code === "EBADF"; }`,
` events.push({ kind: "closed", descriptorClosed });`,
` save();`,
` };`,
` if (mode === "stat_failure") handle.stat = async () => { throw Object.assign(new Error("test probe fstat failure"), { code: "EIO" }); };`,
` }`,
` return handle;`,
`};`,
`const originalLstat = fs.promises.lstat.bind(fs.promises);`,
`fs.promises.lstat = async (candidatePath, ...args) => {`,
` if (String(candidatePath) === probePath) {`,
` let descriptorOpen = false;`,
` try { fs.fstatSync(probeHandle.fd); descriptorOpen = true; } catch {}`,
` events.push({ kind: "path", descriptorOpen });`,
` save();`,
` if (mode === "path_failure") throw Object.assign(new Error("test probe lstat failure"), { code: "EIO" });`,
` }`,
` return originalLstat(candidatePath, ...args);`,
`};`,
`const originalRm = fs.promises.rm.bind(fs.promises);`,
`fs.promises.rm = async (candidatePath, ...args) => {`,
` if (String(candidatePath) === probePath && mode === "remove_failure") throw Object.assign(new Error("test probe remove failure"), { code: "EACCES" });`,
` return originalRm(candidatePath, ...args);`,
`};`,
].join("\n"), "utf8");
return preloadPath;
})();
return probeLifetimePreloadPath;
}
// A test-only preload for PAP-5355: it deterministically simulates a
// same-sandbox peer that wins the gap between the wrapper's final identity
// check and its removal call. nextProbeFileName() is deterministic (pid +
@@ -1121,6 +1181,7 @@ describe("deterministic remote process-session wrapper shutdown (PAP-5316)", ()
// (PAP-5374). seq 1 is sessionDir's probe (the first one
// captureSessionIdentity() runs).
fstatFailure?: { seq: 1 | 2 };
probeLifetime?: "success" | "stat_failure" | "path_failure" | "remove_failure";
}) {
const sessionDir = await mkdtemp(path.join(options?.parentDir ?? os.tmpdir(), "paperclip-wrapper-lifecycle-"));
cleanupDirs.push(sessionDir);
@@ -1155,6 +1216,10 @@ describe("deterministic remote process-session wrapper shutdown (PAP-5316)", ()
if (options.probeSwap.symlinkTarget) env.PAPERCLIP_TEST_PROBE_SWAP_SYMLINK_TARGET = options.probeSwap.symlinkTarget;
execArgv.push("--require", await getProbeSwapPreloadPath());
}
if (options?.probeLifetime) {
env.PAPERCLIP_TEST_PROBE_LIFETIME = options.probeLifetime;
execArgv.push("--require", await getProbeLifetimePreloadPath());
}
if (options?.fstatFailure) {
env.PAPERCLIP_TEST_FSTAT_FAILURE_SEQ = String(options.fstatFailure.seq);
execArgv.push("--require", await getFstatFailurePreloadPath());
@@ -2381,4 +2446,26 @@ describe("deterministic remote process-session wrapper shutdown (PAP-5316)", ()
const probePath = path.join(wrapper.sessionDir, `.paperclip-birthtime-probe-${wrapper.pid}-1`);
expect((await lstat(probePath)).isFile()).toBe(true);
}, 15_000);
it.each(["success", "stat_failure", "path_failure", "remove_failure"] as const)(
"T26 retains the real probe descriptor through cleanup and closes it (%s)",
async (mode) => {
const wrapper = await startWrapperProcess({ outputToStdout: false, probeLifetime: mode });
const tracePath = path.join(wrapper.sessionDir, ".probe-lifetime.json");
type LifetimeEvent = { kind: "path" | "closed"; descriptorOpen?: boolean; descriptorClosed?: boolean };
const readTrace = async (): Promise<LifetimeEvent[]> => JSON.parse(await readFile(tracePath, "utf8").catch(() => "[]"));
await waitFor(async () => (await readTrace()).some((event) => event.kind === "closed"), 4_000);
const events = await readTrace();
const checks = events.filter((event) => event.kind === "path");
expect(checks).toEqual(mode === "stat_failure" ? [] : [{ kind: "path", descriptorOpen: true }]);
expect(events.filter((event) => event.kind === "closed")).toEqual([{ kind: "closed", descriptorClosed: true }]);
const probePath = path.join(wrapper.sessionDir, `.paperclip-birthtime-probe-${wrapper.pid}-1`);
if (mode === "success") {
await expect(lstat(probePath)).rejects.toThrow();
} else {
expect((await lstat(probePath)).isFile()).toBe(true);
}
},
15_000,
);
});
+13 -38
View File
@@ -2884,33 +2884,6 @@ function nextProbeFileName() {
// have this call follow it, because "wx" fails closed on an existing path
// instead of following a link to it.
//
// Cleanup checks identity, not only ownership of the initial create. This
// wrapper reads the probe file's identity, (dev, ino, ctimeMs), off the open
// file descriptor itself (fstat), not off the path, so a peer that swaps the
// path in the short gap after create cannot poison the identity this
// wrapper trusts as its own. Right before removal, this wrapper reads the
// path's identity again and removes it only when that identity still
// matches. A same-sandbox peer that deletes the probe file and creates its
// own entry at the same path in between leaves a different identity behind,
// so this wrapper leaves that entry untouched instead of removing it. This
// covers a peer's replacement file, a peer's replacement directory, and a
// peer's replacement symbolic link alike, because all three change the
// identity this wrapper reads back. The identity check includes ctimeMs,
// not only (dev, ino): a filesystem can hand this call's freed inode number
// straight back out to a peer's very next create at the same path, so
// (dev, ino) alone can match a path this call no longer owns; ctimeMs resets
// on every create, so a peer's replacement carries a different one even when
// the inode number repeats. Node's filesystem API has no call that removes a
// path only when its identity still matches an earlier read as one atomic
// step, so a gap remains between this wrapper's final identity read and the
// removal call itself. A peer that wins this gap can put any entry at the
// probe path before the removal call runs. This can include a pre-existing
// file the peer renames into place, not only a file the peer creates fresh.
// The removal call then removes whatever entry sits at the probe path at
// that moment. Two bounds still hold on that removal. The path always
// stays under dirPath. If the entry is a symbolic link, the removal call
// removes the link itself instead of following it to a different target.
// A non-recursive removal call also fails if the entry is a directory.
async function birthtimeSurvivesProbe(dirPath) {
let before;
try {
@@ -2933,12 +2906,9 @@ async function birthtimeSurvivesProbe(dirPath) {
let ownedIdentity = null;
try {
const createdStats = await handle.stat();
// ctimeMs guards against inode reuse; see the function comment above.
ownedIdentity = { dev: createdStats.dev, ino: createdStats.ino, ctimeMs: createdStats.ctimeMs };
} catch {
ownedIdentity = null;
} finally {
await handle.close().catch(() => undefined);
}
if (!ownedIdentity) {
// fstat on this call's own just-opened descriptor failed. This call then
@@ -2948,6 +2918,7 @@ async function birthtimeSurvivesProbe(dirPath) {
// entry instead of this call's own file. Fail closed right here instead
// of falling through to the birthtime comparison below, so a failed
// identity read can never let this probe report success.
await handle.close().catch(() => undefined);
return "its own probe file's identity could not be read from the open file descriptor";
}
// The one gap the fs API cannot close: this lstat and the removal below
@@ -2956,14 +2927,18 @@ async function birthtimeSurvivesProbe(dirPath) {
// the probe path, including a pre-existing file it renames into place,
// and the removal call below removes whatever entry is there when it
// runs.
const currentStats = await fs.lstat(probePath).catch(() => null);
const stillOwned =
currentStats !== null &&
currentStats.dev === ownedIdentity.dev &&
currentStats.ino === ownedIdentity.ino &&
currentStats.ctimeMs === ownedIdentity.ctimeMs;
if (stillOwned) {
await fs.rm(probePath, { force: true }).catch(() => undefined);
try {
const currentStats = await fs.lstat(probePath).catch(() => null);
const stillOwned =
currentStats !== null &&
currentStats.dev === ownedIdentity.dev &&
currentStats.ino === ownedIdentity.ino &&
currentStats.ctimeMs === ownedIdentity.ctimeMs;
if (stillOwned) {
await fs.rm(probePath, { force: true }).catch(() => undefined);
}
} finally {
await handle.close().catch(() => undefined);
}
let after;
try {
+106 -34
View File
@@ -1,6 +1,7 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createInviteRateLimiter, type InviteRateLimiter } from "../services/invite-rate-limit.js";
const mockAccessService = vi.hoisted(() => ({
isInstanceAdmin: vi.fn(),
@@ -52,7 +53,7 @@ function registerModuleMocks() {
let appImportCounter = 0;
async function createApp(actor: any, db: any = {} as any) {
async function createApp(actor: any, db: any = {} as any, options: { cliAuthCreateRateLimiter?: InviteRateLimiter; cliAuthPollRateLimiter?: InviteRateLimiter } = {}) {
appImportCounter += 1;
const routeModulePath = `../routes/access.js?cli-auth-routes-${appImportCounter}`;
const middlewareModulePath = `../middleware/index.js?cli-auth-routes-${appImportCounter}`;
@@ -84,6 +85,7 @@ async function createApp(actor: any, db: any = {} as any) {
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
...options,
}),
);
app.use(errorHandler);
@@ -135,6 +137,91 @@ describe("cli auth routes", () => {
expect(res.body.approvalUrl).toContain("/cli-auth/12345678-1234-4123-8123-123456789abc?token=pcp_cli_auth_secret");
});
it("bounds anonymous create and poll independently without trusting spoofed forwarding headers", async () => {
let now = 1_000;
mockBoardAuthService.createCliAuthChallenge.mockResolvedValue({
challenge: { id: "12345678-1234-4123-8123-123456789abc", expiresAt: new Date(Date.now() + 60_000) },
challengeSecret: "pcp_cli_auth_secret", pendingBoardToken: "pcp_board_token",
});
mockBoardAuthService.describeCliAuthChallenge.mockResolvedValue({ id: "12345678-1234-4123-8123-123456789abc", status: "pending", requestedAccess: "board" });
const app = await createApp({ type: "none", source: "none" }, {}, {
cliAuthCreateRateLimiter: createInviteRateLimiter({ maxRequests: 2, now: () => now }),
cliAuthPollRateLimiter: createInviteRateLimiter({ maxRequests: 2, now: () => now }),
});
const payload = { command: "paperclipai auth login", requestedAccess: "board" };
for (let i = 0; i < 2; i++) expect((await request(app).post("/api/cli-auth/challenges").set("X-Forwarded-For", `198.51.100.${i}`).send(payload)).status).toBe(201);
const blocked = await request(app).post("/api/cli-auth/challenges").set("X-Forwarded-For", "203.0.113.7").send(payload);
expect(blocked.status).toBe(429);
expect(blocked.headers["retry-after"]).toBe("60");
expect(blocked.headers["cache-control"]).toBe("no-store");
expect(blocked.headers["referrer-policy"]).toBe("no-referrer");
expect(mockBoardAuthService.createCliAuthChallenge).toHaveBeenCalledTimes(2);
const path = "/api/cli-auth/challenges/12345678-1234-4123-8123-123456789abc?token=pcp_cli_auth_secret";
for (let i = 0; i < 2; i++) expect((await request(app).get(path).set("X-Forwarded-For", `198.51.100.${i}`)).status).toBe(200);
expect((await request(app).get(path).set("X-Forwarded-For", "203.0.113.8")).status).toBe(429);
expect(mockBoardAuthService.describeCliAuthChallenge).toHaveBeenCalledTimes(2);
now += 60_000;
expect((await request(app).post("/api/cli-auth/challenges").send(payload)).status).toBe(201);
expect((await request(app).get(path)).status).toBe(200);
});
it("preserves the normal one-second CLI poll cadence throughout the ten-minute challenge", async () => {
let now = Date.now();
vi.spyOn(Date, "now").mockImplementation(() => now);
mockBoardAuthService.describeCliAuthChallenge.mockResolvedValue({ id: "12345678-1234-4123-8123-123456789abc", status: "pending", requestedAccess: "board" });
const app = await createApp({ type: "none", source: "none" });
try {
for (let i = 0; i < 600; i++) {
const response = await request(app).get("/api/cli-auth/challenges/12345678-1234-4123-8123-123456789abc?token=pcp_cli_auth_secret");
expect(response.status).toBe(200);
expect(response.headers["x-ratelimit-limit"]).toBe("120");
now += 1_000;
}
} finally { vi.restoreAllMocks(); }
});
it("keeps challenge capabilities out of HTTP caches and referrers", async () => {
mockBoardAuthService.createCliAuthChallenge.mockResolvedValue({
challenge: { id: "12345678-1234-4123-8123-123456789abc", expiresAt: new Date(Date.now() + 60_000) },
challengeSecret: "pcp_cli_auth_secret", pendingBoardToken: "pcp_board_token",
});
mockBoardAuthService.describeCliAuthChallenge.mockResolvedValue({ id: "12345678-1234-4123-8123-123456789abc", status: "pending", requestedAccess: "board" });
const app = await createApp({ type: "none", source: "none" });
const responses = [
await request(app).post("/api/cli-auth/challenges").send({ command: "paperclipai auth login" }),
await request(app).get("/api/cli-auth/challenges/12345678-1234-4123-8123-123456789abc?token=pcp_cli_auth_secret"),
await request(app).get("/api/cli-auth/challenges/12345678-1234-4123-8123-123456789abc"),
await request(app).get("/api/cli-auth/challenges/not-a-uuid?token=pcp_cli_auth_secret"),
await request(app).post("/api/cli-auth/challenges/12345678-1234-4123-8123-123456789abc/approve").send({ token: "pcp_cli_auth_secret" }),
];
for (const response of responses) {
expect(response.headers["cache-control"]).toBe("no-store");
expect(response.headers["referrer-policy"]).toBe("no-referrer");
}
});
it("does not offer approval for an inaccessible requested organization", async () => {
mockBoardAuthService.describeCliAuthChallenge.mockResolvedValue({
id: "12345678-1234-4123-8123-123456789abc", status: "pending", requestedAccess: "board", requestedCompanyId: "company-private",
});
const app = await createApp({ type: "board", source: "session", userId: "user-1", companyIds: ["company-public"] });
const response = await request(app).get("/api/cli-auth/challenges/12345678-1234-4123-8123-123456789abc?token=pcp_cli_auth_secret");
expect(response.body.canApprove).toBe(false);
});
it("does not duplicate key-creation audit on a same-user approval retry", async () => {
mockBoardAuthService.approveCliAuthChallenge.mockResolvedValue({
status: "approved", created: false,
challenge: { id: "12345678-1234-4123-8123-123456789abc", boardApiKeyId: "board-key-1", requestedAccess: "board", expiresAt: new Date(Date.now() + 60_000) },
});
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-1"]);
const app = await createApp({ type: "board", source: "session", userId: "user-1" });
const response = await request(app).post("/api/cli-auth/challenges/12345678-1234-4123-8123-123456789abc/approve").send({ token: "pcp_cli_auth_secret" });
expect(response.status).toBe(200);
expect(response.body.approved).toBe(true);
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("rejects anonymous access to generic skill documents", async () => {
const indexApp = await createApp({ type: "none", source: "none" });
const skillApp = await createApp({ type: "none", source: "none" });
@@ -299,17 +386,11 @@ describe("cli auth routes", () => {
"pcp_cli_auth_secret",
"user-1",
);
expect(mockLogActivity).toHaveBeenCalledTimes(1);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
companyId: "company-1",
action: "board_api_key.created",
}),
);
expect(mockBoardAuthService.resolveBoardActivityCompanyIds).not.toHaveBeenCalled();
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("logs approve activity for instance admins without company memberships", async () => {
it("delegates instance-admin approval and atomic auditing to the service", async () => {
mockBoardAuthService.approveCliAuthChallenge.mockResolvedValue({
status: "approved",
challenge: {
@@ -334,15 +415,14 @@ describe("cli auth routes", () => {
.send({ token: "pcp_cli_auth_secret" });
expect(res.status).toBe(200);
expect(mockBoardAuthService.resolveBoardActivityCompanyIds).toHaveBeenCalledWith({
userId: "admin-1",
requestedCompanyId: null,
boardApiKeyId: "board-key-2",
});
expect(mockLogActivity).toHaveBeenCalledTimes(2);
expect(mockBoardAuthService.approveCliAuthChallenge).toHaveBeenCalledWith(
"12345678-1234-4123-8123-123456789def", "pcp_cli_auth_secret", "admin-1",
);
expect(mockBoardAuthService.resolveBoardActivityCompanyIds).not.toHaveBeenCalled();
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("logs revoke activity with resolved audit company ids", async () => {
it("delegates owner-bound revocation and atomic audit to the service", async () => {
mockBoardAuthService.assertCurrentBoardKey.mockResolvedValue({
id: "board-key-3",
userId: "admin-2",
@@ -360,17 +440,11 @@ describe("cli auth routes", () => {
const res = await request(app).post("/api/cli-auth/revoke-current").send({});
expect(res.status).toBe(200);
expect(mockBoardAuthService.resolveBoardActivityCompanyIds).toHaveBeenCalledWith({
userId: "admin-2",
boardApiKeyId: "board-key-3",
expect(mockBoardAuthService.revokeBoardApiKey).toHaveBeenCalledWith("board-key-3", {
userId: "admin-2", revokedVia: "cli_auth_logout",
});
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
companyId: "company-z",
action: "board_api_key.revoked",
}),
);
expect(mockBoardAuthService.resolveBoardActivityCompanyIds).not.toHaveBeenCalled();
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("creates a named board API key and logs audit activity", async () => {
@@ -465,13 +539,11 @@ describe("cli auth routes", () => {
const revokeRes = await request(app).delete(`/api/board-api-keys/${keyId}`);
expect(revokeRes.status).toBe(200);
expect(revokeRes.body).toEqual({ ok: true, keyId });
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
companyId: "company-1",
action: "board_api_key.revoked",
}),
);
expect(mockBoardAuthService.revokeBoardApiKey).toHaveBeenCalledWith(keyId, {
userId: "user-1", revokedVia: "board_api_key_lifecycle",
});
expect(mockBoardAuthService.resolveBoardActivityCompanyIds).not.toHaveBeenCalled();
expect(mockLogActivity).not.toHaveBeenCalled();
});
it("rejects malformed board API key IDs before database lookup", async () => {
@@ -251,14 +251,21 @@ const support = await getEmbeddedPostgresTestSupport();
const f = await fixture(names); f.activate();
let release!: () => void;
const held = new Promise<void>(resolve => { release = resolve; });
f.onList(() => held);
await Promise.all([f.access.syncComposioApps(f.connection.id, true, f.actor), f.access.syncComposioApps(f.connection.id, true, f.actor)]);
expect(f.batches).toHaveLength(1);
release();
const result = await finishSync(f);
expect(result.sync).toMatchObject({ status: "ready", total: 36, checked: 36 });
expect(f.batches.map(batch => batch.length)).toEqual([32, 4]);
expect(new Set(f.batches.flat().map(tool => tool.name)).size).toBe(36);
let acknowledgeProviderEntry!: () => void;
const providerEntered = new Promise<void>(resolve => { acknowledgeProviderEntry = resolve; });
f.onList(() => { acknowledgeProviderEntry(); return held; });
try {
await Promise.all([f.access.syncComposioApps(f.connection.id, true, f.actor), f.access.syncComposioApps(f.connection.id, true, f.actor)]);
await providerEntered;
expect(f.batches).toHaveLength(1);
release();
const result = await finishSync(f);
expect(result.sync).toMatchObject({ status: "ready", total: 36, checked: 36 });
expect(f.batches.map(batch => batch.length)).toEqual([32, 4]);
expect(new Set(f.batches.flat().map(tool => tool.name)).size).toBe(36);
} finally {
release();
}
});
it("keeps failed account observations unverified and restores them after a successful sync", async () => {
+11 -1
View File
@@ -1370,6 +1370,8 @@ describe("realizeExecutionWorkspace", () => {
it.each(["missing", "empty"] as const)("records the actual lookup and fetch outcome for a %s origin", async (mode) => {
const { repoRoot } = await createClonedRepoWithRemote();
await runGit(repoRoot, mode === "missing" ? ["remote", "remove", "origin"] : ["config", "remote.origin.url", ""]);
const remoteUrl = mode === "empty" ? await readGit(repoRoot, ["remote", "get-url", "origin"]) : null;
if (mode === "empty") expect(["", "origin"]).toContain(remoteUrl);
let authCalls = 0;
const error = await realizeWorktreeForTest(repoRoot, "absent-fixture", async () => { authCalls++; return null; }).catch(error => error);
expect(error).toBeInstanceOf(UnresolvedWorkspaceBaseRefError);
@@ -1378,6 +1380,12 @@ describe("realizeExecutionWorkspace", () => {
expect(authCalls).toBe(0);
expect(readUnresolvedWorkspaceBaseRefDiagnostic(error)).toEqual({ schemaVersion: 1,
remoteLookup: "failed", authLookup: "not_requested", fetch: "not_attempted", refResolution: "failed", refExitCode: 128 });
} else if (remoteUrl === "") {
expect(error.fetchError).toBeNull();
expect(authCalls).toBe(1);
expect(error.defaultBranch).toBeNull();
expect(readUnresolvedWorkspaceBaseRefDiagnostic(error)).toEqual({ schemaVersion: 1,
remoteLookup: "empty", authLookup: "not_requested", fetch: "not_attempted", refResolution: "failed", refExitCode: 128 });
} else {
// Git returns the remote name when its URL is empty, then the fetch fails.
expect(error.fetchError).toBeTruthy();
@@ -7538,6 +7546,8 @@ describeEmbeddedPostgres("workspace runtime service control persistence", () =>
}
}, 20_000);
// Exhaustion starts and stops all 32 candidates after ownership fails.
// Give their real-process stability checks and verified cleanup bounded headroom.
it("does not accept an occupied allocated port when listener ownership is unavailable", async () => {
const fixture = await createRuntimeFixture();
const cleanupRuntimeHome = await createRuntimeHome();
@@ -7578,7 +7588,7 @@ describeEmbeddedPostgres("workspace runtime service control persistence", () =>
await cleanupRuntimeHome();
await fixture.cleanup();
}
}, 20_000);
}, 60_000);
it("returns a bounded structured conflict and exposes only same-company workspace references", async () => {
const fixture = await createRuntimeFixture({
+31 -67
View File
@@ -14,7 +14,7 @@ import { isIP } from "node:net";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Router } from "express";
import type { Request } from "express";
import type { NextFunction, Request, Response } from "express";
import { and, desc, eq, gt, inArray, isNotNull, isNull, lte, ne } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
@@ -2638,6 +2638,8 @@ export function accessRoutes(
allowedHostnames: string[];
inviteResolutionNetwork?: Partial<InviteResolutionNetwork>;
inviteRateLimiter?: InviteRateLimiter;
cliAuthCreateRateLimiter?: InviteRateLimiter;
cliAuthPollRateLimiter?: InviteRateLimiter;
authPublicBaseUrl?: string;
}
) {
@@ -2681,6 +2683,25 @@ export function accessRoutes(
next();
});
const cliAuthCreateRateLimiter = opts.cliAuthCreateRateLimiter ?? createInviteRateLimiter();
const cliAuthPollRateLimiter = opts.cliAuthPollRateLimiter ?? createInviteRateLimiter({ maxRequests: 120 });
const limitCliAuth = (limiter: InviteRateLimiter) => (req: Request, res: Response, next: NextFunction) => {
const result = limiter.consume(req.ip || req.socket?.remoteAddress || "unknown");
res.setHeader("X-RateLimit-Limit", String(result.limit));
res.setHeader("X-RateLimit-Remaining", String(result.remaining));
if (!result.allowed) {
res.setHeader("Retry-After", String(result.retryAfterSeconds));
next(tooManyRequests("Too many CLI auth requests", { retryAfterSeconds: result.retryAfterSeconds }));
return;
}
next();
};
router.use("/cli-auth/challenges", (_req, res, next) => {
res.setHeader("Cache-Control", "no-store");
res.setHeader("Referrer-Policy", "no-referrer");
next();
});
async function assertInstanceAdmin(req: Request) {
if (req.actor.type !== "board") throw unauthorized();
if (isLocalImplicit(req)) return;
@@ -2763,6 +2784,7 @@ export function accessRoutes(
router.post(
"/cli-auth/challenges",
limitCliAuth(cliAuthCreateRateLimiter),
validate(createCliAuthChallengeSchema),
async (req, res) => {
const created = await boardAuth.createCliAuthChallenge(req.body);
@@ -2784,13 +2806,15 @@ export function accessRoutes(
},
);
router.get("/cli-auth/challenges/:id", async (req, res) => {
router.get("/cli-auth/challenges/:id", limitCliAuth(cliAuthPollRateLimiter), async (req, res) => {
const id = (req.params.id as string).trim();
const token =
typeof req.query.token === "string" ? req.query.token.trim() : "";
if (!id || !token) throw notFound("CLI auth challenge not found");
if (!isUuidLike(id)) throw badRequest("Invalid CLI auth challenge ID");
const challenge = await boardAuth.describeCliAuthChallenge(id, token);
const challenge = await boardAuth.describeCliAuthChallenge(id, token, {
viewerUserId: req.actor.type === "board" ? req.actor.userId : undefined,
});
if (!challenge) throw notFound("CLI auth challenge not found");
const isSignedInBoardUser =
@@ -2799,6 +2823,8 @@ export function accessRoutes(
Boolean(req.actor.userId);
const canApprove =
isSignedInBoardUser &&
(!challenge.requestedCompanyId || Boolean(req.actor.isInstanceAdmin) || isLocalImplicit(req)
|| Boolean(req.actor.companyIds?.includes(challenge.requestedCompanyId))) &&
(challenge.requestedAccess !== "instance_admin_required" ||
isLocalImplicit(req) ||
Boolean(req.actor.isInstanceAdmin));
@@ -2831,30 +2857,6 @@ export function accessRoutes(
userId,
);
if (approved.status === "approved") {
const companyIds = await boardAuth.resolveBoardActivityCompanyIds({
userId,
requestedCompanyId: approved.challenge.requestedCompanyId,
boardApiKeyId: approved.challenge.boardApiKeyId,
});
for (const companyId of companyIds) {
await logActivity(db, {
companyId,
actorType: "user",
actorId: userId,
action: "board_api_key.created",
entityType: "user",
entityId: userId,
details: {
boardApiKeyId: approved.challenge.boardApiKeyId,
requestedAccess: approved.challenge.requestedAccess,
requestedCompanyId: approved.challenge.requestedCompanyId,
challengeId: approved.challenge.id,
},
});
}
}
res.json({
approved: approved.status === "approved",
status: approved.status,
@@ -2958,29 +2960,9 @@ export function accessRoutes(
}
const key = await boardAuth.getBoardApiKeyForUser(keyId, req.actor.userId);
if (!key) throw notFound("Board API key not found");
const revoked = await boardAuth.revokeBoardApiKey(key.id);
const revoked = await boardAuth.revokeBoardApiKey(key.id, { userId: req.actor.userId, revokedVia: "board_api_key_lifecycle" });
if (!revoked) throw notFound("Board API key not found");
const companyIds = await boardAuth.resolveBoardActivityCompanyIds({
userId: req.actor.userId,
boardApiKeyId: key.id,
});
for (const companyId of companyIds) {
await logActivity(db, {
companyId,
actorType: "user",
actorId: req.actor.userId,
action: "board_api_key.revoked",
entityType: "user",
entityId: req.actor.userId,
details: {
boardApiKeyId: key.id,
name: key.name,
revokedVia: "board_api_key_lifecycle",
},
});
}
res.json({ ok: true, keyId: key.id });
});
@@ -2992,25 +2974,7 @@ export function accessRoutes(
req.actor.keyId,
req.actor.userId,
);
await boardAuth.revokeBoardApiKey(key.id);
const companyIds = await boardAuth.resolveBoardActivityCompanyIds({
userId: key.userId,
boardApiKeyId: key.id,
});
for (const companyId of companyIds) {
await logActivity(db, {
companyId,
actorType: "user",
actorId: key.userId,
action: "board_api_key.revoked",
entityType: "user",
entityId: key.userId,
details: {
boardApiKeyId: key.id,
revokedVia: "cli_auth_logout",
},
});
}
await boardAuth.revokeBoardApiKey(key.id, { userId: key.userId, revokedVia: "cli_auth_logout" });
res.json({ revoked: true, keyId: key.id });
});
+422 -1
View File
@@ -1,6 +1,15 @@
import { describe, expect, it, vi } from "vitest";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { randomUUID } from "node:crypto";
import { spawn } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createInviteRateLimiter } from "./invite-rate-limit.js";
import { eq, sql } from "drizzle-orm";
import { activityLog, authUsers, boardApiKeys, cliAuthChallenges, companies, companyMemberships, createDb, getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "@paperclipai/db";
import type { Db } from "@paperclipai/db";
import { boardAuthService } from "./board-auth.js";
import { subscribeCompanyLiveEvents } from "./live-events.js";
describe("boardAuthService touchBoardApiKey", () => {
it("retries the audit write after a transient failure", async () => {
@@ -38,3 +47,415 @@ describe("boardAuthService touchBoardApiKey", () => {
expect(update).toHaveBeenCalledTimes(1);
});
});
const external = process.env.PAPERCLIP_TEST_DATABASE_URL;
const support = external ? { supported: true } : await getEmbeddedPostgresTestSupport();
const databaseSuite = support.supported ? describe : describe.skip;
databaseSuite("CLI challenge authority and transitions (real PostgreSQL)", () => {
let db: ReturnType<typeof createDb>;
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
beforeAll(async () => {
if (external) db = createDb(external);
else {
temporary = await startEmbeddedPostgresTestDatabase("board-cli-challenge-");
db = createDb(temporary.connectionString);
}
}, 60_000);
afterAll(async () => {
await db?.$client.end();
await temporary?.cleanup();
});
async function fixture() {
const companyId = randomUUID(), userId = randomUUID(), otherUserId = randomUUID();
const now = new Date();
await db.insert(authUsers).values([userId, otherUserId].map(id => ({
id, name: "CLI approver", email: `${id}@example.invalid`, createdAt: now, updatedAt: now,
})));
await db.insert(companies).values({ id: companyId, name: "Private CLI organization", issuePrefix: `C${companyId.slice(0, 7)}` });
await db.insert(companyMemberships).values([userId, otherUserId].map(principalId => ({
companyId, principalType: "user", principalId, status: "active", membershipRole: "owner",
})));
const service = boardAuthService(db);
const created = await service.createCliAuthChallenge({ command: "paperclipai auth login", requestedAccess: "board", requestedCompanyId: companyId });
return { companyId, userId, otherUserId, service, ...created };
}
async function waitForChallengeLock() {
const deadline = Date.now() + 5_000;
while (Date.now() < deadline) {
const rows = await db.execute<{ waiting: boolean }>(sql`
select exists(select 1 from pg_stat_activity where datname = current_database()
and pid <> pg_backend_pid() and wait_event_type = 'Lock'
and query ilike '%cli_auth_challenges%') as waiting
`);
if (rows[0]?.waiting) return;
await new Promise(resolve => setTimeout(resolve, 10));
}
throw new Error("CLI challenge contender did not reach the held database lock");
}
it("keeps pending tokens unusable and stores only independent secret digests", async () => {
const f = await fixture();
expect(f.challenge.secretHash).not.toBe(f.challenge.pendingKeyHash);
expect(f.challenge.secretHash).not.toContain(f.challengeSecret);
expect(f.challenge.pendingKeyHash).not.toContain(f.pendingBoardToken);
expect(await f.service.findBoardApiKeyByToken(f.pendingBoardToken)).toBeNull();
expect(await f.service.getCliAuthChallengeBySecret(f.challenge.id, "wrong-secret")).toBeNull();
await db.update(cliAuthChallenges).set({ expiresAt: new Date(Date.now() - 1) }).where(eq(cliAuthChallenges.id, f.challenge.id));
expect((await f.service.approveCliAuthChallenge(f.challenge.id, f.challengeSecret, f.userId)).status).toBe("expired");
expect(await f.service.findBoardApiKeyByToken(f.pendingBoardToken)).toBeNull();
});
it("retains the first key owner and approver on same-user and competing-user replay", async () => {
const f = await fixture();
const first = await f.service.approveCliAuthChallenge(f.challenge.id, f.challengeSecret, f.userId);
await expect(f.service.approveCliAuthChallenge(f.challenge.id, f.challengeSecret, f.otherUserId)).rejects.toThrow("another user");
const repeat = await f.service.approveCliAuthChallenge(f.challenge.id, f.challengeSecret, f.userId);
expect(repeat.challenge).toEqual(first.challenge);
const [stored] = await db.select().from(cliAuthChallenges).where(eq(cliAuthChallenges.id, f.challenge.id));
expect(stored).toEqual(first.challenge);
const key = await f.service.findBoardApiKeyByToken(f.pendingBoardToken);
expect(key?.userId).toBe(f.userId);
expect(stored.approvedByUserId).toBe(key?.userId);
expect(await db.select().from(boardApiKeys).where(eq(boardApiKeys.keyHash, f.challenge.pendingKeyHash))).toHaveLength(1);
});
it("denies an inaccessible requested company before key creation or audit fallback", async () => {
const f = await fixture();
await db.delete(companyMemberships).where(eq(companyMemberships.principalId, f.otherUserId));
await expect(f.service.approveCliAuthChallenge(f.challenge.id, f.challengeSecret, f.otherUserId)).rejects.toThrow("requested organization");
expect(await f.service.findBoardApiKeyByToken(f.pendingBoardToken)).toBeNull();
expect(await f.service.resolveBoardActivityCompanyIds({ userId: f.otherUserId, requestedCompanyId: f.companyId })).toEqual([]);
});
it("rechecks company membership after waiting for the approval lock", async () => {
const f = await fixture();
let release!: () => void, locked!: () => void;
const held = new Promise<void>(resolve => { locked = resolve; });
const unlock = new Promise<void>(resolve => { release = resolve; });
const holder = db.transaction(async tx => {
await tx.execute(sql`select id from cli_auth_challenges where id = ${f.challenge.id} for update`);
locked(); await unlock;
});
await held;
const approval = f.service.approveCliAuthChallenge(f.challenge.id, f.challengeSecret, f.userId);
const outcome = approval.then(value => ({ value }), error => ({ error }));
try {
await waitForChallengeLock();
await db.update(companyMemberships).set({ status: "suspended" }).where(eq(companyMemberships.principalId, f.userId));
} finally { release(); await holder; }
const result = await outcome;
expect(result).toHaveProperty("error");
expect("error" in result ? String(result.error) : "").toContain("requested organization");
expect(await f.service.findBoardApiKeyByToken(f.pendingBoardToken)).toBeNull();
});
it("serializes cancellation behind an actual approval transaction without cancelling its live key", async () => {
const f = await fixture();
let release!: () => void, prepared!: () => void;
const ready = new Promise<void>(resolve => { prepared = resolve; });
const unlock = new Promise<void>(resolve => { release = resolve; });
const gated = Object.create(db) as Db;
gated.transaction = ((callback: Parameters<Db["transaction"]>[0]) => db.transaction(async tx => {
const result = await callback(tx);
prepared(); await unlock;
return result;
})) as Db["transaction"];
const approval = boardAuthService(gated).approveCliAuthChallenge(f.challenge.id, f.challengeSecret, f.userId);
await ready;
const cancellation = f.service.cancelCliAuthChallenge(f.challenge.id, f.challengeSecret);
const outcome = cancellation.then(value => ({ value }), error => ({ error }));
try { await waitForChallengeLock(); }
finally { release(); await approval; }
const result = await outcome;
expect(result).toHaveProperty("value.status", "approved");
const [stored] = await db.select().from(cliAuthChallenges).where(eq(cliAuthChallenges.id, f.challenge.id));
expect(stored.cancelledAt).toBeNull();
expect(stored.approvedByUserId).toBe(f.userId);
expect(await f.service.findBoardApiKeyByToken(f.pendingBoardToken)).toMatchObject({ userId: f.userId });
});
it("publishes approval activity only after key, challenge and audit rows commit", async () => {
const f = await fixture();
const events: string[] = [];
const unsubscribe = subscribeCompanyLiveEvents(f.companyId, event => {
if (event.payload.action === "board_api_key.created") events.push(event.payload.actorId as string);
});
let release!: () => void, prepared!: () => void;
const ready = new Promise<void>(resolve => { prepared = resolve; });
const unlock = new Promise<void>(resolve => { release = resolve; });
const gated = Object.create(db) as Db;
gated.transaction = ((callback: Parameters<Db["transaction"]>[0]) => db.transaction(async tx => {
const result = await callback(tx); prepared(); await unlock; return result;
})) as Db["transaction"];
const service = boardAuthService(gated);
const approval = service.approveCliAuthChallenge(f.challenge.id, f.challengeSecret, f.userId);
try {
await ready;
expect(events).toEqual([]);
expect(await f.service.findBoardApiKeyByToken(f.pendingBoardToken)).toBeNull();
expect(await db.select().from(activityLog).where(eq(activityLog.actorId, f.userId))).toHaveLength(0);
release(); await approval;
expect(events).toEqual([f.userId]);
expect(await db.select().from(activityLog).where(eq(activityLog.actorId, f.userId))).toHaveLength(1);
await service.approveCliAuthChallenge(f.challenge.id, f.challengeSecret, f.userId);
expect(events).toEqual([f.userId]);
} finally { release(); await approval; unsubscribe(); }
});
it("never grants a key after cancellation and keeps duplicate cancellation stable", async () => {
const f = await fixture();
const first = await f.service.cancelCliAuthChallenge(f.challenge.id, f.challengeSecret);
expect((await f.service.cancelCliAuthChallenge(f.challenge.id, f.challengeSecret)).challenge).toEqual(first.challenge);
expect((await f.service.approveCliAuthChallenge(f.challenge.id, f.challengeSecret, f.userId)).status).toBe("cancelled");
expect(await f.service.findBoardApiKeyByToken(f.pendingBoardToken)).toBeNull();
});
it("shows the requested company name only to a currently authorized viewer", async () => {
const f = await fixture();
expect(await f.service.describeCliAuthChallenge(f.challenge.id, f.challengeSecret, { viewerUserId: f.userId }))
.toMatchObject({ requestedCompanyName: "Private CLI organization", approvedByUser: null });
await db.update(companyMemberships).set({ status: "suspended" }).where(eq(companyMemberships.principalId, f.otherUserId));
expect(await f.service.describeCliAuthChallenge(f.challenge.id, f.challengeSecret, { viewerUserId: f.otherUserId }))
.toMatchObject({ requestedCompanyName: null, approvedByUser: null });
});
it("keeps instance-admin approval and current full-Board key authentication compatible", async () => {
const f = await fixture();
const { instanceUserRoles } = await import("@paperclipai/db");
await db.delete(companyMemberships).where(eq(companyMemberships.principalId, f.userId));
await db.insert(instanceUserRoles).values({ userId: f.userId, role: "instance_admin" });
const created = await f.service.createCliAuthChallenge({ command: "paperclipai auth login", requestedAccess: "instance_admin_required", requestedCompanyId: f.companyId });
await expect(f.service.approveCliAuthChallenge(created.challenge.id, created.challengeSecret, f.otherUserId)).rejects.toThrow("Instance admin required");
const approved = await f.service.approveCliAuthChallenge(created.challenge.id, created.challengeSecret, f.userId);
expect(approved.status).toBe("approved");
expect(await f.service.findBoardApiKeyByToken(created.pendingBoardToken)).toMatchObject({ userId: f.userId });
expect((await f.service.resolveBoardAccess(f.userId)).isInstanceAdmin).toBe(true);
});
it("authenticates the real pending, approved and revoked HTTP handshake without synthesizing an anonymous actor", async () => {
const f = await fixture();
const [{ default: express }, { default: request }, { actorMiddleware }, { accessRoutes }, { errorHandler }] = await Promise.all([
import("express"), import("supertest"), import("../middleware/auth.js"), import("../routes/access.js"), import("../middleware/error-handler.js"),
]);
const app = express();
app.use(express.json());
app.use(actorMiddleware(db, { deploymentMode: "authenticated", resolveSession: async req => (req.header("X-Test-Session") === f.userId || req.header("X-Test-Session") === f.otherUserId)
? { session: { id: "isolated-cli-session" }, user: { id: req.header("X-Test-Session"), name: "CLI approver", email: "synthetic-session@example.invalid" } } as any
: null }));
app.use("/api", accessRoutes(db, { deploymentMode: "authenticated", deploymentExposure: "private", bindHost: "127.0.0.1", allowedHostnames: [] }));
app.use(errorHandler);
const created = await request(app).post("/api/cli-auth/challenges")
.send({ command: "paperclipai auth login", requestedCompanyId: f.companyId });
expect(created.status).toBe(201);
const { id, token, boardApiToken } = created.body;
const secondCompanyId = randomUUID();
await db.insert(companies).values({ id: secondCompanyId, name: "Second CLI organization", issuePrefix: `S${secondCompanyId.slice(0, 7)}` });
await db.insert(companyMemberships).values({ companyId: secondCompanyId, principalType: "user", principalId: f.userId, status: "active", membershipRole: "owner" });
expect((await request(app).get("/api/cli-auth/me").set("Authorization", `Bearer ${boardApiToken}`)).status).toBe(401);
const anonymous = await request(app).get(`/api/cli-auth/challenges/${id}?token=${token}`);
expect(anonymous.body).toMatchObject({ status: "pending", canApprove: false, requestedCompanyName: null, approvedByUser: null });
expect((await request(app).post(`/api/cli-auth/challenges/${id}/approve`).send({ token })).status).toBe(401);
await db.delete(companyMemberships).where(eq(companyMemberships.principalId, f.otherUserId));
expect((await request(app).post(`/api/cli-auth/challenges/${id}/approve`).set("X-Test-Session", f.otherUserId).send({ token })).status).toBe(403);
expect(await f.service.findBoardApiKeyByToken(boardApiToken)).toBeNull();
expect(await db.select().from(activityLog).where(eq(activityLog.actorId, f.otherUserId))).toHaveLength(0);
const browser = await request(app).get(`/api/cli-auth/challenges/${id}?token=${token}`).set("X-Test-Session", f.userId);
expect(browser.body).toMatchObject({ canApprove: true, requestedCompanyName: "Private CLI organization" });
await db.execute(sql.raw(`alter table activity_log add constraint board_cli_audit_failure check (action != 'board_api_key.created' or company_id != '${secondCompanyId}')`));
try {
const failed = await request(app).post(`/api/cli-auth/challenges/${id}/approve`).set("X-Test-Session", f.userId).send({ token });
expect(failed.status).toBe(500);
expect(await f.service.findBoardApiKeyByToken(boardApiToken)).toBeNull();
const [pending] = await db.select().from(cliAuthChallenges).where(eq(cliAuthChallenges.id, id));
expect(pending.approvedAt).toBeNull();
expect(pending.boardApiKeyId).toBeNull();
expect(await db.select().from(activityLog).where(eq(activityLog.actorId, f.userId))).toHaveLength(0);
} finally { await db.execute(sql`alter table activity_log drop constraint board_cli_audit_failure`); }
const unsubscribeFailure = subscribeCompanyLiveEvents(f.companyId, event => {
if (event.payload.action === "board_api_key.created") throw new Error("Synthetic publication failure");
});
const secondPublications: string[] = [];
const unsubscribeSecond = subscribeCompanyLiveEvents(secondCompanyId, event => {
if (event.payload.action === "board_api_key.created") secondPublications.push(event.payload.actorId as string);
});
try {
expect((await request(app).post(`/api/cli-auth/challenges/${id}/approve`).set("X-Test-Session", f.userId).send({ token })).status).toBe(200);
expect(secondPublications).toEqual([f.userId]);
} finally { unsubscribeFailure(); unsubscribeSecond(); }
expect((await request(app).post(`/api/cli-auth/challenges/${id}/approve`).set("X-Test-Session", f.userId).send({ token })).status).toBe(200);
const audit = await db.select().from(activityLog).where(eq(activityLog.actorId, f.userId));
expect(audit.filter(row => row.action === "board_api_key.created")).toHaveLength(2);
expect(new Set(audit.map(row => row.companyId))).toEqual(new Set([f.companyId, secondCompanyId]));
expect(JSON.stringify(audit)).not.toContain(boardApiToken);
expect(JSON.stringify(audit)).not.toContain(token);
const identity = await request(app).get("/api/cli-auth/me").set("Authorization", `Bearer ${boardApiToken}`);
expect(identity.body).toMatchObject({ userId: f.userId, source: "board_key" });
expect(new Set(identity.body.companyIds)).toEqual(new Set([f.companyId, secondCompanyId]));
expect((await request(app).post("/api/cli-auth/revoke-current").set("Authorization", `Bearer ${boardApiToken}`)).status).toBe(200);
expect((await request(app).get("/api/cli-auth/me").set("Authorization", `Bearer ${boardApiToken}`)).status).toBe(401);
});
it.each(["cli_auth_logout", "board_api_key_lifecycle"])("keeps owner-bound historical revocation audit after membership loss through %s", async revokedVia => {
const f = await fixture();
const approved = await f.service.approveCliAuthChallenge(f.challenge.id, f.challengeSecret, f.userId);
const keyId = approved.challenge.boardApiKeyId!;
const foreign = await fixture();
await db.update(cliAuthChallenges).set({ boardApiKeyId: keyId, approvedByUserId: foreign.userId, approvedAt: new Date() })
.where(eq(cliAuthChallenges.id, foreign.challenge.id));
await db.update(companyMemberships).set({ status: "suspended" }).where(eq(companyMemberships.principalId, f.userId));
expect(await f.service.resolveBoardActivityCompanyIds({ userId: f.userId, requestedCompanyId: foreign.companyId })).toEqual([]);
const [{ default: express }, { default: request }, { actorMiddleware }, { accessRoutes }, { errorHandler }, { activityRoutes }] = await Promise.all([
import("express"), import("supertest"), import("../middleware/auth.js"), import("../routes/access.js"), import("../middleware/error-handler.js"), import("../routes/activity.js"),
]);
const app = express(); app.use(express.json());
app.use(actorMiddleware(db, { deploymentMode: "authenticated", resolveSession: async req => (req.header("X-Test-Session") === f.userId || req.header("X-Test-Session") === foreign.userId)
? { session: { id: "synthetic-session" }, user: { id: req.header("X-Test-Session"), name: "CLI owner", email: "owner@example.invalid" } } as any : null }));
app.use("/api", accessRoutes(db, { deploymentMode: "authenticated", deploymentExposure: "private", bindHost: "127.0.0.1", allowedHostnames: [] })); app.use("/api", activityRoutes(db)); app.use(errorHandler);
const foreignKey = await f.service.createNamedBoardApiKey({ userId: foreign.userId, name: "Synthetic foreign operator" });
const forged = await request(app).post(`/api/companies/${foreign.companyId}/activity`).set("Authorization", `Bearer ${foreignKey.token}`).send({
actorType: "user", actorId: f.userId, action: "board_api_key.created", entityType: "user", entityId: f.userId,
responsibleUserId: f.userId, responsible_user_id: f.userId, details: { boardApiKeyId: keyId, responsibleUserId: f.userId },
});
expect(forged.status).toBe(201);
expect(forged.body.responsibleUserId).toBeNull();
expect(await f.service.revokeBoardApiKey(keyId, { userId: foreign.userId, revokedVia: "board_api_key_lifecycle" })).toBeNull();
const denied = await request(app).delete(`/api/board-api-keys/${keyId}`).set("X-Test-Session", foreign.userId);
expect(denied.status).toBe(404);
expect(await f.service.findBoardApiKeyByToken(f.pendingBoardToken)).not.toBeNull();
const response = revokedVia === "cli_auth_logout"
? await request(app).post("/api/cli-auth/revoke-current").set("Authorization", `Bearer ${f.pendingBoardToken}`).send({ requestedCompanyId: foreign.companyId })
: await request(app).delete(`/api/board-api-keys/${keyId}`).set("X-Test-Session", f.userId);
expect(response.status).toBe(200);
expect(await f.service.findBoardApiKeyByToken(f.pendingBoardToken)).toBeNull();
const rows = await db.select().from(activityLog).where(eq(activityLog.action, "board_api_key.revoked"));
const matching = rows.filter(row => row.details?.boardApiKeyId === keyId);
expect(matching).toHaveLength(1);
expect(matching[0]).toMatchObject({ companyId: f.companyId, actorId: f.userId, details: { boardApiKeyId: keyId, revokedVia } });
});
it("retains server-derived named-key creation context while rejecting public same-owner audit forgeries", async () => {
const f = await fixture(), foreign = await fixture();
await f.service.approveCliAuthChallenge(f.challenge.id, f.challengeSecret, f.userId);
const [{ default: express }, { default: request }, { actorMiddleware }, { accessRoutes }, { activityRoutes }, { errorHandler }] = await Promise.all([
import("express"), import("supertest"), import("../middleware/auth.js"), import("../routes/access.js"), import("../routes/activity.js"), import("../middleware/error-handler.js"),
]);
const app = express(); app.use(express.json()); app.use(actorMiddleware(db, { deploymentMode: "authenticated", resolveSession: async () => null }));
app.use("/api", accessRoutes(db, { deploymentMode: "authenticated", deploymentExposure: "private", bindHost: "127.0.0.1", allowedHostnames: [] }));
app.use("/api", activityRoutes(db)); app.use(errorHandler);
const created = await request(app).post("/api/board-api-keys").set("Authorization", `Bearer ${f.pendingBoardToken}`)
.send({ name: "Synthetic named key", requestedCompanyId: f.companyId });
expect(created.status).toBe(201);
expect(await db.select().from(cliAuthChallenges).where(eq(cliAuthChallenges.boardApiKeyId, created.body.id))).toHaveLength(0);
const foreignKey = await f.service.createNamedBoardApiKey({ userId: foreign.userId, name: "Synthetic foreign operator" });
const forged = await request(app).post(`/api/companies/${foreign.companyId}/activity`).set("Authorization", `Bearer ${foreignKey.token}`).send({
actorType: "user", actorId: f.userId, action: "board_api_key.created", entityType: "user", entityId: f.userId,
responsibleUserId: f.userId, details: { boardApiKeyId: created.body.id, responsibleUserId: f.userId },
});
expect(forged.status).toBe(201); expect(forged.body.responsibleUserId).toBeNull();
await db.update(companyMemberships).set({ status: "suspended" }).where(eq(companyMemberships.principalId, f.userId));
expect((await request(app).post("/api/cli-auth/revoke-current").set("Authorization", `Bearer ${created.body.token}`)).status).toBe(200);
const rows = (await db.select().from(activityLog).where(eq(activityLog.action, "board_api_key.revoked")))
.filter(row => row.details?.boardApiKeyId === created.body.id);
expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ companyId: f.companyId, actorId: f.userId });
});
it("rolls back revocation when a second company audit fails and retries first-only", async () => {
const f = await fixture();
const secondCompanyId = randomUUID();
await db.insert(companies).values({ id: secondCompanyId, name: "Second revocation context", issuePrefix: `R${secondCompanyId.slice(0, 7)}` });
await db.insert(companyMemberships).values({ companyId: secondCompanyId, principalType: "user", principalId: f.userId, status: "active", membershipRole: "owner" });
const approved = await f.service.approveCliAuthChallenge(f.challenge.id, f.challengeSecret, f.userId);
const keyId = approved.challenge.boardApiKeyId!;
const [{ default: express }, { default: request }, { actorMiddleware }, { accessRoutes }, { errorHandler }] = await Promise.all([
import("express"), import("supertest"), import("../middleware/auth.js"), import("../routes/access.js"), import("../middleware/error-handler.js"),
]);
const app = express(); app.use(express.json()); app.use(actorMiddleware(db, { deploymentMode: "authenticated", resolveSession: async () => null }));
app.use("/api", accessRoutes(db, { deploymentMode: "authenticated", deploymentExposure: "private", bindHost: "127.0.0.1", allowedHostnames: [] })); app.use(errorHandler);
await db.execute(sql.raw(`alter table activity_log add constraint board_cli_revoke_failure check (action != 'board_api_key.revoked' or company_id != '${secondCompanyId}')`));
try {
expect((await request(app).post("/api/cli-auth/revoke-current").set("Authorization", `Bearer ${f.pendingBoardToken}`)).status).toBe(500);
expect(await f.service.findBoardApiKeyByToken(f.pendingBoardToken)).not.toBeNull();
expect((await db.select().from(activityLog).where(eq(activityLog.actorId, f.userId))).filter(row => row.action === "board_api_key.revoked")).toHaveLength(0);
} finally { await db.execute(sql`alter table activity_log drop constraint board_cli_revoke_failure`); }
expect((await request(app).post("/api/cli-auth/revoke-current").set("Authorization", `Bearer ${f.pendingBoardToken}`)).status).toBe(200);
const revoked = (await db.select().from(activityLog).where(eq(activityLog.actorId, f.userId))).filter(row => row.action === "board_api_key.revoked");
expect(new Set(revoked.map(row => row.companyId))).toEqual(new Set([f.companyId, secondCompanyId]));
expect(revoked).toHaveLength(2);
expect((await request(app).post("/api/cli-auth/revoke-current").set("Authorization", `Bearer ${f.pendingBoardToken}`)).status).toBe(401);
expect((await db.select().from(activityLog).where(eq(activityLog.action, "board_api_key.revoked"))).filter(row => row.details?.boardApiKeyId === keyId)).toHaveLength(2);
});
it("finishes three real CLI logins sharing one limited IP without changing their challenges", async () => {
const f = await fixture();
const [{ default: express }, { default: request }, { actorMiddleware }, { accessRoutes }, { errorHandler }] = await Promise.all([
import("express"), import("supertest"), import("../middleware/auth.js"), import("../routes/access.js"), import("../middleware/error-handler.js"),
]);
const clockStart = Date.now(); let clock = clockStart;
const limiter = createInviteRateLimiter({ maxRequests: 120, now: () => clock });
const created: Array<{ id: string; token: string; boardApiToken: string; expiresAt: string }> = [];
let throttled = 0, releaseThrottles!: () => void;
const allThrottled = new Promise<void>(resolve => { releaseThrottles = resolve; });
const app = express(); app.use(express.json());
app.use(actorMiddleware(db, { deploymentMode: "authenticated", resolveSession: async req => req.header("X-Test-Session") === f.userId
? { session: { id: "synthetic-shared-session" }, user: { id: f.userId, name: "CLI owner", email: "owner@example.invalid" } } as any : null }));
app.use((req, res, next) => {
const original = res.json.bind(res);
res.json = body => { if (req.method === "POST" && req.originalUrl.split("?", 1)[0] === "/api/cli-auth/challenges" && res.statusCode === 201) created.push(body); return original(body); };
res.on("finish", () => { if (req.method === "GET" && res.statusCode === 429) { throttled++; if (throttled === 3) releaseThrottles(); } }); next();
});
app.use("/api", accessRoutes(db, { deploymentMode: "authenticated", deploymentExposure: "private", bindHost: "127.0.0.1", allowedHostnames: [], cliAuthPollRateLimiter: limiter })); app.use(errorHandler);
const server = app.listen(0, "127.0.0.1"); await new Promise<void>(resolve => server.once("listening", resolve));
const address = server.address(); if (!address || typeof address === "string") throw new Error("Synthetic CLI server has no port");
const apiBase = `http://127.0.0.1:${address.port}`;
const folder = fs.mkdtempSync(path.join(os.tmpdir(), "board-cli-shared-ip-"));
const children: Array<ReturnType<typeof spawn>> = [];
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
for (let i = 0; i < 120; i++) expect((await fetch(`${apiBase}/api/cli-auth/challenges/${f.challenge.id}?token=${f.challengeSecret}`)).status).toBe(200);
clock = clockStart + 59_900;
const module = new URL("../../../cli/src/client/board-auth.ts", import.meta.url).href;
const loader = new URL("../../node_modules/tsx/dist/loader.mjs", import.meta.url).href;
const outcomes = [0, 1, 2].map(i => {
const worker = path.join(folder, `client-${i}.mjs`);
fs.writeFileSync(worker, `import { loginBoardCli } from ${JSON.stringify(module)}; await loginBoardCli(${JSON.stringify({ apiBase, requestedAccess: "board", requestedCompanyId: f.companyId, storePath: path.join(folder, `auth-${i}.json`), openBrowser: false, print: false })});`, { mode: 0o600 });
const child = spawn(process.execPath, ["--import", loader, worker], { cwd: folder, stdio: ["ignore", "ignore", "pipe"] }); children.push(child);
let errors = ""; child.stderr?.on("data", chunk => { errors += chunk.toString(); });
return new Promise<{ code: number | null; errors: string }>(resolve => child.once("exit", code => resolve({ code, errors })));
});
await Promise.race([allThrottled, new Promise<never>((_, reject) => { timeout = setTimeout(() => reject(new Error("Three CLI workers did not reach the real shared-IP limit")), 5000); })]);
clearTimeout(timeout); expect(created).toHaveLength(3);
for (const challenge of created) expect((await request(app).post(`/api/cli-auth/challenges/${challenge.id}/approve`).set("X-Test-Session", f.userId).send({ token: challenge.token })).status).toBe(200);
clock = clockStart + 60_001;
expect(await Promise.all(outcomes)).toEqual([0, 1, 2].map(() => ({ code: 0, errors: "" })));
expect(created).toHaveLength(3); expect(throttled).toBe(3);
for (let i = 0; i < 3; i++) {
const store = JSON.parse(fs.readFileSync(path.join(folder, `auth-${i}.json`), "utf8"));
const saved = store.credentials[apiBase];
expect(created.some(challenge => challenge.boardApiToken === saved.token)).toBe(true);
expect(saved.userId).toBe(f.userId);
}
for (const challenge of created) {
const [stored] = await db.select().from(cliAuthChallenges).where(eq(cliAuthChallenges.id, challenge.id));
expect(stored.expiresAt.toISOString()).toBe(challenge.expiresAt); expect(stored.approvedByUserId).toBe(f.userId);
}
} finally {
clearTimeout(timeout);
for (const child of children) if (child.exitCode === null) { child.kill("SIGTERM"); await new Promise(resolve => child.once("exit", resolve)); }
server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())); fs.rmSync(folder, { recursive: true, force: true });
}
});
it("omits private organization and approver identity from anonymous capability polling", async () => {
const f = await fixture();
const pending = await f.service.describeCliAuthChallenge(f.challenge.id, f.challengeSecret);
expect(pending?.requestedCompanyName).toBeNull();
await f.service.approveCliAuthChallenge(f.challenge.id, f.challengeSecret, f.userId);
const approved = await f.service.describeCliAuthChallenge(f.challenge.id, f.challengeSecret);
expect(approved).toMatchObject({ status: "approved", requestedCompanyName: null, approvedByUser: null });
expect(JSON.stringify(approved)).not.toContain(`${f.userId}@example.invalid`);
});
});
+123 -91
View File
@@ -2,6 +2,7 @@ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { and, eq, gt, isNull, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
activityLog,
authUsers,
boardApiKeys,
cliAuthChallenges,
@@ -10,6 +11,8 @@ import {
instanceUserRoles,
} from "@paperclipai/db";
import { conflict, forbidden, notFound } from "../errors.js";
import { logActivity, publishActivity, type ActivityPublication } from "./activity-log.js";
import { logger } from "../middleware/logger.js";
export const BOARD_API_KEY_TTL_MS = 30 * 24 * 60 * 60 * 1000;
export const CLI_AUTH_CHALLENGE_TTL_MS = 10 * 60 * 1000;
@@ -66,9 +69,9 @@ export function boardAuthService(db: Db) {
touchedBoardApiKeys.delete(oldestId);
}
}
async function resolveBoardAccess(userId: string) {
async function resolveBoardAccess(userId: string, authorityDb: Pick<Db, "select"> = db) {
const [user, memberships, adminRole] = await Promise.all([
db
authorityDb
.select({
id: authUsers.id,
name: authUsers.name,
@@ -77,7 +80,7 @@ export function boardAuthService(db: Db) {
.from(authUsers)
.where(eq(authUsers.id, userId))
.then((rows) => rows[0] ?? null),
db
authorityDb
.select({
companyId: companyMemberships.companyId,
membershipRole: companyMemberships.membershipRole,
@@ -92,7 +95,7 @@ export function boardAuthService(db: Db) {
),
)
.then((rows) => rows),
db
authorityDb
.select({ id: instanceUserRoles.id })
.from(instanceUserRoles)
.where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin")))
@@ -107,44 +110,27 @@ export function boardAuthService(db: Db) {
};
}
async function activityCompanyIdsForAccess(
access: Awaited<ReturnType<typeof resolveBoardAccess>>,
requestedCompanyId: string | null | undefined,
authorityDb: Pick<Db, "select"> = db,
) {
const companyIds = new Set(access.companyIds);
const requested = requestedCompanyId?.trim();
if (requested && (access.isInstanceAdmin || access.companyIds.includes(requested))) companyIds.add(requested);
if (companyIds.size === 0 && access.isInstanceAdmin) {
const allCompanyIds = await authorityDb.select({ id: companies.id }).from(companies);
for (const company of allCompanyIds) companyIds.add(company.id);
}
return Array.from(companyIds);
}
async function resolveBoardActivityCompanyIds(input: {
userId: string;
requestedCompanyId?: string | null;
boardApiKeyId?: string | null;
}) {
const access = await resolveBoardAccess(input.userId);
const companyIds = new Set(access.companyIds);
if (companyIds.size === 0 && input.requestedCompanyId?.trim()) {
companyIds.add(input.requestedCompanyId.trim());
}
if (companyIds.size === 0 && input.boardApiKeyId?.trim()) {
const challengeCompanyIds = await db
.select({ requestedCompanyId: cliAuthChallenges.requestedCompanyId })
.from(cliAuthChallenges)
.where(eq(cliAuthChallenges.boardApiKeyId, input.boardApiKeyId.trim()))
.then((rows) =>
rows
.map((row) => row.requestedCompanyId?.trim() ?? null)
.filter((value): value is string => Boolean(value)),
);
for (const companyId of challengeCompanyIds) {
companyIds.add(companyId);
}
}
if (companyIds.size === 0 && access.isInstanceAdmin) {
const allCompanyIds = await db
.select({ id: companies.id })
.from(companies)
.then((rows) => rows.map((row) => row.id));
for (const companyId of allCompanyIds) {
companyIds.add(companyId);
}
}
return Array.from(companyIds);
return activityCompanyIdsForAccess(await resolveBoardAccess(input.userId), input.requestedCompanyId);
}
async function findBoardApiKeyByToken(token: string) {
@@ -187,14 +173,47 @@ export function boardAuthService(db: Db) {
return inFlight;
}
async function revokeBoardApiKey(id: string) {
const now = new Date();
return db
.update(boardApiKeys)
.set({ revokedAt: now, lastUsedAt: now })
.where(and(eq(boardApiKeys.id, id), isNull(boardApiKeys.revokedAt)))
.returning()
.then((rows) => rows[0] ?? null);
async function revokeBoardApiKey(id: string, input: {
userId: string;
revokedVia: "cli_auth_logout" | "board_api_key_lifecycle";
}) {
const publications: ActivityPublication[] = [];
const result = await db.transaction(async tx => {
await tx.execute(sql`select ${boardApiKeys.id} from ${boardApiKeys}
where ${boardApiKeys.id} = ${id} and ${boardApiKeys.userId} = ${input.userId} for update`);
const [key] = await tx.select().from(boardApiKeys).where(and(eq(boardApiKeys.id, id), eq(boardApiKeys.userId, input.userId)));
if (!key || key.revokedAt) return null;
const access = await resolveBoardAccess(key.userId, tx);
const companyIds = new Set(await activityCompanyIdsForAccess(access, null, tx));
const created = await tx.select({ companyId: activityLog.companyId }).from(activityLog)
.innerJoin(companies, eq(companies.id, activityLog.companyId))
.where(and(eq(activityLog.actorType, "user"), eq(activityLog.actorId, key.userId),
eq(activityLog.responsibleUserId, key.userId), eq(activityLog.entityType, "user"), eq(activityLog.entityId, key.userId),
eq(activityLog.action, "board_api_key.created"), sql`${activityLog.details}->>'boardApiKeyId' = ${key.id}`));
for (const row of created) companyIds.add(row.companyId);
const approved = await tx.select({ companyId: cliAuthChallenges.requestedCompanyId }).from(cliAuthChallenges)
.innerJoin(companies, eq(companies.id, cliAuthChallenges.requestedCompanyId))
.where(and(eq(cliAuthChallenges.boardApiKeyId, key.id), eq(cliAuthChallenges.approvedByUserId, key.userId),
sql`${cliAuthChallenges.approvedAt} is not null`));
for (const row of approved) if (row.companyId) companyIds.add(row.companyId);
const now = new Date();
const [revoked] = await tx.update(boardApiKeys).set({ revokedAt: now, lastUsedAt: now })
.where(and(eq(boardApiKeys.id, key.id), eq(boardApiKeys.userId, input.userId), isNull(boardApiKeys.revokedAt))).returning();
if (!revoked) return null;
for (const companyId of companyIds) {
await logActivity(tx as unknown as Db, {
companyId, actorType: "user", actorId: key.userId, action: "board_api_key.revoked", entityType: "user", entityId: key.userId,
details: { boardApiKeyId: key.id, revokedVia: input.revokedVia,
...(input.revokedVia === "board_api_key_lifecycle" ? { name: key.name } : {}) },
}, publications);
}
return revoked;
});
for (const publication of publications) {
try { publishActivity(publication); }
catch (err) { logger.warn({ err }, "Failed to publish committed Board key revocation activity"); }
}
return result;
}
async function createNamedBoardApiKey(input: {
@@ -322,26 +341,17 @@ export function boardAuthService(db: Db) {
return challenge;
}
async function describeCliAuthChallenge(id: string, token: string) {
async function describeCliAuthChallenge(id: string, token: string, options: { viewerUserId?: string } = {}) {
const challenge = await getCliAuthChallengeBySecret(id, token);
if (!challenge) return null;
const [company, approvedBy] = await Promise.all([
challenge.requestedCompanyId
? db
.select({ id: companies.id, name: companies.name })
.from(companies)
.where(eq(companies.id, challenge.requestedCompanyId))
.then((rows) => rows[0] ?? null)
: Promise.resolve(null),
challenge.approvedByUserId
? db
.select({ id: authUsers.id, name: authUsers.name, email: authUsers.email })
.from(authUsers)
.where(eq(authUsers.id, challenge.approvedByUserId))
.then((rows) => rows[0] ?? null)
: Promise.resolve(null),
]);
const access = options.viewerUserId ? await resolveBoardAccess(options.viewerUserId) : null;
const canReadCompany = challenge.requestedCompanyId && access
&& (access.isInstanceAdmin || access.companyIds.includes(challenge.requestedCompanyId));
const company = canReadCompany
? await db.select({ name: companies.name }).from(companies)
.where(eq(companies.id, challenge.requestedCompanyId!)).then(rows => rows[0] ?? null)
: null;
return {
id: challenge.id,
@@ -354,19 +364,13 @@ export function boardAuthService(db: Db) {
approvedAt: challenge.approvedAt?.toISOString() ?? null,
cancelledAt: challenge.cancelledAt?.toISOString() ?? null,
expiresAt: challenge.expiresAt.toISOString(),
approvedByUser: approvedBy
? {
id: approvedBy.id,
name: approvedBy.name,
email: approvedBy.email,
}
: null,
approvedByUser: null,
};
}
async function approveCliAuthChallenge(id: string, token: string, userId: string) {
const access = await resolveBoardAccess(userId);
return db.transaction(async (tx) => {
const publications: ActivityPublication[] = [];
const result = await db.transaction(async (tx) => {
await tx.execute(
sql`select ${cliAuthChallenges.id} from ${cliAuthChallenges} where ${cliAuthChallenges.id} = ${id} for update`,
);
@@ -384,6 +388,20 @@ export function boardAuthService(db: Db) {
if (status === "expired") return { status, challenge };
if (status === "cancelled") return { status, challenge };
if (status === "approved") {
if (challenge.approvedByUserId !== userId) {
throw conflict("CLI auth challenge was approved by another user");
}
return { status, challenge, created: false };
}
const access = await resolveBoardAccess(userId, tx);
if (!access.user) throw forbidden("Board user required");
if (challenge.requestedCompanyId && !access.isInstanceAdmin
&& !access.companyIds.includes(challenge.requestedCompanyId)) {
throw forbidden("Access to the requested organization is required");
}
if (challenge.requestedAccess === "instance_admin_required" && !access.isInstanceAdmin) {
throw forbidden("Instance admin required");
}
@@ -416,30 +434,44 @@ export function boardAuthService(db: Db) {
.returning()
.then((rows) => rows[0] ?? challenge);
return { status: "approved" as const, challenge: updated };
const companyIds = await activityCompanyIdsForAccess(access, challenge.requestedCompanyId, tx);
for (const companyId of companyIds) {
await logActivity(tx as unknown as Db, {
companyId, actorType: "user", actorId: userId,
action: "board_api_key.created", entityType: "user", entityId: userId,
details: {
boardApiKeyId: updated.boardApiKeyId,
requestedAccess: updated.requestedAccess,
requestedCompanyId: updated.requestedCompanyId,
challengeId: updated.id,
},
}, publications);
}
return { status: "approved" as const, challenge: updated, created: true };
});
for (const publication of publications) {
try { publishActivity(publication); }
catch (err) { logger.warn({ err }, "Failed to publish committed CLI approval activity"); }
}
return result;
}
async function cancelCliAuthChallenge(id: string, token: string) {
const challenge = await getCliAuthChallengeBySecret(id, token);
if (!challenge) throw notFound("CLI auth challenge not found");
const status = challengeStatusForRow(challenge);
if (status === "approved") return { status, challenge };
if (status === "expired") return { status, challenge };
if (status === "cancelled") return { status, challenge };
const updated = await db
.update(cliAuthChallenges)
.set({
cancelledAt: new Date(),
updatedAt: new Date(),
})
.where(eq(cliAuthChallenges.id, challenge.id))
.returning()
.then((rows) => rows[0] ?? challenge);
return { status: "cancelled" as const, challenge: updated };
return db.transaction(async tx => {
await tx.execute(sql`select ${cliAuthChallenges.id} from ${cliAuthChallenges} where ${cliAuthChallenges.id} = ${id} for update`);
const challenge = await tx.select().from(cliAuthChallenges)
.where(eq(cliAuthChallenges.id, id)).then(rows => rows[0] ?? null);
if (!challenge || !tokenHashesMatch(challenge.secretHash, hashBearerToken(token))) {
throw notFound("CLI auth challenge not found");
}
const status = challengeStatusForRow(challenge);
if (status !== "pending") return { status, challenge };
const updated = await tx.update(cliAuthChallenges)
.set({ cancelledAt: new Date(), updatedAt: new Date() })
.where(eq(cliAuthChallenges.id, challenge.id)).returning()
.then(rows => rows[0] ?? challenge);
return { status: "cancelled" as const, challenge: updated };
});
}
async function assertCurrentBoardKey(keyId: string | undefined, userId: string | undefined) {
+154
View File
@@ -1,5 +1,6 @@
// @vitest-environment jsdom
import { act as reactAct } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -7,6 +8,8 @@ import { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { queryKeys } from "../lib/queryKeys";
import { AuthPage } from "./Auth";
import { CliAuthPage } from "./CliAuth";
import { ApiError } from "../api/client";
const getSessionMock = vi.hoisted(() => vi.fn());
const signInEmailMock = vi.hoisted(() => vi.fn());
@@ -310,3 +313,154 @@ describe("AuthPage", () => {
});
});
});
describe("CliAuthPage rate-limit recovery", () => {
const challengeId = "00000000-0000-4000-8000-000000000429";
const challengeToken = "synthetic-cli-approval-fixture-token";
const challengePath = `/api/cli-auth/challenges/${challengeId}?token=${challengeToken}`;
const challengeQueryKey = ["cli-auth-challenge", challengeId, challengeToken];
const fetchCli = vi.fn<typeof fetch>();
let cliContainer: HTMLDivElement;
let cliRoot: ReturnType<typeof createRoot> | undefined;
let cliClient: QueryClient | undefined;
function response(body: unknown, status = 200) {
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
}
function pending(expiresAt = new Date(Date.now() + 10 * 60_000).toISOString()) {
return { id: challengeId, status: "pending", command: "paperclipai auth login", clientName: "paperclipai cli",
requestedAccess: "board", requestedCompanyId: null, requestedCompanyName: null, approvedAt: null,
cancelledAt: null, expiresAt, approvedByUser: null, requiresSignIn: false, canApprove: true, currentUserId: "operator" };
}
async function advance(milliseconds: number) {
await reactAct(async () => { await vi.advanceTimersByTimeAsync(milliseconds); });
await reactAct(async () => { await vi.advanceTimersByTimeAsync(0); });
}
async function mountCli() {
cliClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } });
cliRoot = createRoot(cliContainer);
await reactAct(async () => {
cliRoot!.render(<MemoryRouter initialEntries={[`/cli-auth/${challengeId}?token=${challengeToken}`]}>
<QueryClientProvider client={cliClient!}><Routes>
<Route path="/cli-auth/:id" element={<CliAuthPage />} />
</Routes></QueryClientProvider>
</MemoryRouter>);
});
await advance(0);
}
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-10-15T20:00:00Z"));
fetchCli.mockReset();
vi.stubGlobal("fetch", fetchCli);
getSessionMock.mockResolvedValue({ user: { id: "operator" }, session: { id: "operator-session" } });
cliContainer = document.createElement("div");
document.body.appendChild(cliContainer);
});
afterEach(async () => {
await reactAct(async () => { cliRoot?.unmount(); });
cliClient?.clear();
cliRoot = undefined;
cliClient = undefined;
cliContainer.remove();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it("waits for the real API retry hint and recovers approval without a reload or automatic mutation", async () => {
fetchCli.mockResolvedValueOnce(response({ error: "Too many CLI auth requests", details: { retryAfterSeconds: 2 } }, 429))
.mockResolvedValueOnce(response(pending()));
await mountCli();
expect(fetchCli).toHaveBeenCalledTimes(1);
expect(cliContainer.textContent).toContain("Retrying shortly");
expect(cliContainer.textContent).not.toContain("CLI auth challenge unavailable");
await advance(1999);
expect(fetchCli).toHaveBeenCalledTimes(1);
await advance(1);
expect(fetchCli).toHaveBeenCalledTimes(2);
expect(cliClient!.getQueryState(challengeQueryKey)).toMatchObject({ status: "success", fetchStatus: "idle", data: { status: "pending" } });
await advance(1);
expect(cliContainer.textContent).toContain("Approve Paperclip CLI access");
expect(fetchCli.mock.calls.map(([url, init]) => [String(url), init?.method]))
.toEqual([[challengePath, "GET"], [challengePath, "GET"]]);
});
it.each([400, 401, 403, 404, 410, 500])("does not retry an ordinary approval GET failure with HTTP%s", async status => {
fetchCli.mockImplementation(async () => response({ error: "Challenge unavailable" }, status));
await mountCli();
expect(cliContainer.textContent).toContain("CLI auth challenge unavailable");
expect(cliClient!.getQueryState(challengeQueryKey)?.error).toBeInstanceOf(ApiError);
await advance(10 * 60_000);
expect(fetchCli).toHaveBeenCalledTimes(1);
});
it("uses a nonzero fallback and stops after three retries when the actual challenge expiry is unknown", async () => {
fetchCli.mockImplementation(async () => response({ error: "Too many CLI auth requests" }, 429));
await mountCli();
expect(cliClient!.getQueryData(challengeQueryKey)).toBeUndefined();
await advance(59_999);
expect(fetchCli).toHaveBeenCalledTimes(1);
await advance(1);
expect(fetchCli).toHaveBeenCalledTimes(2);
await advance(60_000);
expect(fetchCli).toHaveBeenCalledTimes(3);
await advance(60_000);
expect(fetchCli).toHaveBeenCalledTimes(4);
expect(cliClient!.getQueryState(challengeQueryKey)).toMatchObject({ status: "error", fetchStatus: "idle", error: { status: 429 } });
await advance(1);
expect(cliContainer.textContent).toContain("CLI auth challenge unavailable");
await advance(10 * 60_000);
expect(fetchCli).toHaveBeenCalledTimes(4);
});
it("does not schedule a hinted retry beyond the actual expiry from an earlier successful read", async () => {
fetchCli.mockResolvedValueOnce(response(pending(new Date(Date.now() + 1500).toISOString())))
.mockImplementation(async () => response({ error: "Too many CLI auth requests", details: { retryAfterSeconds: 2 } }, 429));
await mountCli();
expect(cliContainer.textContent).toContain("Approve Paperclip CLI access");
await reactAct(async () => { void cliClient!.invalidateQueries({ queryKey: challengeQueryKey }); });
await advance(0);
expect(fetchCli).toHaveBeenCalledTimes(2);
expect(cliContainer.textContent).toContain("CLI auth challenge unavailable");
await advance(10 * 60_000);
expect(fetchCli).toHaveBeenCalledTimes(2);
});
it("does not shorten a hint that falls outside the finite page retry window", async () => {
fetchCli.mockImplementation(async () => response({ error: "Too many CLI auth requests", details: { retryAfterSeconds: 600 } }, 429));
await mountCli();
expect(cliContainer.textContent).toContain("CLI auth challenge unavailable");
await advance(20 * 60_000);
expect(fetchCli).toHaveBeenCalledTimes(1);
});
it("retries only the rate-limited read after one operator approval, without resubmitting it", async () => {
let reads = 0;
fetchCli.mockImplementation(async (_url, init) => {
if (init?.method === "POST") return response({ approved: true, status: "approved" });
reads += 1;
if (reads === 2) return response({ error: "Too many CLI auth requests", details: { retryAfterSeconds: 2 } }, 429);
return response({ ...pending(), status: reads > 2 ? "approved" : "pending" });
});
await mountCli();
const approve = [...cliContainer.querySelectorAll("button")].find(button => button.textContent === "Approve CLI access");
expect(approve).toBeDefined();
await reactAct(async () => { approve!.click(); });
await advance(0);
expect(fetchCli.mock.calls.filter(([, init]) => init?.method === "POST")).toHaveLength(1);
expect(reads).toBe(2);
await advance(2000);
expect(reads).toBe(3);
expect(cliClient!.getQueryState(challengeQueryKey)).toMatchObject({ status: "success", fetchStatus: "idle", data: { status: "approved" } });
await advance(1);
expect(cliContainer.textContent).toContain("CLI access approved");
expect(fetchCli.mock.calls.filter(([, init]) => init?.method === "POST").map(([url]) => String(url)))
.toEqual([`/api/cli-auth/challenges/${challengeId}/approve`]);
});
});
+35 -7
View File
@@ -4,9 +4,23 @@ import { Link, useParams, useSearchParams } from "@/lib/router";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { accessApi } from "../api/access";
import { ApiError } from "../api/client";
import { authApi } from "../api/auth";
import { queryKeys } from "../lib/queryKeys";
function cliAuthRetryDelay(error: unknown): number {
const body = error instanceof ApiError && error.body && typeof error.body === "object"
? error.body as { details?: unknown } : null;
const details = body?.details && typeof body.details === "object"
? body.details as { retryAfterSeconds?: unknown } : null;
const milliseconds = typeof details?.retryAfterSeconds === "number" ? details.retryAfterSeconds * 1000 : Number.NaN;
return Number.isFinite(milliseconds) && milliseconds > 0 ? Math.max(1000, milliseconds) : 60_000;
}
function isCliAuthRateLimit(error: unknown): error is ApiError {
return error instanceof ApiError && error.status === 429;
}
export function CliAuthPage() {
const queryClient = useQueryClient();
const params = useParams();
@@ -23,11 +37,20 @@ export function CliAuthPage() {
queryFn: () => authApi.getSession(),
retry: false,
});
const challengeQueryKey = useMemo(() => ["cli-auth-challenge", challengeId, token] as const, [challengeId, token]);
const retryWindowEndsAt = useMemo(() => Date.now() + 10 * 60_000, [challengeId, token]);
const challengeQuery = useQuery({
queryKey: ["cli-auth-challenge", challengeId, token],
queryKey: challengeQueryKey,
queryFn: () => accessApi.getCliAuthChallenge(challengeId, token),
enabled: challengeId.length > 0 && token.length > 0,
retry: false,
retry: (failureCount, error) => {
if (!isCliAuthRateLimit(error) || failureCount >= 3) return false;
const knownChallenge = queryClient.getQueryData<{ expiresAt: string }>(challengeQueryKey);
const knownExpiry = Date.parse(knownChallenge?.expiresAt ?? "");
const deadline = Number.isFinite(knownExpiry) ? Math.min(knownExpiry, retryWindowEndsAt) : retryWindowEndsAt;
return Date.now() + cliAuthRetryDelay(error) < deadline;
},
retryDelay: (_failureCount, error) => cliAuthRetryDelay(error),
});
const approveMutation = useMutation({
@@ -49,8 +72,11 @@ export function CliAuthPage() {
return <div className="mx-auto max-w-xl py-10 text-sm text-destructive">Invalid CLI auth URL.</div>;
}
if (sessionQuery.isLoading || challengeQuery.isLoading) {
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Loading CLI auth challenge...</div>;
const waitingForRateLimit = isCliAuthRateLimit(challengeQuery.failureReason) && challengeQuery.isFetching;
if (sessionQuery.isLoading || challengeQuery.isLoading || waitingForRateLimit) {
return <div role="status" className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">
{waitingForRateLimit ? "Too many CLI approval requests. Retrying shortly..." : "Loading CLI auth challenge..."}
</div>;
}
if (challengeQuery.error) {
@@ -123,7 +149,7 @@ export function CliAuthPage() {
<Card className="block p-6">
<h1 className="text-xl font-semibold">Approve Paperclip CLI access</h1>
<p className="mt-2 text-sm text-muted-foreground">
A local Paperclip CLI process is requesting board access to this instance.
A CLI client is requesting full board access to the organizations you can access on this instance. Approve only if you started this request in your own terminal.
</p>
<div className="mt-5 space-y-3 text-sm">
@@ -143,7 +169,7 @@ export function CliAuthPage() {
</div>
{challenge.requestedCompanyName && (
<div>
<div className="text-muted-foreground">Requested organization</div>
<div className="text-muted-foreground">Organization for audit context</div>
<div className="text-foreground">{challenge.requestedCompanyName}</div>
</div>
)}
@@ -159,7 +185,9 @@ export function CliAuthPage() {
{!challenge.canApprove && (
<p className="mt-4 text-sm text-destructive">
This challenge requires instance-admin access. Sign in with an instance admin account to approve it.
{challenge.requestedAccess === "instance_admin_required"
? "This challenge requires instance-admin access. Sign in with an instance admin account to approve it."
: "You do not have access to the requested organization."}
</p>
)}