diff --git a/packages/cli/src/commands/__tests__/env.test.ts b/packages/cli/src/commands/__tests__/env.test.ts index 974428c1..cdd0922c 100644 --- a/packages/cli/src/commands/__tests__/env.test.ts +++ b/packages/cli/src/commands/__tests__/env.test.ts @@ -53,15 +53,22 @@ describe("env.ts", () => { it("should update secret when valid secret key is provided", async () => { vi.mocked(promptApiToken).mockResolvedValue("mock-api-token"); const mockSetSecrets = vi.fn().mockResolvedValue(true); + const mockGetAccountId = vi + .fn() + .mockResolvedValue("1234567890abcdef1234567890abcdef"); vi.mocked(CloudflareClient).mockImplementation(function () { return { setCloudflareSecrets: mockSetSecrets, + getAccountId: mockGetAccountId, } as any; }); await envCommand("token"); + expect(promptApiToken).toHaveBeenCalledWith( + "1234567890abcdef1234567890abcdef", + ); expect(mockSetSecrets).toHaveBeenCalledWith({ CF_BEARER_TOKEN: "mock-api-token", }); @@ -73,10 +80,14 @@ describe("env.ts", () => { ); vi.mocked(getScriptSnippet).mockReturnValue("mock-snippet"); const mockSetSecrets = vi.fn().mockResolvedValue(true); + const mockGetAccountId = vi + .fn() + .mockResolvedValue("1234567890abcdef1234567890abcdef"); vi.mocked(CloudflareClient).mockImplementation(function () { return { setCloudflareSecrets: mockSetSecrets, + getAccountId: mockGetAccountId, } as any; }); @@ -86,6 +97,8 @@ describe("env.ts", () => { await envCommand("tracker-script"); + expect(mockGetAccountId).not.toHaveBeenCalled(); + expect(promptTrackerScriptName).toHaveBeenCalledWith(undefined); expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining("Use this HTML snippet"), ); @@ -121,10 +134,14 @@ describe("env.ts", () => { vi.mocked(select).mockResolvedValue("token"); vi.mocked(promptApiToken).mockResolvedValue("mock-api-token"); const mockSetSecrets = vi.fn().mockResolvedValue(true); + const mockGetAccountId = vi + .fn() + .mockResolvedValue("1234567890abcdef1234567890abcdef"); vi.mocked(CloudflareClient).mockImplementation(function () { return { setCloudflareSecrets: mockSetSecrets, + getAccountId: mockGetAccountId, } as any; }); @@ -165,10 +182,14 @@ describe("env.ts", () => { it("should handle secret update failure", async () => { vi.mocked(promptApiToken).mockResolvedValue("mock-api-token"); const mockSetSecrets = vi.fn().mockResolvedValue(false); + const mockGetAccountId = vi + .fn() + .mockResolvedValue("1234567890abcdef1234567890abcdef"); vi.mocked(CloudflareClient).mockImplementation(function () { return { setCloudflareSecrets: mockSetSecrets, + getAccountId: mockGetAccountId, } as any; }); @@ -196,6 +217,15 @@ describe("env.ts", () => { vi.mocked(promptApiToken).mockRejectedValue( new Error("Prompt error"), ); + const mockGetAccountId = vi + .fn() + .mockResolvedValue("1234567890abcdef1234567890abcdef"); + + vi.mocked(CloudflareClient).mockImplementation(function () { + return { + getAccountId: mockGetAccountId, + } as any; + }); const consoleSpy = vi .spyOn(console, "error") .mockImplementation(() => {}); @@ -217,6 +247,48 @@ describe("env.ts", () => { processSpy.mockRestore(); }); + it("should fall back to user-token validation when account ID lookup fails", async () => { + vi.mocked(promptApiToken).mockResolvedValue("mock-api-token"); + const mockSetSecrets = vi.fn().mockResolvedValue(true); + const mockGetAccountId = vi + .fn() + .mockRejectedValue(new Error("Not authenticated")); + + vi.mocked(CloudflareClient).mockImplementation(function () { + return { + setCloudflareSecrets: mockSetSecrets, + getAccountId: mockGetAccountId, + } as any; + }); + + await envCommand("token"); + + expect(promptApiToken).toHaveBeenCalledWith(undefined); + expect(mockSetSecrets).toHaveBeenCalledWith({ + CF_BEARER_TOKEN: "mock-api-token", + }); + }); + + it("should fall back to user-token validation when no account ID is found", async () => { + vi.mocked(promptApiToken).mockResolvedValue("mock-api-token"); + const mockSetSecrets = vi.fn().mockResolvedValue(true); + const mockGetAccountId = vi.fn().mockResolvedValue(null); + + vi.mocked(CloudflareClient).mockImplementation(function () { + return { + setCloudflareSecrets: mockSetSecrets, + getAccountId: mockGetAccountId, + } as any; + }); + + await envCommand("token"); + + expect(promptApiToken).toHaveBeenCalledWith(undefined); + expect(mockSetSecrets).toHaveBeenCalledWith({ + CF_BEARER_TOKEN: "mock-api-token", + }); + }); + it("should throw error when secret configuration not found", async () => { vi.mocked(select).mockResolvedValue("nonexistent"); vi.mocked(isCancel).mockReturnValue(false); diff --git a/packages/cli/src/commands/__tests__/install.test.ts b/packages/cli/src/commands/__tests__/install.test.ts index a87c7751..fb8c94be 100644 --- a/packages/cli/src/commands/__tests__/install.test.ts +++ b/packages/cli/src/commands/__tests__/install.test.ts @@ -11,6 +11,8 @@ vi.mock("@clack/prompts", () => ({ text: vi.fn(), select: vi.fn(), intro: vi.fn(), + note: vi.fn(), + outro: vi.fn(), spinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn(), @@ -20,6 +22,24 @@ vi.mock("@clack/prompts", () => ({ }, })); +vi.mock("../../lib/config.js"); + +vi.mock("../../lib/ui.js", () => ({ + CLI_COLORS: { + orange: [245, 107, 61], + tan: [243, 227, 190], + teal: [0, 205, 205], + }, + MIN_PASSWORD_LENGTH: 8, + getTitle: vi.fn(), + highlightTheme: {}, + getScriptSnippet: vi.fn(), + getPackageSnippet: vi.fn(), + promptForPassword: vi.fn(), + promptApiToken: vi.fn(), + promptTrackerScriptName: vi.fn(), +})); + vi.mock("../../lib/cloudflare.js", () => { const mockValidateToken = vi.fn(); const MockCloudflareClient = vi.fn().mockImplementation(() => ({ @@ -37,15 +57,22 @@ vi.mock("../../lib/cloudflare.js", () => { }); // Now import the actual modules -import { isCancel } from "@clack/prompts"; +import { isCancel, note, confirm, spinner } from "@clack/prompts"; // Import after mocks are set up import { promptDeploy, promptProjectConfig, promptAccountSelection, + install, type AccountInfo, } from "../install.js"; +import { CloudflareClient } from "../../lib/cloudflare.js"; +import { + getWorkerAndDatasetName, + stageDeployConfig, +} from "../../lib/config.js"; +import { promptApiToken } from "../../lib/ui.js"; describe("install prompts", () => { let mockExit: ReturnType; @@ -258,6 +285,60 @@ describe("install prompts", () => { }); }); + describe("install", () => { + it("should prompt for an API token with the selected account ID when CF_BEARER_TOKEN is missing", async () => { + const accountId = "1234567890abcdef1234567890abcdef"; + const apiToken = "m".repeat(40); + + vi.mocked(spinner).mockImplementation( + () => + ({ + start: vi.fn(), + stop: vi.fn(), + }) as any, + ); + + const mockSetSecrets = vi.fn().mockResolvedValue(true); + vi.mocked(CloudflareClient).mockImplementation(function () { + return { + getAccounts: vi + .fn() + .mockResolvedValue([ + { id: accountId, name: "Test Account" }, + ]), + getCloudflareSecrets: vi.fn().mockResolvedValue({ + CF_AUTH_ENABLED: "true", + CF_PASSWORD_HASH: "hash", + CF_JWT_SECRET: "secret", + }), + setCloudflareSecrets: mockSetSecrets, + deploy: vi.fn(), + } as any; + }); + + vi.mocked(getWorkerAndDatasetName).mockReturnValue({ + workerName: "counterscale", + analyticsDataset: "metricsDataset", + }); + vi.mocked(stageDeployConfig).mockResolvedValue(undefined); + vi.mocked(promptApiToken).mockResolvedValue(apiToken); + vi.mocked(confirm).mockResolvedValue(false); + + await install({} as any, "/mock/server/dir", { version: "3.5.0" }); + + expect(promptApiToken).toHaveBeenCalledWith(accountId); + expect(note).toHaveBeenCalledWith( + expect.stringContaining( + `https://dash.cloudflare.com/${accountId}/api-tokens`, + ), + ); + expect(mockSetSecrets).toHaveBeenCalledWith({ + CF_ACCOUNT_ID: accountId, + CF_BEARER_TOKEN: apiToken, + }); + }); + }); + describe("account selection logic", () => { it("should handle single account case", () => { const mockAccounts: AccountInfo[] = [ diff --git a/packages/cli/src/commands/env.ts b/packages/cli/src/commands/env.ts index a1bf345f..b6e7d7fd 100644 --- a/packages/cli/src/commands/env.ts +++ b/packages/cli/src/commands/env.ts @@ -14,7 +14,7 @@ interface SecretConfig { key: SupportedSecret; name: string; description: string; - prompt: () => Promise; + prompt: (accountId?: string) => Promise; } export const SECRETS_BY_ALIAS = new Map([ @@ -89,7 +89,16 @@ export async function envCommand(secretKey?: string) { console.log(`Updating ${selectedSecret.name}...`); - const secretValue = await selectedSecret.prompt(); + let accountId: string | undefined; + if (selectedSecret.key === "CF_BEARER_TOKEN") { + try { + accountId = (await cloudflare.getAccountId()) ?? undefined; + } catch { + accountId = undefined; + } + } + + const secretValue = await selectedSecret.prompt(accountId); const success = await cloudflare.setCloudflareSecrets({ [selectedSecret.key]: secretValue, diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts index a98bc3eb..4b2954ff 100644 --- a/packages/cli/src/commands/install.ts +++ b/packages/cli/src/commands/install.ts @@ -257,11 +257,15 @@ export async function install( "https://dash.cloudflare.com/profile/api-tokens", )} +Or use an account-owned token from your account's API Tokens page: ${chalk.bold( + `https://dash.cloudflare.com/${accountId}/api-tokens`, + )} + Your token needs these permissions: - Account Analytics: Read`, ); - const apiToken = await promptApiToken(); + const apiToken = await promptApiToken(accountId); if (apiToken) { const s = spinner(); s.start(`Setting Cloudflare API token ...`); diff --git a/packages/cli/src/lib/__tests__/cloudflare.test.ts b/packages/cli/src/lib/__tests__/cloudflare.test.ts index d5e05806..6b8eacdf 100644 --- a/packages/cli/src/lib/__tests__/cloudflare.test.ts +++ b/packages/cli/src/lib/__tests__/cloudflare.test.ts @@ -518,6 +518,118 @@ describe("CloudflareClient.validateToken", () => { expect(result.error).toBe("Invalid or expired token"); }); + it("should fall back to the account verify endpoint when the user endpoint returns 401", async () => { + (global.fetch as any) + .mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: "Unauthorized", + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + success: true, + result: { id: "test-id", status: "active" }, + }), + }); + + const result = await CloudflareClient.validateToken( + "account-owned-token", + "1234567890abcdef1234567890abcdef", + ); + + expect(result.valid).toBe(true); + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(global.fetch).toHaveBeenNthCalledWith( + 2, + "https://api.cloudflare.com/client/v4/accounts/1234567890abcdef1234567890abcdef/tokens/verify", + { + method: "GET", + headers: { + Authorization: "Bearer account-owned-token", + "Content-Type": "application/json", + }, + }, + ); + }); + + it("should not fall back to the account verify endpoint without an account ID", async () => { + (global.fetch as any).mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: "Unauthorized", + }); + + const result = await CloudflareClient.validateToken("invalid-token"); + + expect(result.valid).toBe(false); + expect(result.error).toBe("Invalid or expired token"); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it("should return valid: false when both verify endpoints return 401", async () => { + (global.fetch as any) + .mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: "Unauthorized", + }) + .mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: "Unauthorized", + }); + + const result = await CloudflareClient.validateToken( + "invalid-account-token", + "1234567890abcdef1234567890abcdef", + ); + + expect(result.valid).toBe(false); + expect(result.error).toBe("Invalid or expired token"); + }); + + it("should surface the account endpoint error when the fallback fails", async () => { + (global.fetch as any) + .mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: "Unauthorized", + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + success: true, + result: { id: "test-id", status: "disabled" }, + }), + }); + + const result = await CloudflareClient.validateToken( + "disabled-account-token", + "1234567890abcdef1234567890abcdef", + ); + + expect(result.valid).toBe(false); + expect(result.error).toBe("Token is not active"); + }); + + it("should not fall back to the account verify endpoint on non-401 failures", async () => { + (global.fetch as any).mockResolvedValueOnce({ + ok: false, + status: 403, + statusText: "Forbidden", + }); + + const result = await CloudflareClient.validateToken( + "insufficient-permissions", + "1234567890abcdef1234567890abcdef", + ); + + expect(result.valid).toBe(false); + expect(result.error).toBe("Token lacks required permissions"); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + it("should return valid: false for 403 forbidden", async () => { (global.fetch as any).mockResolvedValueOnce({ ok: false, diff --git a/packages/cli/src/lib/__tests__/ui.test.ts b/packages/cli/src/lib/__tests__/ui.test.ts index 30c717a2..49827d40 100644 --- a/packages/cli/src/lib/__tests__/ui.test.ts +++ b/packages/cli/src/lib/__tests__/ui.test.ts @@ -253,6 +253,30 @@ describe("UI module", () => { const result = await promptApiToken(); expect(result).toBe(mockToken); + expect(mockSpinner.stop).toHaveBeenCalledWith("Token Validated"); + }); + + it("should pass the account ID to validateToken", async () => { + const mockToken = "a".repeat(40); + const accountId = "1234567890abcdef1234567890abcdef"; + (isCancel as unknown as ReturnType).mockReturnValue( + false, + ); + const mockPrompts = await import("@clack/prompts"); + ( + mockPrompts.password as unknown as ReturnType + ).mockResolvedValue(mockToken); + + const mockCloudflare = await import("../cloudflare.js"); + vi.mocked( + mockCloudflare.CloudflareClient.validateToken, + ).mockResolvedValue({ valid: true }); + + const result = await promptApiToken(accountId); + expect(result).toBe(mockToken); + expect( + mockCloudflare.CloudflareClient.validateToken, + ).toHaveBeenCalledWith(mockToken, accountId); }); it("should throw error if user cancels", async () => { @@ -290,6 +314,13 @@ describe("UI module", () => { await expect(promptApiToken()).rejects.toThrow( "Invalid token or insufficient permissions", ); + expect(mockSpinner.stop).toHaveBeenCalledWith( + "Token validation failed", + 1, + ); + expect(mockSpinner.stop).not.toHaveBeenCalledWith( + "Token Validated", + ); }); it("should throw error if validation throws", async () => { diff --git a/packages/cli/src/lib/cloudflare.ts b/packages/cli/src/lib/cloudflare.ts index 7a521b92..a2495f27 100644 --- a/packages/cli/src/lib/cloudflare.ts +++ b/packages/cli/src/lib/cloudflare.ts @@ -59,6 +59,15 @@ interface TokenValidationResponse { errors?: Array<{ code: number; message: string }>; } +export interface TokenValidationResult { + valid: boolean; + error?: string; +} + +interface TokenVerificationResult extends TokenValidationResult { + httpStatus?: number; +} + function isWorkerNotFoundError(error: unknown): boolean { if (typeof error !== "string") { return false; @@ -172,34 +181,38 @@ export class CloudflareClient { } } - static async validateToken( + private static async verifyTokenAtUrl( token: string, - ): Promise<{ valid: boolean; error?: string }> { + url: string, + ): Promise { try { - const response = await fetch( - "https://api.cloudflare.com/client/v4/user/tokens/verify", - { - method: "GET", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", }, - ); + }); if (!response.ok) { if (response.status === 401) { - return { valid: false, error: "Invalid or expired token" }; + return { + valid: false, + error: "Invalid or expired token", + httpStatus: 401, + }; } if (response.status === 403) { return { valid: false, error: "Token lacks required permissions", + httpStatus: 403, }; } return { valid: false, error: `HTTP ${response.status}: ${response.statusText}`, + httpStatus: response.status, }; } @@ -227,6 +240,38 @@ export class CloudflareClient { } } + static async validateToken( + token: string, + accountId?: string, + ): Promise { + const userResult = await CloudflareClient.verifyTokenAtUrl( + token, + "https://api.cloudflare.com/client/v4/user/tokens/verify", + ); + + if (userResult.valid) { + return { valid: true }; + } + + // Account-owned tokens (created from an account's API Tokens page) + // always fail the user-scoped verify endpoint, so retry against the + // account-scoped endpoint for the account being installed to. + if (accountId && userResult.httpStatus === 401) { + const accountResult = await CloudflareClient.verifyTokenAtUrl( + token, + `https://api.cloudflare.com/client/v4/accounts/${accountId}/tokens/verify`, + ); + + if (accountResult.valid) { + return { valid: true }; + } + + return { valid: false, error: accountResult.error }; + } + + return { valid: false, error: userResult.error }; + } + async getCloudflareSecrets(): Promise> { let rawSecrets: string; try { diff --git a/packages/cli/src/lib/ui.ts b/packages/cli/src/lib/ui.ts index 68c6f014..2ab575c6 100644 --- a/packages/cli/src/lib/ui.ts +++ b/packages/cli/src/lib/ui.ts @@ -3,6 +3,7 @@ import chalk from "chalk"; import { highlight } from "cli-highlight"; import { password, text, isCancel, cancel, spinner } from "@clack/prompts"; import { CloudflareClient } from "./cloudflare.js"; +import type { TokenValidationResult } from "./cloudflare.js"; export const CLI_COLORS: Record = { orange: [245, 107, 61], @@ -104,7 +105,7 @@ export async function promptForPassword( return userPassword; } -export async function promptApiToken(): Promise { +export async function promptApiToken(accountId?: string): Promise { const cfApiToken = await password({ message: "Enter your Cloudflare API Token", mask: "*", @@ -136,16 +137,9 @@ export async function promptApiToken(): Promise { const s = spinner(); s.start("Validating token..."); + let result: TokenValidationResult; try { - const result = await CloudflareClient.validateToken(cfApiToken); - s.stop("Token Validated"); - - if (!result.valid) { - throw new Error( - result.error || - "Invalid token or insufficient permissions. Please verify your token has 'Account Analytics: Read' permission.", - ); - } + result = await CloudflareClient.validateToken(cfApiToken, accountId); } catch (error) { s.stop(); if (error instanceof Error) { @@ -156,6 +150,16 @@ export async function promptApiToken(): Promise { ); } + if (!result.valid) { + s.stop("Token validation failed", 1); + throw new Error( + result.error || + "Invalid token or insufficient permissions. Please verify your token has 'Account Analytics: Read' permission.", + ); + } + + s.stop("Token Validated"); + return cfApiToken; }