diff --git a/README.md b/README.md index 2b887933df..5f75374bee 100644 --- a/README.md +++ b/README.md @@ -1032,6 +1032,10 @@ npx firecrawl-cli@alexandria alexandria terms accept benzinga \ This posts to `/exchange/provider-terms/accept`. No automatic acceptance or retry occurs. A `409 terms_changed` requires reviewing the new agreement before retrying. -The terms catalog may remain access-gated even when the acceptance endpoint is -available. A failed catalog lookup does not imply acceptance is unavailable. +Agents must present the returned terms and provider links, ask the user for explicit +approval, and wait before accepting. If review is refused or a provider link is +unavailable, show the error and direct an organization admin to +https://www.firecrawl.dev/app/settings?tab=data-sources. Never infer consent from a +failed lookup or automatically retry an acceptance. The API remains authoritative +for organization access and acceptance authority. After confirmed success, rerun the original provider command; its normal credits apply. diff --git a/src/__tests__/alexandria-beta.test.ts b/src/__tests__/alexandria-beta.test.ts index 602454ec31..28a009c7a2 100644 --- a/src/__tests__/alexandria-beta.test.ts +++ b/src/__tests__/alexandria-beta.test.ts @@ -472,6 +472,8 @@ it('relays terms refusals and keeps the request ID on failure', async () => { expect(result.code).toBe(1); const body = JSON.parse(result.stdout); expect(body).toMatchObject(response); + expect(body.guidance).toContain('wait for explicit approval'); + expect(body.guidance).toContain('/app/settings?tab=data-sources'); expect(result.stderr).toContain(body.requestId); expect(requests).toHaveLength(1); }); diff --git a/src/__tests__/commands/terms.test.ts b/src/__tests__/commands/terms.test.ts new file mode 100644 index 0000000000..a833bcd294 --- /dev/null +++ b/src/__tests__/commands/terms.test.ts @@ -0,0 +1,112 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { requestTerms } from '../../commands/terms'; + +vi.mock('../../utils/config', () => ({ + getApiKey: () => 'fc-test', + getConfig: () => ({ apiUrl: 'https://api.example.test' }), +})); +afterEach(() => vi.unstubAllGlobals()); +const digest = 'a'.repeat(64); +const options = { termsVersion: 'B-1', digest, confirm: true }; + +it('presents the selected agreement and requests human approval without accepting', async () => { + const provider = { + provider: 'particle', + terms: { version: 'B-1', digest, document: 'Review these terms.' }, + }; + const fetch = vi + .fn() + .mockResolvedValue(Response.json({ providers: [provider] })); + vi.stubGlobal('fetch', fetch); + const result = await requestTerms('particle', {}); + expect(result).toMatchObject({ success: true, ...provider }); + expect(result.instructions).toContain('explicit approval'); + expect(fetch).toHaveBeenCalledOnce(); + expect(fetch.mock.calls[0][1]).toMatchObject({ + method: 'GET', + redirect: 'error', + }); +}); + +it('preserves refusal details and gives actionable guidance without retrying', async () => { + const fetch = vi + .fn() + .mockResolvedValue( + Response.json( + { + code: 'forbidden', + error: 'This endpoint is not enabled for this team.', + }, + { status: 403 } + ) + ); + vi.stubGlobal('fetch', fetch); + const result = await requestTerms('particle', {}); + expect(result).toMatchObject({ + success: false, + status: 403, + code: 'forbidden', + error: 'This endpoint is not enabled for this team.', + guidance: { + url: 'https://www.firecrawl.dev/app/settings?tab=data-sources', + }, + }); + expect(fetch).toHaveBeenCalledOnce(); +}); + +it('refuses acceptance without confirmation or the exact version and digest before networking', async () => { + const fetch = vi.fn(); + vi.stubGlobal('fetch', fetch); + for (const invalid of [ + { ...options, confirm: false }, + { ...options, termsVersion: '' }, + { ...options, digest: 'invalid' }, + ]) { + await expect(requestTerms('particle', invalid, true)).rejects.toThrow( + 'Review the terms' + ); + } + expect(fetch).not.toHaveBeenCalled(); +}); + +it('sends exactly the explicitly confirmed agreement once', async () => { + const fetch = vi + .fn() + .mockResolvedValue( + Response.json({ success: true, receiptId: 'receipt-test' }) + ); + vi.stubGlobal('fetch', fetch); + expect(await requestTerms('particle', options, true)).toEqual({ + success: true, + receiptId: 'receipt-test', + }); + expect(fetch).toHaveBeenCalledOnce(); + const [url, init] = fetch.mock.calls[0]; + expect(url).toBe('https://api.example.test/exchange/provider-terms/accept'); + expect(JSON.parse(init.body)).toEqual({ + provider: 'particle', + version: 'B-1', + digest, + confirmed: true, + }); +}); + +it('keeps stale agreement failures and never claims acceptance from an ambiguous response', async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce( + Response.json({ code: 'terms_changed', version: 'B-2' }, { status: 409 }) + ) + .mockResolvedValueOnce(Response.json({ receiptId: 'ambiguous' })); + vi.stubGlobal('fetch', fetch); + expect(await requestTerms('particle', options, true)).toMatchObject({ + success: false, + status: 409, + code: 'terms_changed', + version: 'B-2', + }); + await expect(requestTerms('particle', options, true)).rejects.toThrow( + 'did not confirm success' + ); + expect(fetch).toHaveBeenCalledTimes(2); +}); diff --git a/src/commands/alexandria.ts b/src/commands/alexandria.ts index 4497e26a10..4766dbd108 100644 --- a/src/commands/alexandria.ts +++ b/src/commands/alexandria.ts @@ -120,9 +120,9 @@ export async function handleAlexandria( envelope.data?.alexandria?.some((item: any) => item.error); if (failed) process.exitCode = 1; if (envelope.code === 'THIRD_PARTY_DATA_TERMS_REQUIRED') { - console.error( - 'Review the provider terms with firecrawl alexandria terms show . After review, accept with firecrawl alexandria terms accept --terms-version --digest --confirm.' - ); + envelope.guidance = + 'Review the provider terms with firecrawl alexandria terms show . Present the terms to the user and wait for explicit approval. Only then accept with firecrawl alexandria terms accept --terms-version --digest --confirm. If the API-provided link is unavailable, use https://www.firecrawl.dev/app/settings?tab=data-sources. Do not automatically retry or accept.'; + console.error(envelope.guidance); } writeOutput( JSON.stringify(envelope, null, options.pretty ? 2 : undefined), diff --git a/src/commands/terms.ts b/src/commands/terms.ts index 1adddcb43d..b88bf43180 100644 --- a/src/commands/terms.ts +++ b/src/commands/terms.ts @@ -66,7 +66,18 @@ export async function requestTerms( `Terms endpoint returned non-JSON (HTTP ${response.status}).` ); if (!response.ok || body.success === false) - return { ...body, success: false, status: response.status }; + return { + ...body, + success: false, + status: response.status, + ...(response.status === 403 && { + guidance: { + message: + 'Terms access was refused. Show this error to the user and ask an organization admin to review access in Settings. Do not retry or accept automatically.', + url: 'https://www.firecrawl.dev/app/settings?tab=data-sources', + }, + }), + }; if (accept) { if (body.success !== true) throw new Error( @@ -79,7 +90,12 @@ export async function requestTerms( const item = body.providers.find((entry: any) => entry.provider === provider); if (!item) throw new Error('Provider not found in the accessible terms catalog.'); - return { success: true, ...item }; + return { + ...item, + success: true, + instructions: + 'Present the returned terms and any provider document links to the user. Ask for explicit approval before accepting this exact version and digest for their organization. Reading terms does not accept them; stop if approval is absent.', + }; } async function handle(