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