From 22eb64afda29c67d1e43170214ee6464e6e36336 Mon Sep 17 00:00:00 2001 From: Rahul Mehra Date: Sat, 14 Mar 2026 02:12:49 +1030 Subject: [PATCH 1/3] feat: support SAMPLE_API_KEY env var for CI/CD authentication When SAMPLE_API_KEY is set, the CLI uses x-api-key header instead of OAuth Bearer tokens. This enables authentication in CI/CD environments where interactive OAuth flows aren't possible. - API key takes precedence over OAuth credentials when both exist - No token refresh needed for API key auth - Updated error messages to mention SAMPLE_API_KEY as an alternative - Added 25 tests covering API key auth for rpc() and rpcUpload() --- src/lib/__tests__/api.test.ts | 131 ++++++++++++++++++++++++++++++++-- src/lib/api.ts | 41 ++++++++--- src/lib/env.ts | 1 + 3 files changed, 159 insertions(+), 14 deletions(-) diff --git a/src/lib/__tests__/api.test.ts b/src/lib/__tests__/api.test.ts index 9cd9aa9..2058c1b 100644 --- a/src/lib/__tests__/api.test.ts +++ b/src/lib/__tests__/api.test.ts @@ -1,12 +1,17 @@ import { describe, it, expect, afterEach, vi } from "vitest"; -vi.mock("../env.ts", () => ({ - env: { +const { mockEnv } = vi.hoisted(() => ({ + mockEnv: { SAMPLEX_API_URL: "http://localhost:9999", SAMPLEX_LOG_LEVEL: "silent", + SAMPLE_API_KEY: "", }, })); +vi.mock("../env.ts", () => ({ + env: mockEnv, +})); + const { mockLoadCredentials, mockSaveCredentials, mockClearCredentials } = vi.hoisted(() => ({ mockLoadCredentials: vi.fn(), mockSaveCredentials: vi.fn(), @@ -61,17 +66,21 @@ function makeResponse( } // --------------------------------------------------------------------------- -// rpc() +// rpc() — OAuth (default, no API key) // --------------------------------------------------------------------------- describe("rpc()", () => { afterEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); + mockEnv.SAMPLE_API_KEY = ""; }); - it("throws 'Not logged in' when credentials are null", async () => { + it("throws 'Not logged in' with API key hint when credentials are null", async () => { mockLoadCredentials.mockReturnValue(null); - await expect(rpc("some.procedure")).rejects.toThrow("Not logged in"); + const err = await rpc("some.procedure").catch((e: Error) => e); + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toContain("Not logged in"); + expect((err as Error).message).toContain("SAMPLE_API_KEY"); }); it("makes an authenticated POST with Bearer token and correct URL", async () => { @@ -141,6 +150,80 @@ describe("rpc()", () => { }); }); +// --------------------------------------------------------------------------- +// rpc() — API key auth (SAMPLE_API_KEY set) +// --------------------------------------------------------------------------- +describe("rpc() with SAMPLE_API_KEY", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + mockEnv.SAMPLE_API_KEY = ""; + }); + + it("uses x-api-key header instead of Bearer token when SAMPLE_API_KEY is set", async () => { + mockEnv.SAMPLE_API_KEY = "sk_test_key_123"; + const spy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(makeResponse(200, { ok: true })); + + await rpc("site.list"); + + const [, init] = spy.mock.calls[0]!; + const headers = init?.headers as Record; + expect(headers["x-api-key"]).toBe("sk_test_key_123"); + expect(headers["Authorization"]).toBeUndefined(); + }); + + it("does not check or require OAuth credentials when API key is set", async () => { + mockEnv.SAMPLE_API_KEY = "sk_test_key_123"; + mockLoadCredentials.mockReturnValue(null); // No OAuth creds + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(makeResponse(200, { ok: true })); + + // Should not throw "Not logged in" — API key is sufficient + await expect(rpc("site.list")).resolves.toEqual({ ok: true }); + expect(mockLoadCredentials).not.toHaveBeenCalled(); + }); + + it("does not attempt token refresh when API key is set", async () => { + mockEnv.SAMPLE_API_KEY = "sk_test_key_123"; + const spy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(makeResponse(200, { data: "ok" })); + + await rpc("site.list"); + + // Only one fetch (the RPC call), no refresh call + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0]![0]).toContain("/api/rpc/"); + }); + + it("on 401 with API key: throws API key error and does not clear credentials", async () => { + mockEnv.SAMPLE_API_KEY = "sk_bad_key"; + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(makeResponse(401, "Unauthorized")); + + const err = await rpc("some.procedure").catch((e: Error) => e); + expect(err).toBeInstanceOf(Error); + expect((err as Error).message).toContain("API key is invalid or expired"); + expect((err as Error).message).toContain("SAMPLE_API_KEY"); + expect(mockClearCredentials).not.toHaveBeenCalled(); + }); + + it("API key takes precedence over existing OAuth credentials", async () => { + mockEnv.SAMPLE_API_KEY = "sk_priority_key"; + mockLoadCredentials.mockReturnValue(validCreds()); // OAuth creds exist + const spy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(makeResponse(200, { ok: true })); + + await rpc("site.deploy"); + + const [, init] = spy.mock.calls[0]!; + const headers = init?.headers as Record; + expect(headers["x-api-key"]).toBe("sk_priority_key"); + expect(headers["Authorization"]).toBeUndefined(); + }); +}); + // --------------------------------------------------------------------------- // rpcUpload() // --------------------------------------------------------------------------- @@ -148,6 +231,7 @@ describe("rpcUpload()", () => { afterEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); + mockEnv.SAMPLE_API_KEY = ""; }); it("throws 'Not logged in' when credentials are null", async () => { @@ -215,6 +299,42 @@ describe("rpcUpload()", () => { }); }); +// --------------------------------------------------------------------------- +// rpcUpload() — API key auth +// --------------------------------------------------------------------------- +describe("rpcUpload() with SAMPLE_API_KEY", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + mockEnv.SAMPLE_API_KEY = ""; + }); + + it("uses x-api-key header for uploads when SAMPLE_API_KEY is set", async () => { + mockEnv.SAMPLE_API_KEY = "sk_upload_key"; + const spy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(makeResponse(200, { json: { id: "abc" }, meta: [] })); + + const blob = new Blob(["file contents"], { type: "text/plain" }); + await rpcUpload("site.upload", { blob, fieldName: "archive" }, { name: "test" }); + + const [, init] = spy.mock.calls[0]!; + const headers = init?.headers as Record; + expect(headers["x-api-key"]).toBe("sk_upload_key"); + expect(headers["Authorization"]).toBeUndefined(); + }); + + it("on 401 with API key: throws API key error", async () => { + mockEnv.SAMPLE_API_KEY = "sk_bad_key"; + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(makeResponse(401, "Unauthorized")); + + const file = { blob: new Blob(["data"]), fieldName: "file" }; + const err = await rpcUpload("upload.file", file).catch((e: Error) => e); + expect((err as Error).message).toContain("API key is invalid or expired"); + expect(mockClearCredentials).not.toHaveBeenCalled(); + }); +}); + // --------------------------------------------------------------------------- // Token refresh (getValidCredentials via rpc) // --------------------------------------------------------------------------- @@ -222,6 +342,7 @@ describe("token refresh", () => { afterEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); + mockEnv.SAMPLE_API_KEY = ""; }); it("uses token as-is when it is far from expiry", async () => { diff --git a/src/lib/api.ts b/src/lib/api.ts index 3290f5f..5e5434c 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,4 +1,5 @@ import { loadCredentials, saveCredentials, clearCredentials } from "./config"; +import { env } from "./env"; import { apiFetch, apiUrl, UPLOAD_TIMEOUT_MS } from "./http"; import { log } from "./logger"; import { CLI_VERSION } from "./version"; @@ -28,9 +29,22 @@ function handleResponseErrors(response: Response): void { checkUpgradeRequired(response); if (response.status === 401) { - clearCredentials(); - throw new Error("Session expired. Run `samplex login` again."); + if (!env.SAMPLE_API_KEY) { + clearCredentials(); + } + throw new Error( + env.SAMPLE_API_KEY + ? "API key is invalid or expired. Check your SAMPLE_API_KEY environment variable." + : "Session expired. Run `samplex login` again.", + ); + } +} + +function getAuthHeaders(): Record { + if (env.SAMPLE_API_KEY) { + return { "x-api-key": env.SAMPLE_API_KEY }; } + throw new Error("No auth method available"); } async function getValidCredentials() { @@ -85,16 +99,25 @@ async function getValidCredentials() { } } -async function requireCredentials() { +async function requireAuth(): Promise> { + // API key takes precedence — no OAuth needed + if (env.SAMPLE_API_KEY) { + log.debug("Using SAMPLE_API_KEY for authentication"); + return getAuthHeaders(); + } + + // Fall back to OAuth credentials const credentials = await getValidCredentials(); if (!credentials) { - throw new Error("Not logged in. Run `samplex login` first."); + throw new Error( + "Not logged in. Run `samplex login` or set the SAMPLE_API_KEY environment variable.", + ); } - return credentials; + return { Authorization: `Bearer ${credentials.accessToken}` }; } export async function rpc(procedure: string, input?: unknown): Promise { - const credentials = await requireCredentials(); + const authHeaders = await requireAuth(); const path = procedure.replaceAll(".", "/"); const url = apiUrl(`/api/rpc/api-reference/${path}`); @@ -103,7 +126,7 @@ export async function rpc(procedure: string, input?: unknown): Prom if (input !== undefined) log.debug(" input:", JSON.stringify(input)); const headers: Record = { - Authorization: `Bearer ${credentials.accessToken}`, + ...authHeaders, }; let body: string | undefined; @@ -136,7 +159,7 @@ export async function rpcUpload( file: { blob: Blob; fieldName: string }, input: Record = {}, ): Promise { - const credentials = await requireCredentials(); + const authHeaders = await requireAuth(); const path = procedure.replaceAll(".", "/"); const url = apiUrl(`/api/rpc/${path}`); @@ -160,7 +183,7 @@ export async function rpcUpload( const response = await apiFetch(url, { method: "POST", - headers: { Authorization: `Bearer ${credentials.accessToken}` }, + headers: authHeaders, body: form, timeoutMs: UPLOAD_TIMEOUT_MS, }); diff --git a/src/lib/env.ts b/src/lib/env.ts index 2372ec0..bc63d1f 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -6,4 +6,5 @@ export const env = { | "warn" | "error" | "silent", + SAMPLE_API_KEY: process.env.SAMPLE_API_KEY || "", }; From bd2fb3b67415b796aa622d66b25672c16a8cfdf3 Mon Sep 17 00:00:00 2001 From: Rahul Mehra Date: Sun, 15 Mar 2026 16:46:22 +1030 Subject: [PATCH 2/3] fix: update the API key to use SAMPLEX naming convention --- src/lib/__tests__/api.test.ts | 40 +++++++++++++++++------------------ src/lib/api.ts | 16 +++++++------- src/lib/env.ts | 2 +- 3 files changed, 29 insertions(+), 29 deletions(-) diff --git a/src/lib/__tests__/api.test.ts b/src/lib/__tests__/api.test.ts index 2058c1b..81c484d 100644 --- a/src/lib/__tests__/api.test.ts +++ b/src/lib/__tests__/api.test.ts @@ -4,7 +4,7 @@ const { mockEnv } = vi.hoisted(() => ({ mockEnv: { SAMPLEX_API_URL: "http://localhost:9999", SAMPLEX_LOG_LEVEL: "silent", - SAMPLE_API_KEY: "", + SAMPLEX_API_KEY: "", }, })); @@ -72,7 +72,7 @@ describe("rpc()", () => { afterEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); - mockEnv.SAMPLE_API_KEY = ""; + mockEnv.SAMPLEX_API_KEY = ""; }); it("throws 'Not logged in' with API key hint when credentials are null", async () => { @@ -80,7 +80,7 @@ describe("rpc()", () => { const err = await rpc("some.procedure").catch((e: Error) => e); expect(err).toBeInstanceOf(Error); expect((err as Error).message).toContain("Not logged in"); - expect((err as Error).message).toContain("SAMPLE_API_KEY"); + expect((err as Error).message).toContain("SAMPLEX_API_KEY"); }); it("makes an authenticated POST with Bearer token and correct URL", async () => { @@ -151,17 +151,17 @@ describe("rpc()", () => { }); // --------------------------------------------------------------------------- -// rpc() — API key auth (SAMPLE_API_KEY set) +// rpc() — API key auth (SAMPLEX_API_KEY set) // --------------------------------------------------------------------------- -describe("rpc() with SAMPLE_API_KEY", () => { +describe("rpc() with SAMPLEX_API_KEY", () => { afterEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); - mockEnv.SAMPLE_API_KEY = ""; + mockEnv.SAMPLEX_API_KEY = ""; }); - it("uses x-api-key header instead of Bearer token when SAMPLE_API_KEY is set", async () => { - mockEnv.SAMPLE_API_KEY = "sk_test_key_123"; + it("uses x-api-key header instead of Bearer token when SAMPLEX_API_KEY is set", async () => { + mockEnv.SAMPLEX_API_KEY = "sk_test_key_123"; const spy = vi .spyOn(globalThis, "fetch") .mockResolvedValueOnce(makeResponse(200, { ok: true })); @@ -175,7 +175,7 @@ describe("rpc() with SAMPLE_API_KEY", () => { }); it("does not check or require OAuth credentials when API key is set", async () => { - mockEnv.SAMPLE_API_KEY = "sk_test_key_123"; + mockEnv.SAMPLEX_API_KEY = "sk_test_key_123"; mockLoadCredentials.mockReturnValue(null); // No OAuth creds vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(makeResponse(200, { ok: true })); @@ -185,7 +185,7 @@ describe("rpc() with SAMPLE_API_KEY", () => { }); it("does not attempt token refresh when API key is set", async () => { - mockEnv.SAMPLE_API_KEY = "sk_test_key_123"; + mockEnv.SAMPLEX_API_KEY = "sk_test_key_123"; const spy = vi .spyOn(globalThis, "fetch") .mockResolvedValueOnce(makeResponse(200, { data: "ok" })); @@ -198,18 +198,18 @@ describe("rpc() with SAMPLE_API_KEY", () => { }); it("on 401 with API key: throws API key error and does not clear credentials", async () => { - mockEnv.SAMPLE_API_KEY = "sk_bad_key"; + mockEnv.SAMPLEX_API_KEY = "sk_bad_key"; vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(makeResponse(401, "Unauthorized")); const err = await rpc("some.procedure").catch((e: Error) => e); expect(err).toBeInstanceOf(Error); expect((err as Error).message).toContain("API key is invalid or expired"); - expect((err as Error).message).toContain("SAMPLE_API_KEY"); + expect((err as Error).message).toContain("SAMPLEX_API_KEY"); expect(mockClearCredentials).not.toHaveBeenCalled(); }); it("API key takes precedence over existing OAuth credentials", async () => { - mockEnv.SAMPLE_API_KEY = "sk_priority_key"; + mockEnv.SAMPLEX_API_KEY = "sk_priority_key"; mockLoadCredentials.mockReturnValue(validCreds()); // OAuth creds exist const spy = vi .spyOn(globalThis, "fetch") @@ -231,7 +231,7 @@ describe("rpcUpload()", () => { afterEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); - mockEnv.SAMPLE_API_KEY = ""; + mockEnv.SAMPLEX_API_KEY = ""; }); it("throws 'Not logged in' when credentials are null", async () => { @@ -302,15 +302,15 @@ describe("rpcUpload()", () => { // --------------------------------------------------------------------------- // rpcUpload() — API key auth // --------------------------------------------------------------------------- -describe("rpcUpload() with SAMPLE_API_KEY", () => { +describe("rpcUpload() with SAMPLEX_API_KEY", () => { afterEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); - mockEnv.SAMPLE_API_KEY = ""; + mockEnv.SAMPLEX_API_KEY = ""; }); - it("uses x-api-key header for uploads when SAMPLE_API_KEY is set", async () => { - mockEnv.SAMPLE_API_KEY = "sk_upload_key"; + it("uses x-api-key header for uploads when SAMPLEX_API_KEY is set", async () => { + mockEnv.SAMPLEX_API_KEY = "sk_upload_key"; const spy = vi .spyOn(globalThis, "fetch") .mockResolvedValueOnce(makeResponse(200, { json: { id: "abc" }, meta: [] })); @@ -325,7 +325,7 @@ describe("rpcUpload() with SAMPLE_API_KEY", () => { }); it("on 401 with API key: throws API key error", async () => { - mockEnv.SAMPLE_API_KEY = "sk_bad_key"; + mockEnv.SAMPLEX_API_KEY = "sk_bad_key"; vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(makeResponse(401, "Unauthorized")); const file = { blob: new Blob(["data"]), fieldName: "file" }; @@ -342,7 +342,7 @@ describe("token refresh", () => { afterEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); - mockEnv.SAMPLE_API_KEY = ""; + mockEnv.SAMPLEX_API_KEY = ""; }); it("uses token as-is when it is far from expiry", async () => { diff --git a/src/lib/api.ts b/src/lib/api.ts index 5e5434c..f2b1100 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -29,20 +29,20 @@ function handleResponseErrors(response: Response): void { checkUpgradeRequired(response); if (response.status === 401) { - if (!env.SAMPLE_API_KEY) { + if (!env.SAMPLEX_API_KEY) { clearCredentials(); } throw new Error( - env.SAMPLE_API_KEY - ? "API key is invalid or expired. Check your SAMPLE_API_KEY environment variable." + env.SAMPLEX_API_KEY + ? "API key is invalid or expired. Check your SAMPLEX_API_KEY environment variable." : "Session expired. Run `samplex login` again.", ); } } function getAuthHeaders(): Record { - if (env.SAMPLE_API_KEY) { - return { "x-api-key": env.SAMPLE_API_KEY }; + if (env.SAMPLEX_API_KEY) { + return { "x-api-key": env.SAMPLEX_API_KEY }; } throw new Error("No auth method available"); } @@ -101,8 +101,8 @@ async function getValidCredentials() { async function requireAuth(): Promise> { // API key takes precedence — no OAuth needed - if (env.SAMPLE_API_KEY) { - log.debug("Using SAMPLE_API_KEY for authentication"); + if (env.SAMPLEX_API_KEY) { + log.debug("Using SAMPLEX_API_KEY for authentication"); return getAuthHeaders(); } @@ -110,7 +110,7 @@ async function requireAuth(): Promise> { const credentials = await getValidCredentials(); if (!credentials) { throw new Error( - "Not logged in. Run `samplex login` or set the SAMPLE_API_KEY environment variable.", + "Not logged in. Run `samplex login` or set the SAMPLEX_API_KEY environment variable.", ); } return { Authorization: `Bearer ${credentials.accessToken}` }; diff --git a/src/lib/env.ts b/src/lib/env.ts index bc63d1f..03bc604 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -6,5 +6,5 @@ export const env = { | "warn" | "error" | "silent", - SAMPLE_API_KEY: process.env.SAMPLE_API_KEY || "", + SAMPLEX_API_KEY: process.env.SAMPLEX_API_KEY || "", }; From bf3c0535fac5e186d42e100c4f83f9b2cc04356d Mon Sep 17 00:00:00 2001 From: Rahul Mehra Date: Sun, 15 Mar 2026 16:56:01 +1030 Subject: [PATCH 3/3] fix: inline getAuthHeaders, add missing API key test coverage - Remove dead-code getAuthHeaders() helper, inline into requireAuth() - Add SAMPLEX_API_KEY assertion to rpcUpload 401 test for consistency - Add non-401 error tests (500) for both rpc and rpcUpload with API key --- src/lib/__tests__/api.test.ts | 29 +++++++++++++++++++++++++++++ src/lib/api.ts | 9 +-------- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/lib/__tests__/api.test.ts b/src/lib/__tests__/api.test.ts index 81c484d..43fb224 100644 --- a/src/lib/__tests__/api.test.ts +++ b/src/lib/__tests__/api.test.ts @@ -331,6 +331,35 @@ describe("rpcUpload() with SAMPLEX_API_KEY", () => { const file = { blob: new Blob(["data"]), fieldName: "file" }; const err = await rpcUpload("upload.file", file).catch((e: Error) => e); expect((err as Error).message).toContain("API key is invalid or expired"); + expect((err as Error).message).toContain("SAMPLEX_API_KEY"); + expect(mockClearCredentials).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// rpc() / rpcUpload() — non-401 errors with API key +// --------------------------------------------------------------------------- +describe("non-401 errors with SAMPLEX_API_KEY", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + mockEnv.SAMPLEX_API_KEY = ""; + }); + + it("rpc: on 500 with API key, throws with response body text", async () => { + mockEnv.SAMPLEX_API_KEY = "sk_test_key_123"; + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(makeResponse(500, "Internal Server Error")); + + await expect(rpc("some.procedure")).rejects.toThrow("Internal Server Error"); + expect(mockClearCredentials).not.toHaveBeenCalled(); + }); + + it("rpcUpload: on 500 with API key, throws with response body text", async () => { + mockEnv.SAMPLEX_API_KEY = "sk_test_key_123"; + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(makeResponse(500, "Internal Server Error")); + + const file = { blob: new Blob(["data"]), fieldName: "file" }; + await expect(rpcUpload("upload.file", file)).rejects.toThrow("Internal Server Error"); expect(mockClearCredentials).not.toHaveBeenCalled(); }); }); diff --git a/src/lib/api.ts b/src/lib/api.ts index f2b1100..4179807 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -40,13 +40,6 @@ function handleResponseErrors(response: Response): void { } } -function getAuthHeaders(): Record { - if (env.SAMPLEX_API_KEY) { - return { "x-api-key": env.SAMPLEX_API_KEY }; - } - throw new Error("No auth method available"); -} - async function getValidCredentials() { const credentials = loadCredentials(); if (!credentials) return null; @@ -103,7 +96,7 @@ async function requireAuth(): Promise> { // API key takes precedence — no OAuth needed if (env.SAMPLEX_API_KEY) { log.debug("Using SAMPLEX_API_KEY for authentication"); - return getAuthHeaders(); + return { "x-api-key": env.SAMPLEX_API_KEY }; } // Fall back to OAuth credentials