Merge pull request #1635 from paperclipai/pr/pap-768-board-cli-auth

Add browser-based board CLI auth flow
This commit is contained in:
Dotta
2026-03-23 08:47:32 -05:00
committed by GitHub
34 changed files with 13527 additions and 19 deletions

View 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);
});
});

View 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();
});
});

View File

@@ -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");
});
});

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

View File

@@ -0,0 +1,4 @@
export function buildCliCommandLabel(): string {
const args = process.argv.slice(2);
return args.length > 0 ? `paperclipai ${args.join(" ")}` : "paperclipai";
}

View File

@@ -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) {

View 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);
}
}),
);
}

View File

@@ -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));

View File

@@ -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");
}

View File

@@ -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);

View File

@@ -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,
);
});

View 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");

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
]
}
}

View 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),
}),
);

View 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),
}),
);

View File

@@ -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";

View File

@@ -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,

View File

@@ -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({

View File

@@ -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";

View File

@@ -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)

View 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",
}),
);
});
});

View File

@@ -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(),

View File

@@ -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()

View File

@@ -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;
}

View File

@@ -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,

View 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,
};
}

View File

@@ -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";

View File

@@ -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";
};
}
}

View File

@@ -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 />}>

View File

@@ -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 }),
};

View File

@@ -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] ?? "";

View File

@@ -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
View 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>
);
}