| 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>; |
| } |
|
|
| export 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({}), |
| }); |
| } |
|
|