diff --git a/cli/src/__tests__/board-auth.test.ts b/cli/src/__tests__/board-auth.test.ts index f86f539e903..d30db701f74 100644 --- a/cli/src/__tests__/board-auth.test.ts +++ b/cli/src/__tests__/board-auth.test.ts @@ -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(); } + }); +}); diff --git a/cli/src/client/board-auth.ts b/cli/src/client/board-auth.ts index 0f6fe8e85eb..12650763548 100644 --- a/cli/src/client/board-auth.ts +++ b/cli/src/client/board-auth.ts @@ -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(url: string, init?: RequestInit): Promise { const headers = new Headers(init?.headers ?? undefined); if (init?.body !== undefined && !headers.has("content-type")) { @@ -168,7 +172,10 @@ async function requestJson(url: string, init?: RequestInit): Promise { 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; @@ -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( - `${apiBase}/api${challenge.pollPath}?token=${encodeURIComponent(challenge.token)}`, - ); + while (Date.now() < expiresAtMs) { + let status: ChallengeStatusResponse; + try { + status = await requestJson( + `${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 }>( diff --git a/doc/CLI.md b/doc/CLI.md index 592621eb514..646a815d1d4 100644 --- a/doc/CLI.md +++ b/doc/CLI.md @@ -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 ` 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. diff --git a/packages/adapter-utils/src/execution-target-stdin-race.test.ts b/packages/adapter-utils/src/execution-target-stdin-race.test.ts index b92e4eb7d28..58802ad5493 100644 --- a/packages/adapter-utils/src/execution-target-stdin-race.test.ts +++ b/packages/adapter-utils/src/execution-target-stdin-race.test.ts @@ -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 | null = null; + async function getProbeLifetimePreloadPath(): Promise { + 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 => 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, + ); }); diff --git a/packages/adapter-utils/src/execution-target.ts b/packages/adapter-utils/src/execution-target.ts index 7a22a29306d..28b3b60826b 100644 --- a/packages/adapter-utils/src/execution-target.ts +++ b/packages/adapter-utils/src/execution-target.ts @@ -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 { diff --git a/server/src/__tests__/cli-auth-routes.test.ts b/server/src/__tests__/cli-auth-routes.test.ts index 1c4834b6dc0..c60cf770d06 100644 --- a/server/src/__tests__/cli-auth-routes.test.ts +++ b/server/src/__tests__/cli-auth-routes.test.ts @@ -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 () => { diff --git a/server/src/__tests__/composio-app-setup.test.ts b/server/src/__tests__/composio-app-setup.test.ts index 5fd447b57cb..134c85bb664 100644 --- a/server/src/__tests__/composio-app-setup.test.ts +++ b/server/src/__tests__/composio-app-setup.test.ts @@ -251,14 +251,21 @@ const support = await getEmbeddedPostgresTestSupport(); const f = await fixture(names); f.activate(); let release!: () => void; const held = new Promise(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(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 () => { diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 1969d8a4bf7..ef707fb1f6f 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -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({ diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 1819dfece20..526bd1a3b42 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -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; 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 }); }); diff --git a/server/src/services/board-auth.test.ts b/server/src/services/board-auth.test.ts index f5849e57bb2..e0cb4b16394 100644 --- a/server/src/services/board-auth.test.ts +++ b/server/src/services/board-auth.test.ts @@ -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; + let temporary: Awaited> | 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(resolve => { locked = resolve; }); + const unlock = new Promise(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(resolve => { prepared = resolve; }); + const unlock = new Promise(resolve => { release = resolve; }); + const gated = Object.create(db) as Db; + gated.transaction = ((callback: Parameters[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(resolve => { prepared = resolve; }); + const unlock = new Promise(resolve => { release = resolve; }); + const gated = Object.create(db) as Db; + gated.transaction = ((callback: Parameters[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(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(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> = []; + let timeout: ReturnType | 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((_, 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(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`); + }); +}); diff --git a/server/src/services/board-auth.ts b/server/src/services/board-auth.ts index 7478ae1af85..8ee2ad93c4a 100644 --- a/server/src/services/board-auth.ts +++ b/server/src/services/board-auth.ts @@ -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) { 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>, + requestedCompanyId: string | null | undefined, + authorityDb: Pick = 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) { diff --git a/ui/src/pages/Auth.test.tsx b/ui/src/pages/Auth.test.tsx index 82bcd9a7386..d3e8e6f331a 100644 --- a/ui/src/pages/Auth.test.tsx +++ b/ui/src/pages/Auth.test.tsx @@ -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(); + let cliContainer: HTMLDivElement; + let cliRoot: ReturnType | 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( + + } /> + + ); + }); + 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`]); + }); +}); diff --git a/ui/src/pages/CliAuth.tsx b/ui/src/pages/CliAuth.tsx index ae706db6be8..ef59f190d5a 100644 --- a/ui/src/pages/CliAuth.tsx +++ b/ui/src/pages/CliAuth.tsx @@ -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
Invalid CLI auth URL.
; } - if (sessionQuery.isLoading || challengeQuery.isLoading) { - return
Loading CLI auth challenge...
; + const waitingForRateLimit = isCliAuthRateLimit(challengeQuery.failureReason) && challengeQuery.isFetching; + if (sessionQuery.isLoading || challengeQuery.isLoading || waitingForRateLimit) { + return
+ {waitingForRateLimit ? "Too many CLI approval requests. Retrying shortly..." : "Loading CLI auth challenge..."} +
; } if (challengeQuery.error) { @@ -123,7 +149,7 @@ export function CliAuthPage() {

Approve Paperclip CLI access

- 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.

@@ -143,7 +169,7 @@ export function CliAuthPage() {
{challenge.requestedCompanyName && (
-
Requested organization
+
Organization for audit context
{challenge.requestedCompanyName}
)} @@ -159,7 +185,9 @@ export function CliAuthPage() { {!challenge.canApprove && (

- 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."}

)}