feat(api): link keyless prompts to opaque per-identity /k links - #4856
Conversation
Keyless prompts (daily limit, unsupported endpoint, suspicious IP, browser budget, credit reservation) now link to https://firecrawl.dev/k/<id> instead of /signin?utm_source=keyless&utm_medium=api. The id is 8 random lowercase Crockford base32 characters, issued once per (keyless identity, surface) into keyless_signup_links and cached in Redis, so repeated prompts on a surface show the same link. The row holds only the id, the keyless team UUID (the same value as keyless_credit_usage.team_id, so the warehouse joins a signup to the keyless ledger without a secret), the surface and when it was first issued. Nothing about the IP or the surface is in the link. The surface comes from the signals the warehouse uses for usage_source: cli when origin or integration is cli, mcp when origin starts with mcp or the hosted MCP relayed the request with the proxy secret, api otherwise. Issuance never fails a request: it is capped at 300ms, backs off for 30s after a database error, and falls back to the bare /k link. Responses also carry the link as signup_url (and signupUrl on /v2/keyless/eligibility, which accepts ?signup_link=1) so the MCP server and CLI can relay it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the warehouse - Treat a timed-out issuance like a failed one and engage the 30s backoff, so a slow database is not queried once per blocked request. - Classify origin and integration case-insensitively, and let x-origin count when a v1 schema has prefaulted the body origin to "api". - Keep durable link mappings in the keyless snip instead of deleting every surface's row for the shared test identity. - Assert response statuses in the eligibility and browser error tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The unsupported-endpoint (401) and suspicious-IP (403) prompts run before any quota check, so issuing there let anonymous or rotating traffic write a keyless_signup_links row per source IP. Those prompts, and suspicious eligibility refusals, now use the bare /k link. The unsupported-endpoint check moves back ahead of IP resolution, as on main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
All reported issues were addressed across 5 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Fix all with cubic | Re-trigger cubic
Dismissed because Cubic found issues in a newer review.
Count keyless_signup_links before and after the 401 request, so the snip catches issuance that writes a row while still returning the bare link. Skipped when the database has no links table. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the regular signup link - The unsupported-endpoint (401) and suspicious-IP (403) prompts, and suspicious eligibility refusals, now reuse a link the identity was already given, read from the cache only. They still never issue or write a row, but a signup from an identity that has hit a quota prompt now joins. - Whenever no id can be given (no identity, a slow or failing database, the 30s backoff, or a new identity on a pre-quota prompt), the prompt links to the regular signup URL, signin?utm_source=keyless&utm_medium=<surface>, instead of the bare /k. The surface attribution survives; only the per-identity join is lost. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
All reported issues were addressed across 8 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Fix all with cubic | Re-trigger cubic
Dismissed because Cubic found issues in a newer review.
Replace the database-backed short id with a 12-character token that carries the prompt itself: the keyless IPv4, the surface (api/mcp/cli) and the prompt reason (limit, account_only_tool, unsupported_endpoint, suspicious_ip), plus 23 zero check bits, encrypted with FF1 (NIST SP 800-38G) over AES-128 in radix 32 and spelled in lowercase Crockford base32. Nothing is stored per identity: the web app decrypts the token at signup. Keys come from KEYLESS_SIGNUP_LINK_KEYS (comma-separated base64 16-byte keys; the first encrypts, all are tried to decrypt). With no key, or for a non-IPv4 identity, prompts keep the regular keyless signin link. Every keyless prompt now gets a token, including the unsupported-endpoint 401, the suspicious-IP 403 and the eligibility check (signup_link=1 is the account-only tool reason). Token generation is pure CPU with no I/O, so the upsert, Redis cache, timeout, backoff and the keyless_signup_links Drizzle schema are gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
All reported issues were addressed across 12 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Fix all with cubic | Re-trigger cubic
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keyless prompts now link to the caller's own signup link,
https://firecrawl.dev/k/<token>, instead of/signin?utm_source=keyless&utm_medium=api. The token is stateless: it is the prompt itself, encrypted, so nothing is stored per identity.Token (
src/lib/keyless-signup-link.ts, one shared layout comment, mirrored in firecrawl-web)0123456789abcdefghjkmnpqrstvwxyz), no query string.api,mcp,cli), prompt reason (3:limit,account_only_tool,unsupported_endpoint,suspicious_ip; the rest reserved), check (23, all zero). No time in the token.fc-keyless-v1, built on Node's AES with no new dependency. Tested against the NIST FF1 AES-128 samples 1, 2 (radix 10) and 3 (radix 36).Where tokens are used
::ffff:normalization) gets a token:limit: request and credit caps, credit reservation, browser budgetunsupported_endpoint: the 401, with the client IP fromkeylessClientIp(req)suspicious_ip: the 403limitfor requests/credits,suspicious_ipfor suspiciousaccount_only_tool:/v2/keyless/eligibility?signup_link=1, used by MCP account-only tools, whatever the eligibility resultkeylessFallbackSignupUrl(surface)=https://www.firecrawl.dev/signin?utm_source=keyless&utm_medium=<surface>: no key configured, a non-IPv4 identity, or an eligibility refusal because the tier is off or the limiter is down.keylessSignupSurface:cliwhen origin or integration is cli,mcpwhen origin starts with mcp or the hosted MCP relayed with the proxy secret, elseapi; case-insensitive; a v1-prefaulted body originapidefers tox-origin).signup_url; the eligibility endpoint returnssignupUrl. Thekeyless_exhaustedand suspicious-IP logs carrysignupRef, now the token.Removed: the
keyless_signup_linksDrizzle schema, the upsert, the Redis cache, the 300ms timeout and 30s backoff,existingKeylessSignupUrlandresetKeylessSignupLinkStateForTests.Env:
KEYLESS_SIGNUP_LINK_KEYS(added to the config schema and.env.example)openssl rand -base64 16.Tests: unit tests for the token (NIST vectors, shared cross-repo vector
AAECAwQFBgcICQoLDA0ODw==+ {203.0.113.8, mcp, limit} →hrxch5c20tcs, round trip for every surface and reason, every one-character tamper rejected, rotated key, no key, non-IPv4, no IP in the clear), plus prompt, eligibility, browser and auth tests. Thev2/keylesssnip drops the DB-row checks and decodes each token's surface and reason when the test env setsKEYLESS_SIGNUP_LINK_KEYS(skipped otherwise).Summary by cubic
Keyless prompts now link to the caller's own opaque
https://firecrawl.dev/k/<token>URL instead of the tagged/signinlink, so the link reveals nothing about the caller or the surface.KEYLESS_SIGNUP_LINK_KEYS(comma-separated base64 16-byte; the first encrypts, all try to decrypt). With no key, or for a non-IPv4 identity, prompts keep the regular keyless signin URL.usage_sourcesignals —cli,mcp, otherwiseapi— matched case-insensitively; thex-originheader counts when a v1 schema prefaults the body origin toapi.?signup_link=1is the account-only-tool reason.signup_url;/v2/keyless/eligibilityreturnssignupUrlon refusals;keyless_exhaustedlogs addsignupRef; a shared test pins the cross-repokeylessTeamId→ UUID vector used to join signups.Migration
Needs the
/kroute live andKEYLESS_SIGNUP_LINK_KEYSset in both the API and firecrawl-web first; unset keys just send the regular signup link.Written for commit ad37e43. Summary will update on new commits.
Rollout order (ship in this order)
/k/<token>route and signup decrypt (must be live before the API ships, or new links 404)Web and API must share
KEYLESS_SIGNUP_LINK_KEYSbefore the API ships. Set the same value in the web env first, then in the API env. If the API encrypts with a key the web doesn't have, signups keep the raw token inkeyless_refbut get no decoded columns.🤖 Generated with Claude Code