Skip to content
60 changes: 60 additions & 0 deletions cli/src/__tests__/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,63 @@ describe("PaperclipApiClient", () => {
} satisfies Partial<ApiRequestError>);
});
});

describe("PaperclipApiClient Origin header", () => {
afterEach(() => {
vi.restoreAllMocks();
});

it("sends origin header matching apiBase on POST requests", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ ok: true }), { status: 200 }),
);
vi.stubGlobal("fetch", fetchMock);

const client = new PaperclipApiClient({
apiBase: "http://localhost:3100",
apiKey: "token-123",
});

await client.post("/api/auth/sign-in/email", { email: "a@b.com", password: "x" });

const call = fetchMock.mock.calls[0] as [string, RequestInit];
const headers = call[1].headers as Record<string, string>;
expect(headers.origin).toBe("http://localhost:3100");
});

it("sends origin header on rawPost requests", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ ok: true }), { status: 200 }),
);
vi.stubGlobal("fetch", fetchMock);

const client = new PaperclipApiClient({
apiBase: "https://paperclip.example.com",
sessionToken: "session-abc",
});

await client.rawPost("/api/auth/sign-in/email", { email: "a@b.com", password: "x" });

const call = fetchMock.mock.calls[0] as [string, RequestInit];
const headers = call[1].headers as Record<string, string>;
expect(headers.origin).toBe("https://paperclip.example.com");
});

it("strips trailing slashes from apiBase in origin header", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ ok: true }), { status: 200 }),
);
vi.stubGlobal("fetch", fetchMock);

const client = new PaperclipApiClient({
apiBase: "http://localhost:3100///",
apiKey: "token",
});

await client.post("/api/test", {});

const call = fetchMock.mock.calls[0] as [string, RequestInit];
const headers = call[1].headers as Record<string, string>;
expect(headers.origin).toBe("http://localhost:3100");
});
});
2 changes: 2 additions & 0 deletions cli/src/client/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export class PaperclipApiClient {
const url = buildUrl(this.apiBase, path);
const headers: Record<string, string> = {
accept: "application/json",
origin: this.apiBase,
};
if (body !== undefined) {
headers["content-type"] = "application/json";
Expand All @@ -87,6 +88,7 @@ export class PaperclipApiClient {

const headers: Record<string, string> = {
accept: "application/json",
origin: this.apiBase,
...toStringRecord(init.headers),
};

Expand Down
68 changes: 68 additions & 0 deletions cli/src/commands/auth-bootstrap-ceo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { and, eq, gt, isNull } from "drizzle-orm";
import { createDb, instanceUserRoles, invites } from "@paperclipai/db";
import { loadPaperclipEnvFile } from "../config/env.js";
import { readConfig, resolveConfigPath } from "../config/store.js";
import { PaperclipApiClient } from "../client/http.js";
import { readContext, resolveProfile } from "../client/context.js";

function hashToken(token: string) {
return createHash("sha256").update(token).digest("hex");
Expand Down Expand Up @@ -131,3 +133,69 @@ export async function bootstrapCeoInvite(opts: {
await closableDb.$client?.end?.({ timeout: 5 }).catch(() => undefined);
}
}

export async function claimBootstrapInvite(opts: {
claim: string;
apiKey?: string;
apiBase?: string;
context?: string;
profile?: string;
json?: boolean;
}) {
const token = opts.claim.trim();
if (!token) {
console.error(pc.red("Invite token is required with --claim."));
process.exit(1);
}

const context = readContext(opts.context);
const { profile } = resolveProfile(context, opts.profile);

const apiBase =
opts.apiBase?.trim() ||
process.env.PAPERCLIP_API_URL?.trim() ||
profile.apiBase ||
"http://localhost:3100";
const apiKey =
opts.apiKey?.trim() ||
process.env.PAPERCLIP_API_KEY?.trim() ||
profile.apiKey;
const sessionToken = profile.sessionToken;

if (!apiKey && !sessionToken) {
console.error(
pc.red(
"Authentication required. Provide --api-key <pat>, set PAPERCLIP_API_KEY, or run `paperclipai auth login` first.",
),
);
process.exit(1);
}

const api = new PaperclipApiClient({ apiBase, apiKey, sessionToken });

try {
const result = await api.post<{
inviteId: string;
inviteType: string;
bootstrapAccepted: boolean;
userId: string;
}>(`/api/invites/${encodeURIComponent(token)}/accept`, {
requestType: "human",
});

if (opts.json) {
console.log(JSON.stringify(result, null, 2));
return;
}

console.log(pc.green("Promoted to instance admin."));
if (result?.userId) {
console.log(pc.dim(`User ID: ${result.userId}`));
}
console.log(pc.dim("Verify with: paperclipai auth whoami"));
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.error(pc.red(`Failed to claim bootstrap invite: ${message}`));
process.exit(1);
}
}
Loading
Loading