Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "firecrawl-cli",
"version": "1.26.18",
"version": "1.27.0",
"publishConfig": {
"tag": "latest"
},
Expand Down
19 changes: 19 additions & 0 deletions src/__tests__/commands/parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,25 @@ describe('executeParse', () => {
expect(init.body.get('file')).toBeInstanceOf(Blob);
});

it('sends a World ID credential on keyless parse requests', async () => {
initializeConfig({ apiUrl: 'https://api.firecrawl.dev' });
process.env.FIRECRAWL_WORLD_ID = 'fcwid_payload.signature';
try {
await executeParse({ file: filePath });
} finally {
delete process.env.FIRECRAWL_WORLD_ID;
}

const [, init] = mockFetch.mock.calls[0] as [
string,
{ headers?: Record<string, string> },
];
expect(init.headers).toEqual({
'X-Origin': 'cli',
'x-firecrawl-world-id': 'fcwid_payload.signature',
});
});

it('includes the bearer token when an API key is configured', async () => {
initializeConfig({
apiKey: 'fc-test-key',
Expand Down
241 changes: 241 additions & 0 deletions src/__tests__/commands/world-id.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
/**
* Tests for World ID verification (device flow) and the keyless header.
*/

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';

const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'firecrawl-world-id-'));

vi.mock('../../utils/credentials', () => ({
getConfigDirectoryPath: () => configDir,
loadCredentials: () => null,
}));
vi.mock('../../utils/auth', () => ({ openBrowser: vi.fn(async () => {}) }));

import {
verifyWorldId,
WORLD_ID_UNAVAILABLE_MESSAGE,
} from '../../commands/world-id';
import { keylessGet, keylessRequest } from '../../utils/client';
import {
activeWorldIdCredential,
clearWorldIdCredential,
loadWorldIdCredential,
saveWorldIdCredential,
} from '../../utils/world-id';

const CREDENTIAL = 'fcwid_payload.signature';
const START = {
success: true,
device_handle: 'handle-1',
user_code: 'ABCD-EFGH',
verification_uri: 'https://world.example/device',
expires_in: 1200,
interval: 5,
};

function stubResponses(...responses: Array<[number, unknown]>) {
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => {
const [status, body] = responses.shift() ?? [500, {}];
return new Response(JSON.stringify(body), { status });
});
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}

const quiet = { log: () => {}, sleep: async () => {}, openBrowser: false };

describe('firecrawl world-id', () => {
beforeEach(() => {
clearWorldIdCredential();
delete process.env.FIRECRAWL_WORLD_ID;
});

afterEach(() => {
vi.unstubAllGlobals();
});

it('runs the device flow and stores the credential', async () => {
const fetchMock = stubResponses(
[200, START],
[400, { success: false, error: 'authorization_pending' }],
[
200,
{
success: true,
credential: CREDENTIAL,
expires_at: '2099-01-01T00:00:00.000Z',
},
]
);

const stored = await verifyWorldId(quiet);

expect(stored).toEqual({
credential: CREDENTIAL,
expiresAt: '2099-01-01T00:00:00.000Z',
});
expect(loadWorldIdCredential()).toEqual(stored);
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
'https://api.firecrawl.dev/v2/keyless/world-id/device',
'https://api.firecrawl.dev/v2/keyless/world-id/token',
'https://api.firecrawl.dev/v2/keyless/world-id/token',
]);
expect(JSON.parse(String(fetchMock.mock.calls[1][1]?.body))).toEqual({
device_handle: 'handle-1',
});
const headers = fetchMock.mock.calls[0][1]?.headers as Record<
string,
string
>;
expect(headers['X-Origin']).toBe('cli');
expect(headers.Authorization).toBeUndefined();
});

it('keeps polling through a transport error', async () => {
const responses: Array<[number, unknown] | Error> = [
[200, START],
new TypeError('fetch failed'),
[
200,
{
success: true,
credential: CREDENTIAL,
expires_at: '2099-01-01T00:00:00.000Z',
},
],
];
vi.stubGlobal(
'fetch',
vi.fn(async () => {
const next = responses.shift()!;
if (next instanceof Error) throw next;
return new Response(JSON.stringify(next[1]), { status: next[0] });
})
);

await expect(verifyWorldId(quiet)).resolves.toMatchObject({
credential: CREDENTIAL,
});
});

it('refuses a credential without a valid expiry', async () => {
stubResponses(
[200, START],
[200, { success: true, credential: CREDENTIAL }]
);

await expect(verifyWorldId(quiet)).rejects.toThrow('no valid expiry');
expect(loadWorldIdCredential()).toBeNull();
});

it('says so when the API has World ID turned off', async () => {
stubResponses([404, { success: false, error: 'Not found' }]);

await expect(verifyWorldId(quiet)).rejects.toThrow(
WORLD_ID_UNAVAILABLE_MESSAGE
);
expect(loadWorldIdCredential()).toBeNull();
});

it('slows down when asked and stops on a declined approval', async () => {
const sleep = vi.fn(async (_ms: number) => {});
stubResponses(
[200, START],
[400, { success: false, error: 'slow_down' }],
[400, { success: false, error: 'access_denied' }]
);

await expect(verifyWorldId({ ...quiet, sleep })).rejects.toThrow(
'World ID verification was declined.'
);
expect(sleep.mock.calls.map(([ms]) => ms)).toEqual([5000, 10000]);
});

it('reports a World ID that is not on the allowlist', async () => {
stubResponses(
[200, START],
[
403,
{
success: false,
error: 'subject_not_allowed',
subject_hash: 'h'.repeat(43),
},
]
);

await expect(verifyWorldId(quiet)).rejects.toThrow('h'.repeat(43));
});

it('gives up when the code expires', async () => {
let clock = 0;
stubResponses(
[200, { ...START, expires_in: 10 }],
[400, { success: false, error: 'authorization_pending' }],
[400, { success: false, error: 'authorization_pending' }]
);

await expect(
verifyWorldId({
...quiet,
now: () => clock,
sleep: async (ms: number) => {
clock += ms;
},
})
).rejects.toThrow('The World ID code expired');
});

it('sends the stored credential on keyless requests only while unexpired', async () => {
const fetchMock = stubResponses(
[200, { success: true }],
[200, { success: true }]
);
saveWorldIdCredential({
credential: CREDENTIAL,
expiresAt: '2099-01-01T00:00:00.000Z',
});

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<string, string>;
expect(headers['x-firecrawl-world-id']).toBe(CREDENTIAL);
expect(headers.Authorization).toBeUndefined();
}

saveWorldIdCredential({
credential: CREDENTIAL,
expiresAt: '2000-01-01T00:00:00.000Z',
});
expect(activeWorldIdCredential()).toBeUndefined();
});

