mirror of
https://github.com/paperclipai/paperclip
synced 2026-03-25 11:21:48 +00:00
Merge pull request #1635 from paperclipai/pr/pap-768-board-cli-auth
Add browser-based board CLI auth flow
This commit is contained in:
16
cli/src/__tests__/auth-command-registration.test.ts
Normal file
16
cli/src/__tests__/auth-command-registration.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Command } from "commander";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { registerClientAuthCommands } from "../commands/client/auth.js";
|
||||
|
||||
describe("registerClientAuthCommands", () => {
|
||||
it("registers auth commands without duplicate company-id flags", () => {
|
||||
const program = new Command();
|
||||
const auth = program.command("auth");
|
||||
|
||||
expect(() => registerClientAuthCommands(auth)).not.toThrow();
|
||||
|
||||
const login = auth.commands.find((command) => command.name() === "login");
|
||||
expect(login).toBeDefined();
|
||||
expect(login?.options.filter((option) => option.long === "--company-id")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
53
cli/src/__tests__/board-auth.test.ts
Normal file
53
cli/src/__tests__/board-auth.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getStoredBoardCredential,
|
||||
readBoardAuthStore,
|
||||
removeStoredBoardCredential,
|
||||
setStoredBoardCredential,
|
||||
} from "../client/board-auth.js";
|
||||
|
||||
function createTempAuthPath(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-auth-"));
|
||||
return path.join(dir, "auth.json");
|
||||
}
|
||||
|
||||
describe("board auth store", () => {
|
||||
it("returns an empty store when the file does not exist", () => {
|
||||
const authPath = createTempAuthPath();
|
||||
expect(readBoardAuthStore(authPath)).toEqual({
|
||||
version: 1,
|
||||
credentials: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("stores and retrieves credentials by normalized api base", () => {
|
||||
const authPath = createTempAuthPath();
|
||||
setStoredBoardCredential({
|
||||
apiBase: "http://localhost:3100/",
|
||||
token: "token-123",
|
||||
userId: "user-1",
|
||||
storePath: authPath,
|
||||
});
|
||||
|
||||
expect(getStoredBoardCredential("http://localhost:3100", authPath)).toMatchObject({
|
||||
apiBase: "http://localhost:3100",
|
||||
token: "token-123",
|
||||
userId: "user-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("removes stored credentials", () => {
|
||||
const authPath = createTempAuthPath();
|
||||
setStoredBoardCredential({
|
||||
apiBase: "http://localhost:3100",
|
||||
token: "token-123",
|
||||
storePath: authPath,
|
||||
});
|
||||
|
||||
expect(removeStoredBoardCredential("http://localhost:3100", authPath)).toBe(true);
|
||||
expect(getStoredBoardCredential("http://localhost:3100", authPath)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -58,4 +58,26 @@ describe("PaperclipApiClient", () => {
|
||||
details: { issueId: "1" },
|
||||
} satisfies Partial<ApiRequestError>);
|
||||
});
|
||||
|
||||
it("retries once after interactive auth recovery", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ error: "Board access required" }), { status: 403 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const recoverAuth = vi.fn().mockResolvedValue("board-token-123");
|
||||
const client = new PaperclipApiClient({
|
||||
apiBase: "http://localhost:3100",
|
||||
recoverAuth,
|
||||
});
|
||||
|
||||
const result = await client.post<{ ok: boolean }>("/api/test", { hello: "world" });
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(recoverAuth).toHaveBeenCalledOnce();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
const retryHeaders = fetchMock.mock.calls[1]?.[1]?.headers as Record<string, string>;
|
||||
expect(retryHeaders.authorization).toBe("Bearer board-token-123");
|
||||
});
|
||||
});
|
||||
|
||||
282
cli/src/client/board-auth.ts
Normal file
282
cli/src/client/board-auth.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import pc from "picocolors";
|
||||
import { buildCliCommandLabel } from "./command-label.js";
|
||||
import { resolveDefaultCliAuthPath } from "../config/home.js";
|
||||
|
||||
type RequestedAccess = "board" | "instance_admin_required";
|
||||
|
||||
interface BoardAuthCredential {
|
||||
apiBase: string;
|
||||
token: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
userId?: string | null;
|
||||
}
|
||||
|
||||
interface BoardAuthStore {
|
||||
version: 1;
|
||||
credentials: Record<string, BoardAuthCredential>;
|
||||
}
|
||||
|
||||
interface CreateChallengeResponse {
|
||||
id: string;
|
||||
token: string;
|
||||
boardApiToken: string;
|
||||
approvalPath: string;
|
||||
approvalUrl: string | null;
|
||||
pollPath: string;
|
||||
expiresAt: string;
|
||||
suggestedPollIntervalMs: number;
|
||||
}
|
||||
|
||||
interface ChallengeStatusResponse {
|
||||
id: string;
|
||||
status: "pending" | "approved" | "cancelled" | "expired";
|
||||
command: string;
|
||||
clientName: string | null;
|
||||
requestedAccess: RequestedAccess;
|
||||
requestedCompanyId: string | null;
|
||||
requestedCompanyName: string | null;
|
||||
approvedAt: string | null;
|
||||
cancelledAt: string | null;
|
||||
expiresAt: string;
|
||||
approvedByUser: { id: string; name: string; email: string } | null;
|
||||
}
|
||||
|
||||
function defaultBoardAuthStore(): BoardAuthStore {
|
||||
return {
|
||||
version: 1,
|
||||
credentials: {},
|
||||
};
|
||||
}
|
||||
|
||||
function toStringOrNull(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function normalizeApiBase(apiBase: string): string {
|
||||
return apiBase.trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function resolveBoardAuthStorePath(overridePath?: string): string {
|
||||
if (overridePath?.trim()) return path.resolve(overridePath.trim());
|
||||
if (process.env.PAPERCLIP_AUTH_STORE?.trim()) return path.resolve(process.env.PAPERCLIP_AUTH_STORE.trim());
|
||||
return resolveDefaultCliAuthPath();
|
||||
}
|
||||
|
||||
export function readBoardAuthStore(storePath?: string): BoardAuthStore {
|
||||
const filePath = resolveBoardAuthStorePath(storePath);
|
||||
if (!fs.existsSync(filePath)) return defaultBoardAuthStore();
|
||||
|
||||
const raw = JSON.parse(fs.readFileSync(filePath, "utf8")) as Partial<BoardAuthStore> | null;
|
||||
const credentials = raw?.credentials && typeof raw.credentials === "object" ? raw.credentials : {};
|
||||
const normalized: Record<string, BoardAuthCredential> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(credentials)) {
|
||||
if (typeof value !== "object" || value === null) continue;
|
||||
const record = value as unknown as Record<string, unknown>;
|
||||
const apiBase = toStringOrNull(record.apiBase);
|
||||
const token = toStringOrNull(record.token);
|
||||
const createdAt = toStringOrNull(record.createdAt);
|
||||
const updatedAt = toStringOrNull(record.updatedAt);
|
||||
if (!apiBase || !token || !createdAt || !updatedAt) continue;
|
||||
normalized[normalizeApiBase(key)] = {
|
||||
apiBase,
|
||||
token,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
userId: toStringOrNull(record.userId),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
credentials: normalized,
|
||||
};
|
||||
}
|
||||
|
||||
export function writeBoardAuthStore(store: BoardAuthStore, storePath?: string): void {
|
||||
const filePath = resolveBoardAuthStorePath(storePath);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
|
||||
}
|
||||
|
||||
export function getStoredBoardCredential(apiBase: string, storePath?: string): BoardAuthCredential | null {
|
||||
const store = readBoardAuthStore(storePath);
|
||||
return store.credentials[normalizeApiBase(apiBase)] ?? null;
|
||||
}
|
||||
|
||||
export function setStoredBoardCredential(input: {
|
||||
apiBase: string;
|
||||
token: string;
|
||||
userId?: string | null;
|
||||
storePath?: string;
|
||||
}): BoardAuthCredential {
|
||||
const normalizedApiBase = normalizeApiBase(input.apiBase);
|
||||
const store = readBoardAuthStore(input.storePath);
|
||||
const now = new Date().toISOString();
|
||||
const existing = store.credentials[normalizedApiBase];
|
||||
const credential: BoardAuthCredential = {
|
||||
apiBase: normalizedApiBase,
|
||||
token: input.token.trim(),
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
userId: input.userId ?? existing?.userId ?? null,
|
||||
};
|
||||
store.credentials[normalizedApiBase] = credential;
|
||||
writeBoardAuthStore(store, input.storePath);
|
||||
return credential;
|
||||
}
|
||||
|
||||
export function removeStoredBoardCredential(apiBase: string, storePath?: string): boolean {
|
||||
const normalizedApiBase = normalizeApiBase(apiBase);
|
||||
const store = readBoardAuthStore(storePath);
|
||||
if (!store.credentials[normalizedApiBase]) return false;
|
||||
delete store.credentials[normalizedApiBase];
|
||||
writeBoardAuthStore(store, storePath);
|
||||
return true;
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
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")) {
|
||||
headers.set("content-type", "application/json");
|
||||
}
|
||||
if (!headers.has("accept")) {
|
||||
headers.set("accept", "application/json");
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => null);
|
||||
const message =
|
||||
body && typeof body === "object" && typeof (body as { error?: unknown }).error === "string"
|
||||
? (body as { error: string }).error
|
||||
: `Request failed: ${response.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
function openUrl(url: string): boolean {
|
||||
const platform = process.platform;
|
||||
try {
|
||||
if (platform === "darwin") {
|
||||
const child = spawn("open", [url], { detached: true, stdio: "ignore" });
|
||||
child.unref();
|
||||
return true;
|
||||
}
|
||||
if (platform === "win32") {
|
||||
const child = spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" });
|
||||
child.unref();
|
||||
return true;
|
||||
}
|
||||
const child = spawn("xdg-open", [url], { detached: true, stdio: "ignore" });
|
||||
child.unref();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginBoardCli(params: {
|
||||
apiBase: string;
|
||||
requestedAccess: RequestedAccess;
|
||||
requestedCompanyId?: string | null;
|
||||
clientName?: string | null;
|
||||
command?: string;
|
||||
storePath?: string;
|
||||
print?: boolean;
|
||||
}): Promise<{ token: string; approvalUrl: string; userId?: string | null }> {
|
||||
const apiBase = normalizeApiBase(params.apiBase);
|
||||
const createUrl = `${apiBase}/api/cli-auth/challenges`;
|
||||
const command = params.command?.trim() || buildCliCommandLabel();
|
||||
|
||||
const challenge = await requestJson<CreateChallengeResponse>(createUrl, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
command,
|
||||
clientName: params.clientName?.trim() || "paperclipai cli",
|
||||
requestedAccess: params.requestedAccess,
|
||||
requestedCompanyId: params.requestedCompanyId?.trim() || null,
|
||||
}),
|
||||
});
|
||||
|
||||
const approvalUrl = challenge.approvalUrl ?? `${apiBase}${challenge.approvalPath}`;
|
||||
if (params.print !== false) {
|
||||
console.error(pc.bold("Board authentication required"));
|
||||
console.error(`Open this URL in your browser to approve CLI access:\n${approvalUrl}`);
|
||||
}
|
||||
|
||||
const opened = openUrl(approvalUrl);
|
||||
if (params.print !== false && opened) {
|
||||
console.error(pc.dim("Opened the approval page in your browser."));
|
||||
}
|
||||
|
||||
const expiresAtMs = Date.parse(challenge.expiresAt);
|
||||
const pollMs = Math.max(500, challenge.suggestedPollIntervalMs || 1000);
|
||||
|
||||
while (Number.isFinite(expiresAtMs) ? Date.now() < expiresAtMs : true) {
|
||||
const status = await requestJson<ChallengeStatusResponse>(
|
||||
`${apiBase}/api${challenge.pollPath}?token=${encodeURIComponent(challenge.token)}`,
|
||||
);
|
||||
|
||||
if (status.status === "approved") {
|
||||
const me = await requestJson<{ userId: string; user?: { id: string } | null }>(
|
||||
`${apiBase}/api/cli-auth/me`,
|
||||
{
|
||||
headers: {
|
||||
authorization: `Bearer ${challenge.boardApiToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
setStoredBoardCredential({
|
||||
apiBase,
|
||||
token: challenge.boardApiToken,
|
||||
userId: me.userId ?? me.user?.id ?? null,
|
||||
storePath: params.storePath,
|
||||
});
|
||||
return {
|
||||
token: challenge.boardApiToken,
|
||||
approvalUrl,
|
||||
userId: me.userId ?? me.user?.id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
if (status.status === "cancelled") {
|
||||
throw new Error("CLI auth challenge was cancelled.");
|
||||
}
|
||||
if (status.status === "expired") {
|
||||
throw new Error("CLI auth challenge expired before approval.");
|
||||
}
|
||||
|
||||
await sleep(pollMs);
|
||||
}
|
||||
|
||||
throw new Error("CLI auth challenge expired before approval.");
|
||||
}
|
||||
|
||||
export async function revokeStoredBoardCredential(params: {
|
||||
apiBase: string;
|
||||
token: string;
|
||||
}): Promise<void> {
|
||||
const apiBase = normalizeApiBase(params.apiBase);
|
||||
await requestJson<{ revoked: boolean }>(`${apiBase}/api/cli-auth/revoke-current`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${params.token}`,
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
4
cli/src/client/command-label.ts
Normal file
4
cli/src/client/command-label.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export function buildCliCommandLabel(): string {
|
||||
const args = process.argv.slice(2);
|
||||
return args.length > 0 ? `paperclipai ${args.join(" ")}` : "paperclipai";
|
||||
}
|
||||
@@ -17,21 +17,30 @@ interface RequestOptions {
|
||||
ignoreNotFound?: boolean;
|
||||
}
|
||||
|
||||
interface RecoverAuthInput {
|
||||
path: string;
|
||||
method: string;
|
||||
error: ApiRequestError;
|
||||
}
|
||||
|
||||
interface ApiClientOptions {
|
||||
apiBase: string;
|
||||
apiKey?: string;
|
||||
runId?: string;
|
||||
recoverAuth?: (input: RecoverAuthInput) => Promise<string | null>;
|
||||
}
|
||||
|
||||
export class PaperclipApiClient {
|
||||
readonly apiBase: string;
|
||||
readonly apiKey?: string;
|
||||
apiKey?: string;
|
||||
readonly runId?: string;
|
||||
readonly recoverAuth?: (input: RecoverAuthInput) => Promise<string | null>;
|
||||
|
||||
constructor(opts: ApiClientOptions) {
|
||||
this.apiBase = opts.apiBase.replace(/\/+$/, "");
|
||||
this.apiKey = opts.apiKey?.trim() || undefined;
|
||||
this.runId = opts.runId?.trim() || undefined;
|
||||
this.recoverAuth = opts.recoverAuth;
|
||||
}
|
||||
|
||||
get<T>(path: string, opts?: RequestOptions): Promise<T | null> {
|
||||
@@ -56,7 +65,16 @@ export class PaperclipApiClient {
|
||||
return this.request<T>(path, { method: "DELETE" }, opts);
|
||||
}
|
||||
|
||||
private async request<T>(path: string, init: RequestInit, opts?: RequestOptions): Promise<T | null> {
|
||||
setApiKey(apiKey: string | undefined) {
|
||||
this.apiKey = apiKey?.trim() || undefined;
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
opts?: RequestOptions,
|
||||
hasRetriedAuth = false,
|
||||
): Promise<T | null> {
|
||||
const url = buildUrl(this.apiBase, path);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
@@ -86,7 +104,19 @@ export class PaperclipApiClient {
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw await toApiError(response);
|
||||
const apiError = await toApiError(response);
|
||||
if (!hasRetriedAuth && this.recoverAuth) {
|
||||
const recoveredToken = await this.recoverAuth({
|
||||
path,
|
||||
method: String(init.method ?? "GET").toUpperCase(),
|
||||
error: apiError,
|
||||
});
|
||||
if (recoveredToken) {
|
||||
this.setApiKey(recoveredToken);
|
||||
return this.request<T>(path, init, opts, true);
|
||||
}
|
||||
}
|
||||
throw apiError;
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
|
||||
113
cli/src/commands/client/auth.ts
Normal file
113
cli/src/commands/client/auth.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { Command } from "commander";
|
||||
import {
|
||||
getStoredBoardCredential,
|
||||
loginBoardCli,
|
||||
removeStoredBoardCredential,
|
||||
revokeStoredBoardCredential,
|
||||
} from "../../client/board-auth.js";
|
||||
import {
|
||||
addCommonClientOptions,
|
||||
handleCommandError,
|
||||
printOutput,
|
||||
resolveCommandContext,
|
||||
type BaseClientOptions,
|
||||
} from "./common.js";
|
||||
|
||||
interface AuthLoginOptions extends BaseClientOptions {
|
||||
instanceAdmin?: boolean;
|
||||
}
|
||||
|
||||
interface AuthLogoutOptions extends BaseClientOptions {}
|
||||
interface AuthWhoamiOptions extends BaseClientOptions {}
|
||||
|
||||
export function registerClientAuthCommands(auth: Command): void {
|
||||
addCommonClientOptions(
|
||||
auth
|
||||
.command("login")
|
||||
.description("Authenticate the CLI for board-user access")
|
||||
.option("--instance-admin", "Request instance-admin approval instead of plain board access", false)
|
||||
.action(async (opts: AuthLoginOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const login = await loginBoardCli({
|
||||
apiBase: ctx.api.apiBase,
|
||||
requestedAccess: opts.instanceAdmin ? "instance_admin_required" : "board",
|
||||
requestedCompanyId: ctx.companyId ?? null,
|
||||
command: "paperclipai auth login",
|
||||
});
|
||||
printOutput(
|
||||
{
|
||||
ok: true,
|
||||
apiBase: ctx.api.apiBase,
|
||||
userId: login.userId ?? null,
|
||||
approvalUrl: login.approvalUrl,
|
||||
},
|
||||
{ json: ctx.json },
|
||||
);
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
{ includeCompany: true },
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
auth
|
||||
.command("logout")
|
||||
.description("Remove the stored board-user credential for this API base")
|
||||
.action(async (opts: AuthLogoutOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const credential = getStoredBoardCredential(ctx.api.apiBase);
|
||||
if (!credential) {
|
||||
printOutput({ ok: true, apiBase: ctx.api.apiBase, revoked: false, removedLocalCredential: false }, { json: ctx.json });
|
||||
return;
|
||||
}
|
||||
let revoked = false;
|
||||
try {
|
||||
await revokeStoredBoardCredential({
|
||||
apiBase: ctx.api.apiBase,
|
||||
token: credential.token,
|
||||
});
|
||||
revoked = true;
|
||||
} catch {
|
||||
// Remove the local credential even if the server-side revoke fails.
|
||||
}
|
||||
const removedLocalCredential = removeStoredBoardCredential(ctx.api.apiBase);
|
||||
printOutput(
|
||||
{
|
||||
ok: true,
|
||||
apiBase: ctx.api.apiBase,
|
||||
revoked,
|
||||
removedLocalCredential,
|
||||
},
|
||||
{ json: ctx.json },
|
||||
);
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
addCommonClientOptions(
|
||||
auth
|
||||
.command("whoami")
|
||||
.description("Show the current board-user identity for this API base")
|
||||
.action(async (opts: AuthWhoamiOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const me = await ctx.api.get<{
|
||||
user: { id: string; name: string; email: string } | null;
|
||||
userId: string;
|
||||
isInstanceAdmin: boolean;
|
||||
companyIds: string[];
|
||||
source: string;
|
||||
keyId: string | null;
|
||||
}>("/api/cli-auth/me");
|
||||
printOutput(me, { json: ctx.json });
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import pc from "picocolors";
|
||||
import type { Command } from "commander";
|
||||
import { getStoredBoardCredential, loginBoardCli } from "../../client/board-auth.js";
|
||||
import { buildCliCommandLabel } from "../../client/command-label.js";
|
||||
import { readConfig } from "../../config/store.js";
|
||||
import { readContext, resolveProfile, type ClientContextProfile } from "../../client/context.js";
|
||||
import { ApiRequestError, PaperclipApiClient } from "../../client/http.js";
|
||||
@@ -53,10 +55,12 @@ export function resolveCommandContext(
|
||||
profile.apiBase ||
|
||||
inferApiBaseFromConfig(options.config);
|
||||
|
||||
const apiKey =
|
||||
const explicitApiKey =
|
||||
options.apiKey?.trim() ||
|
||||
process.env.PAPERCLIP_API_KEY?.trim() ||
|
||||
readKeyFromProfileEnv(profile);
|
||||
const storedBoardCredential = explicitApiKey ? null : getStoredBoardCredential(apiBase);
|
||||
const apiKey = explicitApiKey || storedBoardCredential?.token;
|
||||
|
||||
const companyId =
|
||||
options.companyId?.trim() ||
|
||||
@@ -69,7 +73,27 @@ export function resolveCommandContext(
|
||||
);
|
||||
}
|
||||
|
||||
const api = new PaperclipApiClient({ apiBase, apiKey });
|
||||
const api = new PaperclipApiClient({
|
||||
apiBase,
|
||||
apiKey,
|
||||
recoverAuth: explicitApiKey || !canAttemptInteractiveBoardAuth()
|
||||
? undefined
|
||||
: async ({ error }) => {
|
||||
const requestedAccess = error.message.includes("Instance admin required")
|
||||
? "instance_admin_required"
|
||||
: "board";
|
||||
if (!shouldRecoverBoardAuth(error)) {
|
||||
return null;
|
||||
}
|
||||
const login = await loginBoardCli({
|
||||
apiBase,
|
||||
requestedAccess,
|
||||
requestedCompanyId: companyId ?? null,
|
||||
command: buildCliCommandLabel(),
|
||||
});
|
||||
return login.token;
|
||||
},
|
||||
});
|
||||
return {
|
||||
api,
|
||||
companyId,
|
||||
@@ -79,6 +103,16 @@ export function resolveCommandContext(
|
||||
};
|
||||
}
|
||||
|
||||
function shouldRecoverBoardAuth(error: ApiRequestError): boolean {
|
||||
if (error.status === 401) return true;
|
||||
if (error.status !== 403) return false;
|
||||
return error.message.includes("Board access required") || error.message.includes("Instance admin required");
|
||||
}
|
||||
|
||||
function canAttemptInteractiveBoardAuth(): boolean {
|
||||
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
||||
}
|
||||
|
||||
export function printOutput(data: unknown, opts: { json?: boolean; label?: string } = {}): void {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
|
||||
@@ -33,6 +33,10 @@ export function resolveDefaultContextPath(): string {
|
||||
return path.resolve(resolvePaperclipHomeDir(), "context.json");
|
||||
}
|
||||
|
||||
export function resolveDefaultCliAuthPath(): string {
|
||||
return path.resolve(resolvePaperclipHomeDir(), "auth.json");
|
||||
}
|
||||
|
||||
export function resolveDefaultEmbeddedPostgresDir(instanceId?: string): string {
|
||||
return path.resolve(resolvePaperclipInstanceRoot(instanceId), "db");
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.
|
||||
import { loadPaperclipEnvFile } from "./config/env.js";
|
||||
import { registerWorktreeCommands } from "./commands/worktree.js";
|
||||
import { registerPluginCommands } from "./commands/client/plugin.js";
|
||||
import { registerClientAuthCommands } from "./commands/client/auth.js";
|
||||
|
||||
const program = new Command();
|
||||
const DATA_DIR_OPTION_HELP =
|
||||
@@ -151,6 +152,8 @@ auth
|
||||
.option("--base-url <url>", "Public base URL used to print invite link")
|
||||
.action(bootstrapCeoInvite);
|
||||
|
||||
registerClientAuthCommands(auth);
|
||||
|
||||
program.parseAsync().catch((err) => {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
|
||||
@@ -154,4 +154,78 @@ describe("applyPendingMigrations", () => {
|
||||
},
|
||||
20_000,
|
||||
);
|
||||
|
||||
it(
|
||||
"replays migration 0044 safely when its schema changes already exist",
|
||||
async () => {
|
||||
const connectionString = await createTempDatabase();
|
||||
|
||||
await applyPendingMigrations(connectionString);
|
||||
|
||||
const sql = postgres(connectionString, { max: 1, onnotice: () => {} });
|
||||
try {
|
||||
const illegalToadHash = await migrationHash("0044_illegal_toad.sql");
|
||||
|
||||
await sql.unsafe(
|
||||
`DELETE FROM "drizzle"."__drizzle_migrations" WHERE hash = '${illegalToadHash}'`,
|
||||
);
|
||||
|
||||
const columns = await sql.unsafe<{ column_name: string }[]>(
|
||||
`
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'instance_settings'
|
||||
AND column_name = 'general'
|
||||
`,
|
||||
);
|
||||
expect(columns).toHaveLength(1);
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
|
||||
const pendingState = await inspectMigrations(connectionString);
|
||||
expect(pendingState).toMatchObject({
|
||||
status: "needsMigrations",
|
||||
pendingMigrations: ["0044_illegal_toad.sql"],
|
||||
reason: "pending-migrations",
|
||||
});
|
||||
|
||||
await applyPendingMigrations(connectionString);
|
||||
|
||||
const finalState = await inspectMigrations(connectionString);
|
||||
expect(finalState.status).toBe("upToDate");
|
||||
},
|
||||
20_000,
|
||||
);
|
||||
|
||||
it(
|
||||
"enforces a unique board_api_keys.key_hash after migration 0044",
|
||||
async () => {
|
||||
const connectionString = await createTempDatabase();
|
||||
|
||||
await applyPendingMigrations(connectionString);
|
||||
|
||||
const sql = postgres(connectionString, { max: 1, onnotice: () => {} });
|
||||
try {
|
||||
await sql.unsafe(`
|
||||
INSERT INTO "user" ("id", "name", "email", "email_verified", "created_at", "updated_at")
|
||||
VALUES ('user-1', 'User One', 'user@example.com', true, now(), now())
|
||||
`);
|
||||
await sql.unsafe(`
|
||||
INSERT INTO "board_api_keys" ("id", "user_id", "name", "key_hash", "created_at")
|
||||
VALUES ('00000000-0000-0000-0000-000000000001', 'user-1', 'Key One', 'dup-hash', now())
|
||||
`);
|
||||
await expect(
|
||||
sql.unsafe(`
|
||||
INSERT INTO "board_api_keys" ("id", "user_id", "name", "key_hash", "created_at")
|
||||
VALUES ('00000000-0000-0000-0000-000000000002', 'user-1', 'Key Two', 'dup-hash', now())
|
||||
`),
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
},
|
||||
20_000,
|
||||
);
|
||||
});
|
||||
|
||||
56
packages/db/src/migrations/0044_illegal_toad.sql
Normal file
56
packages/db/src/migrations/0044_illegal_toad.sql
Normal file
@@ -0,0 +1,56 @@
|
||||
CREATE TABLE IF NOT EXISTS "board_api_keys" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"key_hash" text NOT NULL,
|
||||
"last_used_at" timestamp with time zone,
|
||||
"revoked_at" timestamp with time zone,
|
||||
"expires_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "cli_auth_challenges" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"secret_hash" text NOT NULL,
|
||||
"command" text NOT NULL,
|
||||
"client_name" text,
|
||||
"requested_access" text DEFAULT 'board' NOT NULL,
|
||||
"requested_company_id" uuid,
|
||||
"pending_key_hash" text NOT NULL,
|
||||
"pending_key_name" text NOT NULL,
|
||||
"approved_by_user_id" text,
|
||||
"board_api_key_id" uuid,
|
||||
"approved_at" timestamp with time zone,
|
||||
"cancelled_at" timestamp with time zone,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "instance_settings" ADD COLUMN IF NOT EXISTS "general" jsonb DEFAULT '{}'::jsonb NOT NULL;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'board_api_keys_user_id_user_id_fk') THEN
|
||||
ALTER TABLE "board_api_keys" ADD CONSTRAINT "board_api_keys_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cli_auth_challenges_requested_company_id_companies_id_fk') THEN
|
||||
ALTER TABLE "cli_auth_challenges" ADD CONSTRAINT "cli_auth_challenges_requested_company_id_companies_id_fk" FOREIGN KEY ("requested_company_id") REFERENCES "public"."companies"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cli_auth_challenges_approved_by_user_id_user_id_fk') THEN
|
||||
ALTER TABLE "cli_auth_challenges" ADD CONSTRAINT "cli_auth_challenges_approved_by_user_id_user_id_fk" FOREIGN KEY ("approved_by_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'cli_auth_challenges_board_api_key_id_board_api_keys_id_fk') THEN
|
||||
ALTER TABLE "cli_auth_challenges" ADD CONSTRAINT "cli_auth_challenges_board_api_key_id_board_api_keys_id_fk" FOREIGN KEY ("board_api_key_id") REFERENCES "public"."board_api_keys"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS "board_api_keys_key_hash_idx";--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "board_api_keys_key_hash_idx" ON "board_api_keys" USING btree ("key_hash");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "board_api_keys_user_idx" ON "board_api_keys" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "cli_auth_challenges_secret_hash_idx" ON "cli_auth_challenges" USING btree ("secret_hash");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "cli_auth_challenges_approved_by_idx" ON "cli_auth_challenges" USING btree ("approved_by_user_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "cli_auth_challenges_requested_company_idx" ON "cli_auth_challenges" USING btree ("requested_company_id");
|
||||
11701
packages/db/src/migrations/meta/0044_snapshot.json
Normal file
11701
packages/db/src/migrations/meta/0044_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -309,6 +309,13 @@
|
||||
"when": 1774008910991,
|
||||
"tag": "0043_reflective_captain_universe",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 44,
|
||||
"version": "7",
|
||||
"when": 1774269579794,
|
||||
"tag": "0044_illegal_toad",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
20
packages/db/src/schema/board_api_keys.ts
Normal file
20
packages/db/src/schema/board_api_keys.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { pgTable, uuid, text, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { authUsers } from "./auth.js";
|
||||
|
||||
export const boardApiKeys = pgTable(
|
||||
"board_api_keys",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: text("user_id").notNull().references(() => authUsers.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
keyHash: text("key_hash").notNull(),
|
||||
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
||||
revokedAt: timestamp("revoked_at", { withTimezone: true }),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
keyHashIdx: uniqueIndex("board_api_keys_key_hash_idx").on(table.keyHash),
|
||||
userIdx: index("board_api_keys_user_idx").on(table.userId),
|
||||
}),
|
||||
);
|
||||
30
packages/db/src/schema/cli_auth_challenges.ts
Normal file
30
packages/db/src/schema/cli_auth_challenges.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { pgTable, uuid, text, timestamp, index } from "drizzle-orm/pg-core";
|
||||
import { authUsers } from "./auth.js";
|
||||
import { companies } from "./companies.js";
|
||||
import { boardApiKeys } from "./board_api_keys.js";
|
||||
|
||||
export const cliAuthChallenges = pgTable(
|
||||
"cli_auth_challenges",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
secretHash: text("secret_hash").notNull(),
|
||||
command: text("command").notNull(),
|
||||
clientName: text("client_name"),
|
||||
requestedAccess: text("requested_access").notNull().default("board"),
|
||||
requestedCompanyId: uuid("requested_company_id").references(() => companies.id, { onDelete: "set null" }),
|
||||
pendingKeyHash: text("pending_key_hash").notNull(),
|
||||
pendingKeyName: text("pending_key_name").notNull(),
|
||||
approvedByUserId: text("approved_by_user_id").references(() => authUsers.id, { onDelete: "set null" }),
|
||||
boardApiKeyId: uuid("board_api_key_id").references(() => boardApiKeys.id, { onDelete: "set null" }),
|
||||
approvedAt: timestamp("approved_at", { withTimezone: true }),
|
||||
cancelledAt: timestamp("cancelled_at", { withTimezone: true }),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
secretHashIdx: index("cli_auth_challenges_secret_hash_idx").on(table.secretHash),
|
||||
approvedByIdx: index("cli_auth_challenges_approved_by_idx").on(table.approvedByUserId),
|
||||
requestedCompanyIdx: index("cli_auth_challenges_requested_company_idx").on(table.requestedCompanyId),
|
||||
}),
|
||||
);
|
||||
@@ -4,6 +4,8 @@ export { authUsers, authSessions, authAccounts, authVerifications } from "./auth
|
||||
export { instanceSettings } from "./instance_settings.js";
|
||||
export { instanceUserRoles } from "./instance_user_roles.js";
|
||||
export { agents } from "./agents.js";
|
||||
export { boardApiKeys } from "./board_api_keys.js";
|
||||
export { cliAuthChallenges } from "./cli_auth_challenges.js";
|
||||
export { companyMemberships } from "./company_memberships.js";
|
||||
export { principalPermissionGrants } from "./principal_permission_grants.js";
|
||||
export { invites } from "./invites.js";
|
||||
|
||||
@@ -444,6 +444,9 @@ export {
|
||||
acceptInviteSchema,
|
||||
listJoinRequestsQuerySchema,
|
||||
claimJoinRequestApiKeySchema,
|
||||
boardCliAuthAccessLevelSchema,
|
||||
createCliAuthChallengeSchema,
|
||||
resolveCliAuthChallengeSchema,
|
||||
updateMemberPermissionsSchema,
|
||||
updateUserCompanyAccessSchema,
|
||||
type CreateCostEvent,
|
||||
@@ -455,6 +458,9 @@ export {
|
||||
type AcceptInvite,
|
||||
type ListJoinRequestsQuery,
|
||||
type ClaimJoinRequestApiKey,
|
||||
type BoardCliAuthAccessLevel,
|
||||
type CreateCliAuthChallenge,
|
||||
type ResolveCliAuthChallenge,
|
||||
type UpdateMemberPermissions,
|
||||
type UpdateUserCompanyAccess,
|
||||
companySkillSourceTypeSchema,
|
||||
|
||||
@@ -52,6 +52,28 @@ export const claimJoinRequestApiKeySchema = z.object({
|
||||
|
||||
export type ClaimJoinRequestApiKey = z.infer<typeof claimJoinRequestApiKeySchema>;
|
||||
|
||||
export const boardCliAuthAccessLevelSchema = z.enum([
|
||||
"board",
|
||||
"instance_admin_required",
|
||||
]);
|
||||
|
||||
export type BoardCliAuthAccessLevel = z.infer<typeof boardCliAuthAccessLevelSchema>;
|
||||
|
||||
export const createCliAuthChallengeSchema = z.object({
|
||||
command: z.string().min(1).max(240),
|
||||
clientName: z.string().max(120).optional().nullable(),
|
||||
requestedAccess: boardCliAuthAccessLevelSchema.default("board"),
|
||||
requestedCompanyId: z.string().uuid().optional().nullable(),
|
||||
});
|
||||
|
||||
export type CreateCliAuthChallenge = z.infer<typeof createCliAuthChallengeSchema>;
|
||||
|
||||
export const resolveCliAuthChallengeSchema = z.object({
|
||||
token: z.string().min(16).max(256),
|
||||
});
|
||||
|
||||
export type ResolveCliAuthChallenge = z.infer<typeof resolveCliAuthChallengeSchema>;
|
||||
|
||||
export const updateMemberPermissionsSchema = z.object({
|
||||
grants: z.array(
|
||||
z.object({
|
||||
|
||||
@@ -226,6 +226,9 @@ export {
|
||||
acceptInviteSchema,
|
||||
listJoinRequestsQuerySchema,
|
||||
claimJoinRequestApiKeySchema,
|
||||
boardCliAuthAccessLevelSchema,
|
||||
createCliAuthChallengeSchema,
|
||||
resolveCliAuthChallengeSchema,
|
||||
updateMemberPermissionsSchema,
|
||||
updateUserCompanyAccessSchema,
|
||||
type CreateCompanyInvite,
|
||||
@@ -233,6 +236,9 @@ export {
|
||||
type AcceptInvite,
|
||||
type ListJoinRequestsQuery,
|
||||
type ClaimJoinRequestApiKey,
|
||||
type BoardCliAuthAccessLevel,
|
||||
type CreateCliAuthChallenge,
|
||||
type ResolveCliAuthChallenge,
|
||||
type UpdateMemberPermissions,
|
||||
type UpdateUserCompanyAccess,
|
||||
} from "./access.js";
|
||||
|
||||
@@ -3,7 +3,10 @@ import express from "express";
|
||||
import request from "supertest";
|
||||
import { boardMutationGuard } from "../middleware/board-mutation-guard.js";
|
||||
|
||||
function createApp(actorType: "board" | "agent", boardSource: "session" | "local_implicit" = "session") {
|
||||
function createApp(
|
||||
actorType: "board" | "agent",
|
||||
boardSource: "session" | "local_implicit" | "board_key" = "session",
|
||||
) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
@@ -29,11 +32,26 @@ describe("boardMutationGuard", () => {
|
||||
expect(res.status).toBe(204);
|
||||
});
|
||||
|
||||
it("blocks board mutations without trusted origin", async () => {
|
||||
const app = createApp("board");
|
||||
const res = await request(app).post("/mutate").send({ ok: true });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toEqual({ error: "Board mutation requires trusted browser origin" });
|
||||
it("blocks board mutations without trusted origin", () => {
|
||||
const middleware = boardMutationGuard();
|
||||
const req = {
|
||||
method: "POST",
|
||||
actor: { type: "board", userId: "board", source: "session" },
|
||||
header: () => undefined,
|
||||
} as any;
|
||||
const res = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn(),
|
||||
} as any;
|
||||
const next = vi.fn();
|
||||
|
||||
middleware(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: "Board mutation requires trusted browser origin",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows local implicit board mutations without origin", async () => {
|
||||
@@ -42,6 +60,12 @@ describe("boardMutationGuard", () => {
|
||||
expect(res.status).toBe(204);
|
||||
});
|
||||
|
||||
it("allows board bearer-key mutations without origin", async () => {
|
||||
const app = createApp("board", "board_key");
|
||||
const res = await request(app).post("/mutate").send({ ok: true });
|
||||
expect(res.status).toBe(204);
|
||||
});
|
||||
|
||||
it("allows board mutations from trusted origin", async () => {
|
||||
const app = createApp("board");
|
||||
const res = await request(app)
|
||||
|
||||
230
server/src/__tests__/cli-auth-routes.test.ts
Normal file
230
server/src/__tests__/cli-auth-routes.test.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockAccessService = vi.hoisted(() => ({
|
||||
isInstanceAdmin: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
canUser: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAgentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockBoardAuthService = vi.hoisted(() => ({
|
||||
createCliAuthChallenge: vi.fn(),
|
||||
describeCliAuthChallenge: vi.fn(),
|
||||
approveCliAuthChallenge: vi.fn(),
|
||||
cancelCliAuthChallenge: vi.fn(),
|
||||
resolveBoardAccess: vi.fn(),
|
||||
resolveBoardActivityCompanyIds: vi.fn(),
|
||||
assertCurrentBoardKey: vi.fn(),
|
||||
revokeBoardApiKey: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
boardAuthService: () => mockBoardAuthService,
|
||||
logActivity: mockLogActivity,
|
||||
notifyHireApproved: vi.fn(),
|
||||
deduplicateAgentName: vi.fn((name: string) => name),
|
||||
}));
|
||||
|
||||
function createApp(actor: any) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = actor;
|
||||
next();
|
||||
});
|
||||
return import("../routes/access.js").then(({ accessRoutes }) =>
|
||||
import("../middleware/index.js").then(({ errorHandler }) => {
|
||||
app.use(
|
||||
"/api",
|
||||
accessRoutes({} as any, {
|
||||
deploymentMode: "authenticated",
|
||||
deploymentExposure: "private",
|
||||
bindHost: "127.0.0.1",
|
||||
allowedHostnames: [],
|
||||
}),
|
||||
);
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
describe("cli auth routes", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("creates a CLI auth challenge with approval metadata", async () => {
|
||||
mockBoardAuthService.createCliAuthChallenge.mockResolvedValue({
|
||||
challenge: {
|
||||
id: "challenge-1",
|
||||
expiresAt: new Date("2026-03-23T13:00:00.000Z"),
|
||||
},
|
||||
challengeSecret: "pcp_cli_auth_secret",
|
||||
pendingBoardToken: "pcp_board_token",
|
||||
});
|
||||
|
||||
const app = await createApp({ type: "none", source: "none" });
|
||||
const res = await request(app)
|
||||
.post("/api/cli-auth/challenges")
|
||||
.send({
|
||||
command: "paperclipai company import",
|
||||
clientName: "paperclipai cli",
|
||||
requestedAccess: "board",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toMatchObject({
|
||||
id: "challenge-1",
|
||||
token: "pcp_cli_auth_secret",
|
||||
boardApiToken: "pcp_board_token",
|
||||
approvalPath: "/cli-auth/challenge-1?token=pcp_cli_auth_secret",
|
||||
pollPath: "/cli-auth/challenges/challenge-1",
|
||||
expiresAt: "2026-03-23T13:00:00.000Z",
|
||||
});
|
||||
expect(res.body.approvalUrl).toContain("/cli-auth/challenge-1?token=pcp_cli_auth_secret");
|
||||
});
|
||||
|
||||
it("marks challenge status as requiring sign-in for anonymous viewers", async () => {
|
||||
mockBoardAuthService.describeCliAuthChallenge.mockResolvedValue({
|
||||
id: "challenge-1",
|
||||
status: "pending",
|
||||
command: "paperclipai company import",
|
||||
clientName: "paperclipai cli",
|
||||
requestedAccess: "board",
|
||||
requestedCompanyId: null,
|
||||
requestedCompanyName: null,
|
||||
approvedAt: null,
|
||||
cancelledAt: null,
|
||||
expiresAt: "2026-03-23T13:00:00.000Z",
|
||||
approvedByUser: null,
|
||||
});
|
||||
|
||||
const app = await createApp({ type: "none", source: "none" });
|
||||
const res = await request(app).get("/api/cli-auth/challenges/challenge-1?token=pcp_cli_auth_secret");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.requiresSignIn).toBe(true);
|
||||
expect(res.body.canApprove).toBe(false);
|
||||
});
|
||||
|
||||
it("approves a CLI auth challenge for a signed-in board user", async () => {
|
||||
mockBoardAuthService.approveCliAuthChallenge.mockResolvedValue({
|
||||
status: "approved",
|
||||
challenge: {
|
||||
id: "challenge-1",
|
||||
boardApiKeyId: "board-key-1",
|
||||
requestedAccess: "board",
|
||||
requestedCompanyId: "company-1",
|
||||
expiresAt: new Date("2026-03-23T13:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
mockBoardAuthService.resolveBoardAccess.mockResolvedValue({
|
||||
user: { id: "user-1", name: "User One", email: "user@example.com" },
|
||||
companyIds: ["company-1"],
|
||||
isInstanceAdmin: false,
|
||||
});
|
||||
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-1"]);
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: ["company-1"],
|
||||
});
|
||||
const res = await request(app)
|
||||
.post("/api/cli-auth/challenges/challenge-1/approve")
|
||||
.send({ token: "pcp_cli_auth_secret" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
approved: true,
|
||||
status: "approved",
|
||||
userId: "user-1",
|
||||
keyId: "board-key-1",
|
||||
expiresAt: "2026-03-23T13:00:00.000Z",
|
||||
});
|
||||
expect(mockLogActivity).toHaveBeenCalledTimes(1);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
companyId: "company-1",
|
||||
action: "board_api_key.created",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("logs approve activity for instance admins without company memberships", async () => {
|
||||
mockBoardAuthService.approveCliAuthChallenge.mockResolvedValue({
|
||||
status: "approved",
|
||||
challenge: {
|
||||
id: "challenge-2",
|
||||
boardApiKeyId: "board-key-2",
|
||||
requestedAccess: "instance_admin_required",
|
||||
requestedCompanyId: null,
|
||||
expiresAt: new Date("2026-03-23T13:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-a", "company-b"]);
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "admin-1",
|
||||
source: "session",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [],
|
||||
});
|
||||
const res = await request(app)
|
||||
.post("/api/cli-auth/challenges/challenge-2/approve")
|
||||
.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);
|
||||
});
|
||||
|
||||
it("logs revoke activity with resolved audit company ids", async () => {
|
||||
mockBoardAuthService.assertCurrentBoardKey.mockResolvedValue({
|
||||
id: "board-key-3",
|
||||
userId: "admin-2",
|
||||
});
|
||||
mockBoardAuthService.resolveBoardActivityCompanyIds.mockResolvedValue(["company-z"]);
|
||||
|
||||
const app = await createApp({
|
||||
type: "board",
|
||||
userId: "admin-2",
|
||||
keyId: "board-key-3",
|
||||
source: "board_key",
|
||||
isInstanceAdmin: true,
|
||||
companyIds: [],
|
||||
});
|
||||
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(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
companyId: "company-z",
|
||||
action: "board_api_key.revoked",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -23,11 +23,22 @@ const mockAgentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockBoardAuthService = vi.hoisted(() => ({
|
||||
createCliAuthChallenge: vi.fn(),
|
||||
describeCliAuthChallenge: vi.fn(),
|
||||
approveCliAuthChallenge: vi.fn(),
|
||||
cancelCliAuthChallenge: vi.fn(),
|
||||
resolveBoardAccess: vi.fn(),
|
||||
assertCurrentBoardKey: vi.fn(),
|
||||
revokeBoardApiKey: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
boardAuthService: () => mockBoardAuthService,
|
||||
deduplicateAgentName: vi.fn(),
|
||||
logActivity: mockLogActivity,
|
||||
notifyHireApproved: vi.fn(),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { verifyLocalAgentJwt } from "../agent-auth-jwt.js";
|
||||
import type { DeploymentMode } from "@paperclipai/shared";
|
||||
import type { BetterAuthSessionResult } from "../auth/better-auth.js";
|
||||
import { logger } from "./logger.js";
|
||||
import { boardAuthService } from "../services/board-auth.js";
|
||||
|
||||
function hashToken(token: string) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
@@ -18,6 +19,7 @@ interface ActorMiddlewareOptions {
|
||||
}
|
||||
|
||||
export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHandler {
|
||||
const boardAuth = boardAuthService(db);
|
||||
return async (req, _res, next) => {
|
||||
req.actor =
|
||||
opts.deploymentMode === "local_trusted"
|
||||
@@ -80,6 +82,25 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
|
||||
return;
|
||||
}
|
||||
|
||||
const boardKey = await boardAuth.findBoardApiKeyByToken(token);
|
||||
if (boardKey) {
|
||||
const access = await boardAuth.resolveBoardAccess(boardKey.userId);
|
||||
if (access.user) {
|
||||
await boardAuth.touchBoardApiKey(boardKey.id);
|
||||
req.actor = {
|
||||
type: "board",
|
||||
userId: boardKey.userId,
|
||||
companyIds: access.companyIds,
|
||||
isInstanceAdmin: access.isInstanceAdmin,
|
||||
keyId: boardKey.id,
|
||||
runId: runIdHeader || undefined,
|
||||
source: "board_key",
|
||||
};
|
||||
next();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const tokenHash = hashToken(token);
|
||||
const key = await db
|
||||
.select()
|
||||
|
||||
@@ -49,10 +49,9 @@ export function boardMutationGuard(): RequestHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
// Local-trusted mode uses an implicit board actor for localhost-only development.
|
||||
// In this mode, origin/referer headers can be omitted by some clients for multipart
|
||||
// uploads; do not block those mutations.
|
||||
if (req.actor.source === "local_implicit") {
|
||||
// Local-trusted mode and board bearer keys are not browser-session requests.
|
||||
// In these modes, origin/referer headers can be absent; do not block those mutations.
|
||||
if (req.actor.source === "local_implicit" || req.actor.source === "board_key") {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -19,10 +19,12 @@ import {
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
acceptInviteSchema,
|
||||
createCliAuthChallengeSchema,
|
||||
claimJoinRequestApiKeySchema,
|
||||
createCompanyInviteSchema,
|
||||
createOpenClawInvitePromptSchema,
|
||||
listJoinRequestsQuerySchema,
|
||||
resolveCliAuthChallengeSchema,
|
||||
updateMemberPermissionsSchema,
|
||||
updateUserCompanyAccessSchema,
|
||||
PERMISSION_KEYS
|
||||
@@ -40,6 +42,7 @@ import { validate } from "../middleware/validate.js";
|
||||
import {
|
||||
accessService,
|
||||
agentService,
|
||||
boardAuthService,
|
||||
deduplicateAgentName,
|
||||
logActivity,
|
||||
notifyHireApproved
|
||||
@@ -95,6 +98,10 @@ function requestBaseUrl(req: Request) {
|
||||
return `${proto}://${host}`;
|
||||
}
|
||||
|
||||
function buildCliAuthApprovalPath(challengeId: string, token: string) {
|
||||
return `/cli-auth/${challengeId}?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
function readSkillMarkdown(skillName: string): string | null {
|
||||
const normalized = skillName.trim().toLowerCase();
|
||||
if (
|
||||
@@ -1537,6 +1544,7 @@ export function accessRoutes(
|
||||
) {
|
||||
const router = Router();
|
||||
const access = accessService(db);
|
||||
const boardAuth = boardAuthService(db);
|
||||
const agents = agentService(db);
|
||||
|
||||
async function assertInstanceAdmin(req: Request) {
|
||||
@@ -1594,6 +1602,166 @@ export function accessRoutes(
|
||||
throw conflict("Board claim challenge is no longer available");
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/cli-auth/challenges",
|
||||
validate(createCliAuthChallengeSchema),
|
||||
async (req, res) => {
|
||||
const created = await boardAuth.createCliAuthChallenge(req.body);
|
||||
const approvalPath = buildCliAuthApprovalPath(
|
||||
created.challenge.id,
|
||||
created.challengeSecret,
|
||||
);
|
||||
const baseUrl = requestBaseUrl(req);
|
||||
res.status(201).json({
|
||||
id: created.challenge.id,
|
||||
token: created.challengeSecret,
|
||||
boardApiToken: created.pendingBoardToken,
|
||||
approvalPath,
|
||||
approvalUrl: baseUrl ? `${baseUrl}${approvalPath}` : null,
|
||||
pollPath: `/cli-auth/challenges/${created.challenge.id}`,
|
||||
expiresAt: created.challenge.expiresAt.toISOString(),
|
||||
suggestedPollIntervalMs: 1000,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
router.get("/cli-auth/challenges/:id", 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");
|
||||
const challenge = await boardAuth.describeCliAuthChallenge(id, token);
|
||||
if (!challenge) throw notFound("CLI auth challenge not found");
|
||||
|
||||
const isSignedInBoardUser =
|
||||
req.actor.type === "board" &&
|
||||
(req.actor.source === "session" || isLocalImplicit(req)) &&
|
||||
Boolean(req.actor.userId);
|
||||
const canApprove =
|
||||
isSignedInBoardUser &&
|
||||
(challenge.requestedAccess !== "instance_admin_required" ||
|
||||
isLocalImplicit(req) ||
|
||||
Boolean(req.actor.isInstanceAdmin));
|
||||
|
||||
res.json({
|
||||
...challenge,
|
||||
requiresSignIn: !isSignedInBoardUser,
|
||||
canApprove,
|
||||
currentUserId: req.actor.type === "board" ? req.actor.userId ?? null : null,
|
||||
});
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/cli-auth/challenges/:id/approve",
|
||||
validate(resolveCliAuthChallengeSchema),
|
||||
async (req, res) => {
|
||||
const id = (req.params.id as string).trim();
|
||||
if (
|
||||
req.actor.type !== "board" ||
|
||||
(!req.actor.userId && !isLocalImplicit(req))
|
||||
) {
|
||||
throw unauthorized("Sign in before approving CLI access");
|
||||
}
|
||||
|
||||
const userId = req.actor.userId ?? "local-board";
|
||||
const approved = await boardAuth.approveCliAuthChallenge(
|
||||
id,
|
||||
req.body.token,
|
||||
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,
|
||||
userId,
|
||||
keyId: approved.challenge.boardApiKeyId ?? null,
|
||||
expiresAt: approved.challenge.expiresAt.toISOString(),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/cli-auth/challenges/:id/cancel",
|
||||
validate(resolveCliAuthChallengeSchema),
|
||||
async (req, res) => {
|
||||
const id = (req.params.id as string).trim();
|
||||
const cancelled = await boardAuth.cancelCliAuthChallenge(id, req.body.token);
|
||||
res.json({
|
||||
status: cancelled.status,
|
||||
cancelled: cancelled.status === "cancelled",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
router.get("/cli-auth/me", async (req, res) => {
|
||||
if (req.actor.type !== "board" || !req.actor.userId) {
|
||||
throw unauthorized("Board authentication required");
|
||||
}
|
||||
const accessSnapshot = await boardAuth.resolveBoardAccess(req.actor.userId);
|
||||
res.json({
|
||||
user: accessSnapshot.user,
|
||||
userId: req.actor.userId,
|
||||
isInstanceAdmin: accessSnapshot.isInstanceAdmin,
|
||||
companyIds: accessSnapshot.companyIds,
|
||||
source: req.actor.source ?? "none",
|
||||
keyId: req.actor.source === "board_key" ? req.actor.keyId ?? null : null,
|
||||
});
|
||||
});
|
||||
|
||||
router.post("/cli-auth/revoke-current", async (req, res) => {
|
||||
if (req.actor.type !== "board" || req.actor.source !== "board_key") {
|
||||
throw badRequest("Current board API key context is required");
|
||||
}
|
||||
const key = await boardAuth.assertCurrentBoardKey(
|
||||
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",
|
||||
},
|
||||
});
|
||||
}
|
||||
res.json({ revoked: true, keyId: key.id });
|
||||
});
|
||||
|
||||
async function assertCompanyPermission(
|
||||
req: Request,
|
||||
companyId: string,
|
||||
|
||||
354
server/src/services/board-auth.ts
Normal file
354
server/src/services/board-auth.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { and, eq, isNull, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
authUsers,
|
||||
boardApiKeys,
|
||||
cliAuthChallenges,
|
||||
companies,
|
||||
companyMemberships,
|
||||
instanceUserRoles,
|
||||
} from "@paperclipai/db";
|
||||
import { conflict, forbidden, notFound } from "../errors.js";
|
||||
|
||||
export const BOARD_API_KEY_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
export const CLI_AUTH_CHALLENGE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
export type CliAuthChallengeStatus = "pending" | "approved" | "cancelled" | "expired";
|
||||
|
||||
export function hashBearerToken(token: string) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
export function tokenHashesMatch(left: string, right: string) {
|
||||
const leftBytes = Buffer.from(left, "utf8");
|
||||
const rightBytes = Buffer.from(right, "utf8");
|
||||
return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes);
|
||||
}
|
||||
|
||||
export function createBoardApiToken() {
|
||||
return `pcp_board_${randomBytes(24).toString("hex")}`;
|
||||
}
|
||||
|
||||
export function createCliAuthSecret() {
|
||||
return `pcp_cli_auth_${randomBytes(24).toString("hex")}`;
|
||||
}
|
||||
|
||||
export function boardApiKeyExpiresAt(nowMs: number = Date.now()) {
|
||||
return new Date(nowMs + BOARD_API_KEY_TTL_MS);
|
||||
}
|
||||
|
||||
export function cliAuthChallengeExpiresAt(nowMs: number = Date.now()) {
|
||||
return new Date(nowMs + CLI_AUTH_CHALLENGE_TTL_MS);
|
||||
}
|
||||
|
||||
function challengeStatusForRow(row: typeof cliAuthChallenges.$inferSelect): CliAuthChallengeStatus {
|
||||
if (row.cancelledAt) return "cancelled";
|
||||
if (row.expiresAt.getTime() <= Date.now()) return "expired";
|
||||
if (row.approvedAt && row.boardApiKeyId) return "approved";
|
||||
return "pending";
|
||||
}
|
||||
|
||||
export function boardAuthService(db: Db) {
|
||||
async function resolveBoardAccess(userId: string) {
|
||||
const [user, memberships, adminRole] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: authUsers.id,
|
||||
name: authUsers.name,
|
||||
email: authUsers.email,
|
||||
})
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, userId))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
db
|
||||
.select({ companyId: companyMemberships.companyId })
|
||||
.from(companyMemberships)
|
||||
.where(
|
||||
and(
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.principalId, userId),
|
||||
eq(companyMemberships.status, "active"),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows.map((row) => row.companyId)),
|
||||
db
|
||||
.select({ id: instanceUserRoles.id })
|
||||
.from(instanceUserRoles)
|
||||
.where(and(eq(instanceUserRoles.userId, userId), eq(instanceUserRoles.role, "instance_admin")))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
]);
|
||||
|
||||
return {
|
||||
user,
|
||||
companyIds: memberships,
|
||||
isInstanceAdmin: Boolean(adminRole),
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
async function findBoardApiKeyByToken(token: string) {
|
||||
const tokenHash = hashBearerToken(token);
|
||||
const now = new Date();
|
||||
return db
|
||||
.select()
|
||||
.from(boardApiKeys)
|
||||
.where(
|
||||
and(
|
||||
eq(boardApiKeys.keyHash, tokenHash),
|
||||
isNull(boardApiKeys.revokedAt),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows.find((row) => !row.expiresAt || row.expiresAt.getTime() > now.getTime()) ?? null);
|
||||
}
|
||||
|
||||
async function touchBoardApiKey(id: string) {
|
||||
await db.update(boardApiKeys).set({ lastUsedAt: new Date() }).where(eq(boardApiKeys.id, id));
|
||||
}
|
||||
|
||||
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 createCliAuthChallenge(input: {
|
||||
command: string;
|
||||
clientName?: string | null;
|
||||
requestedAccess: "board" | "instance_admin_required";
|
||||
requestedCompanyId?: string | null;
|
||||
}) {
|
||||
const challengeSecret = createCliAuthSecret();
|
||||
const pendingBoardToken = createBoardApiToken();
|
||||
const expiresAt = cliAuthChallengeExpiresAt();
|
||||
const labelBase = input.clientName?.trim() || "paperclipai cli";
|
||||
const pendingKeyName =
|
||||
input.requestedAccess === "instance_admin_required"
|
||||
? `${labelBase} (instance admin)`
|
||||
: `${labelBase} (board)`;
|
||||
|
||||
const created = await db
|
||||
.insert(cliAuthChallenges)
|
||||
.values({
|
||||
secretHash: hashBearerToken(challengeSecret),
|
||||
command: input.command.trim(),
|
||||
clientName: input.clientName?.trim() || null,
|
||||
requestedAccess: input.requestedAccess,
|
||||
requestedCompanyId: input.requestedCompanyId?.trim() || null,
|
||||
pendingKeyHash: hashBearerToken(pendingBoardToken),
|
||||
pendingKeyName,
|
||||
expiresAt,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
return {
|
||||
challenge: created,
|
||||
challengeSecret,
|
||||
pendingBoardToken,
|
||||
};
|
||||
}
|
||||
|
||||
async function getCliAuthChallenge(id: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(cliAuthChallenges)
|
||||
.where(eq(cliAuthChallenges.id, id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function getCliAuthChallengeBySecret(id: string, token: string) {
|
||||
const challenge = await getCliAuthChallenge(id);
|
||||
if (!challenge) return null;
|
||||
if (!tokenHashesMatch(challenge.secretHash, hashBearerToken(token))) return null;
|
||||
return challenge;
|
||||
}
|
||||
|
||||
async function describeCliAuthChallenge(id: string, token: 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),
|
||||
]);
|
||||
|
||||
return {
|
||||
id: challenge.id,
|
||||
status: challengeStatusForRow(challenge),
|
||||
command: challenge.command,
|
||||
clientName: challenge.clientName ?? null,
|
||||
requestedAccess: challenge.requestedAccess as "board" | "instance_admin_required",
|
||||
requestedCompanyId: challenge.requestedCompanyId ?? null,
|
||||
requestedCompanyName: company?.name ?? null,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
async function approveCliAuthChallenge(id: string, token: string, userId: string) {
|
||||
const access = await resolveBoardAccess(userId);
|
||||
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 === "expired") return { status, challenge };
|
||||
if (status === "cancelled") return { status, challenge };
|
||||
|
||||
if (challenge.requestedAccess === "instance_admin_required" && !access.isInstanceAdmin) {
|
||||
throw forbidden("Instance admin required");
|
||||
}
|
||||
|
||||
let boardKeyId = challenge.boardApiKeyId;
|
||||
if (!boardKeyId) {
|
||||
const createdKey = await tx
|
||||
.insert(boardApiKeys)
|
||||
.values({
|
||||
userId,
|
||||
name: challenge.pendingKeyName,
|
||||
keyHash: challenge.pendingKeyHash,
|
||||
expiresAt: boardApiKeyExpiresAt(),
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
boardKeyId = createdKey.id;
|
||||
}
|
||||
|
||||
const approvedAt = challenge.approvedAt ?? new Date();
|
||||
const updated = await tx
|
||||
.update(cliAuthChallenges)
|
||||
.set({
|
||||
approvedByUserId: userId,
|
||||
boardApiKeyId: boardKeyId,
|
||||
approvedAt,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(cliAuthChallenges.id, challenge.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? challenge);
|
||||
|
||||
return { status: "approved" as const, challenge: updated };
|
||||
});
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
async function assertCurrentBoardKey(keyId: string | undefined, userId: string | undefined) {
|
||||
if (!keyId || !userId) throw conflict("Board API key context is required");
|
||||
const key = await db
|
||||
.select()
|
||||
.from(boardApiKeys)
|
||||
.where(and(eq(boardApiKeys.id, keyId), eq(boardApiKeys.userId, userId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!key || key.revokedAt) throw notFound("Board API key not found");
|
||||
return key;
|
||||
}
|
||||
|
||||
return {
|
||||
resolveBoardAccess,
|
||||
findBoardApiKeyByToken,
|
||||
touchBoardApiKey,
|
||||
revokeBoardApiKey,
|
||||
createCliAuthChallenge,
|
||||
getCliAuthChallengeBySecret,
|
||||
describeCliAuthChallenge,
|
||||
approveCliAuthChallenge,
|
||||
cancelCliAuthChallenge,
|
||||
assertCurrentBoardKey,
|
||||
resolveBoardActivityCompanyIds,
|
||||
};
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export { heartbeatService } from "./heartbeat.js";
|
||||
export { dashboardService } from "./dashboard.js";
|
||||
export { sidebarBadgeService } from "./sidebar-badges.js";
|
||||
export { accessService } from "./access.js";
|
||||
export { boardAuthService } from "./board-auth.js";
|
||||
export { instanceSettingsService } from "./instance-settings.js";
|
||||
export { companyPortabilityService } from "./company-portability.js";
|
||||
export { executionWorkspaceService } from "./execution-workspaces.js";
|
||||
|
||||
2
server/src/types/express.d.ts
vendored
2
server/src/types/express.d.ts
vendored
@@ -12,7 +12,7 @@ declare global {
|
||||
isInstanceAdmin?: boolean;
|
||||
keyId?: string;
|
||||
runId?: string;
|
||||
source?: "local_implicit" | "session" | "agent_key" | "agent_jwt" | "none";
|
||||
source?: "local_implicit" | "session" | "board_key" | "agent_key" | "agent_jwt" | "none";
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import { OrgChart } from "./pages/OrgChart";
|
||||
import { NewAgent } from "./pages/NewAgent";
|
||||
import { AuthPage } from "./pages/Auth";
|
||||
import { BoardClaimPage } from "./pages/BoardClaim";
|
||||
import { CliAuthPage } from "./pages/CliAuth";
|
||||
import { InviteLandingPage } from "./pages/InviteLanding";
|
||||
import { NotFoundPage } from "./pages/NotFound";
|
||||
import { queryKeys } from "./lib/queryKeys";
|
||||
@@ -302,6 +303,7 @@ export function App() {
|
||||
<Routes>
|
||||
<Route path="auth" element={<AuthPage />} />
|
||||
<Route path="board-claim/:token" element={<BoardClaimPage />} />
|
||||
<Route path="cli-auth/:id" element={<CliAuthPage />} />
|
||||
<Route path="invite/:token" element={<InviteLandingPage />} />
|
||||
|
||||
<Route element={<CloudAccessGate />}>
|
||||
|
||||
@@ -64,6 +64,23 @@ type BoardClaimStatus = {
|
||||
claimedByUserId: string | null;
|
||||
};
|
||||
|
||||
type CliAuthChallengeStatus = {
|
||||
id: string;
|
||||
status: "pending" | "approved" | "cancelled" | "expired";
|
||||
command: string;
|
||||
clientName: string | null;
|
||||
requestedAccess: "board" | "instance_admin_required";
|
||||
requestedCompanyId: string | null;
|
||||
requestedCompanyName: string | null;
|
||||
approvedAt: string | null;
|
||||
cancelledAt: string | null;
|
||||
expiresAt: string;
|
||||
approvedByUser: { id: string; name: string; email: string } | null;
|
||||
requiresSignIn: boolean;
|
||||
canApprove: boolean;
|
||||
currentUserId: string | null;
|
||||
};
|
||||
|
||||
type CompanyInviteCreated = {
|
||||
id: string;
|
||||
token: string;
|
||||
@@ -127,4 +144,16 @@ export const accessApi = {
|
||||
|
||||
claimBoard: (token: string, code: string) =>
|
||||
api.post<{ claimed: true; userId: string }>(`/board-claim/${token}/claim`, { code }),
|
||||
|
||||
getCliAuthChallenge: (id: string, token: string) =>
|
||||
api.get<CliAuthChallengeStatus>(`/cli-auth/challenges/${id}?token=${encodeURIComponent(token)}`),
|
||||
|
||||
approveCliAuthChallenge: (id: string, token: string) =>
|
||||
api.post<{ approved: boolean; status: string; userId: string; keyId: string | null; expiresAt: string }>(
|
||||
`/cli-auth/challenges/${id}/approve`,
|
||||
{ token },
|
||||
),
|
||||
|
||||
cancelCliAuthChallenge: (id: string, token: string) =>
|
||||
api.post<{ cancelled: boolean; status: string }>(`/cli-auth/challenges/${id}/cancel`, { token }),
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
toCompanyRelativePath,
|
||||
} from "./company-routes";
|
||||
|
||||
const GLOBAL_SEGMENTS = new Set(["auth", "invite", "board-claim", "docs"]);
|
||||
const GLOBAL_SEGMENTS = new Set(["auth", "invite", "board-claim", "cli-auth", "docs"]);
|
||||
|
||||
export function isRememberableCompanyPath(path: string): boolean {
|
||||
const pathname = path.split("?")[0] ?? "";
|
||||
|
||||
@@ -17,7 +17,7 @@ const BOARD_ROUTE_ROOTS = new Set([
|
||||
"design-guide",
|
||||
]);
|
||||
|
||||
const GLOBAL_ROUTE_ROOTS = new Set(["auth", "invite", "board-claim", "docs", "instance"]);
|
||||
const GLOBAL_ROUTE_ROOTS = new Set(["auth", "invite", "board-claim", "cli-auth", "docs", "instance"]);
|
||||
|
||||
export function normalizeCompanyPrefix(prefix: string): string {
|
||||
return prefix.trim().toUpperCase();
|
||||
|
||||
184
ui/src/pages/CliAuth.tsx
Normal file
184
ui/src/pages/CliAuth.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useParams, useSearchParams } from "@/lib/router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { accessApi } from "../api/access";
|
||||
import { authApi } from "../api/auth";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
|
||||
export function CliAuthPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const params = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const challengeId = (params.id ?? "").trim();
|
||||
const token = (searchParams.get("token") ?? "").trim();
|
||||
const currentPath = useMemo(
|
||||
() => `/cli-auth/${encodeURIComponent(challengeId)}${token ? `?token=${encodeURIComponent(token)}` : ""}`,
|
||||
[challengeId, token],
|
||||
);
|
||||
|
||||
const sessionQuery = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
retry: false,
|
||||
});
|
||||
const challengeQuery = useQuery({
|
||||
queryKey: ["cli-auth-challenge", challengeId, token],
|
||||
queryFn: () => accessApi.getCliAuthChallenge(challengeId, token),
|
||||
enabled: challengeId.length > 0 && token.length > 0,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: () => accessApi.approveCliAuthChallenge(challengeId, token),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
|
||||
await challengeQuery.refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: () => accessApi.cancelCliAuthChallenge(challengeId, token),
|
||||
onSuccess: async () => {
|
||||
await challengeQuery.refetch();
|
||||
},
|
||||
});
|
||||
|
||||
if (!challengeId || !token) {
|
||||
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>;
|
||||
}
|
||||
|
||||
if (challengeQuery.error) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<h1 className="text-lg font-semibold">CLI auth challenge unavailable</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{challengeQuery.error instanceof Error ? challengeQuery.error.message : "Challenge is invalid or expired."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const challenge = challengeQuery.data;
|
||||
if (!challenge) {
|
||||
return <div className="mx-auto max-w-xl py-10 text-sm text-destructive">CLI auth challenge unavailable.</div>;
|
||||
}
|
||||
|
||||
if (challenge.status === "approved") {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<h1 className="text-xl font-semibold">CLI access approved</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
The Paperclip CLI can now finish authentication on the requesting machine.
|
||||
</p>
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
Command: <span className="font-mono text-foreground">{challenge.command}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (challenge.status === "cancelled" || challenge.status === "expired") {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{challenge.status === "expired" ? "CLI auth challenge expired" : "CLI auth challenge cancelled"}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Start the CLI auth flow again from your terminal to generate a new approval request.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (challenge.requiresSignIn || !sessionQuery.data) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<h1 className="text-xl font-semibold">Sign in required</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Sign in or create an account, then return to this page to approve the CLI access request.
|
||||
</p>
|
||||
<Button asChild className="mt-4">
|
||||
<Link to={`/auth?next=${encodeURIComponent(currentPath)}`}>Sign in / Create account</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
<div className="rounded-lg border border-border bg-card 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.
|
||||
</p>
|
||||
|
||||
<div className="mt-5 space-y-3 text-sm">
|
||||
<div>
|
||||
<div className="text-muted-foreground">Command</div>
|
||||
<div className="font-mono text-foreground">{challenge.command}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Client</div>
|
||||
<div className="text-foreground">{challenge.clientName ?? "paperclipai cli"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Requested access</div>
|
||||
<div className="text-foreground">
|
||||
{challenge.requestedAccess === "instance_admin_required" ? "Instance admin" : "Board"}
|
||||
</div>
|
||||
</div>
|
||||
{challenge.requestedCompanyName && (
|
||||
<div>
|
||||
<div className="text-muted-foreground">Requested company</div>
|
||||
<div className="text-foreground">{challenge.requestedCompanyName}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(approveMutation.error || cancelMutation.error) && (
|
||||
<p className="mt-4 text-sm text-destructive">
|
||||
{(approveMutation.error ?? cancelMutation.error) instanceof Error
|
||||
? ((approveMutation.error ?? cancelMutation.error) as Error).message
|
||||
: "Failed to update CLI auth challenge"}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!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.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex gap-3">
|
||||
<Button
|
||||
onClick={() => approveMutation.mutate()}
|
||||
disabled={!challenge.canApprove || approveMutation.isPending || cancelMutation.isPending}
|
||||
>
|
||||
{approveMutation.isPending ? "Approving..." : "Approve CLI access"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => cancelMutation.mutate()}
|
||||
disabled={approveMutation.isPending || cancelMutation.isPending}
|
||||
>
|
||||
{cancelMutation.isPending ? "Cancelling..." : "Cancel"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user