From fc672649f5e1d7f444c2b3aef6946334af510b5d Mon Sep 17 00:00:00 2001 From: Rakshith Ramprakash Date: Wed, 30 Sep 2026 15:41:38 +1000 Subject: [PATCH 1/2] feat: print the API's keyless signup link as-is and send X-Origin: cli The API now links keyless prompts to the caller's own opaque signup link, https://firecrawl.dev/k/, issued per keyless identity and surface. The CLI no longer rewrites utm_medium=api to cli in those messages (there is nothing to rewrite), and instead identifies itself so the API issues a CLI link: keyless requests send X-Origin: cli, which covers the requests without a body (GET research and developer lookups, interact stop) and multipart parse, whose options are parsed after auth. Co-Authored-By: Claude Opus 5.5 --- src/__tests__/commands/parse.test.ts | 3 +- src/__tests__/utils/client.test.ts | 81 ++++++++++++++++------------ src/commands/interact.ts | 15 +++--- src/commands/parse.ts | 15 ++++-- src/utils/client.ts | 28 +++++----- 5 files changed, 77 insertions(+), 65 deletions(-) diff --git a/src/__tests__/commands/parse.test.ts b/src/__tests__/commands/parse.test.ts index 18b126d664..119a51124c 100644 --- a/src/__tests__/commands/parse.test.ts +++ b/src/__tests__/commands/parse.test.ts @@ -61,7 +61,8 @@ describe('executeParse', () => { ]; expect(url).toBe('https://api.firecrawl.dev/v2/parse'); expect(init.method).toBe('POST'); - expect(init.headers).toBeUndefined(); + // No Authorization; X-Origin attributes the keyless call to the CLI. + expect(init.headers).toEqual({ 'X-Origin': 'cli' }); const options = JSON.parse(init.body.get('options') as string); expect(options).toEqual({ diff --git a/src/__tests__/utils/client.test.ts b/src/__tests__/utils/client.test.ts index 806a5119cc..8664d4546f 100644 --- a/src/__tests__/utils/client.test.ts +++ b/src/__tests__/utils/client.test.ts @@ -1,66 +1,79 @@ /** * Tests for keyless request errors * - * The API links every keyless prompt to signup tagged `utm_medium=api`. The CLI - * must retag that link as `cli` so signups started from the CLI are attributed - * to it. + * The API links every keyless prompt to the caller's own opaque signup link, + * https://firecrawl.dev/k/, which the site resolves to CLI attribution when + * the request came from the CLI. The CLI prints that link unchanged and tells + * the API it is the CLI with X-Origin, including on requests without a body. */ import { describe, it, expect, vi, afterEach } from 'vitest'; -import { - keylessGet, - keylessRequest, - withCliSignupTag, -} from '../../utils/client'; +import { keylessGet, keylessRequest } from '../../utils/client'; -const API_LIMIT_MESSAGE = `You've hit Firecrawl's keyless free tier rate limit. To continue now, create a free API key at https://www.firecrawl.dev/signin?utm_source=keyless&utm_medium=api +const OWN_SIGNUP_URL = 'https://firecrawl.dev/k/7fq2xab9'; + +const API_LIMIT_MESSAGE = `You've hit Firecrawl's keyless free tier rate limit. To continue now, create a free API key at ${OWN_SIGNUP_URL} Then authenticate with: Authorization: Bearer YOUR_API_KEY`; -const CLI_SIGNUP_URL = - 'https://www.firecrawl.dev/signin?utm_source=keyless&utm_medium=cli'; +// Before the /k links, the API sent a UTM-tagged link. An API still sending it +// must not be rewritten into something else. +const LEGACY_LIMIT_MESSAGE = + "You've hit Firecrawl's keyless free tier rate limit. To continue now, create a free API key at https://www.firecrawl.dev/signin?utm_source=keyless&utm_medium=api"; function stubFetch(status: number, body: unknown) { - vi.stubGlobal( - 'fetch', - vi.fn(async () => new Response(JSON.stringify(body), { status })) + const fetchMock = vi.fn( + async (_url: string, _init?: RequestInit) => + new Response(JSON.stringify(body), { status }) ); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; } -describe('withCliSignupTag', () => { - it('retags the keyless signup link as cli', () => { - const message = withCliSignupTag(API_LIMIT_MESSAGE); - - expect(message).toContain(CLI_SIGNUP_URL); - expect(message).not.toContain('utm_medium=api'); - }); - - it('leaves messages without the keyless signup link unchanged', () => { - expect(withCliSignupTag('Firecrawl request failed (HTTP 500)')).toBe( - 'Firecrawl request failed (HTTP 500)' - ); - }); -}); - describe('keyless requests', () => { afterEach(() => { vi.unstubAllGlobals(); }); - it('reports the keyless limit with the cli signup link', async () => { - stubFetch(429, { success: false, error: API_LIMIT_MESSAGE }); + it('reports the keyless limit with the API-issued signup link unchanged', async () => { + stubFetch(429, { + success: false, + error: API_LIMIT_MESSAGE, + signup_url: OWN_SIGNUP_URL, + }); await expect( keylessRequest('/v2/scrape', { url: 'https://example.com' }) - ).rejects.toThrow(CLI_SIGNUP_URL); + ).rejects.toThrow(API_LIMIT_MESSAGE); }); - it('reports the keyless limit on GET requests with the cli signup link', async () => { + it('reports the keyless limit on GET requests with the API-issued link', async () => { stubFetch(429, { success: false, error: API_LIMIT_MESSAGE }); await expect(keylessGet('/v2/research/search?q=test')).rejects.toThrow( - CLI_SIGNUP_URL + OWN_SIGNUP_URL ); }); + + it('no longer rewrites a legacy UTM link', async () => { + stubFetch(429, { success: false, error: LEGACY_LIMIT_MESSAGE }); + + await expect( + keylessRequest('/v2/scrape', { url: 'https://example.com' }) + ).rejects.toThrow(LEGACY_LIMIT_MESSAGE); + }); + + it('identifies the CLI with X-Origin on POST and GET requests', async () => { + const fetchMock = stubFetch(200, { success: true }); + + await keylessRequest('/v2/scrape', { url: 'https://example.com' }); + await keylessGet('/v2/research/search?q=test'); + + for (const [, init] of fetchMock.mock.calls) { + const headers = init?.headers as Record; + expect(headers['X-Origin']).toBe('cli'); + expect(headers.Authorization).toBeUndefined(); + } + }); }); diff --git a/src/commands/interact.ts b/src/commands/interact.ts index 346250122c..6a99b36dbe 100644 --- a/src/commands/interact.ts +++ b/src/commands/interact.ts @@ -3,7 +3,7 @@ * Execute AI prompts or code against a scraped page in a live browser session */ -import { getClient, isKeylessMode, withCliSignupTag } from '../utils/client'; +import { getClient, isKeylessMode, KEYLESS_CLI_HEADERS } from '../utils/client'; import { getConfig, validateConfig } from '../utils/config'; import { getScrapeId, @@ -59,6 +59,7 @@ function buildHeaders(apiKey: string | undefined, keyless: boolean) { if (!keyless && apiKey) { headers.Authorization = `Bearer ${apiKey}`; } + if (keyless) Object.assign(headers, KEYLESS_CLI_HEADERS); return headers; } @@ -100,10 +101,8 @@ export async function handleInteractExecute( if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error( - withCliSignupTag( - (errorData as any).error || - `HTTP ${response.status}: ${response.statusText}` - ) + (errorData as any).error || + `HTTP ${response.status}: ${response.statusText}` ); } @@ -169,10 +168,8 @@ export async function handleInteractStop( if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error( - withCliSignupTag( - (errorData as any).error || - `HTTP ${response.status}: ${response.statusText}` - ) + (errorData as any).error || + `HTTP ${response.status}: ${response.statusText}` ); } diff --git a/src/commands/parse.ts b/src/commands/parse.ts index 6082940a20..7e7e7ab8ba 100644 --- a/src/commands/parse.ts +++ b/src/commands/parse.ts @@ -11,7 +11,7 @@ import * as path from 'path'; import type { FormatOption } from 'firecrawl'; import type { ParseOptions, ParseResult } from '../types/parse'; import type { ScrapeFormat } from '../types/scrape'; -import { getClient, isKeylessMode, withCliSignupTag } from '../utils/client'; +import { getClient, isKeylessMode, KEYLESS_CLI_HEADERS } from '../utils/client'; import { getConfig, validateConfig } from '../utils/config'; import { handleScrapeOutput } from '../utils/output'; @@ -184,8 +184,14 @@ export async function executeParse( try { const response = await fetch(`${apiUrl}/v2/parse`, { method: 'POST', + // Multipart options are parsed after auth, so the header carries the + // CLI origin to the keyless check. headers: - !keyless && apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined, + !keyless && apiKey + ? { Authorization: `Bearer ${apiKey}` } + : keyless + ? { ...KEYLESS_CLI_HEADERS } + : undefined, body: form, }); @@ -195,10 +201,9 @@ export async function executeParse( const payload = (await response.json().catch(() => ({}))) as any; if (!response.ok || payload?.success === false) { - const message = withCliSignupTag( + const message = payload?.error || - `HTTP ${response.status}: ${response.statusText || 'Request failed'}` - ); + `HTTP ${response.status}: ${response.statusText || 'Request failed'}`; return { success: false, error: message }; } diff --git a/src/utils/client.ts b/src/utils/client.ts index b6844bf142..d61d16e89a 100644 --- a/src/utils/client.ts +++ b/src/utils/client.ts @@ -30,15 +30,15 @@ export function isKeylessMode(apiKey?: string, apiUrl?: string): boolean { } /** - * The API's keyless prompts link to signup tagged `utm_medium=api`. Retag them - * as `cli` so accounts created from the CLI are attributed to the CLI. + * Headers for keyless requests. The API reads X-Origin to attribute keyless + * use, and a keyless prompt's signup link, to the CLI; requests without a body + * (GET research and developer lookups, interact stop) carry nothing else. + * Keyless error messages are printed as the API sends them: their + * firecrawl.dev/k/ link already resolves to CLI attribution. */ -export function withCliSignupTag(message: string): string { - return message.replaceAll( - 'utm_source=keyless&utm_medium=api', - 'utm_source=keyless&utm_medium=cli' - ); -} +export const KEYLESS_CLI_HEADERS: Readonly> = { + 'X-Origin': 'cli', +}; export async function keylessRequest( path: string, @@ -47,15 +47,13 @@ export async function keylessRequest( const apiUrl = (getConfig().apiUrl || DEFAULT_API_URL).replace(/\/$/, ''); const response = await fetch(`${apiUrl}${path}`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...KEYLESS_CLI_HEADERS }, body: JSON.stringify(body), }); const json: any = await response.json().catch(() => ({})); if (!response.ok) { throw new Error( - withCliSignupTag( - json?.error || `Firecrawl request failed (HTTP ${response.status})` - ) + json?.error || `Firecrawl request failed (HTTP ${response.status})` ); } return json; @@ -65,14 +63,12 @@ export async function keylessGet(path: string): Promise { const apiUrl = (getConfig().apiUrl || DEFAULT_API_URL).replace(/\/$/, ''); const response = await fetch(`${apiUrl}${path}`, { method: 'GET', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...KEYLESS_CLI_HEADERS }, }); const json: any = await response.json().catch(() => ({})); if (!response.ok) { throw new Error( - withCliSignupTag( - json?.error || `Firecrawl request failed (HTTP ${response.status})` - ) + json?.error || `Firecrawl request failed (HTTP ${response.status})` ); } return json; From 96a6932ffbfe17d968968cface8bb31e0e7d8148 Mon Sep 17 00:00:00 2001 From: Rakshith Ramprakash Date: Wed, 30 Sep 2026 18:52:17 +1000 Subject: [PATCH 2/2] chore: bump CLI version to 1.25.0 Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a0d7afd03e..f2c709f270 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "firecrawl-cli", - "version": "1.24.6", + "version": "1.25.0", "publishConfig": { "tag": "latest" },