diff --git a/README.md b/README.md index e094496..ce0f123 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,18 @@ unclaimed Zenrows Free plan account**, stores the key in `.zenrows/secrets.json` (0600, gitignored), and records the account in `.zenrows/account.json` (no secret — just the accountId, Free-period info, and a claim link). +Zenrows may first ask the agent one short question before it creates the +account. The command then fails with `SIGNUP_CHALLENGE` and prints the +question; answer it and re-run the original command: + +```bash +zenrows signup --answer "" +``` + +A wrong or late answer gets a new question. Too many questions or wrong +answers from one network pause signups there for an hour +(`SIGNUP_CHALLENGE_LOCKED`). + When you (or your agent) want to keep the usage and history, **claim** the account — this confirms an email (Google, GitHub or a 6-digit code); the same key keeps working: @@ -96,10 +108,11 @@ zenrows usage # plan usage, credits & concurrency (does not count again Opt out of auto-provisioning at any time: ```bash -zenrows fetch --no-signup # do not auto-create an account for this call -zenrows signup --agent # explicitly provision a Free plan account, headless -zenrows login --api-key # use an existing key (stored 0600, gitignored) -zenrows login --env # use the ZENROWS_API_KEY environment variable +zenrows fetch --no-signup # do not auto-create an account for this call +zenrows signup --agent # explicitly provision a Free plan account, headless +zenrows signup --answer "" # answer the signup question (SIGNUP_CHALLENGE) +zenrows login --api-key # use an existing key (stored 0600, gitignored) +zenrows login --env # use the ZENROWS_API_KEY environment variable ``` Set `auto_signup: false` in `.zenrows/policy.json` to disable auto-provisioning diff --git a/agent-plugin/skills/zenrows/SKILL.md b/agent-plugin/skills/zenrows/SKILL.md index e0da8f9..b3041e4 100644 --- a/agent-plugin/skills/zenrows/SKILL.md +++ b/agent-plugin/skills/zenrows/SKILL.md @@ -20,7 +20,10 @@ You do **not** need an API key in the plugin package. - **stdio** (`npx -y @zenrows/mcp`): on first cloud call the server can auto-create a **free, unclaimed** Zenrows Free plan account and persist the - key locally. When the Free plan nears its limit (or the user asks), surface + key locally. Zenrows may first ask one short question (which word is a + bird, which word does not belong): the tool call + returns it; answer it yourself with the `account_signup_answer` tool, then + retry the call. When the Free plan nears its limit (or the user asks), surface the **claim URL** so a human can claim the account (confirming an email with Google, GitHub or a 6-digit code). Claim links expire after 30 days. The same key keeps working after claim. Never handle payment or upgrades — diff --git a/skills/zenrows/SKILL.md b/skills/zenrows/SKILL.md index d8fe34a..ac7c1f6 100644 --- a/skills/zenrows/SKILL.md +++ b/skills/zenrows/SKILL.md @@ -20,6 +20,13 @@ the toolkit automatically creates a **free, unclaimed Zenrows Free plan account* key locally — no human step required. To opt out, pass `--no-signup` or set `auto_signup: false` in `.zenrows/policy.json`, then use `zenrows login --api-key `. +Before it creates the account, Zenrows may ask you one short question (error code +`SIGNUP_CHALLENGE`, with the question in the message and in `error.challenge.question`). +Answer it yourself, do not ask the human, then run `zenrows signup --answer ""` +and re-run your original command. A wrong answer returns a new question the same way. +After too many questions or wrong answers from one network (`SIGNUP_CHALLENGE_LOCKED`), wait an hour or use +`zenrows login --api-key `. + When the Free plan nears its limit (or the user asks), tell the human they can **claim** the account to keep their usage and history: run `zenrows account claim` (or open the claim URL shown on quota errors). It prints a valid link, requesting a fresh one when the stored link is diff --git a/src/cli/commands/signup.ts b/src/cli/commands/signup.ts index 15feb81..19ff892 100644 --- a/src/cli/commands/signup.ts +++ b/src/cli/commands/signup.ts @@ -4,9 +4,13 @@ * There is no documented programmatic signup API, so we never fake account * creation: we explain what is needed, open the signup URL, and hand off to * `login`. + * + * `--agent` provisions a Free plan account headless, and `--answer` answers the + * one short question signup may ask an agent first (SIGNUP_CHALLENGE). */ -import { saveApiKey } from "../../core/auth.ts"; -import { readAccount, signupAgent, writeAccount } from "../../core/agent-account.ts"; +import { answerSignupChallenge, readAccount, signupAgent } from "../../core/agent-account.ts"; +import { storeSignup } from "../../core/ensure-key.ts"; +import { ToolkitError } from "../../core/errors.ts"; import { attributionEnabled } from "../../core/config.ts"; import { log } from "../../core/logger.ts"; import { openUrl, SIGNUP_URL, withParams } from "../../core/open-url.ts"; @@ -16,17 +20,55 @@ import { parse, type Command, type RunContext } from "../command.ts"; export const signup: Command = { name: "signup", summary: "Create a Zenrows account / obtain an API key (opens the browser).", - usage: "zenrows signup [--agent] [--no-open]", + usage: 'zenrows signup [--agent] [--no-open] [--answer ""]', + help: [ + "Without flags, opens the Zenrows signup page in the browser.", + "", + " --agent Create a Free plan account headless and store its key.", + " --no-open Print the signup URL instead of opening it.", + ' --answer "" Answer the question Zenrows asked before creating an account', + " (error SIGNUP_CHALLENGE). Answer it yourself, then re-run", + " your original command.", + ].join("\n"), async run(argv: string[], ctx: RunContext): Promise { - const { values } = parse(argv, { "no-open": { type: "boolean" }, agent: { type: "boolean" } }); + const { values } = parse(argv, { + "no-open": { type: "boolean" }, + agent: { type: "boolean" }, + answer: { type: "string" }, + }); + + if (values.answer !== undefined) { + const answer = String(values.answer).trim(); + if (!answer) { + throw new ToolkitError({ + code: "INVALID_USAGE", + message: "--answer needs the answer text.", + likely_cause: "An empty value was passed to --answer.", + next_action: 'Run: zenrows signup --answer ""', + }); + } + const res = await answerSignupChallenge(answer); + const account = storeSignup(res); + log.success("Right answer. Created a Zenrows Free plan account and stored its API key."); + log.dim(`Claim it anytime (keeps your usage): ${account.claimUrl}`); + log.info("Now re-run your original command."); + if (ctx.json) { + log.out( + JSON.stringify( + { ok: true, accountId: res.accountId, claimUrl: res.claimUrl, next_action: "Re-run your original command." }, + null, + 2, + ), + ); + } + return 0; + } if (values.agent) { const account = readAccount(); if (account) { log.info("An agent account already exists. Claim it with: zenrows account claim"); return 0; } const res = await signupAgent(); - saveApiKey(res.apiKey); - const now = new Date().toISOString(); - writeAccount({ accountId: res.accountId, unclaimed: true, claimUrl: res.claimUrl, createdAt: now, claimUrlIssuedAt: now }); + storeSignup(res); log.success("Created a Zenrows Free plan account."); log.dim(`Claim it anytime: ${res.claimUrl}`); if (ctx.json) log.out(JSON.stringify({ ok: true, accountId: res.accountId, claimUrl: res.claimUrl }, null, 2)); diff --git a/src/core/agent-account.ts b/src/core/agent-account.ts index 96c11b4..dc21dbd 100644 --- a/src/core/agent-account.ts +++ b/src/core/agent-account.ts @@ -13,6 +13,18 @@ import { ToolkitError } from "./errors.ts"; import { AGENT_SIGNUP_API_URL, WELL_KNOWN_PROTECTED_RESOURCE } from "./open-url.ts"; import { detectClient } from "./provenance.ts"; import { findWorkspace, readJson, workspacePaths, writePrivateJson } from "./workspace.ts"; +import { + challengeError, + challengeExpired, + challengeLockedError, + clearChallenge, + isChallengeLocked, + noChallengeError, + parseChallenge, + readChallenge, + wasWrongAnswer, + writeChallenge, +} from "./signup-challenge.ts"; /** Env var to override the signup endpoint (highest priority; for local testing). */ export const SIGNUP_URL_ENV = "ZENROWS_AGENT_SIGNUP_URL"; @@ -137,11 +149,31 @@ export interface SignupResponse { } export async function signupAgent( - opts: { url?: string; fetchImpl?: typeof fetch } = {}, + opts: { url?: string; fetchImpl?: typeof fetch; projectRoot?: string } = {}, ): Promise { - const urls = opts.url ? [opts.url] : await signupCandidates(undefined, { fetchImpl: opts.fetchImpl }); - const doFetch = opts.fetchImpl ?? fetch; + const urls = opts.url ? [opts.url] : await signupCandidates(opts.projectRoot, { fetchImpl: opts.fetchImpl }); + return postSignup(urls, undefined, opts); +} + +/** + * Answer the pending signup question (see signup-challenge.ts). Posts it to + * the signup URL that asked it; a right answer creates the account like a + * plain signup does. A wrong answer stores and reports the new question. + */ +export async function answerSignupChallenge( + answer: string, + opts: { fetchImpl?: typeof fetch; projectRoot?: string } = {}, +): Promise { + const pending = readChallenge(opts.projectRoot); + if (!pending) throw noChallengeError(false); + if (challengeExpired(pending)) { + clearChallenge(opts.projectRoot); + throw noChallengeError(true); + } + return postSignup([pending.signupUrl], { challenge_id: pending.id, answer }, opts); +} +function signupHeaders(projectRoot?: string): Record { // Signup is the one functional request the CLI must make — no separate // telemetry beacon exists. When attribution is enabled we ride anonymous // provenance on it (a random id + coarse host/runtime brand). No PII, no URLs. @@ -149,7 +181,7 @@ export async function signupAgent( const headers: Record = { "content-type": "application/json", "User-Agent": CLI_USER_AGENT, - ...agentClientHeader(), + ...agentClientHeader({ projectRoot }), }; if (attributionEnabled()) { const p = detectClient(); @@ -161,15 +193,27 @@ export async function signupAgent( headers["X-ZR-Node"] = p.node; headers["X-ZR-CI"] = p.ci ? "1" : "0"; } + return headers; +} + +async function postSignup( + urls: string[], + body: { challenge_id: string; answer: string } | undefined, + opts: { fetchImpl?: typeof fetch; projectRoot?: string }, +): Promise { + const doFetch = opts.fetchImpl ?? fetch; + const headers = signupHeaders(opts.projectRoot); // Try each candidate in order; on any failure fall back to the next (the last // is always the built-in default). This is the safety net: a wrong/blocked // discovered endpoint can never leave provisioning worse off than no discovery. + // A signup question or the wrong-answer lock is a real answer from a working + // endpoint, so it ends the loop. let lastErr: ToolkitError | undefined; for (const url of urls) { let res: Response; try { - res = await doFetch(url, { method: "POST", headers }); + res = await doFetch(url, { method: "POST", headers, ...(body ? { body: JSON.stringify(body) } : {}) }); } catch (err) { lastErr = new ToolkitError({ code: "BACKEND_UNAVAILABLE", @@ -179,8 +223,24 @@ export async function signupAgent( }); continue; } - if (res.status === 201) return (await res.json()) as SignupResponse; - lastErr = signupError(res.status, await res.text()); + if (res.status === 201) { + clearChallenge(opts.projectRoot); + return (await res.json()) as SignupResponse; + } + const text = await res.text(); + if (res.status === 428) { + const challenge = parseChallenge(text, url); + if (challenge) { + writeChallenge(challenge, opts.projectRoot); + throw challengeError(challenge, { wrong: body !== undefined || wasWrongAnswer(text) }); + } + } + if (res.status === 429 && isChallengeLocked(text)) { + clearChallenge(opts.projectRoot); + const ra = Number(res.headers.get("retry-after")); + throw challengeLockedError(Number.isFinite(ra) && ra > 0 ? ra : undefined); + } + lastErr = signupError(res.status, text); } throw ( lastErr ?? diff --git a/src/core/ensure-key.ts b/src/core/ensure-key.ts index 07ae1c9..6f5f456 100644 --- a/src/core/ensure-key.ts +++ b/src/core/ensure-key.ts @@ -4,11 +4,13 @@ * When no key is configured and `policy.auto_signup` is enabled, this creates a * free unclaimed Free plan account (via the public signup endpoint), persists the * key (0600) and the account metadata, and returns the key. Otherwise it defers - * to `requireApiKey`, which throws AUTH_MISSING. + * to `requireApiKey`, which throws AUTH_MISSING. Signup may first ask one + * short question (SIGNUP_CHALLENGE), which the agent answers with + * `zenrows signup --answer`. */ import type { AgentAccount, Policy } from "../types/index.ts"; import { requireApiKey, resolveApiKey, saveApiKey } from "./auth.ts"; -import { signupAgent, writeAccount } from "./agent-account.ts"; +import { signupAgent, writeAccount, type SignupResponse } from "./agent-account.ts"; export async function ensureApiKey( policy: Policy, @@ -23,8 +25,16 @@ export async function ensureApiKey( if (!policy.auto_signup) return requireApiKey(opts.projectRoot); // throws AUTH_MISSING const doSignup = opts.signupImpl ?? signupAgent; - const res = await doSignup(); - saveApiKey(res.apiKey, opts.projectRoot); + // May throw SIGNUP_CHALLENGE: the agent answers with `zenrows signup --answer`. + const res = await doSignup({ projectRoot: opts.projectRoot }); + const account = storeSignup(res, opts.projectRoot); + opts.onProvision?.(account); + return res.apiKey; +} + +/** Persist a fresh agent signup: the key (0600) and the account metadata. */ +export function storeSignup(res: SignupResponse, projectRoot?: string): AgentAccount { + saveApiKey(res.apiKey, projectRoot); const account: AgentAccount = { accountId: res.accountId, unclaimed: true, @@ -32,7 +42,6 @@ export async function ensureApiKey( createdAt: new Date().toISOString(), }; account.claimUrlIssuedAt = account.createdAt; - writeAccount(account, opts.projectRoot); - opts.onProvision?.(account); - return res.apiKey; + writeAccount(account, projectRoot); + return account; } diff --git a/src/core/errors.ts b/src/core/errors.ts index 04cd13f..cd32a64 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -23,6 +23,9 @@ export type ErrorCode = | "POLICY_BROWSER_DISABLED" | "SIGNUP_RATE_LIMITED" | "SIGNUP_FAILED" + | "SIGNUP_CHALLENGE" + | "SIGNUP_CHALLENGE_LOCKED" + | "SIGNUP_CHALLENGE_MISSING" | "UNKNOWN_FLAG" | "DOMAIN_FORBIDDEN" | "FETCH_FAILED" @@ -90,6 +93,8 @@ export interface ToolkitErrorShape { idempotency_key?: string; /** Seconds to wait before a retry (the `Retry-After` header). */ retry_after?: number; + /** The question signup asks before it creates an account (SIGNUP_CHALLENGE). */ + challenge?: { id: string; question: string; expires_at: string }; } export class ToolkitError extends Error { @@ -102,6 +107,7 @@ export class ToolkitError extends Error { readonly job_id?: string; readonly idempotency_key?: string; readonly retry_after?: number; + readonly challenge?: { id: string; question: string; expires_at: string }; constructor(shape: ToolkitErrorShape) { super(shape.message); @@ -115,6 +121,7 @@ export class ToolkitError extends Error { this.job_id = shape.job_id; this.idempotency_key = shape.idempotency_key; this.retry_after = shape.retry_after; + this.challenge = shape.challenge; } toJSON(): ToolkitErrorShape { @@ -129,6 +136,7 @@ export class ToolkitError extends Error { ...(this.job_id ? { job_id: this.job_id } : {}), ...(this.idempotency_key ? { idempotency_key: this.idempotency_key } : {}), ...(this.retry_after !== undefined ? { retry_after: this.retry_after } : {}), + ...(this.challenge ? { challenge: this.challenge } : {}), }; } } diff --git a/src/core/signup-challenge.ts b/src/core/signup-challenge.ts new file mode 100644 index 0000000..af03fe8 --- /dev/null +++ b/src/core/signup-challenge.ts @@ -0,0 +1,136 @@ +/** + * The signup challenge: before it creates an account, the signup endpoint may + * answer 428 with one short natural-language question for the agent running + * the CLI to answer itself. The CLI never tries to solve it. + * + * Each CLI run is its own process, so the pending question is kept in + * `.zenrows/signup-challenge.json` (no secret: the id, the question, when it + * expires, and the signup URL that issued it, which is where the answer goes). + */ +import { existsSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { ToolkitError } from "./errors.ts"; +import { findWorkspace, readJson, workspacePaths, writeJson } from "./workspace.ts"; + +export interface PendingChallenge { + id: string; + prompt: string; + /** ISO timestamp after which the server no longer accepts an answer. */ + expiresAt: string; + /** The signup URL that issued the question; the answer is posted there. */ + signupUrl: string; +} + +/** The command an agent runs to answer the pending question. */ +export const ANSWER_COMMAND = 'zenrows signup --answer ""'; + +export function challengePath(projectRoot?: string): string { + const ws = projectRoot ? workspacePaths(projectRoot) : (findWorkspace() ?? workspacePaths()); + return join(ws.dir, "signup-challenge.json"); +} + +export function readChallenge(projectRoot?: string): PendingChallenge | null { + try { + const c = readJson(challengePath(projectRoot)); + if (!c || typeof c.id !== "string" || typeof c.prompt !== "string" || typeof c.signupUrl !== "string") return null; + return c; + } catch { + return null; + } +} + +export function writeChallenge(c: PendingChallenge, projectRoot?: string): void { + writeJson(challengePath(projectRoot), c); +} + +export function clearChallenge(projectRoot?: string): void { + const file = challengePath(projectRoot); + if (!existsSync(file)) return; + try { + unlinkSync(file); + } catch { + // best effort + } +} + +/** True when the stored question can no longer be answered. */ +export function challengeExpired(c: PendingChallenge, now = Date.now()): boolean { + const t = Date.parse(c.expiresAt); + return Number.isFinite(t) && t <= now; +} + +interface ChallengeBody { + error?: unknown; + message?: unknown; + challenge?: { id?: unknown; prompt?: unknown; expires_in?: unknown }; +} + +function parseBody(body: string): ChallengeBody | null { + try { + const json = JSON.parse(body) as unknown; + return json && typeof json === "object" ? (json as ChallengeBody) : null; + } catch { + return null; + } +} + +/** + * Parse a 428 `challenge_required` body into a pending challenge, or null when + * the body is not one (then the caller reports a generic failure). + */ +export function parseChallenge(body: string, signupUrl: string, now = Date.now()): PendingChallenge | null { + const json = parseBody(body); + if (!json || json.error !== "challenge_required") return null; + const ch = json.challenge; + if (!ch || typeof ch.id !== "string" || !ch.id || typeof ch.prompt !== "string" || !ch.prompt.trim()) return null; + const ttl = typeof ch.expires_in === "number" && ch.expires_in > 0 ? ch.expires_in : 600; + return { id: ch.id, prompt: ch.prompt.trim(), expiresAt: new Date(now + ttl * 1000).toISOString(), signupUrl }; +} + +/** True when a 428 says the previous answer was wrong or came too late. */ +export function wasWrongAnswer(body: string): boolean { + const msg = parseBody(body)?.message; + return typeof msg === "string" && /^wrong or expired/i.test(msg.trim()); +} + +/** True when a 429 is the per-network lock after too many questions or wrong answers. */ +export function isChallengeLocked(body: string): boolean { + return parseBody(body)?.error === "challenge_locked"; +} + +/** The error an agent sees while a question is waiting for its answer. */ +export function challengeError(c: PendingChallenge, opts: { wrong?: boolean } = {}): ToolkitError { + const lead = opts.wrong ? "That answer was wrong or came too late, so here is a new question. " : ""; + return new ToolkitError({ + code: "SIGNUP_CHALLENGE", + message: `${lead}Zenrows needs one quick answer before it creates an account: ${c.prompt}`, + likely_cause: "No API key is configured, and new accounts created by agents answer one short question first.", + next_action: `Answer the question yourself, then run: ${ANSWER_COMMAND}. When it succeeds, re-run your original command.`, + suggested_commands: [ANSWER_COMMAND, "zenrows login --api-key "], + challenge: { id: c.id, question: c.prompt, expires_at: c.expiresAt }, + }); +} + +export function challengeLockedError(retryAfter?: number): ToolkitError { + return new ToolkitError({ + code: "SIGNUP_CHALLENGE_LOCKED", + message: "Too many signup questions or wrong answers from this network, so Zenrows paused account creation here.", + likely_cause: "Too many signup questions asked, or wrong answers given, from this network within an hour.", + next_action: + "Try again in an hour by re-running your original command, which asks a new question. If you already have a Zenrows API key, use it now: zenrows login --api-key (or set ZENROWS_API_KEY).", + suggested_commands: ["zenrows login --api-key "], + ...(retryAfter !== undefined ? { retry_after: retryAfter } : {}), + }); +} + +export function noChallengeError(expired: boolean): ToolkitError { + return new ToolkitError({ + code: "SIGNUP_CHALLENGE_MISSING", + message: expired ? "The signup question expired before it was answered." : "There is no signup question waiting for an answer.", + likely_cause: expired + ? "Questions are valid for a few minutes." + : "No signup was attempted from this directory, or the question was already answered.", + next_action: "Re-run your original command (for example zenrows fetch ) to get a new question, then answer it with: " + ANSWER_COMMAND, + suggested_commands: ["zenrows fetch "], + }); +} diff --git a/src/core/workspace.ts b/src/core/workspace.ts index 41e186d..18b2c6b 100644 --- a/src/core/workspace.ts +++ b/src/core/workspace.ts @@ -85,6 +85,7 @@ export function ensureGitignore(paths: WorkspacePaths): void { `${WORKSPACE_DIRNAME}/secrets.json`, `${WORKSPACE_DIRNAME}/config.json`, `${WORKSPACE_DIRNAME}/account.json`, + `${WORKSPACE_DIRNAME}/signup-challenge.json`, `${WORKSPACE_DIRNAME}/runs/`, `${WORKSPACE_DIRNAME}/traces/`, `${WORKSPACE_DIRNAME}/logs/`, diff --git a/tests/signup-challenge.test.ts b/tests/signup-challenge.test.ts new file mode 100644 index 0000000..8c7c16d --- /dev/null +++ b/tests/signup-challenge.test.ts @@ -0,0 +1,264 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { tempRoot } from "./helpers.ts"; +import { createWorkspace } from "../src/core/workspace.ts"; +import { resolveApiKey } from "../src/core/auth.ts"; +import { answerSignupChallenge, readAccount, signupAgent } from "../src/core/agent-account.ts"; +import { ensureApiKey } from "../src/core/ensure-key.ts"; +import { ToolkitError } from "../src/core/errors.ts"; +import { defaultPolicy } from "../src/core/policy.ts"; +import { challengePath, readChallenge, writeChallenge } from "../src/core/signup-challenge.ts"; +import { printError } from "../src/cli/output.ts"; +import { signup } from "../src/cli/commands/signup.ts"; + +const URL_ = "https://stub.test/api/agent/signup"; + +function challenge428(id: string, prompt: string, message = "To create a Zenrows account, answer: ...") { + return new Response( + JSON.stringify({ message, error: "challenge_required", next: "...", challenge: { id, prompt, expires_in: 600 } }), + { status: 428, headers: { "content-type": "application/json" } }, + ); +} + +type Call = { url: string; body?: string }; + +function stubFetch(responses: Array<() => Response>, calls: Call[]): typeof fetch { + return (async (url: string, init?: RequestInit) => { + calls.push({ url: String(url), body: typeof init?.body === "string" ? init.body : undefined }); + const next = responses.shift(); + if (!next) throw new Error("unexpected request " + url); + return next(); + }) as unknown as typeof fetch; +} + +function withTelemetryOff(fn: () => Promise): Promise { + const prev = process.env.ZENROWS_TELEMETRY; + process.env.ZENROWS_TELEMETRY = "off"; + return fn().finally(() => { + if (prev === undefined) delete process.env.ZENROWS_TELEMETRY; + else process.env.ZENROWS_TELEMETRY = prev; + }); +} + +test("a 428 challenge_required is stored and reported as SIGNUP_CHALLENGE with the question", async () => { + const { root, cleanup } = tempRoot(); + try { + createWorkspace(root); + const calls: Call[] = []; + const fetchImpl = stubFetch([() => challenge428("abcd1234", "which of norway, walrus, pear is a country?")], calls); + await withTelemetryOff(async () => { + await assert.rejects( + () => signupAgent({ url: URL_, fetchImpl, projectRoot: root }), + (e: unknown) => { + assert.ok(e instanceof ToolkitError); + assert.equal(e.code, "SIGNUP_CHALLENGE"); + assert.equal( + e.message, + "Zenrows needs one quick answer before it creates an account: which of norway, walrus, pear is a country?", + ); + assert.match(e.next_action, /zenrows signup --answer ""/); + assert.doesNotMatch(e.message + e.next_action + e.likely_cause, /—/); + const json = e.toJSON(); + assert.equal(json.challenge?.question, "which of norway, walrus, pear is a country?"); + assert.equal(json.challenge?.id, "abcd1234"); + assert.ok(Date.parse(json.challenge!.expires_at) > Date.now()); + return true; + }, + ); + }); + const stored = readChallenge(root); + assert.equal(stored?.id, "abcd1234"); + assert.equal(stored?.signupUrl, URL_); + assert.equal(calls[0]!.body, undefined, "a plain signup sends no body"); + } finally { + cleanup(); + } +}); + +test("--json error output carries the code, question and answer command", async () => { + const out: string[] = []; + const prevWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = ((s: string) => { out.push(String(s)); return true; }) as typeof process.stdout.write; + try { + const { root, cleanup } = tempRoot(); + try { + createWorkspace(root); + const fetchImpl = stubFetch([() => challenge428("abcd1234", "odd one out: gecko, chin, badger, ferret?")], []); + const err = await withTelemetryOff(() => signupAgent({ url: URL_, fetchImpl, projectRoot: root }).catch((e) => e)); + printError(err, true); + } finally { + cleanup(); + } + } finally { + process.stdout.write = prevWrite; + } + const parsed = JSON.parse(out.join("")); + assert.equal(parsed.ok, false); + assert.equal(parsed.error.code, "SIGNUP_CHALLENGE"); + assert.equal(parsed.error.challenge.question, "odd one out: gecko, chin, badger, ferret?"); + assert.deepEqual(parsed.error.suggested_commands[0], 'zenrows signup --answer ""'); +}); + +test("ensureApiKey surfaces SIGNUP_CHALLENGE and stores no key", async () => { + const { root, cleanup } = tempRoot(); + try { + createWorkspace(root); + const fetchImpl = stubFetch([() => challenge428("abcd1234", "q?")], []); + await withTelemetryOff(async () => { + await assert.rejects( + () => ensureApiKey(defaultPolicy(), { projectRoot: root, signupImpl: (o) => signupAgent({ ...o, url: URL_, fetchImpl }) }), + (e: unknown) => e instanceof ToolkitError && e.code === "SIGNUP_CHALLENGE", + ); + }); + assert.equal(resolveApiKey(root).key, undefined); + assert.equal(readChallenge(root)?.id, "abcd1234"); + } finally { + cleanup(); + } +}); + +test("a right answer posts {challenge_id, answer} to the issuing URL and clears the question", async () => { + const { root, cleanup } = tempRoot(); + try { + createWorkspace(root); + writeChallenge({ id: "abcd1234", prompt: "q?", expiresAt: new Date(Date.now() + 60_000).toISOString(), signupUrl: URL_ }, root); + const calls: Call[] = []; + const fetchImpl = stubFetch( + [() => new Response(JSON.stringify({ apiKey: "zr-new", accountId: "u1", claimUrl: "https://x/claim/t" }), { status: 201 })], + calls, + ); + const res = await withTelemetryOff(() => answerSignupChallenge("norway", { fetchImpl, projectRoot: root })); + assert.equal(res.apiKey, "zr-new"); + assert.equal(calls[0]!.url, URL_); + assert.deepEqual(JSON.parse(calls[0]!.body!), { challenge_id: "abcd1234", answer: "norway" }); + assert.equal(existsSync(challengePath(root)), false); + } finally { + cleanup(); + } +}); + +test("a wrong answer stores the new question and says the answer was wrong", async () => { + const { root, cleanup } = tempRoot(); + try { + createWorkspace(root); + writeChallenge({ id: "old00000", prompt: "q?", expiresAt: new Date(Date.now() + 60_000).toISOString(), signupUrl: URL_ }, root); + const fetchImpl = stubFetch([() => challenge428("new11111", "odd one out: gecko, chin, badger, ferret?", "Wrong or expired. To create...")], []); + await withTelemetryOff(async () => { + await assert.rejects( + () => answerSignupChallenge("7", { fetchImpl, projectRoot: root }), + (e: unknown) => + e instanceof ToolkitError && + e.code === "SIGNUP_CHALLENGE" && + /^That answer was wrong or came too late/.test(e.message) && + e.message.endsWith("odd one out: gecko, chin, badger, ferret?"), + ); + }); + assert.equal(readChallenge(root)?.id, "new11111"); + } finally { + cleanup(); + } +}); + +test("429 challenge_locked becomes SIGNUP_CHALLENGE_LOCKED with retry_after", async () => { + const { root, cleanup } = tempRoot(); + try { + createWorkspace(root); + writeChallenge({ id: "abcd1234", prompt: "q?", expiresAt: new Date(Date.now() + 60_000).toISOString(), signupUrl: URL_ }, root); + const fetchImpl = stubFetch( + [() => new Response(JSON.stringify({ error: "challenge_locked", message: "x" }), { status: 429, headers: { "retry-after": "3600" } })], + [], + ); + await withTelemetryOff(async () => { + await assert.rejects( + () => answerSignupChallenge("x", { fetchImpl, projectRoot: root }), + (e: unknown) => { + assert.ok(e instanceof ToolkitError); + assert.equal(e.code, "SIGNUP_CHALLENGE_LOCKED"); + assert.equal(e.retry_after, 3600); + assert.match(e.next_action, /in an hour/); + assert.match(e.next_action, /zenrows login --api-key/); + return true; + }, + ); + }); + assert.equal(readChallenge(root), null); + } finally { + cleanup(); + } +}); + +test("no pending question, or an expired one, is SIGNUP_CHALLENGE_MISSING without a request", async () => { + const { root, cleanup } = tempRoot(); + try { + createWorkspace(root); + const calls: Call[] = []; + const fetchImpl = stubFetch([], calls); + await assert.rejects( + () => answerSignupChallenge("x", { fetchImpl, projectRoot: root }), + (e: unknown) => e instanceof ToolkitError && e.code === "SIGNUP_CHALLENGE_MISSING" && /re-run your original command/i.test(e.next_action), + ); + writeChallenge({ id: "abcd1234", prompt: "q?", expiresAt: new Date(Date.now() - 1000).toISOString(), signupUrl: URL_ }, root); + await assert.rejects( + () => answerSignupChallenge("x", { fetchImpl, projectRoot: root }), + (e: unknown) => e instanceof ToolkitError && e.code === "SIGNUP_CHALLENGE_MISSING" && /expired/.test(e.message), + ); + assert.equal(readChallenge(root), null); + assert.equal(calls.length, 0); + } finally { + cleanup(); + } +}); + +test("signup --answer stores the key and account and prints the --json result", async () => { + const { root, cleanup } = tempRoot(); + const prevFetch = globalThis.fetch; + const prevCwd = process.cwd(); + const out: string[] = []; + const prevWrite = process.stdout.write.bind(process.stdout); + try { + createWorkspace(root); + writeChallenge({ id: "abcd1234", prompt: "q?", expiresAt: new Date(Date.now() + 60_000).toISOString(), signupUrl: URL_ }, root); + globalThis.fetch = stubFetch( + [() => new Response(JSON.stringify({ apiKey: "zr-new", accountId: "u1", claimUrl: "https://x/claim/t" }), { status: 201 })], + [], + ); + process.chdir(root); + process.stdout.write = ((s: string) => { out.push(String(s)); return true; }) as typeof process.stdout.write; + const code = await withTelemetryOff(async () => signup.run(["--answer", "norway"], { json: true, yes: true })); + assert.equal(code, 0); + assert.equal(resolveApiKey(root).key, "zr-new"); + assert.equal(readAccount(root)?.accountId, "u1"); + assert.equal(readAccount(root)?.unclaimed, true); + const parsed = JSON.parse(out.join("")); + assert.deepEqual(parsed, { ok: true, accountId: "u1", claimUrl: "https://x/claim/t", next_action: "Re-run your original command." }); + } finally { + process.stdout.write = prevWrite; + process.chdir(prevCwd); + globalThis.fetch = prevFetch; + cleanup(); + } +}); + +test("signup --answer with an empty value is INVALID_USAGE", async () => { + await assert.rejects( + async () => signup.run(["--answer", " "], { json: true, yes: true }), + (e: unknown) => e instanceof ToolkitError && e.code === "INVALID_USAGE", + ); +}); + +test("a 428 that is not a challenge falls through to SIGNUP_FAILED", async () => { + const fetchImpl = stubFetch([() => new Response("nope", { status: 428 })], []); + const { root, cleanup } = tempRoot(); + try { + await withTelemetryOff(async () => { + await assert.rejects( + () => signupAgent({ url: URL_, fetchImpl, projectRoot: root }), + (e: unknown) => e instanceof ToolkitError && e.code === "SIGNUP_FAILED", + ); + }); + assert.equal(readChallenge(root), null); + } finally { + cleanup(); + } +});