diff --git a/.changeset/api-validate-json-body.md b/.changeset/api-validate-json-body.md new file mode 100644 index 000000000..408073018 --- /dev/null +++ b/.changeset/api-validate-json-body.md @@ -0,0 +1,5 @@ +--- +"clerk": patch +--- + +Reject an invalid `clerk api` request body on your machine instead of sending it. The error echoes what arrived and, when a `-d` value reached the CLI with its double quotes stripped or wrapped in literal single quotes, names the shell quoting behind it — an unquoted body in a POSIX shell, or PowerShell before 7.3 and cmd.exe on Windows — and suggests the same request with `--file`, which no shell can mangle. Those shell-quoting rejections carry the error code `invalid_json_shell_quoting`; other parse failures keep `invalid_json`. diff --git a/packages/cli-core/src/commands/api/README.md b/packages/cli-core/src/commands/api/README.md index 1ddb6b500..66fa8116e 100644 --- a/packages/cli-core/src/commands/api/README.md +++ b/packages/cli-core/src/commands/api/README.md @@ -65,6 +65,36 @@ clerk api /v1/platform/applications --platform clerk api --fapi /environment --app app_123 --instance dev ``` +## Request bodies and shell quoting + +`-d` takes the body exactly as your shell hands it over, and the CLI parses it +before sending — an unparseable payload fails locally with the reason, instead of +costing a round trip and a server-side byte offset. When the value reached the +CLI with its double quotes stripped, or wrapped in literal single quotes, the +error names the shell quoting behind it. + +The `-d '{"key":"value"}'` form in the examples above is POSIX shell syntax: the +single quotes keep bash and zsh from consuming the double quotes inside. Leave +them off and the shell strips those quotes, so the CLI receives `{key:value}`. + +Even with the single quotes, that form fails in PowerShell before 7.3 and in +cmd.exe: + +- **PowerShell before 7.3** passes an argument's embedded double quotes to a + native program unescaped, so the program's command-line parser consumes them: + `-d '{"user_id":"x"}'` arrives as `{user_id:x}`. PowerShell 7.3 fixed this. +- **cmd.exe** gives `'` no special meaning, so the wrapping single quotes are + passed through as part of the value, and the double quotes inside are consumed + the same way: `'{user_id:x}'`. + +`--file` and piped stdin sidestep the shell entirely and behave the same +everywhere, so prefer them for anything non-trivial and in scripts: + +```sh +clerk api /users --file body.json +cat body.json | clerk api /users +``` + ## Options | Flag | Description | diff --git a/packages/cli-core/src/commands/api/index.test.ts b/packages/cli-core/src/commands/api/index.test.ts index 151e765ec..96d48a378 100644 --- a/packages/cli-core/src/commands/api/index.test.ts +++ b/packages/cli-core/src/commands/api/index.test.ts @@ -726,4 +726,156 @@ describe("api command", () => { await runApi("/users", { data: '{"from":"inline"}', file: bodyFile }); expect(JSON.parse(capturedBody)).toEqual({ from: "inline" }); }); + + // --- request body is parse-checked before it goes out --- + + test("rejects invalid -d without making a request", async () => { + let requested = false; + stubFetch(async () => { + requested = true; + return new Response("{}", { status: 200 }); + }); + + // What PowerShell before 7.3 leaves of -d '{"first_name":"Alice"}'. + await expect(runApi("/users", { data: "{first_name:Alice}" })).rejects.toThrow( + "Invalid JSON in --data", + ); + expect(requested).toBe(false); + }); + + test('rejects an explicit -d "" instead of sending a bodyless request', async () => { + let requested = false; + stubFetch(async () => { + requested = true; + return new Response("{}", { status: 200 }); + }); + + await expect(runApi("/users", { data: "" })).rejects.toThrow( + "Invalid JSON in --data: the body is empty.", + ); + expect(requested).toBe(false); + }); + + test("rejects an invalid --file body", async () => { + const bodyFile = join(tempDir, "broken.json"); + await Bun.write(bodyFile, '{"first_name":"Alice"'); + + await expect(runApi("/users", { file: bodyFile })).rejects.toThrow( + `Invalid JSON in --file ${bodyFile}`, + ); + }); + + /** + * Make stdin look like a pipe carrying `text`: not a TTY, and its async + * iterator yields that one chunk. Returns the restore function; isTTY itself + * is put back by afterEach. + */ + function pipeStdin(text: string): () => void { + Object.defineProperty(process.stdin, "isTTY", { + value: false, + writable: true, + configurable: true, + }); + const original = process.stdin[Symbol.asyncIterator]; + Object.defineProperty(process.stdin, Symbol.asyncIterator, { + value: async function* () { + if (text) yield Buffer.from(text); + }, + writable: true, + configurable: true, + }); + return () => { + Object.defineProperty(process.stdin, Symbol.asyncIterator, { + value: original, + writable: true, + configurable: true, + }); + }; + } + + test("rejects an invalid piped body", async () => { + const restore = pipeStdin("not json at all"); + try { + await expect(runApi("/users")).rejects.toThrow("Invalid JSON in the piped request body"); + } finally { + restore(); + } + }); + + // CI jobs and cron have a non-TTY stdin with nothing on it; a plain GET must + // still go out rather than be rejected as an empty body. + test("treats an empty non-TTY stdin as no body, not an empty one", async () => { + let capturedMethod = ""; + let capturedBody: unknown = "unset"; + stubFetch(async (_input, init) => { + capturedMethod = init?.method as string; + capturedBody = init?.body; + return new Response(JSON.stringify(mockUsers), { status: 200 }); + }); + + const restore = pipeStdin(""); + try { + await runApi("/users"); + } finally { + restore(); + } + expect(capturedMethod).toBe("GET"); + expect(capturedBody).toBeUndefined(); + }); + + test("forwards a piped body untrimmed", async () => { + let capturedBody = ""; + stubFetch(async (_input, init) => { + capturedBody = init?.body as string; + return new Response("{}", { status: 200 }); + }); + + const raw = '{"first_name": "Alice"}\n'; + const restore = pipeStdin(raw); + try { + await runApi("/users"); + } finally { + restore(); + } + expect(capturedBody).toBe(raw); + }); + + test("--dry-run rejects an invalid body too", async () => { + await expect(runApi("/users", { dryRun: true, data: "{first_name:Alice}" })).rejects.toThrow( + "Invalid JSON in --data", + ); + }); + + test("forwards a valid body byte-for-byte", async () => { + let capturedBody = ""; + stubFetch(async (_input, init) => { + capturedBody = init?.body as string; + return new Response("{}", { status: 200 }); + }); + + const raw = '{"first_name": "Alice"}'; + await runApi("/users", { data: raw }); + expect(capturedBody).toBe(raw); + }); + + test("the suggested --file command repeats the caller's targeting flags", async () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "win32", writable: true }); + try { + const error = (await runApi("/environment", { + fapi: true, + app: "app_1", + instance: "dev", + method: "post", + data: "{a:b}", + }).catch((e: unknown) => e)) as CliError; + expect(error.code).toBe(ERROR_CODE.INVALID_JSON_SHELL_QUOTING); + expect(error.examples?.[0]?.command).toBe( + "clerk api --fapi /environment -X POST --app app_1 --instance dev --file body.json", + ); + expect(error.examples?.[0]?.command).not.toContain("sk_"); + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform, writable: true }); + } + }); }); diff --git a/packages/cli-core/src/commands/api/index.ts b/packages/cli-core/src/commands/api/index.ts index ce9f6c003..34d35808f 100644 --- a/packages/cli-core/src/commands/api/index.ts +++ b/packages/cli-core/src/commands/api/index.ts @@ -7,6 +7,7 @@ import { bapiRequest } from "../../lib/bapi.ts"; import { fapiRequest } from "../../lib/fapi.ts"; import { resolveFapiHost } from "./fapi.ts"; import { ApiError, ERROR_CODE, throwUsageError, throwUserAbort } from "../../lib/errors.ts"; +import { validateJsonBody } from "../../lib/json-body.ts"; import { isHuman } from "../../mode.ts"; import { confirm } from "../../lib/prompts.ts"; import { withSpinner, intro, outro, pausedOutro } from "../../lib/spinner.ts"; @@ -87,7 +88,7 @@ export async function api( } // 1. Resolve the request body - const body = await resolveBody(options); + const body = await resolveBody(options, endpoint); // 2. Determine HTTP method const method = (options.method ?? (body ? "POST" : "GET")).toUpperCase(); @@ -175,25 +176,47 @@ export async function api( } } -async function resolveBody(options: { data?: string; file?: string }): Promise { - if (options.data) return options.data; +/** + * Resolve the request body from `-d`, `--file`, or piped stdin, and parse-check + * it before it can reach the API. The request's targeting flags ride along only + * so the error's suggested command hits the same endpoint; the secret key is + * deliberately not among them, since the suggestion is printed. + */ +async function resolveBody(options: ApiOptions, endpoint: string): Promise { + const request = { + endpoint, + method: options.method, + fapi: options.fapi, + platform: options.platform, + app: options.app, + instance: options.instance, + }; + + // Presence, not truthiness: an explicit `-d ""` is an empty body to reject, + // not a request with no body. + if (options.data !== undefined) { + return validateJsonBody(options.data, { kind: "data" }, request); + } if (options.file) { const file = Bun.file(options.file); if (!(await file.exists())) { throwUsageError(`File not found: ${options.file}`, undefined, ERROR_CODE.FILE_NOT_FOUND); } - return file.text(); + return validateJsonBody(await file.text(), { kind: "file", path: options.file }, request); } - // Read from stdin if piped + // Read from stdin if piped. A non-TTY stdin is not proof of a pipe — CI + // jobs, cron, and `< /dev/null` look the same and yield nothing — so nothing + // (or only whitespace) on stdin means no body rather than an empty one. What + // does arrive is forwarded untrimmed, like a --file body. if (!process.stdin.isTTY) { const chunks: Buffer[] = []; for await (const chunk of process.stdin) { chunks.push(Buffer.from(chunk)); } - const text = Buffer.concat(chunks).toString("utf-8").trim(); - if (text) return text; + const text = Buffer.concat(chunks).toString("utf-8"); + if (text.trim()) return validateJsonBody(text, { kind: "stdin" }, request); } return null; @@ -277,6 +300,11 @@ export function registerApi(program: Program): void { command: 'clerk api /users -d \'{"first_name":"Alice"}\'', description: "POST with a JSON body", }, + { + command: "clerk api /users --file body.json", + description: + "POST a body from a file — no shell quoting, so it works the same in PowerShell and cmd.exe", + }, { command: "clerk api --fapi /environment --app --instance dev", description: "GET the public FAPI environment payload", diff --git a/packages/cli-core/src/commands/users/README.md b/packages/cli-core/src/commands/users/README.md index 286e6e467..13b8023be 100644 --- a/packages/cli-core/src/commands/users/README.md +++ b/packages/cli-core/src/commands/users/README.md @@ -94,6 +94,11 @@ clerk users create --app app_123 --instance prod -d '{"email_address":["alice@ex clerk users create --file user.json --dry-run ``` +The `-d '{"…"}'` form is POSIX shell syntax. It fails in cmd.exe, which passes +the wrapping single quotes through as part of the value, and in PowerShell +before 7.3, which strips an argument's embedded double quotes. Prefer the +curated flags, or `--file`, on Windows and in scripts. + Supported curated flags: - `--email ` diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index 0c7dcd5c0..247e02776 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -42,6 +42,8 @@ export const ERROR_CODE = { INVALID_WEBHOOK_SIGNATURE: "invalid_webhook_signature", /** Input is not valid JSON or not an object. */ INVALID_JSON: "invalid_json", + /** A `clerk api -d` body arrived visibly mangled by shell quoting — stripped or wrapped. */ + INVALID_JSON_SHELL_QUOTING: "invalid_json_shell_quoting", /** Failed to fetch or parse the OpenAPI catalog. */ CATALOG_ERROR: "catalog_error", /** Doctor checks found issues. */ diff --git a/packages/cli-core/src/lib/json-body.test.ts b/packages/cli-core/src/lib/json-body.test.ts new file mode 100644 index 000000000..1fe238efe --- /dev/null +++ b/packages/cli-core/src/lib/json-body.test.ts @@ -0,0 +1,244 @@ +import { test, expect, describe, afterEach } from "bun:test"; +import { CliError, ERROR_CODE } from "./errors.ts"; +import { validateJsonBody, type JsonBodyRequest, type JsonBodySource } from "./json-body.ts"; + +const DATA: JsonBodySource = { kind: "data" }; +const USERS: JsonBodyRequest = { endpoint: "/users" }; + +function rejection(raw: string, source: JsonBodySource = DATA, request = USERS): CliError { + try { + validateJsonBody(raw, source, request); + } catch (error) { + return error as CliError; + } + throw new Error(`expected ${raw} to be rejected`); +} + +const originalPlatform = process.platform; +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { value: platform, writable: true }); +} +afterEach(() => setPlatform(originalPlatform)); + +describe("validateJsonBody", () => { + test("returns the body byte-for-byte when it parses", () => { + const raw = ' {"first_name": "Alice"}\n'; + expect(validateJsonBody(raw, DATA, USERS)).toBe(raw); + }); + + test("accepts arrays and top-level primitives", () => { + // The API decodes into a struct, so a non-object is a type error there, not + // a syntax error — this check only stands in for the syntax check. + expect(validateJsonBody('[{"id":1}]', DATA, USERS)).toBe('[{"id":1}]'); + expect(validateJsonBody("42", DATA, USERS)).toBe("42"); + }); + + test("rejects malformed JSON as a usage error with INVALID_JSON", () => { + const error = rejection('{"first_name":"Jo"'); + expect(error).toBeInstanceOf(CliError); + expect(error.code).toBe(ERROR_CODE.INVALID_JSON); + expect(error.exitCode).toBe(2); + }); + + test("rejects an empty body without echoing a blank Received line", () => { + const error = rejection(" \n", { kind: "file", path: "empty.json" }); + expect(error.code).toBe(ERROR_CODE.INVALID_JSON); + expect(error.message).toBe("Invalid JSON in --file empty.json: the body is empty."); + }); + + test("names the source and echoes what arrived", () => { + setPlatform("linux"); + const error = rejection("{user_id:user_123}"); + expect(error.message).toContain("Invalid JSON in --data"); + expect(error.message).toContain("Received: {user_id:user_123}"); + }); + + test("names the file or the pipe when the body did not come from -d", () => { + expect(rejection("{user_id:x}", { kind: "file", path: "body.json" }).message).toContain( + "Invalid JSON in --file body.json", + ); + expect(rejection("{user_id:x}", { kind: "stdin" }).message).toContain( + "Invalid JSON in the piped request body", + ); + }); + + test("does not blame the shell for ordinary malformed JSON", () => { + const error = rejection('{"first_name":"Jo"'); + expect(error.code).toBe(ERROR_CODE.INVALID_JSON); + expect(error.message).not.toContain("shell"); + expect(error.message).not.toContain("--file"); + expect(error.examples).toBeUndefined(); + }); + + // A `'` where JSON wants a `"` is someone writing a dict literal, on any + // platform; the parser's own message already names single quotes. + test.each(["darwin", "win32"])( + "does not blame the shell for Python-style single-quoted JSON on %s", + (platform) => { + setPlatform(platform); + const error = rejection("{'first_name': 'Alice'}"); + expect(error.code).toBe(ERROR_CODE.INVALID_JSON); + expect(error.message).not.toContain("shell"); + expect(error.examples).toBeUndefined(); + }, + ); + + // Neither went through argument parsing, so stripped quotes are just a typo. + const NON_SHELL_SOURCES: [string, JsonBodySource][] = [ + ["a file", { kind: "file", path: "body.json" }], + ["a pipe", { kind: "stdin" }], + ]; + test.each(NON_SHELL_SOURCES)("does not blame the shell for a body from %s", (_, source) => { + const error = rejection("{user_id:x}", source); + expect(error.code).toBe(ERROR_CODE.INVALID_JSON); + expect(error.message).not.toContain("shell"); + expect(error.examples).toBeUndefined(); + }); + + test("truncates a long body in the echo", () => { + const error = rejection(`{user_id:${"x".repeat(500)}}`); + expect(error.message).toContain("…"); + expect(error.message).not.toContain("x".repeat(300)); + }); + + test("collapses whitespace so the echo stays on one line", () => { + const error = rejection('{\n first_name:\n "Alice"\n}'); + expect(error.message).toContain('Received: { first_name: "Alice" }'); + }); + + // --- shell quoting, POSIX --- + + describe("on a POSIX shell", () => { + test("diagnoses stripped quotes as a missing pair of single quotes", () => { + setPlatform("darwin"); + // What bash leaves of an unquoted -d {"user_id":"user_123"}. + const error = rejection("{user_id:user_123}"); + expect(error.code).toBe(ERROR_CODE.INVALID_JSON_SHELL_QUOTING); + expect(error.message).toContain("Every double quote is missing"); + expect(error.message).toContain("wrap the body in single quotes"); + expect(error.message).not.toContain("PowerShell"); + expect(error.message).not.toContain("cmd.exe"); + }); + + test("suggests the quoted form first, then --file", () => { + setPlatform("linux"); + const error = rejection("[{id:1}]"); + expect(error.examples?.map((e) => e.command)).toEqual([ + `clerk api /users -d '{"key":"value"}'`, + "clerk api /users --file body.json", + ]); + }); + + test("still diagnoses a stripped body whose value has an apostrophe", () => { + setPlatform("darwin"); + expect(rejection("{name:O'Brien}").code).toBe(ERROR_CODE.INVALID_JSON_SHELL_QUOTING); + }); + + test("does not read literal single quotes as cmd.exe", () => { + setPlatform("darwin"); + const error = rejection('\'{"user_id":"user_123"}\''); + expect(error.code).toBe(ERROR_CODE.INVALID_JSON); + expect(error.message).not.toContain("cmd.exe"); + }); + }); + + // --- shell quoting, Windows --- + + describe("on Windows", () => { + test("diagnoses stripped quotes as PowerShell or cmd.exe argument passing", () => { + setPlatform("win32"); + // What PowerShell before 7.3 leaves of -d '{"user_id":"user_123"}'. + const error = rejection("{user_id:user_123}"); + expect(error.code).toBe(ERROR_CODE.INVALID_JSON_SHELL_QUOTING); + expect(error.message).toContain("Every double quote is missing"); + expect(error.message).toContain("PowerShell before 7.3"); + expect(error.message).toContain("cmd.exe"); + expect(error.message).not.toContain("wrap the body in single quotes"); + expect(error.examples?.map((e) => e.command)).toEqual(["clerk api /users --file body.json"]); + }); + + test("diagnoses a body still wrapped in literal single quotes as cmd.exe", () => { + setPlatform("win32"); + const error = rejection("'{user_id:user_123}'"); + expect(error.code).toBe(ERROR_CODE.INVALID_JSON_SHELL_QUOTING); + expect(error.message).toContain("literal single quotes"); + expect(error.message).toContain("cmd.exe"); + expect(error.message).not.toContain("PowerShell"); + }); + + test("states the remedy in the message, not only in the suggested command", () => { + setPlatform("win32"); + const error = rejection("{first_name:Alice}"); + expect(error.message).toContain( + "To fix it, move the body into a file and pass it with --file", + ); + }); + }); + + // --- the suggested command targets the caller's own request --- + + describe("suggested command", () => { + test("carries an explicit method", () => { + setPlatform("win32"); + const error = rejection("{first_name:x}", DATA, { + endpoint: "/users/user_1", + method: "patch", + }); + expect(error.examples?.[0]?.command).toBe( + "clerk api /users/user_1 -X PATCH --file body.json", + ); + }); + + test("carries --fapi, --app, and --instance so it hits the same API", () => { + setPlatform("win32"); + const error = rejection("{a:b}", DATA, { + endpoint: "/environment", + fapi: true, + app: "app_1", + instance: "dev", + }); + expect(error.examples?.[0]?.command).toBe( + "clerk api --fapi /environment --app app_1 --instance dev --file body.json", + ); + }); + + test("carries --platform", () => { + setPlatform("win32"); + const error = rejection("{a:b}", DATA, { endpoint: "/applications", platform: true }); + expect(error.examples?.[0]?.command).toBe( + "clerk api --platform /applications --file body.json", + ); + }); + + // Unquoted, `&` backgrounds the command and `?` is a glob that zsh refuses + // to leave unmatched, so the suggestion would not hit the same endpoint. + test("quotes an endpoint with a query string for a POSIX shell", () => { + setPlatform("darwin"); + const error = rejection("{a:b}", DATA, { endpoint: "/users?limit=1&offset=20" }); + expect(error.examples?.map((e) => e.command)).toEqual([ + `clerk api '/users?limit=1&offset=20' -d '{"key":"value"}'`, + "clerk api '/users?limit=1&offset=20' --file body.json", + ]); + }); + + test("quotes an endpoint with a query string for a Windows shell", () => { + setPlatform("win32"); + const error = rejection("{a:b}", DATA, { endpoint: "/users?limit=1&offset=20" }); + expect(error.examples?.[0]?.command).toBe( + 'clerk api "/users?limit=1&offset=20" --file body.json', + ); + }); + + test.each<[NodeJS.Platform, string]>([ + ["linux", `clerk api /users --app 'o'\\''brien' --file body.json`], + ["win32", 'clerk api /users --app "o""brien" --file body.json'], + ])("escapes a quote inside a quoted argument on %s", (platform, command) => { + setPlatform(platform); + const error = rejection("{a:b}", DATA, { + endpoint: "/users", + app: platform === "win32" ? 'o"brien' : "o'brien", + }); + expect(error.examples?.at(-1)?.command).toBe(command); + }); + }); +}); diff --git a/packages/cli-core/src/lib/json-body.ts b/packages/cli-core/src/lib/json-body.ts new file mode 100644 index 000000000..f5ff5a7d2 --- /dev/null +++ b/packages/cli-core/src/lib/json-body.ts @@ -0,0 +1,229 @@ +/** + * Local parse check for raw JSON request bodies that the CLI forwards verbatim + * (`clerk api -d` / `--file` / piped stdin). + * + * Those bodies are the one payload the CLI never builds itself: every other + * command parses its input and re-serializes with `JSON.stringify`, so a bad + * value fails on the user's machine. `clerk api` stamps + * `Content-Type: application/json` on whatever string it is handed, so an + * unparseable body used to fail only at the API — a round trip later, with a + * server-side byte offset and no clue as to what mangled it. + * + * A `-d` value is the one body that crosses the shell as an argument, and two + * mangled shapes are recognizable from the value alone: + * + * - Every double quote gone (`{user_id:x}`). In POSIX shells that is an + * unquoted `-d {"user_id":"x"}`: the shell consumes the quotes. On Windows + * they are lost even from `-d '{"user_id":"x"}'`: PowerShell before 7.3 + * hands an argument's embedded double quotes to a native program + * unescaped, so the program's own command-line parser consumes them. + * - Wrapped in literal single quotes (`'{user_id:x}'`). cmd.exe gives `'` no + * special meaning, so a POSIX-quoted argument keeps its wrapping quotes + * and, as above, loses the double quotes inside. + * + * The diagnosis is keyed on the platform, so a Mac user who forgot the quotes + * is told to add them rather than told about PowerShell. Bodies from `--file` + * or stdin never went through argument parsing, so they get no shell blame. + */ + +import { ERROR_CODE, errorMessage, throwUsageError } from "./errors.ts"; +import type { Example } from "./help.ts"; + +/** Where a raw body came from. Only `data` crossed the shell as an argument. */ +export type JsonBodySource = { kind: "data" } | { kind: "file"; path: string } | { kind: "stdin" }; + +/** + * The `clerk api` invocation the body belongs to, so a suggested command + * targets the same request. Values are repeated verbatim in the error, so this + * carries targeting flags only — never the secret key. + */ +export interface JsonBodyRequest { + endpoint: string; + method?: string; + fapi?: boolean; + platform?: boolean; + app?: string; + instance?: string; +} + +interface ShellQuotingDiagnosis { + cause: string; + remedy: string; + examples: Example[]; +} + +/** How much of a rejected body to echo back, so the error stays readable. */ +const PREVIEW_LIMIT = 200; + +const FILE_REMEDY = + "To fix it, move the body into a file and pass it with --file. A file reaches the CLI " + + "exactly as written, whatever the shell does to arguments."; + +function sourceLabel(source: JsonBodySource): string { + switch (source.kind) { + case "data": + return "--data"; + case "file": + return `--file ${source.path}`; + case "stdin": + return "the piped request body"; + } +} + +/** One-line rendering of what actually arrived, for the "Received:" line. */ +function preview(raw: string): string { + const collapsed = raw.replace(/\s+/g, " ").trim(); + return collapsed.length > PREVIEW_LIMIT ? `${collapsed.slice(0, PREVIEW_LIMIT)}…` : collapsed; +} + +/** + * Quote one request-derived argument for the shell the suggestion will be + * pasted into, so an endpoint such as `/users?limit=1&offset=20` stays one + * word instead of a backgrounded command and a glob. Plain paths and + * identifiers stay bare, so the common case reads as typed. Windows gets + * double quotes, the one form cmd.exe and PowerShell both honor, with a + * literal `"` inside doubled; everywhere else single quotes pass the value + * through untouched. + */ +function quoteArg(value: string): string { + if (/^[A-Za-z0-9_\-./:@]+$/.test(value)) return value; + if (process.platform === "win32") return `"${value.replace(/"/g, '""')}"`; + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +/** + * `clerk api …` with the caller's own targeting flags and `body` in place of + * the original `-d`, so the suggestion is runnable as printed. The method is + * repeated explicitly; without one, a body makes `clerk api` default to POST. + */ +function apiCommand(request: JsonBodyRequest, body: string): string { + const parts = ["clerk api"]; + if (request.fapi) parts.push("--fapi"); + if (request.platform) parts.push("--platform"); + parts.push(quoteArg(request.endpoint)); + if (request.method) parts.push("-X", quoteArg(request.method.toUpperCase())); + if (request.app) parts.push("--app", quoteArg(request.app)); + if (request.instance) parts.push("--instance", quoteArg(request.instance)); + parts.push(body); + return parts.join(" "); +} + +/** + * Name the shell quoting behind a `-d` value that failed to parse, when its + * shape gives it away. Returns undefined for ordinary malformed JSON. + */ +function diagnoseShellQuoting( + raw: string, + request: JsonBodyRequest, +): ShellQuotingDiagnosis | undefined { + const trimmed = raw.trim(); + const windows = process.platform === "win32"; + const fileExample: Example = { + command: apiCommand(request, "--file body.json"), + description: "The same request, with the body read from a file", + }; + + // A JSON object or array with no `"` anywhere had its quotes stripped: any + // object key, and any string value, would have to carry a pair. A `'` right + // where JSON would put a `"` is Python-style quoting instead, which no shell + // produces — the parse error already says single quotes are not allowed. + const quotesStripped = + /^[{[]/.test(trimmed) && !trimmed.includes('"') && !/[{[,:]\s*'/.test(trimmed); + if (quotesStripped) { + if (windows) { + return { + cause: + "Every double quote is missing, so the shell removed them before the CLI saw the " + + "value. PowerShell before 7.3 passes the double quotes inside an argument to a native " + + "program unescaped, so its command-line parser consumes them; cmd.exe does the same.", + remedy: FILE_REMEDY, + examples: [fileExample], + }; + } + return { + cause: + "Every double quote is missing, so the shell removed them before the CLI saw the " + + "value. Without quotes around the whole body, the shell treats the double quotes " + + "inside it as its own and strips them.", + remedy: + "To fix it, wrap the body in single quotes so the shell passes it through untouched, " + + "or move it into a file and pass it with --file.", + examples: [ + { + command: apiCommand(request, `-d '{"key":"value"}'`), + description: "Single quotes keep the shell out of the body", + }, + fileExample, + ], + }; + } + + // POSIX-style `-d '{"a":1}'` reaching us with its wrapping quotes intact. + // Only cmd.exe does this; every other shell consumes the single quotes. + if (windows && trimmed.length > 1 && trimmed.startsWith("'") && trimmed.endsWith("'")) { + return { + cause: + "The body arrived wrapped in literal single quotes. cmd.exe gives ' no special " + + "meaning, so a POSIX-quoted -d '{...}' keeps its wrapping quotes, and the double " + + "quotes inside are consumed by the command-line parser.", + remedy: FILE_REMEDY, + examples: [fileExample], + }; + } + + return undefined; +} + +/** + * Parse-check a raw request body before it goes out on the wire, throwing a + * usage error instead of letting the API reject it. + * + * Returns the body unchanged — callers keep forwarding the exact bytes the user + * supplied, so a valid payload is never reformatted on its way through. + * + * Accepts any syntactically valid JSON, including top-level primitives: the + * goal is to catch what the API's decoder would report as a syntax error, not + * to second-guess an endpoint's schema. + * + * A `-d` body that the shell visibly mangled is reported under + * `INVALID_JSON_SHELL_QUOTING`, with the cause and a runnable way around it, + * so those rejections can be counted apart from ordinary typos. + */ +export function validateJsonBody( + raw: string, + source: JsonBodySource, + request: JsonBodyRequest, +): string { + const label = sourceLabel(source); + + if (!raw.trim()) { + throwUsageError( + `Invalid JSON in ${label}: the body is empty.`, + undefined, + ERROR_CODE.INVALID_JSON, + ); + } + + try { + JSON.parse(raw); + return raw; + } catch (error) { + const detail = + `Invalid JSON in ${label}: ${errorMessage(error)}\n\n` + ` Received: ${preview(raw)}`; + + // Ordinary malformed JSON — a missing brace, a trailing comma. The parse + // message and the echo above already say what to change, and suggesting a + // different way to pass the same broken body would only be noise. + const diagnosis = source.kind === "data" ? diagnoseShellQuoting(raw, request) : undefined; + if (!diagnosis) { + throwUsageError(detail, undefined, ERROR_CODE.INVALID_JSON); + } + + throwUsageError( + `${detail}\n\n${diagnosis.cause}\n\n${diagnosis.remedy}`, + undefined, + ERROR_CODE.INVALID_JSON_SHELL_QUOTING, + diagnosis.examples, + ); + } +}