it('prefers FIRECRAWL_WORLD_ID over the stored credential', () => {
saveWorldIdCredential({
credential: CREDENTIAL,
expiresAt: '2099-01-01T00:00:00.000Z',
});
process.env.FIRECRAWL_WORLD_ID = 'fcwid_from.env';

expect(activeWorldIdCredential()).toBe('fcwid_from.env');
});

it('stores the credential readable by the owner only', () => {
fs.writeFileSync(path.join(configDir, 'world-id.json'), '{}', {
mode: 0o644,
});
saveWorldIdCredential({
credential: CREDENTIAL,
expiresAt: '2099-01-01T00:00:00.000Z',
});
const mode =
fs.statSync(path.join(configDir, 'world-id.json')).mode & 0o777;
expect(mode).toBe(0o600);
});
});
9 changes: 9 additions & 0 deletions src/__tests__/utils/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
interactiveLogin,
isAuthenticated,
manualLogin,
openBrowser,
} from '../../utils/auth';
import { initializeConfig, resetConfig } from '../../utils/config';
import * as credentials from '../../utils/credentials';
Expand Down Expand Up @@ -225,3 +226,11 @@ describe('Authentication Utilities', () => {
});
});
});

describe('openBrowser', () => {
it('refuses anything but a web URL before launching a process', async () => {
await expect(openBrowser('file:///etc/passwd')).rejects.toThrow(
'Refusing to open a non-web URL'
);
});
});
4 changes: 2 additions & 2 deletions src/commands/interact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Execute AI prompts or code against a scraped page in a live browser session
*/

import { getClient, isKeylessMode, KEYLESS_CLI_HEADERS } from '../utils/client';
import { getClient, isKeylessMode, keylessHeaders } from '../utils/client';
import { getConfig, validateConfig } from '../utils/config';
import {
getScrapeId,
Expand Down Expand Up @@ -60,7 +60,7 @@ function buildHeaders(apiKey: string | undefined, keyless: boolean) {
if (!keyless && apiKey) {
headers.Authorization = `Bearer ${apiKey}`;
}
if (keyless) Object.assign(headers, KEYLESS_CLI_HEADERS);
if (keyless) Object.assign(headers, keylessHeaders());
return { ...headers, ...integrationHeaders() };
}

Expand Down
4 changes: 2 additions & 2 deletions src/commands/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, KEYLESS_CLI_HEADERS } from '../utils/client';
import { getClient, isKeylessMode, keylessHeaders } from '../utils/client';
import { getConfig, validateConfig } from '../utils/config';
import { handleScrapeOutput } from '../utils/output';
import { integrationHeaders } from '../utils/integration';
Expand Down Expand Up @@ -189,7 +189,7 @@ export async function executeParse(
// CLI origin to the keyless check.
headers: {
...(!keyless && apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
...(keyless ? KEYLESS_CLI_HEADERS : {}),
...(keyless ? keylessHeaders() : {}),
...integrationHeaders(),
},
body: form,
Expand Down
Loading
Loading