Skip to content

Commit bceea7f

Browse files
rakshith48claude
andauthored
feat: print the API's keyless signup link as-is and send X-Origin: cli (#291)
* 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/<id>, 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 <noreply@anthropic.com> * chore: bump CLI version to 1.25.0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 0ddd6b4 commit bceea7f

6 files changed

Lines changed: 78 additions & 66 deletions

File tree

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "firecrawl-cli",
3-
"version": "1.24.6",
3+
"version": "1.25.0",
44
"publishConfig": {
55
"tag": "latest"
66
},

‎src/__tests__/commands/parse.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@ describe('executeParse', () => {
6161
];
6262
expect(url).toBe('https://api.firecrawl.dev/v2/parse');
6363
expect(init.method).toBe('POST');
64-
expect(init.headers).toBeUndefined();
64+
// No Authorization; X-Origin attributes the keyless call to the CLI.
65+
expect(init.headers).toEqual({ 'X-Origin': 'cli' });
6566

6667
const options = JSON.parse(init.body.get('options') as string);
6768
expect(options).toEqual({

‎src/__tests__/utils/client.test.ts‎

Lines changed: 47 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,79 @@
11
/**
22
* Tests for keyless request errors
33
*
4-
* The API links every keyless prompt to signup tagged `utm_medium=api`. The CLI
5-
* must retag that link as `cli` so signups started from the CLI are attributed
6-
* to it.
4+
* The API links every keyless prompt to the caller's own opaque signup link,
5+
* https://firecrawl.dev/k/<id>, which the site resolves to CLI attribution when
6+
* the request came from the CLI. The CLI prints that link unchanged and tells
7+
* the API it is the CLI with X-Origin, including on requests without a body.
78
*/
89

910
import { describe, it, expect, vi, afterEach } from 'vitest';
10-
import {
11-
keylessGet,
12-
keylessRequest,
13-
withCliSignupTag,
14-
} from '../../utils/client';
11+
import { keylessGet, keylessRequest } from '../../utils/client';
1512

16-
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
13+
const OWN_SIGNUP_URL = 'https://firecrawl.dev/k/7fq2xab9';
14+
15+
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}
1716
1817
Then authenticate with:
1918
Authorization: Bearer YOUR_API_KEY`;
2019

21-
const CLI_SIGNUP_URL =
22-
'https://www.firecrawl.dev/signin?utm_source=keyless&utm_medium=cli';
20+
// Before the /k links, the API sent a UTM-tagged link. An API still sending it
21+
// must not be rewritten into something else.
22+
const LEGACY_LIMIT_MESSAGE =
23+
"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";
2324

2425
function stubFetch(status: number, body: unknown) {
25-
vi.stubGlobal(
26-
'fetch',
27-
vi.fn(async () => new Response(JSON.stringify(body), { status }))
26+
const fetchMock = vi.fn(
27+
async (_url: string, _init?: RequestInit) =>
28+
new Response(JSON.stringify(body), { status })
2829
);
30+
vi.stubGlobal('fetch', fetchMock);
31+
return fetchMock;
2932
}
3033

31-
describe('withCliSignupTag', () => {
32-
it('retags the keyless signup link as cli', () => {
33-
const message = withCliSignupTag(API_LIMIT_MESSAGE);
34-
35-
expect(message).toContain(CLI_SIGNUP_URL);
36-
expect(message).not.toContain('utm_medium=api');
37-
});
38-
39-
it('leaves messages without the keyless signup link unchanged', () => {
40-
expect(withCliSignupTag('Firecrawl request failed (HTTP 500)')).toBe(
41-
'Firecrawl request failed (HTTP 500)'
42-
);
43-
});
44-
});
45-
4634
describe('keyless requests', () => {
4735
afterEach(() => {
4836
vi.unstubAllGlobals();
4937
});
5038

51-
it('reports the keyless limit with the cli signup link', async () => {
52-
stubFetch(429, { success: false, error: API_LIMIT_MESSAGE });
39+
it('reports the keyless limit with the API-issued signup link unchanged', async () => {
40+
stubFetch(429, {
41+
success: false,
42+
error: API_LIMIT_MESSAGE,
43+
signup_url: OWN_SIGNUP_URL,
44+
});
5345

5446
await expect(
5547
keylessRequest('/v2/scrape', { url: 'https://example.com' })
56-
).rejects.toThrow(CLI_SIGNUP_URL);
48+
).rejects.toThrow(API_LIMIT_MESSAGE);
5749
});
5850

59-
it('reports the keyless limit on GET requests with the cli signup link', async () => {
51+
it('reports the keyless limit on GET requests with the API-issued link', async () => {
6052
stubFetch(429, { success: false, error: API_LIMIT_MESSAGE });
6153

6254
await expect(keylessGet('/v2/research/search?q=test')).rejects.toThrow(
63-
CLI_SIGNUP_URL
55+
OWN_SIGNUP_URL
6456
);
6557
});
58+
59+
it('no longer rewrites a legacy UTM link', async () => {
60+
stubFetch(429, { success: false, error: LEGACY_LIMIT_MESSAGE });
61+
62+
await expect(
63+
keylessRequest('/v2/scrape', { url: 'https://example.com' })
64+
).rejects.toThrow(LEGACY_LIMIT_MESSAGE);
65+
});
66+
67+
it('identifies the CLI with X-Origin on POST and GET requests', async () => {
68+
const fetchMock = stubFetch(200, { success: true });
69+
70+
await keylessRequest('/v2/scrape', { url: 'https://example.com' });
71+
await keylessGet('/v2/research/search?q=test');
72+
73+
for (const [, init] of fetchMock.mock.calls) {
74+
const headers = init?.headers as Record<string, string>;
75+
expect(headers['X-Origin']).toBe('cli');
76+
expect(headers.Authorization).toBeUndefined();
77+
}
78+
});
6679
});

‎src/commands/interact.ts‎

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* Execute AI prompts or code against a scraped page in a live browser session
44
*/
55

6-
import { getClient, isKeylessMode, withCliSignupTag } from '../utils/client';
6+
import { getClient, isKeylessMode, KEYLESS_CLI_HEADERS } from '../utils/client';
77
import { getConfig, validateConfig } from '../utils/config';
88
import {
99
getScrapeId,
@@ -59,6 +59,7 @@ function buildHeaders(apiKey: string | undefined, keyless: boolean) {
5959
if (!keyless && apiKey) {
6060
headers.Authorization = `Bearer ${apiKey}`;
6161
}
62+
if (keyless) Object.assign(headers, KEYLESS_CLI_HEADERS);
6263
return headers;
6364
}
6465

@@ -100,10 +101,8 @@ export async function handleInteractExecute(
100101
if (!response.ok) {
101102
const errorData = await response.json().catch(() => ({}));
102103
throw new Error(
103-
withCliSignupTag(
104-
(errorData as any).error ||
105-
`HTTP ${response.status}: ${response.statusText}`
106-
)
104+
(errorData as any).error ||
105+
`HTTP ${response.status}: ${response.statusText}`
107106
);
108107
}
109108

@@ -169,10 +168,8 @@ export async function handleInteractStop(
169168
if (!response.ok) {
170169
const errorData = await response.json().catch(() => ({}));
171170
throw new Error(
172-
withCliSignupTag(
173-
(errorData as any).error ||
174-
`HTTP ${response.status}: ${response.statusText}`
175-
)
171+
(errorData as any).error ||
172+
`HTTP ${response.status}: ${response.statusText}`
176173
);
177174
}
178175

‎src/commands/parse.ts‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import * as path from 'path';
1111
import type { FormatOption } from 'firecrawl';
1212
import type { ParseOptions, ParseResult } from '../types/parse';
1313
import type { ScrapeFormat } from '../types/scrape';
14-
import { getClient, isKeylessMode, withCliSignupTag } from '../utils/client';
14+
import { getClient, isKeylessMode, KEYLESS_CLI_HEADERS } from '../utils/client';
1515
import { getConfig, validateConfig } from '../utils/config';
1616
import { handleScrapeOutput } from '../utils/output';
1717

@@ -184,8 +184,14 @@ export async function executeParse(
184184
try {
185185
const response = await fetch(`${apiUrl}/v2/parse`, {
186186
method: 'POST',
187+
// Multipart options are parsed after auth, so the header carries the
188+
// CLI origin to the keyless check.
187189
headers:
188-
!keyless && apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined,
190+
!keyless && apiKey
191+
? { Authorization: `Bearer ${apiKey}` }
192+
: keyless
193+
? { ...KEYLESS_CLI_HEADERS }
194+
: undefined,
189195
body: form,
190196
});
191197

@@ -195,10 +201,9 @@ export async function executeParse(
195201
const payload = (await response.json().catch(() => ({}))) as any;
196202

197203
if (!response.ok || payload?.success === false) {
198-
const message = withCliSignupTag(
204+
const message =
199205
payload?.error ||
200-
`HTTP ${response.status}: ${response.statusText || 'Request failed'}`
201-
);
206+
`HTTP ${response.status}: ${response.statusText || 'Request failed'}`;
202207
return { success: false, error: message };
203208
}
204209

‎src/utils/client.ts‎

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,15 @@ export function isKeylessMode(apiKey?: string, apiUrl?: string): boolean {
3030
}
3131

3232
/**
33-
* The API's keyless prompts link to signup tagged `utm_medium=api`. Retag them
34-
* as `cli` so accounts created from the CLI are attributed to the CLI.
33+
* Headers for keyless requests. The API reads X-Origin to attribute keyless
34+
* use, and a keyless prompt's signup link, to the CLI; requests without a body
35+
* (GET research and developer lookups, interact stop) carry nothing else.
36+
* Keyless error messages are printed as the API sends them: their
37+
* firecrawl.dev/k/<id> link already resolves to CLI attribution.
3538
*/
36-
export function withCliSignupTag(message: string): string {
37-
return message.replaceAll(
38-
'utm_source=keyless&utm_medium=api',
39-
'utm_source=keyless&utm_medium=cli'
40-
);
41-
}
39+
export const KEYLESS_CLI_HEADERS: Readonly<Record<string, string>> = {
40+
'X-Origin': 'cli',
41+
};
4242

4343
export async function keylessRequest(
4444
path: string,
@@ -47,15 +47,13 @@ export async function keylessRequest(
4747
const apiUrl = (getConfig().apiUrl || DEFAULT_API_URL).replace(/\/$/, '');
4848
const response = await fetch(`${apiUrl}${path}`, {
4949
method: 'POST',
50-
headers: { 'Content-Type': 'application/json' },
50+
headers: { 'Content-Type': 'application/json', ...KEYLESS_CLI_HEADERS },
5151
body: JSON.stringify(body),
5252
});
5353
const json: any = await response.json().catch(() => ({}));
5454
if (!response.ok) {
5555
throw new Error(
56-
withCliSignupTag(
57-
json?.error || `Firecrawl request failed (HTTP ${response.status})`
58-
)
56+
json?.error || `Firecrawl request failed (HTTP ${response.status})`
5957
);
6058
}
6159
return json;
@@ -65,14 +63,12 @@ export async function keylessGet(path: string): Promise<any> {
6563
const apiUrl = (getConfig().apiUrl || DEFAULT_API_URL).replace(/\/$/, '');
6664
const response = await fetch(`${apiUrl}${path}`, {
6765
method: 'GET',
68-
headers: { 'Content-Type': 'application/json' },
66+
headers: { 'Content-Type': 'application/json', ...KEYLESS_CLI_HEADERS },
6967
});
7068
const json: any = await response.json().catch(() => ({}));
7169
if (!response.ok) {
7270
throw new Error(
73-
withCliSignupTag(
74-
json?.error || `Firecrawl request failed (HTTP ${response.status})`
75-
)
71+
json?.error || `Firecrawl request failed (HTTP ${response.status})`
7672
);
7773
}
7874
return json;

0 commit comments

Comments
 (0)