From c2dd13b8bd0e403be88c1c7faeca413fa82467a9 Mon Sep 17 00:00:00 2001 From: Mison Date: Mon, 28 Sep 2026 22:57:29 +0800 Subject: [PATCH 01/18] feat(cli): redesign the OAuth callback page and defer its response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page the browser lands on after `petdex login` was two unstyled strings from the 0.1.0 commit: no mark, no type, no viewport, no way out, and the same page for all three refusal reasons. It now renders from `callback-page.ts` using the app's own tokens — the card surface, the brand purple, the CTA's three-layer shadow and the monospace eyebrow all come from `src/app/globals.css` — in the browser's language (en/es/zh), with light and dark from `prefers-color-scheme`. The success page tries to close its tab after five seconds and gives up if the reader touches anything first; both pages offer a button through to the profile. The response is also no longer sent the moment the code arrives. It was sent before the exchange, so a failed exchange reported success in the browser and failure in the terminal. `waitForCallback` now resolves without answering, and `respond(outcome)` writes the page once every step that can still fail has succeeded — including the keychain write, which is why success is answered after `setJson`. Splitting the old `settled` flag was necessary to do this: one flag meant both "the callback was consumed" and "the browser was answered", and those now happen at different times. `respond` is idempotent and never throws, `close` acts as a safety net for a flow that failed without reporting, and the harden headers are limited to four that cannot break a page served over http://127.0.0.1. --- .../src/cli-auth/clerk-cli-auth.test.ts | 163 +++++++ .../petdex-cli/src/cli-auth/clerk-cli-auth.ts | 19 + .../src/cli-auth/lib/auth-server.test.ts | 330 ++++++++++++++ .../src/cli-auth/lib/auth-server.ts | 244 ++++++++-- .../src/cli-auth/lib/callback-page.test.ts | 217 +++++++++ .../src/cli-auth/lib/callback-page.ts | 419 ++++++++++++++++++ 6 files changed, 1348 insertions(+), 44 deletions(-) create mode 100644 packages/petdex-cli/src/cli-auth/clerk-cli-auth.test.ts create mode 100644 packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts create mode 100644 packages/petdex-cli/src/cli-auth/lib/callback-page.test.ts create mode 100644 packages/petdex-cli/src/cli-auth/lib/callback-page.ts diff --git a/packages/petdex-cli/src/cli-auth/clerk-cli-auth.test.ts b/packages/petdex-cli/src/cli-auth/clerk-cli-auth.test.ts new file mode 100644 index 000000000..cd14e13c7 --- /dev/null +++ b/packages/petdex-cli/src/cli-auth/clerk-cli-auth.test.ts @@ -0,0 +1,163 @@ +import { afterEach, describe, expect, test } from "bun:test"; + +import { ClerkCliAuth } from "./clerk-cli-auth"; +import type { CredentialStore } from "./types"; + +const ISSUER = "https://clerk.test"; + +/** The token endpoint only parses JSON when the response says it is JSON. */ +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +/** In-memory store, so a login never touches the OS keychain. */ +function memoryStore(): CredentialStore { + const values = new Map(); + return { + get: async (key) => values.get(key) ?? null, + set: async (key, value) => { + values.set(key, value); + }, + delete: async (key) => { + values.delete(key); + }, + }; +} + +const realFetch = globalThis.fetch; +const unhandled: unknown[] = []; +const onUnhandled = (reason: unknown) => unhandled.push(reason); + +afterEach(() => { + globalThis.fetch = realFetch; + unhandled.length = 0; +}); + +/** + * Drive a full login against a fake issuer, and return the page the browser + * was served once the flow finished. + */ +async function runLogin(options: { + tokenStatus?: number; + userinfoStatus?: number; + store?: CredentialStore; + openBrowserThrows?: boolean; +}) { + const store = options.store ?? memoryStore(); + let captured: Promise | null = null; + + const auth = new ClerkCliAuth({ + clientId: "client-id", + issuer: ISSUER, + storage: store, + callbackPort: 0, + timeoutMs: 4000, + openBrowser: async (authorizeUrl: string) => { + if (options.openBrowserThrows) throw new Error("no browser available"); + const url = new URL(authorizeUrl); + const redirectUri = url.searchParams.get("redirect_uri") as string; + const state = url.searchParams.get("state") as string; + const callback = new URL(redirectUri); + callback.searchParams.set("code", "auth-code"); + callback.searchParams.set("state", state); + // Deliberately not awaited: like a real browser, this request stays + // open until the CLI has finished the exchange and answered it. + captured = fetch(callback.toString()); + }, + }); + + type FetchArgs = Parameters; + globalThis.fetch = (async (input: FetchArgs[0], init?: FetchArgs[1]) => { + const url = String(input); + if (url.startsWith(`${ISSUER}/oauth/token`)) { + if (options.tokenStatus && options.tokenStatus >= 400) { + return json({ error: "invalid_grant" }, options.tokenStatus); + } + return json({ access_token: "token", token_type: "Bearer" }); + } + if (url.startsWith(`${ISSUER}/oauth/userinfo`)) { + if (options.userinfoStatus && options.userinfoStatus >= 400) { + return json({ error: "unavailable" }, options.userinfoStatus); + } + return json({ sub: "user_1" }); + } + return realFetch(input, init); + }) as typeof fetch; + + const outcome = await auth.login().then( + (value) => ({ ok: true as const, value }), + (error: Error) => ({ ok: false as const, error }), + ); + const page: Response | null = captured ? await (captured as Promise) : null; + return { outcome, page }; +} + +describe("clerk cli auth login page", () => { + test("serves the success page only after the credentials are stored", async () => { + const store = memoryStore(); + const { outcome, page } = await runLogin({ store }); + + expect(outcome.ok).toBe(true); + expect(page?.status).toBe(200); + const html = await page?.text(); + expect(html).toContain('class="chip chip-success"'); + expect(await store.get("tokens")).toContain("token"); + expect(await store.get("user")).toContain("user_1"); + }); + + test("shows a failure page when the code exchange fails", async () => { + // The regression this deferral exists for: the page used to say + // "signed in" while the terminal reported a failed exchange. + const { outcome, page } = await runLogin({ tokenStatus: 400 }); + + expect(outcome.ok).toBe(false); + expect(page?.status).toBe(500); + const html = await page?.text(); + expect(html).toContain('class="chip chip-danger"'); + expect(html).not.toContain('class="chip chip-success"'); + }); + + test("shows a failure page when the userinfo call fails", async () => { + const { outcome, page } = await runLogin({ userinfoStatus: 500 }); + + expect(outcome.ok).toBe(false); + expect(page?.status).toBe(500); + expect(await page?.text()).toContain('class="chip chip-danger"'); + }); + + test("shows a failure page when the credentials cannot be stored", async () => { + const failing: CredentialStore = { + get: async () => null, + set: async () => { + throw new Error("keychain unavailable"); + }, + delete: async () => {}, + }; + const { outcome, page } = await runLogin({ store: failing }); + + expect(outcome.ok).toBe(false); + expect(page?.status).toBe(500); + expect(await page?.text()).toContain('class="chip chip-danger"'); + }); + + test("fails without an unhandled rejection when the browser cannot open", async () => { + process.on("unhandledRejection", onUnhandled); + try { + const { outcome, page } = await runLogin({ openBrowserThrows: true }); + expect(outcome.ok).toBe(false); + expect((outcome as { error: Error }).error.message).toContain( + "Failed to open authorization URL", + ); + // No callback was ever delivered, so there is no page to inspect. + expect(page).toBeNull(); + // Let the rejection queue drain before asserting it stayed empty. + await Bun.sleep(20); + expect(unhandled).toEqual([]); + } finally { + process.off("unhandledRejection", onUnhandled); + } + }); +}); diff --git a/packages/petdex-cli/src/cli-auth/clerk-cli-auth.ts b/packages/petdex-cli/src/cli-auth/clerk-cli-auth.ts index 6b01a039f..09c34ee85 100644 --- a/packages/petdex-cli/src/cli-auth/clerk-cli-auth.ts +++ b/packages/petdex-cli/src/cli-auth/clerk-cli-auth.ts @@ -145,7 +145,26 @@ export class ClerkCliAuth { }); await this.setJson("user", user); + // Last, and only once every step that can still fail has succeeded. + // The browser is holding its connection open until this call, so + // answering earlier would report success for a login that then failed + // to save its credentials — the same mismatch this deferral exists to + // remove, just moved one step later. + server.respond({ kind: "success" }); return { tokens, user }; + } catch (error) { + // Also reached when the opener rejects after the browser has already + // delivered its callback, which is the case where only this call can + // tell the reader anything. A no-op if the response was already sent. + const reason = (error as { code?: unknown }).code; + server.respond({ + kind: "error", + reason: + reason === "userinfo" || reason === "storage" + ? (`${reason}_failed` as const) + : "token_exchange_failed", + }); + throw error; } finally { server.close(); } diff --git a/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts b/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts new file mode 100644 index 000000000..50ca808c8 --- /dev/null +++ b/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts @@ -0,0 +1,330 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; + +import { ClerkCliAuthError } from "../types"; +import { type AuthServerHandle, startAuthServer } from "./auth-server"; + +const STATE = "expected-state"; + +let handle: AuthServerHandle | undefined; +const aborters: AbortController[] = []; + +afterEach(() => { + // A held response leaves a connection that is still waiting to be answered, + // and `server.close()` deliberately keeps those alive. Not aborting here + // would leave the test process unable to exit. + for (const controller of aborters.splice(0)) controller.abort(); + handle?.close(); + handle = undefined; +}); + +async function start( + overrides: Partial[0]> = {}, +): Promise { + handle = await startAuthServer({ + expectedState: STATE, + port: 0, + timeoutMs: 150, + ...overrides, + }); + return handle; +} + +function callbackUrl( + handle: AuthServerHandle, + query: Record = {}, +): string { + const url = new URL(handle.redirectUri); + for (const [key, value] of Object.entries(query)) { + url.searchParams.set(key, value); + } + return url.toString(); +} + +/** A request whose promise is kept, so the test can inspect it before it settles. */ +function beginFetch(url: string) { + const controller = new AbortController(); + aborters.push(controller); + // A rejected request is normal here (the reader closing the tab), so the + // outcome is a result rather than a throw. Tests that need the response + // check the status first. + const response: Promise = fetch(url).then( + (res) => res, + () => null, + ); + return { controller, response }; +} + +/** Status of a finished request, failing the test if it never arrived. */ +async function statusOf(page: Promise): Promise { + const res = await page; + expect(res).not.toBeNull(); + return (res as Response).status; +} + +/** True while `promise` has not settled. */ +async function stillPending(promise: Promise): Promise { + const marker = Symbol("pending"); + return ( + (await Promise.race([promise, Bun.sleep(20).then(() => marker)])) === marker + ); +} + +describe("auth server deferred response", () => { + test("holds the page until respond is called, then answers with the success page", async () => { + const server = await start(); + const { response } = beginFetch( + callbackUrl(server, { code: "abc", state: STATE }), + ); + + expect(await server.waitForCallback()).toEqual({ + code: "abc", + state: STATE, + }); + expect(await stillPending(response)).toBe(true); + + expect(server.respond({ kind: "success" })).toBe(true); + + const res = await response; + expect(res?.status).toBe(200); + expect(res?.headers.get("content-type")).toContain("text/html"); + expect(await res?.text()).toContain('href="https://petdex.dev/my-pets"'); + }); + + test("answers with a failure page when the server is closed without a respond", async () => { + const server = await start(); + const { response } = beginFetch( + callbackUrl(server, { code: "abc", state: STATE }), + ); + await server.waitForCallback(); + expect(await stillPending(response)).toBe(true); + + server.close(); + + const res = await response; + expect(res?.status).toBe(500); + expect(await res?.text()).toContain('class="chip chip-danger"'); + }); + + test("reports false when the reader closed the tab first", async () => { + const server = await start(); + const { controller, response } = beginFetch( + callbackUrl(server, { code: "abc", state: STATE }), + ); + await server.waitForCallback(); + + controller.abort(); + await response; + // The socket teardown is asynchronous, so wait for the connection to + // actually go away rather than guessing at a delay. `respond` reporting + // false is what tells us the held response was dropped. + expect(server.respond({ kind: "success" })).toBe(false); + }); + + test("is idempotent across two respond calls", async () => { + const server = await start(); + const { response } = beginFetch( + callbackUrl(server, { code: "abc", state: STATE }), + ); + await server.waitForCallback(); + + expect(server.respond({ kind: "success" })).toBe(true); + expect(server.respond({ kind: "error", reason: "timeout" })).toBe(false); + expect(await (await response)!.text()).toContain( + 'class="chip chip-success"', + ); + }); + + test("reports false when responding after close", async () => { + const server = await start(); + server.close(); + expect(server.respond({ kind: "success" })).toBe(false); + }); + + test("survives being closed twice", async () => { + const server = await start(); + expect(() => { + server.close(); + server.close(); + }).not.toThrow(); + }); +}); + +describe("auth server rejected callbacks", () => { + test("answers a declined authorization with the injected error page", async () => { + const server = await start({ errorHtml: "

custom failure

" }); + const pending = server.waitForCallback().catch((error: Error) => error); + + const res = await fetch( + callbackUrl(server, { + error: "access_denied", + error_description: "user said no", + state: STATE, + }), + ); + + expect(res.status).toBe(400); + expect(await res.text()).toBe("

custom failure

"); + const error = (await pending) as ClerkCliAuthError; + expect(error.code).toBe("token_exchange"); + }); + + test("rejects a mismatched state", async () => { + const server = await start(); + const pending = server.waitForCallback().catch((error: Error) => error); + + const res = await fetch( + callbackUrl(server, { code: "abc", state: "someone-else" }), + ); + + expect(res.status).toBe(400); + expect(await res.text()).toContain("state"); + expect(((await pending) as ClerkCliAuthError).code).toBe("state_mismatch"); + }); + + test("rejects a callback with no code", async () => { + const server = await start(); + const pending = server.waitForCallback().catch((error: Error) => error); + + const res = await fetch(callbackUrl(server, { state: STATE })); + + expect(res.status).toBe(400); + expect(((await pending) as ClerkCliAuthError).code).toBe("token_exchange"); + }); +}); + +describe("auth server while a response is held", () => { + test("hands over to a second valid callback and ends the first", async () => { + const server = await start(); + const first = beginFetch( + callbackUrl(server, { code: "one", state: STATE }), + ); + expect(await server.waitForCallback()).toEqual({ + code: "one", + state: STATE, + }); + + const second = beginFetch( + callbackUrl(server, { code: "two", state: STATE }), + ); + // The superseded response is answered so its connection stops waiting. + expect(await statusOf(first.response)).toBe(409); + + expect(server.respond({ kind: "success" })).toBe(true); + expect(await statusOf(second.response)).toBe(200); + }); + + test("answers a bogus callback without disturbing the held one", async () => { + const server = await start(); + const held = beginFetch(callbackUrl(server, { code: "abc", state: STATE })); + await server.waitForCallback(); + + const bogus = await fetch( + callbackUrl(server, { code: "x", state: "not-the-state" }), + ); + expect(bogus.status).toBe(400); + + expect(server.respond({ kind: "success" })).toBe(true); + expect(await statusOf(held.response)).toBe(200); + }); + + test("still serves an ordinary 404 for a favicon while holding", async () => { + const server = await start(); + const held = beginFetch(callbackUrl(server, { code: "abc", state: STATE })); + await server.waitForCallback(); + + const favicon = await fetch(`http://127.0.0.1:${server.port}/favicon.ico`); + expect(favicon.status).toBe(404); + + expect(server.respond({ kind: "success" })).toBe(true); + expect(await statusOf(held.response)).toBe(200); + }); +}); + +describe("auth server timeouts", () => { + test("rejects when no callback arrives in time", async () => { + const server = await start({ timeoutMs: 120 }); + const error = (await server + .waitForCallback() + .catch((err: Error) => err)) as ClerkCliAuthError; + expect(error.code).toBe("timeout"); + }); + + test("answers the held page when the exchange window expires", async () => { + const server = await start({ timeoutMs: 120 }); + const { response } = beginFetch( + callbackUrl(server, { code: "abc", state: STATE }), + ); + await server.waitForCallback(); + + const res = await response; + expect(res?.status).toBe(500); + expect(await res?.text()).toContain('class="chip chip-danger"'); + }); +}); + +describe("auth server page rendering", () => { + test("escapes provider text that reaches the page", async () => { + const server = await start(); + const { response } = beginFetch( + callbackUrl(server, { + error: "access_denied", + error_description: "", + state: STATE, + }), + ); + await server.waitForCallback().catch(() => {}); + + const html = await (await response)?.text(); + expect(html).not.toContain(" { + const server = await start({ successHtml: "

done

" }); + const { response } = beginFetch( + callbackUrl(server, { code: "abc", state: STATE }), + ); + await server.waitForCallback(); + server.respond({ kind: "success" }); + + expect(await (await response)?.text()).toBe("

done

"); + }); + + test("sets the hardening headers", async () => { + const server = await start(); + const { response } = beginFetch( + callbackUrl(server, { code: "abc", state: STATE }), + ); + await server.waitForCallback(); + server.respond({ kind: "success" }); + + const headers = (await response)?.headers; + expect(headers?.get("x-content-type-options")).toBe("nosniff"); + expect(headers?.get("referrer-policy")).toBe("no-referrer"); + expect(headers?.get("cache-control")).toBe("no-store"); + expect(headers?.get("x-frame-options")).toBe("DENY"); + }); +}); + +describe("auth server startup", () => { + test("reports a port already in use as a config error", async () => { + const blocker: Server = createServer(); + await new Promise((resolve) => + blocker.listen(0, "127.0.0.1", () => resolve()), + ); + const taken = (blocker.address() as AddressInfo).port; + + try { + const error = (await startAuthServer({ + expectedState: STATE, + port: taken, + }).catch((err: Error) => err)) as ClerkCliAuthError; + expect(error).toBeInstanceOf(ClerkCliAuthError); + expect(error.code).toBe("config"); + } finally { + await new Promise((resolve) => blocker.close(() => resolve())); + } + }); +}); diff --git a/packages/petdex-cli/src/cli-auth/lib/auth-server.ts b/packages/petdex-cli/src/cli-auth/lib/auth-server.ts index c19e79e89..f00efeeb2 100644 --- a/packages/petdex-cli/src/cli-auth/lib/auth-server.ts +++ b/packages/petdex-cli/src/cli-auth/lib/auth-server.ts @@ -1,7 +1,12 @@ -import { createServer, type Server } from "node:http"; +import { createServer, type Server, type ServerResponse } from "node:http"; import type { AddressInfo } from "node:net"; import { ClerkCliAuthError } from "../types.js"; +import { + type CallbackFailureReason, + type CallbackOutcome, + renderCallbackPage, +} from "./callback-page.js"; export interface AuthServerOptions { expectedState: string; @@ -9,26 +14,48 @@ export interface AuthServerOptions { timeoutMs?: number; successHtml?: string; errorHtml?: string; + /** Origin the success page's button points at. Defaults to the CLI's. */ + appUrl?: string; } export interface AuthServerHandle { port: number; redirectUri: string; + /** + * The redirect arrived and carried a usable authorization code. Resolves as + * soon as the code is validated — the browser is still waiting for its page + * at this point, and stays waiting until `respond` is called. + */ waitForCallback(): Promise<{ code: string; state: string }>; + /** + * Write the page the browser has been waiting for. + * + * Deferred on purpose: the code exchange, the userinfo call and the keychain + * write all happen between `waitForCallback` and this call, and the page has + * to report their outcome rather than the redirect's. Idempotent, never + * throws, never blocks — a second call (or a timeout that already answered) + * is a no-op returning false. + */ + respond(outcome: CallbackOutcome): boolean; close(): void; } -const DEFAULT_SUCCESS_HTML = ` - -Authentication complete -

Authentication complete

You can close this tab and return to your terminal.

-`; +/** Immediate refusals the redirect itself produces. */ +const EMPTY_CODE = "token_exchange"; -const DEFAULT_ERROR_HTML = ` - -Authentication failed -

Authentication failed

You can close this tab and return to your terminal.

-`; +function headers(html: string): Record { + return { + "Content-Type": "text/html; charset=utf-8", + "Content-Length": String(Buffer.byteLength(html)), + // The full app CSP is deliberately not reused: it carries + // `upgrade-insecure-requests`, which would rewrite this page's own + // http://127.0.0.1 origin to https and break it. + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", + "Cache-Control": "no-store", + "X-Frame-Options": "DENY", + }; +} function oauthCallbackError(code: string, message: string): ClerkCliAuthError { return new ClerkCliAuthError(code, message); @@ -41,13 +68,29 @@ export function startAuthServer( expectedState, port = 0, timeoutMs = 120_000, - successHtml = DEFAULT_SUCCESS_HTML, - errorHtml = DEFAULT_ERROR_HTML, + successHtml, + errorHtml, + appUrl, } = options; + const pageFor = (outcome: CallbackOutcome, detail?: string): string => { + if (outcome.kind === "success" && successHtml !== undefined) + return successHtml; + // An injected errorHtml is a single string and so cannot distinguish + // reasons. That is the documented behaviour: the terminal is the source + // of truth for why a login failed. + if (outcome.kind === "error" && errorHtml !== undefined) return errorHtml; + return renderCallbackPage(outcome, appUrl, detail); + }; + let timeout: NodeJS.Timeout | undefined; - let settled = false; let closed = false; + /** The callback has been consumed: accepted, refused, timed out or torn down. */ + let callbackSettled = false; + /** The browser has been answered. */ + let responseSent = false; + /** The held-open response, while we wait on the exchange. */ + let pendingResponse: ServerResponse | null = null; let resolveCallback!: (value: { code: string; state: string }) => void; let rejectCallback!: (reason: ClerkCliAuthError) => void; @@ -57,6 +100,11 @@ export function startAuthServer( rejectCallback = reject; }, ); + // `login()` may never await this promise: if the browser opener throws + // first, nothing consumes the rejection that `close()` then produces. + // Attaching a no-op handler marks it observed; awaiting it later still + // rejects with the same error. + void callbackPromise.catch(() => {}); const closeListening = (server: Server) => { if (closed) return; @@ -64,13 +112,67 @@ export function startAuthServer( server.close(); }; - const server = createServer((req, res) => { - if (settled) { - res.writeHead(410, { "Content-Type": "text/plain; charset=utf-8" }); - res.end("Authentication callback already handled."); - return; + const isAlive = (res: ServerResponse | null): res is ServerResponse => + res !== null && + !res.writableEnded && + !res.destroyed && + res.socket !== null && + !res.socket.destroyed; + + /** A response we no longer need — the client is gone, or a newer one replaced it. */ + const endQuietly = (res: ServerResponse, status: number, body: string) => { + if (!isAlive(res)) return; + try { + res.writeHead(status, { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + }); + res.end(body); + } catch { + // The socket died between the check and the write. Nothing to do. + } + }; + + function sendOutcome( + server: Server, + outcome: CallbackOutcome, + detail?: string, + ): boolean { + if (responseSent) return false; + responseSent = true; + if (timeout) clearTimeout(timeout); + const target = pendingResponse; + pendingResponse = null; + if (!isAlive(target)) { + closeListening(server); + return false; + } + try { + const html = pageFor(outcome, detail); + target.writeHead(outcome.kind === "success" ? 200 : 500, headers(html)); + // Closing the listener here rather than after `end` returns is + // measured behaviour, not style: a keep-alive connection that has just + // been written to stays in the server's handle set until + // `keepAliveTimeout`, which holds the event loop open and keeps + // `petdex login` from exiting for up to five seconds. + target.end(html, () => closeListening(server)); + } catch { + closeListening(server); + return false; } + return true; + } + const server = createServer((req, res) => { + // Requests are surfaced to the caller as events only — an unhandled + // 'error' on a response stream (a client that vanished mid-write) would + // otherwise become an uncaught exception and take the CLI down. + res.on("error", () => {}); + + // Method and path are checked before whether the callback was consumed, + // so that a favicon or a stray path requested while we hold the response + // open still gets an ordinary 404 rather than "already handled". if (req.method !== "GET" || !req.url) { res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); res.end("Not found"); @@ -89,28 +191,42 @@ export function startAuthServer( const error = url.searchParams.get("error"); const errorDescription = url.searchParams.get("error_description") ?? error; - const settle = ( + const failure = ( + reason: CallbackFailureReason, statusCode: number, - html: string, - errorToReject?: ClerkCliAuthError, + err: ClerkCliAuthError, ) => { - settled = true; - if (timeout) clearTimeout(timeout); - res.writeHead(statusCode, { "Content-Type": "text/html; charset=utf-8" }); - res.end(html, () => { - if (errorToReject) { - rejectCallback(errorToReject); - } else if (code && state) { - resolveCallback({ code, state }); + // A bogus request must not be able to disturb the legitimate response we + // are holding: it answers only itself and leaves every piece of state + // alone. + if (callbackSettled) { + const html = pageFor({ kind: "error", reason }); + if (!isAlive(res)) return; + try { + res.writeHead(statusCode, headers(html)); + res.end(html); + } catch { + // Client already gone. } + return; + } + callbackSettled = true; + responseSent = true; + if (timeout) clearTimeout(timeout); + const html = pageFor({ kind: "error", reason }, err.message); + try { + res.writeHead(statusCode, headers(html)); + res.end(html, () => closeListening(server)); + } catch { closeListening(server); - }); + } + rejectCallback(err); }; if (error) { - settle( + failure( + "authorization_denied", 400, - errorHtml, oauthCallbackError( "token_exchange", `OAuth authorization failed: ${errorDescription ?? "unknown error"}`, @@ -120,9 +236,9 @@ export function startAuthServer( } if (state !== expectedState) { - settle( + failure( + "state_mismatch", 400, - errorHtml, oauthCallbackError( "state_mismatch", "OAuth callback state did not match.", @@ -132,18 +248,46 @@ export function startAuthServer( } if (!code) { - settle( + failure( + "missing_code", 400, - errorHtml, oauthCallbackError( - "token_exchange", + EMPTY_CODE, "OAuth callback did not include an authorization code.", ), ); return; } - settle(200, successHtml); + // A valid code, and the one case that does not answer immediately. + if (callbackSettled && pendingResponse !== null) { + // The reader reloaded or reopened the callback. The superseded response + // has to be ended, not just dropped: `server.close()` deliberately keeps + // connections that are still waiting for their response, so an abandoned + // one would pin the event loop. + endQuietly(pendingResponse, 409, "Superseded by a newer callback."); + pendingResponse = null; + } + const firstCallback = !callbackSettled; + callbackSettled = true; + pendingResponse = res; + res.on("close", () => { + if (pendingResponse === res) pendingResponse = null; + }); + + // Restart the clock rather than sharing phase one's budget. A reader who + // spent 115 seconds on the consent screen would otherwise leave five + // seconds for the exchange. + if (timeout) clearTimeout(timeout); + timeout = setTimeout(() => { + sendOutcome( + server, + { kind: "error", reason: "timeout" }, + `No response from the authorization server within ${timeoutMs}ms.`, + ); + }, timeoutMs); + + if (firstCallback) resolveCallback({ code, state }); }); return new Promise((resolve, reject) => { @@ -162,8 +306,8 @@ export function startAuthServer( const redirectUri = `http://127.0.0.1:${actualPort}/callback`; timeout = setTimeout(() => { - if (settled) return; - settled = true; + if (callbackSettled) return; + callbackSettled = true; rejectCallback( new ClerkCliAuthError( "timeout", @@ -177,10 +321,11 @@ export function startAuthServer( port: actualPort, redirectUri, waitForCallback: () => callbackPromise, + respond: (outcome) => sendOutcome(server, outcome), close: () => { if (timeout) clearTimeout(timeout); - if (!settled) { - settled = true; + if (!callbackSettled) { + callbackSettled = true; rejectCallback( new ClerkCliAuthError( "timeout", @@ -188,7 +333,18 @@ export function startAuthServer( ), ); } - closeListening(server); + // Safety net. A sign-in that got as far as the redirect and then + // failed without anyone calling `respond` — an injected opener + // rejecting after the browser already finished — would otherwise + // leave the tab spinning forever, with its connection pinning the + // event loop and keeping `petdex login` alive. The reason stays + // generic on purpose: the specifics belong to whoever knew them, + // and a wrong reason is worse than a vague one. + if ( + !responseSent && + !sendOutcome(server, { kind: "error", reason: "closed" }) + ) + closeListening(server); }, }); }); diff --git a/packages/petdex-cli/src/cli-auth/lib/callback-page.test.ts b/packages/petdex-cli/src/cli-auth/lib/callback-page.test.ts new file mode 100644 index 000000000..b66a1dfc8 --- /dev/null +++ b/packages/petdex-cli/src/cli-auth/lib/callback-page.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { + type CallbackFailureReason, + callbackStringsByLocale, + clampDetail, + defaultAppUrl, + escapeHtml, + LOCALES_FOR_TEST, + renderCallbackPage, +} from "./callback-page"; + +const REASONS: CallbackFailureReason[] = [ + "authorization_denied", + "state_mismatch", + "missing_code", + "token_exchange_failed", + "userinfo_failed", + "storage_failed", + "timeout", + "closed", +]; + +const APP_URL = "https://petdex.dev"; + +function successPage(): string { + return renderCallbackPage({ kind: "success" }, APP_URL); +} + +function errorPage(reason: CallbackFailureReason): string { + return renderCallbackPage({ kind: "error", reason }, APP_URL); +} + +describe("callback page strings", () => { + test("every locale carries the same keys", () => { + const strings = callbackStringsByLocale(); + const reference = Object.keys(strings.en).sort(); + for (const locale of LOCALES_FOR_TEST) { + expect(Object.keys(strings[locale]).sort()).toEqual(reference); + expect(Object.keys(strings[locale].reason).sort()).toEqual( + [...REASONS].sort(), + ); + } + }); + + test("no translation is left empty", () => { + const strings = callbackStringsByLocale(); + for (const locale of LOCALES_FOR_TEST) { + const entry = strings[locale]; + expect(entry.eyebrow.length).toBeGreaterThan(0); + expect(entry.successTitle.length).toBeGreaterThan(0); + for (const reason of REASONS) { + expect(entry.reason[reason].length).toBeGreaterThan(0); + } + } + }); + + test("each failure reason reads differently", () => { + const { reason } = callbackStringsByLocale().en; + const seen = new Set(); + for (const key of REASONS) seen.add(reason[key]); + expect(seen.size).toBe(REASONS.length); + }); +}); + +describe("callback page document", () => { + test("declares a viewport so the card is readable on a phone", () => { + const html = successPage(); + expect(html).toContain( + '', + ); + }); + + test("carries a lang attribute for the script to correct", () => { + expect(successPage()).toContain(''); + }); + + test("inlines the brand mark", () => { + const html = successPage(); + expect(html).toContain(" { + expect(successPage()).not.toContain("innerHTML"); + expect(errorPage("timeout")).not.toContain("innerHTML"); + }); + + test("requests nothing from the network", () => { + const html = successPage(); + // The listening socket is closed the moment the page is written, so any + // subresource would fail. The SVG namespace is a URI we never fetch, so + // the check targets the things a browser actually resolves. + expect(html).not.toContain(" m[1]); + expect(hrefs).toEqual([`${APP_URL}/my-pets`]); + }); + + test("escapes the string table so it cannot break out of the JSON block", () => { + const html = successPage(); + const match = html.match( + /` : ""; + + return [ + "", + '', + "", + '', + '', + '', + `${escapeHtml(STRINGS.en.eyebrow)}`, + ``, + "", + "", + '
', + MARK_SVG, + '

', + ``, + ``, + "", + '

', + '

', + detailBlock, + cta, + "
", + ``, + ``, + closeScript, + "", + "", + ] + .filter(Boolean) + .join("\n"); +} From 7c9791b14a50b6fa15fdc8eb51b2492a314db02f Mon Sep 17 00:00:00 2001 From: Mison Date: Mon, 28 Sep 2026 23:13:41 +0800 Subject: [PATCH 02/18] fix(cli): bound the token calls, and stop reporting a stale session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two problems found while working on the callback page. `requestTokens` and `fetchUserInfo` used bare `fetch` with no signal, so a stalled connection left `login()` waiting forever — the callback server's timeout answered the browser, but nothing broke the request the CLI was sitting on. Both now carry a 15s deadline, matching what the OAuth smoke script already does for its authorize probe. `whoami` returned the cached profile without checking that a usable credential still existed beside it. `logout` clears both records through `Promise.allSettled`, so a half-cleared store leaves the profile behind with no tokens, and the command reported "signed in" for a session that would 401 on the next command — the one place the user cannot act on it. It now confirms the token first, which turns a confusing failure later into a prompt to sign in again. Also carries CLI-side localization for the messages these paths emit. Only the copy this flow owns is translated; the rest of the CLI's output is still English. Locale comes from the POSIX environment (LC_ALL, then LC_MESSAGES, then LANG), because a terminal has no URL and no i18n bundle. The website's message files cannot be reused here: the root tsconfig excludes `packages/`, and importing them would pull every UI string into the CLI bundle for a handful of error messages. --- .../src/cli-auth/clerk-cli-auth.test.ts | 4 +- .../petdex-cli/src/cli-auth/clerk-cli-auth.ts | 12 +++- .../src/cli-auth/lib/auth-server.test.ts | 5 +- .../src/cli-auth/lib/auth-server.ts | 10 ++- .../src/cli-auth/lib/token-exchange.ts | 24 +++++-- .../petdex-cli/src/i18n/auth-messages.test.ts | 57 +++++++++++++++ packages/petdex-cli/src/i18n/auth-messages.ts | 69 +++++++++++++++++++ packages/petdex-cli/src/i18n/locale.test.ts | 53 ++++++++++++++ packages/petdex-cli/src/i18n/locale.ts | 51 ++++++++++++++ 9 files changed, 267 insertions(+), 18 deletions(-) create mode 100644 packages/petdex-cli/src/i18n/auth-messages.test.ts create mode 100644 packages/petdex-cli/src/i18n/auth-messages.ts create mode 100644 packages/petdex-cli/src/i18n/locale.test.ts create mode 100644 packages/petdex-cli/src/i18n/locale.ts diff --git a/packages/petdex-cli/src/cli-auth/clerk-cli-auth.test.ts b/packages/petdex-cli/src/cli-auth/clerk-cli-auth.test.ts index cd14e13c7..fc3b959bf 100644 --- a/packages/petdex-cli/src/cli-auth/clerk-cli-auth.test.ts +++ b/packages/petdex-cli/src/cli-auth/clerk-cli-auth.test.ts @@ -91,7 +91,9 @@ async function runLogin(options: { (value) => ({ ok: true as const, value }), (error: Error) => ({ ok: false as const, error }), ); - const page: Response | null = captured ? await (captured as Promise) : null; + const page: Response | null = captured + ? await (captured as Promise) + : null; return { outcome, page }; } diff --git a/packages/petdex-cli/src/cli-auth/clerk-cli-auth.ts b/packages/petdex-cli/src/cli-auth/clerk-cli-auth.ts index 09c34ee85..68542709f 100644 --- a/packages/petdex-cli/src/cli-auth/clerk-cli-auth.ts +++ b/packages/petdex-cli/src/cli-auth/clerk-cli-auth.ts @@ -195,8 +195,18 @@ export class ClerkCliAuth { } async whoami(): Promise { + // The cached profile is not evidence of a usable session: `logout` clears + // both records through Promise.allSettled, so a half-cleared store can + // leave the profile behind with no tokens next to it. Reporting that as + // "signed in" hides the problem until some later command 401s, which is + // the one place the user cannot act on it. Confirming the credential + // first turns a confusing failure into a prompt to sign in again. const cachedUser = await this.getJson("user"); - if (cachedUser) return cachedUser; + if (cachedUser) { + const accessToken = await this.getAccessToken(); + if (!accessToken) return null; + return cachedUser; + } const accessToken = await this.getAccessToken(); if (!accessToken) return null; diff --git a/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts b/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts index 50ca808c8..47b91eefa 100644 --- a/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts +++ b/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts @@ -131,9 +131,8 @@ describe("auth server deferred response", () => { expect(server.respond({ kind: "success" })).toBe(true); expect(server.respond({ kind: "error", reason: "timeout" })).toBe(false); - expect(await (await response)!.text()).toContain( - 'class="chip chip-success"', - ); + const page = await response; + expect(await page?.text()).toContain('class="chip chip-success"'); }); test("reports false when responding after close", async () => { diff --git a/packages/petdex-cli/src/cli-auth/lib/auth-server.ts b/packages/petdex-cli/src/cli-auth/lib/auth-server.ts index f00efeeb2..ea7167143 100644 --- a/packages/petdex-cli/src/cli-auth/lib/auth-server.ts +++ b/packages/petdex-cli/src/cli-auth/lib/auth-server.ts @@ -1,6 +1,7 @@ import { createServer, type Server, type ServerResponse } from "node:http"; import type { AddressInfo } from "node:net"; +import { authMessages } from "../../i18n/auth-messages.js"; import { ClerkCliAuthError } from "../types.js"; import { type CallbackFailureReason, @@ -283,7 +284,7 @@ export function startAuthServer( sendOutcome( server, { kind: "error", reason: "timeout" }, - `No response from the authorization server within ${timeoutMs}ms.`, + authMessages().callbackTimeout(timeoutMs), ); }, timeoutMs); @@ -311,7 +312,7 @@ export function startAuthServer( rejectCallback( new ClerkCliAuthError( "timeout", - `OAuth callback timed out after ${timeoutMs}ms.`, + authMessages().callbackTimeout(timeoutMs), ), ); closeListening(server); @@ -327,10 +328,7 @@ export function startAuthServer( if (!callbackSettled) { callbackSettled = true; rejectCallback( - new ClerkCliAuthError( - "timeout", - "OAuth callback server was closed.", - ), + new ClerkCliAuthError("timeout", authMessages().callbackClosed), ); } // Safety net. A sign-in that got as far as the redirect and then diff --git a/packages/petdex-cli/src/cli-auth/lib/token-exchange.ts b/packages/petdex-cli/src/cli-auth/lib/token-exchange.ts index fd3119006..42cfcf044 100644 --- a/packages/petdex-cli/src/cli-auth/lib/token-exchange.ts +++ b/packages/petdex-cli/src/cli-auth/lib/token-exchange.ts @@ -1,3 +1,4 @@ +import { authMessages } from "../../i18n/auth-messages.js"; import type { TokenSet, UserInfo } from "../types.js"; import { ClerkCliAuthError } from "../types.js"; @@ -30,6 +31,16 @@ interface OAuthTokenResponse { token_type?: unknown; } +/** + * How long either network call may take before it is abandoned. + * + * Without this, a stalled connection leaves `login()` waiting forever: the + * callback server's own timeout answers the browser, but nothing breaks the + * request the CLI is sitting on. The smoke script in `scripts/` already + * bounded its authorize probe this way. + */ +const REQUEST_TIMEOUT_MS = 15_000; + function endpoint(issuer: string, path: string): string { return `${issuer.replace(/\/+$/, "")}${path}`; } @@ -88,11 +99,12 @@ async function requestTokens( method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: body.toString(), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); } catch (error) { throw new ClerkCliAuthError( "token_exchange", - `Token request failed: ${(error as Error).message}`, + authMessages().tokenRequestFailed((error as Error).message), ); } @@ -108,10 +120,7 @@ async function requestTokens( if (!response.ok) { throw new ClerkCliAuthError( "token_exchange", - messageFromBody( - parsed, - `Token request failed with HTTP ${response.status}.`, - ), + messageFromBody(parsed, authMessages().tokenRequestHttp(response.status)), ); } @@ -158,11 +167,12 @@ export async function fetchUserInfo(params: UserInfoParams): Promise { try { response = await fetch(endpoint(params.issuer, "/oauth/userinfo"), { headers: { Authorization: `Bearer ${params.accessToken}` }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); } catch (error) { throw new ClerkCliAuthError( "userinfo", - `Userinfo request failed: ${(error as Error).message}`, + authMessages().userinfoRequestFailed((error as Error).message), ); } @@ -180,7 +190,7 @@ export async function fetchUserInfo(params: UserInfoParams): Promise { "userinfo", messageFromBody( parsed, - `Userinfo request failed with HTTP ${response.status}.`, + authMessages().userinfoRequestHttp(response.status), ), ); } diff --git a/packages/petdex-cli/src/i18n/auth-messages.test.ts b/packages/petdex-cli/src/i18n/auth-messages.test.ts new file mode 100644 index 000000000..7857e2950 --- /dev/null +++ b/packages/petdex-cli/src/i18n/auth-messages.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; + +import { authMessagesByLocale } from "./auth-messages"; +import { CLI_LOCALES } from "./locale"; + +type MessageKey = keyof ReturnType["en"]; + +const FN_KEYS: MessageKey[] = [ + "callbackTimeout", + "tokenRequestFailed", + "tokenRequestHttp", + "userinfoRequestFailed", + "userinfoRequestHttp", +]; +const STRING_KEYS: MessageKey[] = ["callbackClosed"]; + +describe("cli auth messages", () => { + test("every locale defines the same keys", () => { + const reference = Object.keys(authMessagesByLocale().en).sort(); + for (const locale of CLI_LOCALES) { + expect(Object.keys(authMessagesByLocale()[locale]).sort()).toEqual( + reference, + ); + } + }); + + test("no translation is left empty", () => { + for (const locale of CLI_LOCALES) { + const messages = authMessagesByLocale()[locale]; + for (const key of STRING_KEYS) { + expect(messages[key].length).toBeGreaterThan(0); + } + for (const key of FN_KEYS) { + const built = (messages[key] as (arg: never) => string)(1 as never); + expect(built.length).toBeGreaterThan(0); + } + } + }); + + test("interpolates the value rather than dropping it", () => { + for (const locale of CLI_LOCALES) { + const messages = authMessagesByLocale()[locale]; + expect(messages.callbackTimeout(4200)).toContain("4200"); + expect(messages.tokenRequestHttp(503)).toContain("503"); + expect(messages.userinfoRequestHttp(500)).toContain("500"); + expect(messages.tokenRequestFailed("boom")).toContain("boom"); + expect(messages.userinfoRequestFailed("boom")).toContain("boom"); + } + }); + + test("each locale reads differently", () => { + const sentences = CLI_LOCALES.map( + (locale) => authMessagesByLocale()[locale].callbackClosed, + ); + expect(new Set(sentences).size).toBe(CLI_LOCALES.length); + }); +}); diff --git a/packages/petdex-cli/src/i18n/auth-messages.ts b/packages/petdex-cli/src/i18n/auth-messages.ts new file mode 100644 index 000000000..d6947c3b4 --- /dev/null +++ b/packages/petdex-cli/src/i18n/auth-messages.ts @@ -0,0 +1,69 @@ +/** + * Terminal-facing copy for the CLI auth flow. + * + * Only messages this module actually emits are here. The rest of the CLI's + * output is still English; localizing all of it is a separate pass. Keeping + * the table small means it stays reviewable against the page strings in + * `cli-auth/lib/callback-page.ts`, which cover the same failures for the + * browser. + */ + +import { type CliLocale, resolveCliLocale } from "./locale"; + +type AuthMessages = { + /** Browser-facing page copy lives in callback-page.ts; these are terminal. */ + callbackTimeout: (ms: number) => string; + callbackClosed: string; + tokenRequestFailed: (detail: string) => string; + tokenRequestHttp: (status: number) => string; + userinfoRequestFailed: (detail: string) => string; + userinfoRequestHttp: (status: number) => string; +}; + +const MESSAGES: Record = { + en: { + callbackTimeout: (ms) => `OAuth callback timed out after ${ms}ms.`, + callbackClosed: "OAuth callback server was closed.", + tokenRequestFailed: (detail) => `Token request failed: ${detail}`, + tokenRequestHttp: (status) => `Token request failed with HTTP ${status}.`, + userinfoRequestFailed: (detail) => `Userinfo request failed: ${detail}`, + userinfoRequestHttp: (status) => + `Userinfo request failed with HTTP ${status}.`, + }, + es: { + callbackTimeout: (ms) => + `El callback de OAuth agotó el tiempo tras ${ms}ms.`, + callbackClosed: "Se cerró el servidor de callback de OAuth.", + tokenRequestFailed: (detail) => `Falló la petición de token: ${detail}`, + tokenRequestHttp: (status) => + `La petición de token falló con HTTP ${status}.`, + userinfoRequestFailed: (detail) => + `Falló la petición de userinfo: ${detail}`, + userinfoRequestHttp: (status) => + `La petición de userinfo falló con HTTP ${status}.`, + }, + zh: { + callbackTimeout: (ms) => `OAuth 回调在 ${ms}ms 后超时。`, + callbackClosed: "OAuth 回调服务器已关闭。", + tokenRequestFailed: (detail) => `令牌请求失败:${detail}`, + tokenRequestHttp: (status) => `令牌请求失败,HTTP ${status}。`, + userinfoRequestFailed: (detail) => `用户信息请求失败:${detail}`, + userinfoRequestHttp: (status) => `用户信息请求失败,HTTP ${status}。`, + }, +}; + +/** + * Copy for the current locale. + * + * `resolveCliLocale` is called per lookup rather than cached: the CLI is a + * short-lived process, and a test that sets `LANG` must not be defeated by + * a value read at import time. + */ +export function authMessages(env?: NodeJS.ProcessEnv): AuthMessages { + return MESSAGES[resolveCliLocale(env)]; +} + +/** Exposed for the key-parity test. */ +export function authMessagesByLocale(): Record { + return MESSAGES; +} diff --git a/packages/petdex-cli/src/i18n/locale.test.ts b/packages/petdex-cli/src/i18n/locale.test.ts new file mode 100644 index 000000000..f86bfc685 --- /dev/null +++ b/packages/petdex-cli/src/i18n/locale.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; + +import { CLI_LOCALES, resolveCliLocale } from "./locale"; + +function env(values: Partial>): NodeJS.ProcessEnv { + return values as NodeJS.ProcessEnv; +} + +describe("cli locale resolution", () => { + test("reads a plain language tag", () => { + expect(resolveCliLocale(env({ LANG: "zh" }))).toBe("zh"); + expect(resolveCliLocale(env({ LANG: "es" }))).toBe("es"); + expect(resolveCliLocale(env({ LANG: "en" }))).toBe("en"); + }); + + test("reads the region, encoding and modifier forms", () => { + expect(resolveCliLocale(env({ LANG: "zh_CN.UTF-8" }))).toBe("zh"); + expect(resolveCliLocale(env({ LANG: "es_MX.UTF-8@euro" }))).toBe("es"); + expect(resolveCliLocale(env({ LANG: "en_US.UTF-8" }))).toBe("en"); + expect(resolveCliLocale(env({ LANG: "zh-CN" }))).toBe("zh"); + }); + + test("prefers LC_ALL, then LC_MESSAGES, then LANG", () => { + expect( + resolveCliLocale( + env({ + LC_ALL: "zh_CN.UTF-8", + LC_MESSAGES: "es_ES.UTF-8", + LANG: "en_US", + }), + ), + ).toBe("zh"); + expect( + resolveCliLocale(env({ LC_MESSAGES: "es_ES.UTF-8", LANG: "en_US" })), + ).toBe("es"); + expect(resolveCliLocale(env({ LANG: "zh_CN.UTF-8" }))).toBe("zh"); + }); + + test("falls back to english when nothing names a language", () => { + // POSIX and C are libc's "unset" spellings, not languages. + expect(resolveCliLocale(env({}))).toBe("en"); + expect(resolveCliLocale(env({ LANG: "C" }))).toBe("en"); + expect(resolveCliLocale(env({ LANG: "POSIX" }))).toBe("en"); + expect(resolveCliLocale(env({ LANG: "fr_FR.UTF-8" }))).toBe("en"); + expect(resolveCliLocale(env({ LANG: "" }))).toBe("en"); + }); + + test("every advertised locale resolves", () => { + for (const locale of CLI_LOCALES) { + expect(resolveCliLocale(env({ LANG: locale }))).toBe(locale); + } + }); +}); diff --git a/packages/petdex-cli/src/i18n/locale.ts b/packages/petdex-cli/src/i18n/locale.ts new file mode 100644 index 000000000..34be015c3 --- /dev/null +++ b/packages/petdex-cli/src/i18n/locale.ts @@ -0,0 +1,51 @@ +/** + * Locale resolution for the CLI's own output. + * + * The app resolves locale from the URL and the website's i18n bundle; a + * terminal has neither, so this reads the POSIX environment instead: + * `LC_ALL` wins over `LC_MESSAGES`, which wins over `LANG`, matching how + * libc decides. Anything unrecognized falls back to English rather than + * guessing. + * + * This is deliberately independent of the website's `src/i18n/messages/*` + * bundle. The CLI is published as a standalone package and the root + * tsconfig excludes `packages/`, so importing that bundle is not possible + * — and even if it were, it would pull every UI string into the CLI bundle + * to serve a handful of error messages. + */ + +export const CLI_LOCALES = ["en", "es", "zh"] as const; +export type CliLocale = (typeof CLI_LOCALES)[number]; + +/** Value is `ll` or `ll_CC` (optionally `.encoding` / `@modifier`). */ +function parseLocaleTag(value: string | undefined): CliLocale | null { + if (!value) return null; + const language = value + .split(".")[0] + .split("@")[0] + .split("_")[0] + .split("-")[0] + .trim() + .toLowerCase(); + if (language === "en" || language === "es" || language === "zh") + return language; + return null; +} + +/** + * The locale to print CLI messages in. + * + * `POSIX` and `C` are libc's "no locale configured" values, and a trailing + * `UTF-8` suffix is common; neither names a language, so both fall through + * to English. + */ +export function resolveCliLocale( + env: NodeJS.ProcessEnv = process.env, +): CliLocale { + return ( + parseLocaleTag(env.LC_ALL) ?? + parseLocaleTag(env.LC_MESSAGES) ?? + parseLocaleTag(env.LANG) ?? + "en" + ); +} From d7b26e3b20853f3dbabc33334b50d6bac8b06c79 Mon Sep 17 00:00:00 2001 From: Mison Date: Mon, 28 Sep 2026 23:53:08 +0800 Subject: [PATCH 03/18] fix(cli): keep the callback port open so a late reader sees the timeout page The phase-one deadline closed the listener as it fired. The reader that deadline just expired for is being redirected to this port at that moment, so closing first handed them the browser's own network-error page instead of the timeout page this flow renders for exactly that case. The listener now stays up until a callback is answered or `close()` runs in the caller's `finally`, which already covers the read that never arrives. Verified across the reachable paths: a late valid callback gets the 500 timeout page, a late callback that fails state validation still gets its 400, and the normal path still releases the port on `close()`. Also drops a constant that only restated its single call site's literal. --- .../src/cli-auth/lib/auth-server.test.ts | 31 +++++++++++++++++++ .../src/cli-auth/lib/auth-server.ts | 12 ++++--- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts b/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts index 47b91eefa..f8477fe9a 100644 --- a/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts +++ b/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts @@ -261,6 +261,37 @@ describe("auth server timeouts", () => { expect(res?.status).toBe(500); expect(await res?.text()).toContain('class="chip chip-danger"'); }); + + test("still serves the timeout page to a callback that arrives late", async () => { + // The reader this deadline expired for is being redirected to this port + // right now. Closing the listener on the deadline would hand them the + // browser's own network-error page instead of the one meant for them. + const server = await start({ timeoutMs: 120 }); + await server.waitForCallback().catch(() => {}); + + const res = await fetch( + callbackUrl(server, { code: "late", state: STATE }), + ); + expect(res.status).toBe(500); + expect(await res.text()).toContain('class="chip chip-danger"'); + }); + + test("refuses a late callback that fails state validation", async () => { + const server = await start({ timeoutMs: 120 }); + await server.waitForCallback().catch(() => {}); + + const res = await fetch(callbackUrl(server, { code: "x", state: "wrong" })); + expect(res.status).toBe(400); + }); + + test("releases the port on close after a timeout", async () => { + const server = await start({ timeoutMs: 120 }); + await server.waitForCallback().catch(() => {}); + server.close(); + + const res = await fetch(server.redirectUri).catch(() => null); + expect(res).toBeNull(); + }); }); describe("auth server page rendering", () => { diff --git a/packages/petdex-cli/src/cli-auth/lib/auth-server.ts b/packages/petdex-cli/src/cli-auth/lib/auth-server.ts index ea7167143..bbe1288f9 100644 --- a/packages/petdex-cli/src/cli-auth/lib/auth-server.ts +++ b/packages/petdex-cli/src/cli-auth/lib/auth-server.ts @@ -41,9 +41,6 @@ export interface AuthServerHandle { close(): void; } -/** Immediate refusals the redirect itself produces. */ -const EMPTY_CODE = "token_exchange"; - function headers(html: string): Record { return { "Content-Type": "text/html; charset=utf-8", @@ -253,7 +250,7 @@ export function startAuthServer( "missing_code", 400, oauthCallbackError( - EMPTY_CODE, + "token_exchange", "OAuth callback did not include an authorization code.", ), ); @@ -309,13 +306,18 @@ export function startAuthServer( timeout = setTimeout(() => { if (callbackSettled) return; callbackSettled = true; + // The listener deliberately stays up. The reader this deadline just + // expired for is being redirected to this port right now, and closing + // would hand them the browser's own network-error page instead of the + // timeout page meant for them. A callback that arrives afterwards is + // answered with that page and closes the listener on its way out; if + // none ever arrives, `close()` in the caller's `finally` does it. rejectCallback( new ClerkCliAuthError( "timeout", authMessages().callbackTimeout(timeoutMs), ), ); - closeListening(server); }, timeoutMs); resolve({ From 3d782f3fa96256785d0e1970d0fb2d4b762b0b8d Mon Sep 17 00:00:00 2001 From: Mison Date: Tue, 29 Sep 2026 00:11:28 +0800 Subject: [PATCH 04/18] fix(cli): require the expected state before a callback may end a login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A request that cannot prove it carries the state this server generated can reach loopback from any page the reader has open, and does not need to read the response to have an effect. `/callback?error=x` and even a bare `/callback` were checked for `error` before `state`, so either could reject the callback promise — cancelling a sign-in the caller knows nothing about, repeatably, without authenticating. Check `state` first and split the refusal in two: - `answerOnly` sends a page to the request that made an unproven claim and touches nothing else: no flag, no deadline, no promise. - `refuseLogin` ends the attempt, and is now reachable only once the state matches, so only the browser the login was actually issued to can decide it. A forged request now leaves the login pending until its own timeout. The existing test for a mismatched state asserted the old behaviour and now asserts that it does not settle the login; the forgery cases cover all five shapes that used to work. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/cli-auth/lib/auth-server.test.ts | 70 ++++++++++++++++++- .../src/cli-auth/lib/auth-server.ts | 60 ++++++++++------ 2 files changed, 105 insertions(+), 25 deletions(-) diff --git a/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts b/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts index f8477fe9a..6e6041dbf 100644 --- a/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts +++ b/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts @@ -169,7 +169,10 @@ describe("auth server rejected callbacks", () => { expect(error.code).toBe("token_exchange"); }); - test("rejects a mismatched state", async () => { + test("answers a mismatched state without settling the login", async () => { + // The mismatch is reported to the caller that made it, and to nobody + // else. Letting it reject the login would hand every web page the reader + // has open a way to cancel a sign-in by asking for a refusal. const server = await start(); const pending = server.waitForCallback().catch((error: Error) => error); @@ -179,7 +182,7 @@ describe("auth server rejected callbacks", () => { expect(res.status).toBe(400); expect(await res.text()).toContain("state"); - expect(((await pending) as ClerkCliAuthError).code).toBe("state_mismatch"); + expect(((await pending) as ClerkCliAuthError).code).toBe("timeout"); }); test("rejects a callback with no code", async () => { @@ -228,6 +231,69 @@ describe("auth server while a response is held", () => { expect(await statusOf(held.response)).toBe(200); }); + test("lets a forged callback answer only itself, not the login", async () => { + // A page the reader has open, or anything else on the machine, can reach + // loopback without being able to read the response. If a request that + // cannot prove it carries the expected state were allowed to settle the + // callback, any of them could cancel a sign-in it knows nothing about by + // asking for a refusal. Each of these must get its own 400 and leave the + // login exactly as it was. + const forgeries: Record[] = [ + { error: "access_denied", state: "not-the-state" }, + { code: "x", state: "not-the-state" }, + { state: "not-the-state" }, + { error: "access_denied" }, + {}, + ]; + + for (const query of forgeries) { + // A deadline well past this iteration, so the assertions below are about + // what the forgery did and not about how fast the machine is. + const server = await start({ timeoutMs: 5_000 }); + let outcome: unknown; + const pending = server.waitForCallback().then( + (value) => (outcome = value), + (error: Error) => (outcome = error), + ); + + const res = await fetch(callbackUrl(server, query)); + expect(res.status).toBe(400); + expect(await res.text()).toContain("state"); + + // Still undecided: the forgery neither settled the promise nor spent + // the response. + expect(await stillPending(pending)).toBe(true); + expect(outcome).toBeUndefined(); + + // Proof the held response was left alone: a real callback that arrives + // afterwards is still served. + const real = beginFetch( + callbackUrl(server, { code: "abc", state: STATE }), + ); + expect(await server.waitForCallback()).toEqual({ + code: "abc", + state: STATE, + }); + expect(server.respond({ kind: "success" })).toBe(true); + expect(await statusOf(real.response)).toBe(200); + } + }); + + test("answers a forged callback while a real one is held", async () => { + const server = await start(); + const held = beginFetch(callbackUrl(server, { code: "abc", state: STATE })); + await server.waitForCallback(); + + const forged = await fetch( + callbackUrl(server, { error: "access_denied", state: "not-the-state" }), + ); + expect(forged.status).toBe(400); + + // The refusal is the forgery's alone; the held page is untouched. + expect(server.respond({ kind: "success" })).toBe(true); + expect(await statusOf(held.response)).toBe(200); + }); + test("still serves an ordinary 404 for a favicon while holding", async () => { const server = await start(); const held = beginFetch(callbackUrl(server, { code: "abc", state: STATE })); diff --git a/packages/petdex-cli/src/cli-auth/lib/auth-server.ts b/packages/petdex-cli/src/cli-auth/lib/auth-server.ts index bbe1288f9..182bfed15 100644 --- a/packages/petdex-cli/src/cli-auth/lib/auth-server.ts +++ b/packages/petdex-cli/src/cli-auth/lib/auth-server.ts @@ -189,7 +189,32 @@ export function startAuthServer( const error = url.searchParams.get("error"); const errorDescription = url.searchParams.get("error_description") ?? error; - const failure = ( + /** + * Answer this request and nothing else. + * + * Used for every request that has not proven it carries the state this + * server generated. Anything on the machine — and any web page the reader + * has open, which can reach loopback without reading the response — can + * send `/callback?error=x` or `/callback` and would otherwise be able to + * decide the outcome of a login it knows nothing about. Such a request + * must not set a flag, move a deadline, or settle the promise. + */ + const answerOnly = (reason: CallbackFailureReason, statusCode: number) => { + const html = pageFor({ kind: "error", reason }); + if (!isAlive(res)) return; + try { + res.writeHead(statusCode, headers(html)); + res.end(html); + } catch { + // Client already gone. + } + }; + + /** + * Refuse the login itself. Only reachable once the request has proven it + * carries the expected state, so a stranger cannot end the attempt. + */ + const refuseLogin = ( reason: CallbackFailureReason, statusCode: number, err: ClerkCliAuthError, @@ -198,14 +223,7 @@ export function startAuthServer( // are holding: it answers only itself and leaves every piece of state // alone. if (callbackSettled) { - const html = pageFor({ kind: "error", reason }); - if (!isAlive(res)) return; - try { - res.writeHead(statusCode, headers(html)); - res.end(html); - } catch { - // Client already gone. - } + answerOnly(reason, statusCode); return; } callbackSettled = true; @@ -221,8 +239,16 @@ export function startAuthServer( rejectCallback(err); }; + // State is checked first, so that a request which cannot prove it belongs + // to this login is only ever answered for itself. Checking `error` first + // would let any caller cancel the attempt by asking for a refusal. + if (state !== expectedState) { + answerOnly("state_mismatch", 400); + return; + } + if (error) { - failure( + refuseLogin( "authorization_denied", 400, oauthCallbackError( @@ -233,20 +259,8 @@ export function startAuthServer( return; } - if (state !== expectedState) { - failure( - "state_mismatch", - 400, - oauthCallbackError( - "state_mismatch", - "OAuth callback state did not match.", - ), - ); - return; - } - if (!code) { - failure( + refuseLogin( "missing_code", 400, oauthCallbackError( From 6a8762248ad5e4b6ae7ebc930949cc297cf24bd0 Mon Sep 17 00:00:00 2001 From: Mison Date: Tue, 29 Sep 2026 00:13:32 +0800 Subject: [PATCH 05/18] fix(cli): only let the button point at an http or https origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app URL reaches an `href`, and escaping does not make it safe: `javascript:` and `data:` are valid URLs, so both survive `escapeHtml` intact and clickable. A wrong `PETDEX_URL` — the one input this takes — would put a live script link on the callback page. Resolve the URL and keep the configured value only for http and https, falling back to the production origin otherwise. A misconfigured origin is a configuration error, and the reader should not be handed a link from it. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/cli-auth/lib/callback-page.test.ts | 19 ++++++++++++++++++ .../src/cli-auth/lib/callback-page.ts | 20 ++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/petdex-cli/src/cli-auth/lib/callback-page.test.ts b/packages/petdex-cli/src/cli-auth/lib/callback-page.test.ts index b66a1dfc8..b0afa2da0 100644 --- a/packages/petdex-cli/src/cli-auth/lib/callback-page.test.ts +++ b/packages/petdex-cli/src/cli-auth/lib/callback-page.test.ts @@ -140,6 +140,25 @@ describe("callback page success state", () => { expect(html).toContain('href="https://staging.test/my-pets"'); }); + test("refuses an app URL whose scheme is not http or https", () => { + // The value lands in an href. `javascript:` and `data:` are valid URLs, so + // they survive escaping and stay clickable; only the scheme tells them + // apart from a real origin. + for (const hostile of [ + "javascript:alert(1)", + "data:text/html,", + ]) { + const html = renderCallbackPage({ kind: "success" }, hostile); + expect(html).not.toContain(hostile); + expect(html).toContain(`href="${APP_URL}/my-pets"`); + } + }); + + test("refuses an app URL that is not a URL at all", () => { + const html = renderCallbackPage({ kind: "success" }, "not a url"); + expect(html).toContain(`href="${APP_URL}/my-pets"`); + }); + test("tries to close the tab after five seconds", () => { const html = successPage(); expect(html).toContain("setTimeout(function () { window.close(); }, 5000)"); diff --git a/packages/petdex-cli/src/cli-auth/lib/callback-page.ts b/packages/petdex-cli/src/cli-auth/lib/callback-page.ts index 3e6393eb7..d4c3b39df 100644 --- a/packages/petdex-cli/src/cli-auth/lib/callback-page.ts +++ b/packages/petdex-cli/src/cli-auth/lib/callback-page.ts @@ -51,8 +51,26 @@ export function defaultAppUrl(env: NodeJS.ProcessEnv = process.env): string { return env.PETDEX_URL ?? "https://petdex.dev"; } +/** + * The origin the button may point at. + * + * The value reaches an `href`, so anything that is not a trustworthy web + * address is discarded rather than escaped: `javascript:` and `data:` are + * perfectly valid URLs, and the escape below would leave both intact and + * clickable. Falling back to the production origin is the safe answer — this + * only ever comes from `PETDEX_URL`, and a wrong value is a misconfiguration, + * not something the reader should be handed a script from. + */ function resolveAppUrl(appUrl: string): string { - return appUrl.replace(/\/+$/, ""); + try { + const url = new URL(appUrl); + if (url.protocol === "http:" || url.protocol === "https:") { + return appUrl.replace(/\/+$/, ""); + } + } catch { + // Not a URL at all. Same answer as a URL with the wrong scheme. + } + return defaultAppUrl({}); } export function escapeHtml(value: string): string { From 66f46f5b9c4335552746487bc28cd6d904602459 Mon Sep 17 00:00:00 2001 From: Mison Date: Tue, 29 Sep 2026 08:47:48 +0800 Subject: [PATCH 06/18] fix(cli): send the page-security headers on every reply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The security headers were a property of whichever branch answered: the two 404s sent a bare Content-Type and the superseded-response 409 sent a partial set. All three now go through shared builders, so nosniff, no-referrer, no-store and X-Frame-Options hold for the whole listener. The HTML answers also gain a Content-Security-Policy of `default-src 'none'`. The page is self-contained by construction, so nothing needs to be allowed in, and an injected element can no longer load a script, an image or a beacon — the exfiltration path a page on loopback would otherwise offer. `'unsafe-inline'` is required for the inline style and scripts, which is also what keeps the `successHtml` / `errorHtml` injection points working unchanged; this is defence in depth, and the escaping of every value that reaches the page remains the actual guarantee. Verified in a headless browser: the page still renders and its own script still fills in the strings, while a probe page under the same policy has its outbound fetch blocked. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/cli-auth/lib/auth-server.test.ts | 30 +++++++++++ .../src/cli-auth/lib/auth-server.ts | 53 +++++++++++++++---- 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts b/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts index 6e6041dbf..b37fcc003 100644 --- a/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts +++ b/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts @@ -122,6 +122,36 @@ describe("auth server deferred response", () => { expect(server.respond({ kind: "success" })).toBe(false); }); + test("sends the page-security headers on the answer", async () => { + const server = await start(); + const { response } = beginFetch( + callbackUrl(server, { code: "abc", state: STATE }), + ); + await server.waitForCallback(); + server.respond({ kind: "success" }); + + const res = await response; + expect(res?.headers.get("content-security-policy")).toBe( + "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'", + ); + expect(res?.headers.get("x-content-type-options")).toBe("nosniff"); + expect(res?.headers.get("referrer-policy")).toBe("no-referrer"); + expect(res?.headers.get("cache-control")).toBe("no-store"); + expect(res?.headers.get("x-frame-options")).toBe("DENY"); + }); + + test("sends the same headers on a plain-text reply", async () => { + // The protections belong to the listener, not to whichever branch + // answered. A 404 that skipped them would be a gap on the same origin. + const server = await start(); + const res = await fetch(`http://127.0.0.1:${server.port}/favicon.ico`); + expect(res.status).toBe(404); + expect(res.headers.get("x-content-type-options")).toBe("nosniff"); + expect(res.headers.get("cache-control")).toBe("no-store"); + expect(res.headers.get("referrer-policy")).toBe("no-referrer"); + expect(res.headers.get("x-frame-options")).toBe("DENY"); + }); + test("is idempotent across two respond calls", async () => { const server = await start(); const { response } = beginFetch( diff --git a/packages/petdex-cli/src/cli-auth/lib/auth-server.ts b/packages/petdex-cli/src/cli-auth/lib/auth-server.ts index 182bfed15..203aed719 100644 --- a/packages/petdex-cli/src/cli-auth/lib/auth-server.ts +++ b/packages/petdex-cli/src/cli-auth/lib/auth-server.ts @@ -41,13 +41,50 @@ export interface AuthServerHandle { close(): void; } +/** + * The page is self-contained by construction — inline style, inline SVG, and + * the locale table in a data block — so nothing has to be allowed in. `none` + * for everything else means an injected element cannot load a script, an + * image or a beacon, which is the exfiltration path a page on loopback would + * otherwise offer. + * + * `'unsafe-inline'` is required for the two inline scripts and the style, and + * it is what keeps the `successHtml` / `errorHtml` injection points working + * unchanged. It is the reason this is defence in depth rather than a fix: the + * real guarantee is that every value reaching the page is escaped first. + * + * The app's own CSP is deliberately not reused — it carries + * `upgrade-insecure-requests`, which would rewrite this page's + * `http://127.0.0.1` origin to https and break it. + */ +const PAGE_CSP = + "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'"; + function headers(html: string): Record { return { "Content-Type": "text/html; charset=utf-8", "Content-Length": String(Buffer.byteLength(html)), - // The full app CSP is deliberately not reused: it carries - // `upgrade-insecure-requests`, which would rewrite this page's own - // http://127.0.0.1 origin to https and break it. + "Content-Security-Policy": PAGE_CSP, + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "no-referrer", + "Cache-Control": "no-store", + "X-Frame-Options": "DENY", + }; +} + +/** + * The same protections for the plain-text replies. + * + * These carry no HTML, but they are served from the same loopback origin and + * the same listener, so leaving them bare would make the security headers a + * property of which branch answered rather than of the server. `nosniff` + * matters most here: the bodies are attacker-influenced only in the sense that + * an arbitrary path is echoed into none of them, but a browser must not be + * free to reinterpret a reply as a document. + */ +function textHeaders(): Record { + return { + "Content-Type": "text/plain; charset=utf-8", "X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer", "Cache-Control": "no-store", @@ -121,11 +158,7 @@ export function startAuthServer( const endQuietly = (res: ServerResponse, status: number, body: string) => { if (!isAlive(res)) return; try { - res.writeHead(status, { - "Content-Type": "text/plain; charset=utf-8", - "Cache-Control": "no-store", - "X-Content-Type-Options": "nosniff", - }); + res.writeHead(status, textHeaders()); res.end(body); } catch { // The socket died between the check and the write. Nothing to do. @@ -172,14 +205,14 @@ export function startAuthServer( // so that a favicon or a stray path requested while we hold the response // open still gets an ordinary 404 rather than "already handled". if (req.method !== "GET" || !req.url) { - res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + res.writeHead(404, textHeaders()); res.end("Not found"); return; } const url = new URL(req.url, "http://127.0.0.1"); if (url.pathname !== "/callback") { - res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); + res.writeHead(404, textHeaders()); res.end("Clerk CLI auth server is waiting for /callback."); return; } From cf2e29ee1cb4fd3e94ac30f483e393a0e12e3311 Mon Sep 17 00:00:00 2001 From: Mison Date: Tue, 29 Sep 2026 08:50:47 +0800 Subject: [PATCH 07/18] test(cli): cover the request deadline on the token and userinfo calls The bound added to `token-exchange.ts` had no direct test: the login-flow suites reach those functions but never assert the signal, so dropping it would have gone unnoticed until someone's network stalled. Assert the signal is present rather than waiting out the 15s deadline. `AbortSignal.timeout` aborting is Node's guarantee, not this module's; what belongs to the module is remembering to pass it, and that is what is checked. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/cli-auth/lib/token-exchange.test.ts | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 packages/petdex-cli/src/cli-auth/lib/token-exchange.test.ts diff --git a/packages/petdex-cli/src/cli-auth/lib/token-exchange.test.ts b/packages/petdex-cli/src/cli-auth/lib/token-exchange.test.ts new file mode 100644 index 000000000..925c4199e --- /dev/null +++ b/packages/petdex-cli/src/cli-auth/lib/token-exchange.test.ts @@ -0,0 +1,114 @@ +import { afterEach, describe, expect, test } from "bun:test"; + +import { ClerkCliAuthError } from "../types"; +import { exchangeCodeForTokens, fetchUserInfo } from "./token-exchange"; + +const realFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +type FetchArgs = Parameters; + +function stubFetch( + impl: (input: FetchArgs[0], init?: FetchArgs[1]) => Promise, +) { + globalThis.fetch = impl as typeof fetch; +} + +const params = { + issuer: "https://clerk.test", + clientId: "client-id", + code: "auth-code", + codeVerifier: "verifier", + redirectUri: "http://127.0.0.1:1/callback", +}; + +/** + * Both network calls must carry a deadline. + * + * Without one a stalled connection leaves `login()` waiting forever: the + * callback server's own timeout answers the browser, but nothing breaks the + * request the CLI is sitting on. + * + * The assertions are on the signal rather than on a real expiry. Waiting out + * the 15s deadline would cost the suite half a minute to re-prove that + * `AbortSignal.timeout` aborts, which is Node's guarantee and not this + * module's; what belongs to this module is remembering to pass it at all. + */ +describe("token exchange request bounds", () => { + test("puts a deadline on the token request", async () => { + let signal: AbortSignal | undefined; + stubFetch(async (_input, init) => { + signal = init?.signal ?? undefined; + throw new Error("stopped before the request went out"); + }); + + await exchangeCodeForTokens(params).catch(() => {}); + + expect(signal).toBeInstanceOf(AbortSignal); + expect(signal?.aborted).toBe(false); + }); + + test("puts a deadline on the userinfo request", async () => { + let signal: AbortSignal | undefined; + stubFetch(async (_input, init) => { + signal = init?.signal ?? undefined; + throw new Error("stopped before the request went out"); + }); + + await fetchUserInfo({ + issuer: "https://clerk.test", + accessToken: "token", + }).catch(() => {}); + + expect(signal).toBeInstanceOf(AbortSignal); + expect(signal?.aborted).toBe(false); + }); + + test("reports a request that failed as a token_exchange error", async () => { + stubFetch(async () => { + throw new Error("socket hang up"); + }); + + const error = (await exchangeCodeForTokens(params).catch( + (err: Error) => err, + )) as ClerkCliAuthError; + expect(error).toBeInstanceOf(ClerkCliAuthError); + expect(error.code).toBe("token_exchange"); + expect(error.message).toContain("Token request failed"); + }); + + test("reports an HTTP failure from the token endpoint", async () => { + stubFetch( + async () => + new Response(JSON.stringify({ error: "invalid_grant" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }), + ); + + const error = (await exchangeCodeForTokens(params).catch( + (err: Error) => err, + )) as ClerkCliAuthError; + expect(error.code).toBe("token_exchange"); + expect(error.message).toBe("invalid_grant"); + }); + + test("refuses a token response with no access_token", async () => { + stubFetch( + async () => + new Response(JSON.stringify({ token_type: "Bearer" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + const error = (await exchangeCodeForTokens(params).catch( + (err: Error) => err, + )) as ClerkCliAuthError; + expect(error.code).toBe("token_exchange"); + expect(error.message).toContain("access_token"); + }); +}); From 43a1911476c448eca50f310393035a0c1e33574b Mon Sep 17 00:00:00 2001 From: Mison Date: Tue, 29 Sep 2026 08:52:12 +0800 Subject: [PATCH 08/18] docs(cli): correct the stated reason for not reusing the app CSP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment claimed the app policy carries `upgrade-insecure-requests`, which would rewrite this page's loopback origin to https and break it. That is not what happens: loopback is a potentially-trustworthy origin, so browsers exempt it from the directive. Measured, with the directive set and a same-origin subresource requested over http: on `http://127.0.0.1` the subresource loads, and on `http://example.test` resolved to the same address it is upgraded to https and fails. So the directive is a non-issue here and the real reason is the one the comment now gives — the app policy allowlists Clerk, R2 and the analytics hosts, none of which a self-contained page needs. The policy itself is unchanged; only the reasoning was wrong. Co-Authored-By: Claude Opus 5 (1M context) --- packages/petdex-cli/src/cli-auth/lib/auth-server.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/petdex-cli/src/cli-auth/lib/auth-server.ts b/packages/petdex-cli/src/cli-auth/lib/auth-server.ts index 203aed719..9ddc86f70 100644 --- a/packages/petdex-cli/src/cli-auth/lib/auth-server.ts +++ b/packages/petdex-cli/src/cli-auth/lib/auth-server.ts @@ -53,9 +53,16 @@ export interface AuthServerHandle { * unchanged. It is the reason this is defence in depth rather than a fix: the * real guarantee is that every value reaching the page is escaped first. * - * The app's own CSP is deliberately not reused — it carries - * `upgrade-insecure-requests`, which would rewrite this page's - * `http://127.0.0.1` origin to https and break it. + * The app's own CSP in `next.config.ts` is deliberately not reused: it + * allowlists Clerk, the R2 buckets and the Vercel analytics hosts, none of + * which a self-contained page needs, and a wider policy here would only widen + * what an injected element could reach. + * + * `upgrade-insecure-requests` is *not* the reason, though it looks like it + * should be. Loopback is a potentially-trustworthy origin, so browsers exempt + * it from that directive. Measured: a subresource on `http://127.0.0.1` loads + * under it, while the same subresource on `http://example.test` mapped to the + * same address is upgraded to https and fails. */ const PAGE_CSP = "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'"; From 9a72f7979dbe954204c7f5bfcf8ae49a99393190 Mon Sep 17 00:00:00 2001 From: Mison Date: Tue, 29 Sep 2026 09:37:29 +0800 Subject: [PATCH 09/18] fix(cli): keep the detail line, and drop the error page's dead CTA copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects on the failure pages, both found by running the page's own script rather than reading its markup. The detail line was rendered into `

`, and the locale script then wrote `resource` over it — a key that is the empty string in all three tables. Every failure page therefore showed an empty bordered rule where the reason belonged, including the provider text the timeout and exchange paths pass in. The paragraph now carries no role, so the script leaves it alone. The error copy for the call to action was written in all three locales and pushed into the table, but the anchor is only emitted on success, so the reader never saw it. It described an action the page does not offer — a retry has to happen in the terminal — so the strings are removed rather than a link invented for them. The failure pages render exactly as before. The tests only ever asserted the raw markup, which is why both survived; the new ones execute the script and check what a reader would actually see. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/cli-auth/lib/callback-page.test.ts | 85 +++++++++++++++++++ .../src/cli-auth/lib/callback-page.ts | 14 ++- 2 files changed, 91 insertions(+), 8 deletions(-) diff --git a/packages/petdex-cli/src/cli-auth/lib/callback-page.test.ts b/packages/petdex-cli/src/cli-auth/lib/callback-page.test.ts index b0afa2da0..d56f0ace0 100644 --- a/packages/petdex-cli/src/cli-auth/lib/callback-page.test.ts +++ b/packages/petdex-cli/src/cli-auth/lib/callback-page.test.ts @@ -209,6 +209,91 @@ describe("callback page detail line", () => { }); }); +/** + * Run the page's own i18n script against a minimal document. + * + * The string assertions elsewhere in this file only look at the markup, which + * is how a role the script writes over — the detail line — went unnoticed: + * the text was in the HTML and gone by the time a reader saw it. Anything + * asserting what a reader ends up seeing has to run the script. + */ +function runPageScript(html: string, language = "en") { + const payload = + /` : ""; From 471e73cdfd67c07f6d985780970f8e99cbb46716 Mon Sep 17 00:00:00 2001 From: Mison Date: Tue, 29 Sep 2026 10:05:29 +0800 Subject: [PATCH 10/18] fix(cli): answer a malformed request target instead of crashing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `new URL(req.url, "http://127.0.0.1")` is the one unguarded call in a handler that wraps its other three writes in try. It throws on request lines that are valid to send but not valid URLs — `GET //` is the cheapest, and `///`, `http://[` and `http://:80/` all do it too. The throw is uncaught, so it takes the whole process down while the user is mid-login: no page, no message, the CLI just exits. Reproduced against the module: a raw `GET //` on the callback port ends in `UNCAUGHT: Invalid URL`. It needs no attacker — a stray request, or any page in the reader's browser that can reach loopback, is enough. An unparseable target is simply not the callback, so it now gets the same 404 a stray path gets. The regression test sends raw request lines over a socket, because fetch normalises the target before it goes out and never reaches the handler with the malformed form. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/cli-auth/lib/auth-server.test.ts | 43 +++++++++++++++++++ .../src/cli-auth/lib/auth-server.ts | 15 ++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts b/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts index b37fcc003..d448a7a21 100644 --- a/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts +++ b/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { createServer, type Server } from "node:http"; import type { AddressInfo } from "node:net"; +import { connect } from "node:net"; import { ClerkCliAuthError } from "../types"; import { type AuthServerHandle, startAuthServer } from "./auth-server"; @@ -71,6 +72,48 @@ async function stillPending(promise: Promise): Promise { ); } +/** Send a raw request line, bypassing fetch's normalisation of the target. */ +function rawRequest(port: number, target: string): Promise { + return new Promise((resolve) => { + const socket = connect(port, "127.0.0.1", () => { + socket.write( + `GET ${target} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n`, + ); + }); + let buf = ""; + socket.on("data", (chunk) => (buf += chunk)); + socket.on("close", () => resolve(buf.split("\r\n")[0] ?? "")); + socket.on("error", (error) => resolve(`error: ${error.message}`)); + setTimeout(() => { + socket.destroy(); + resolve(buf.split("\r\n")[0] ?? "timeout"); + }, 800); + }); +} + +describe("auth server malformed request targets", () => { + test("answers an unparseable request target instead of throwing", async () => { + // `new URL()` rejects some valid request lines — `GET //` is the cheapest + // to send. An uncaught throw here kills the CLI mid-login, because this + // handler is the only thing running. + const server = await start(); + const targets = ["//", "///", "http://[", "http://:80/"]; + + for (const target of targets) { + const status = await rawRequest(server.port, target); + expect(status).toContain("404"); + } + + // The server survived all of them and still serves the callback. + const { response } = beginFetch( + callbackUrl(server, { code: "abc", state: STATE }), + ); + await server.waitForCallback(); + expect(server.respond({ kind: "success" })).toBe(true); + expect(await statusOf(response)).toBe(200); + }); +}); + describe("auth server deferred response", () => { test("holds the page until respond is called, then answers with the success page", async () => { const server = await start(); diff --git a/packages/petdex-cli/src/cli-auth/lib/auth-server.ts b/packages/petdex-cli/src/cli-auth/lib/auth-server.ts index 9ddc86f70..2aae8754d 100644 --- a/packages/petdex-cli/src/cli-auth/lib/auth-server.ts +++ b/packages/petdex-cli/src/cli-auth/lib/auth-server.ts @@ -217,7 +217,20 @@ export function startAuthServer( return; } - const url = new URL(req.url, "http://127.0.0.1"); + // A request target is not guaranteed to parse. `GET //` is the cheapest + // example — it is a valid request line that `new URL()` rejects — and an + // uncaught throw here takes the whole CLI down mid-login, since this + // handler is the only thing running. Anything unparseable is simply not + // the callback. + let url: URL; + try { + url = new URL(req.url, "http://127.0.0.1"); + } catch { + res.writeHead(404, textHeaders()); + res.end("Clerk CLI auth server is waiting for /callback."); + return; + } + if (url.pathname !== "/callback") { res.writeHead(404, textHeaders()); res.end("Clerk CLI auth server is waiting for /callback."); From d052fb6b21c9ebbc657a977e076973b4612cf39d Mon Sep 17 00:00:00 2001 From: Mison Date: Tue, 29 Sep 2026 11:29:12 +0800 Subject: [PATCH 11/18] test(cli): stop a slow machine from failing the malformed-target case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The case makes four raw round-trips before it reaches the real callback, and it ran them against the default 150ms deadline. That deadline is a budget for the reader's browsing, not for the test's own traffic: on a loaded machine the first-phase clock expired mid-case, `waitForCallback()` rejected with `timeout`, and the failure read as a broken server rather than a slow one. Reproduced deterministically by delaying 200ms between the raw requests and the callback — the old code answered 500 and rejected, and now resolves. The loopback round-trips themselves cost about 25ms, which is why it surfaced roughly once in eight runs and not on every one. Co-Authored-By: Claude Opus 5 (1M context) --- packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts b/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts index d448a7a21..945fbf53e 100644 --- a/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts +++ b/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts @@ -96,7 +96,12 @@ describe("auth server malformed request targets", () => { // `new URL()` rejects some valid request lines — `GET //` is the cheapest // to send. An uncaught throw here kills the CLI mid-login, because this // handler is the only thing running. - const server = await start(); + // A generous deadline on purpose. This case makes four raw round-trips + // before the real callback, and the default 150ms is a budget for the + // reader's browsing, not for the test's own traffic — on a loaded machine + // the first-phase clock would expire mid-test and the failure would look + // like a broken server rather than a slow one. + const server = await start({ timeoutMs: 5_000 }); const targets = ["//", "///", "http://[", "http://:80/"]; for (const target of targets) { From 30abca008ad22e4168f57473d3251fdabf498a0e Mon Sep 17 00:00:00 2001 From: Mison Date: Tue, 29 Sep 2026 11:29:22 +0800 Subject: [PATCH 12/18] fix(cli): answer a callback that lands after the login finished MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A connection accepted before the listener closed can have its request parsed after `respond()` has already run — a reader reloading the finished page, or a stale tab. That request matched the callback branch and was held, but `sendOutcome` returns early once `responseSent` is set, so nothing could ever answer it: the tab spun until the process exited, and the socket pinned the event loop in the meantime. Answer it for itself, the way a request with the wrong state is answered. The state check still runs first, so this only ever replies to a caller that already knows the state this server generated. Measured before the fix: a socket opened, then `respond()` called, then a request written to that socket, got no response at all. It now gets a 409. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/cli-auth/lib/auth-server.test.ts | 44 +++++++++++++++++-- .../src/cli-auth/lib/auth-server.ts | 24 +++++++--- 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts b/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts index 945fbf53e..fe431bd43 100644 --- a/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts +++ b/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts @@ -385,6 +385,41 @@ describe("auth server while a response is held", () => { }); }); +describe("auth server after the login is answered", () => { + test("answers a callback that arrives on an already-open connection", async () => { + // A connection accepted before the listener closed can have its request + // parsed after `respond()` has run. Holding it would leave a socket that + // nothing can ever answer — `sendOutcome` returns early once + // `responseSent` is set — so the tab would spin until the process exits. + const server = await start(); + const socket = connect(server.port, "127.0.0.1"); + await new Promise((resolve) => socket.on("connect", () => resolve())); + + const first = beginFetch( + callbackUrl(server, { code: "one", state: STATE }), + ); + await server.waitForCallback(); + expect(server.respond({ kind: "success" })).toBe(true); + expect(await statusOf(first.response)).toBe(200); + + const reply = await new Promise((resolve) => { + let buf = ""; + socket.on("data", (chunk) => (buf += chunk)); + socket.on("close", () => resolve(buf.split("\r\n")[0] ?? "")); + socket.on("error", () => resolve("error")); + socket.write( + `GET /callback?code=two&state=${STATE} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n`, + ); + setTimeout(() => { + socket.destroy(); + resolve("no response"); + }, 1500); + }); + + expect(reply).toContain("409"); + }); +}); + describe("auth server timeouts", () => { test("rejects when no callback arrives in time", async () => { const server = await start({ timeoutMs: 120 }); @@ -407,9 +442,12 @@ describe("auth server timeouts", () => { }); test("still serves the timeout page to a callback that arrives late", async () => { - // The reader this deadline expired for is being redirected to this port - // right now. Closing the listener on the deadline would hand them the - // browser's own network-error page instead of the one meant for them. + // Pins the module's contract, not a user-visible outcome: the deadline + // leaves the listener up so an in-flight request is answered rather than + // refused. In the real flow `login()` answers and closes within a tick of + // the rejection, so a reader who only arrives after the timeout gets the + // browser's own error page — keeping the process alive for them would + // mean outliving the failure that just ended the login. const server = await start({ timeoutMs: 120 }); await server.waitForCallback().catch(() => {}); diff --git a/packages/petdex-cli/src/cli-auth/lib/auth-server.ts b/packages/petdex-cli/src/cli-auth/lib/auth-server.ts index 2aae8754d..ced4ebb35 100644 --- a/packages/petdex-cli/src/cli-auth/lib/auth-server.ts +++ b/packages/petdex-cli/src/cli-auth/lib/auth-server.ts @@ -324,6 +324,16 @@ export function startAuthServer( return; } + // The login has already been answered, so this cannot belong to it — it + // is a reader reloading a finished page, or a stale tab. Holding it would + // leave a connection that nothing can ever answer: `sendOutcome` returns + // early once `responseSent` is set, so the page would spin until the + // process exits. Answer it for itself, the way a mismatched state is. + if (responseSent) { + endQuietly(res, 409, "This login is already complete."); + return; + } + // A valid code, and the one case that does not answer immediately. if (callbackSettled && pendingResponse !== null) { // The reader reloaded or reopened the callback. The superseded response @@ -373,12 +383,14 @@ export function startAuthServer( timeout = setTimeout(() => { if (callbackSettled) return; callbackSettled = true; - // The listener deliberately stays up. The reader this deadline just - // expired for is being redirected to this port right now, and closing - // would hand them the browser's own network-error page instead of the - // timeout page meant for them. A callback that arrives afterwards is - // answered with that page and closes the listener on its way out; if - // none ever arrives, `close()` in the caller's `finally` does it. + // The listener is left up rather than closed here, so that a request + // already in flight is answered instead of refused. It does not stay + // up for long: the caller's `respond()` and then its `close()` — and + // `login()` always runs both — release the port within a tick, and the + // process exits with the caller. A reader who only reaches the port + // after that gets the browser's own network-error page, because + // serving them would mean keeping the CLI alive past its own failure. + // Closing here would only widen that window by a few microseconds. rejectCallback( new ClerkCliAuthError( "timeout", From a9fa1992c1d08b377f27c12d591b9da10ea4dae4 Mon Sep 17 00:00:00 2001 From: Mison Date: Tue, 29 Sep 2026 11:29:56 +0800 Subject: [PATCH 13/18] test(cli): drive a hostile value through the JSON escape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The angle-bracket escape in `safeJson` had no test that could fail: the three locale tables contain no `<`, so asserting on a rendered page proved nothing — deleting the escape left every assertion green. Export the function and feed it ``, then assert both that no raw `<` survives and that the result still parses back to the original string. Removing the escape now fails the suite. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/cli-auth/lib/callback-page.test.ts | 19 +++++++++++++++++++ .../src/cli-auth/lib/callback-page.ts | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/petdex-cli/src/cli-auth/lib/callback-page.test.ts b/packages/petdex-cli/src/cli-auth/lib/callback-page.test.ts index d56f0ace0..e7ef41854 100644 --- a/packages/petdex-cli/src/cli-auth/lib/callback-page.test.ts +++ b/packages/petdex-cli/src/cli-auth/lib/callback-page.test.ts @@ -10,6 +10,7 @@ import { escapeHtml, LOCALES_FOR_TEST, renderCallbackPage, + safeJson, } from "./callback-page"; const REASONS: CallbackFailureReason[] = [ @@ -253,6 +254,24 @@ function runPageScript(html: string, language = "en") { return { text, nodes }; } +describe("callback page JSON embedding", () => { + test("escapes an angle bracket so a string cannot close the script block", () => { + // The real tables contain no `<`, so asserting on a rendered page proves + // nothing about the escape — delete it and the page still has no `<`. + // This drives a hostile value through the function that guards it. + const hostile = ""; + const payload = safeJson({ strings: { en: { title: hostile } } }); + + // No raw `<` survives, so the element cannot be closed early. + expect(payload).not.toContain("<"); + expect(payload).toContain("\\u003c"); + // The escape is valid JSON and parses back to the original character. + expect(JSON.parse(payload)).toEqual({ + strings: { en: { title: hostile } }, + }); + }); +}); + describe("callback page after its script runs", () => { test("keeps the detail line the server rendered", () => { // The script used to write an always-empty `resource` string over this diff --git a/packages/petdex-cli/src/cli-auth/lib/callback-page.ts b/packages/petdex-cli/src/cli-auth/lib/callback-page.ts index ae2bea3cb..eef4c70a4 100644 --- a/packages/petdex-cli/src/cli-auth/lib/callback-page.ts +++ b/packages/petdex-cli/src/cli-auth/lib/callback-page.ts @@ -183,7 +183,7 @@ export const LOCALES_FOR_TEST = LOCALES; * would close the element early and spill the rest into the document. The * escape is valid JSON and parses back to the original character. */ -function safeJson(value: unknown): string { +export function safeJson(value: unknown): string { return JSON.stringify(value).replaceAll("<", "\\u003c"); } From abaaa6d30ade822c6c82069390957ce0099e6fc8 Mon Sep 17 00:00:00 2001 From: Mison Date: Tue, 29 Sep 2026 11:30:07 +0800 Subject: [PATCH 14/18] test(cli): assert the deadline is bounded, not merely present MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both cases asserted `signal instanceof AbortSignal` and `aborted === false`. A plain `new AbortController().signal` satisfies both while never aborting, so swapping `AbortSignal.timeout(n)` for one left the suite green — the assertion could not see the thing it named. Spy on `AbortSignal.timeout` and assert one bounded deadline is requested per call. The controller substitution now fails two cases. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/cli-auth/lib/token-exchange.test.ts | 40 ++++++++++++++----- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/packages/petdex-cli/src/cli-auth/lib/token-exchange.test.ts b/packages/petdex-cli/src/cli-auth/lib/token-exchange.test.ts index 925c4199e..be0e41696 100644 --- a/packages/petdex-cli/src/cli-auth/lib/token-exchange.test.ts +++ b/packages/petdex-cli/src/cli-auth/lib/token-exchange.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { ClerkCliAuthError } from "../types"; import { exchangeCodeForTokens, fetchUserInfo } from "./token-exchange"; @@ -32,13 +32,30 @@ const params = { * callback server's own timeout answers the browser, but nothing breaks the * request the CLI is sitting on. * - * The assertions are on the signal rather than on a real expiry. Waiting out - * the 15s deadline would cost the suite half a minute to re-prove that - * `AbortSignal.timeout` aborts, which is Node's guarantee and not this - * module's; what belongs to this module is remembering to pass it at all. + * These watch `AbortSignal.timeout` rather than waiting out the real 15s, + * which would cost the suite half a minute to re-prove that a timeout signal + * aborts — Node's guarantee, not this module's. What belongs to the module is + * asking for a bounded deadline at all, and that is what is asserted. A + * signal from `new AbortController()` would satisfy "is an AbortSignal" while + * never aborting; it cannot satisfy this. */ describe("token exchange request bounds", () => { - test("puts a deadline on the token request", async () => { + const realTimeout = AbortSignal.timeout; + let deadlines: number[]; + + beforeEach(() => { + deadlines = []; + AbortSignal.timeout = ((ms: number) => { + deadlines.push(ms); + return realTimeout(ms); + }) as typeof AbortSignal.timeout; + }); + + afterEach(() => { + AbortSignal.timeout = realTimeout; + }); + + test("asks for a bounded deadline on the token request", async () => { let signal: AbortSignal | undefined; stubFetch(async (_input, init) => { signal = init?.signal ?? undefined; @@ -48,10 +65,13 @@ describe("token exchange request bounds", () => { await exchangeCodeForTokens(params).catch(() => {}); expect(signal).toBeInstanceOf(AbortSignal); - expect(signal?.aborted).toBe(false); + expect(deadlines).toHaveLength(1); + expect(deadlines[0]).toBeGreaterThan(0); + // Bounded, not merely present: an hour is not a deadline anyone waits out. + expect(deadlines[0]).toBeLessThanOrEqual(60_000); }); - test("puts a deadline on the userinfo request", async () => { + test("asks for a bounded deadline on the userinfo request", async () => { let signal: AbortSignal | undefined; stubFetch(async (_input, init) => { signal = init?.signal ?? undefined; @@ -64,7 +84,9 @@ describe("token exchange request bounds", () => { }).catch(() => {}); expect(signal).toBeInstanceOf(AbortSignal); - expect(signal?.aborted).toBe(false); + expect(deadlines).toHaveLength(1); + expect(deadlines[0]).toBeGreaterThan(0); + expect(deadlines[0]).toBeLessThanOrEqual(60_000); }); test("reports a request that failed as a token_exchange error", async () => { From b211f82c2371a76c44fa02c0e944b5ace49a53c6 Mon Sep 17 00:00:00 2001 From: Mison Date: Tue, 29 Sep 2026 13:07:34 +0800 Subject: [PATCH 15/18] feat(cli): set the callback page in the site's own type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page fell back to whatever sans-serif the OS had, so a login landed on something that did not look like Petdex at all. It now carries Geist, the face the website is set in, inlined as base64 woff2 in two @font-face blocks. Inlining is the only option rather than a preference: `auth-server.ts` closes its listener the moment it answers, so the page can never fetch a font file. That is also why the CSP gains `font-src data:` — the one directive the faces need, and one that cannot reach the network. The subsets come from the `geist` npm package (v1.7.2, SIL OFL 1.1), cut to Latin-1 plus the punctuation the page can render, with the weight axis clamped to 400-600 and only the four layout features the Latin text uses. That is 17.2KB and 8.3KB, about 34KB of base64. `scripts/build-callback-fonts.ts` regenerates them; it is not a build step, because it needs Python and the package tarball, neither of which the published CLI should depend on. The stack names Geist first and keeps the system faces behind it, which is what keeps the Chinese copy working: Geist carries no CJK glyphs, so those characters fall through to the system face exactly as they do on the website. Verified in headless Chrome — `document.fonts` reports both faces `loaded`, the Latin text measures differently from `system-ui`, and the CJK text measures identically to it. Also adds `` ahead of the stylesheet, so the first frame is painted with the right background instead of flashing the light one. A first attempt at this shipped a page whose fonts were 72 bytes. The generated module wrapped the base64 across adjacent string literals, which JavaScript does not concatenate — only the first line bound, and the rest became dead expressions. The build, the type check and every existing test passed, because nothing asserted the font was actually in the document. There is now a test that decodes each data URI and checks for the woff2 magic; reverting the fix fails it. Co-Authored-By: Claude Opus 5 (1M context) --- .../scripts/build-callback-fonts.ts | 174 ++++++++ .../src/cli-auth/lib/auth-server.test.ts | 5 +- .../src/cli-auth/lib/auth-server.ts | 7 +- .../src/cli-auth/lib/callback-fonts.ts | 380 ++++++++++++++++++ .../src/cli-auth/lib/callback-page.test.ts | 40 ++ .../src/cli-auth/lib/callback-page.ts | 51 ++- 6 files changed, 651 insertions(+), 6 deletions(-) create mode 100644 packages/petdex-cli/scripts/build-callback-fonts.ts create mode 100644 packages/petdex-cli/src/cli-auth/lib/callback-fonts.ts diff --git a/packages/petdex-cli/scripts/build-callback-fonts.ts b/packages/petdex-cli/scripts/build-callback-fonts.ts new file mode 100644 index 000000000..c975b1790 --- /dev/null +++ b/packages/petdex-cli/scripts/build-callback-fonts.ts @@ -0,0 +1,174 @@ +/** + * Regenerates `src/cli-auth/lib/callback-fonts.ts`. + * + * Run from this package after the page's copy gains a character outside the + * subset: the page would otherwise render that one character in the system + * face, part-way through a sentence. + * + * GEIST_PACKAGE_DIR=/path/to/unpacked/geist bun run scripts/build-callback-fonts.ts + * + * `GEIST_PACKAGE_DIR` is the `geist` npm package unpacked from its tarball + * (`npm pack geist && tar xzf geist-*.tgz`), version 1.7.2 at the time of + * writing. The subsetting itself needs Python with `fontTools` and `brotli`; + * point `PYTHON` at that interpreter if it is not the default `python3`. + * All of that is build-time only. The published CLI carries the finished + * base64 strings and never runs this, and it is deliberately not a package + * script so `bun run build` cannot depend on the tools being installed. + */ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +/** The `geist` package's variable fonts, unpacked from its npm tarball. */ +const GEIST_PACKAGE = process.env.GEIST_PACKAGE_DIR; +if (!GEIST_PACKAGE) { + console.error("Set GEIST_PACKAGE_DIR to the unpacked `geist` npm package."); + process.exit(1); +} + +/** Interpreter holding `fontTools` and `brotli`. */ +const PYTHON_BIN = process.env.PYTHON ?? "python3"; + +const SANS_SOURCE = join( + GEIST_PACKAGE, + "dist/fonts/geist-sans/Geist-Variable.woff2", +); +const MONO_SOURCE = join( + GEIST_PACKAGE, + "dist/fonts/geist-mono/GeistMono-Variable.woff2", +); + +/** + * Latin-1 plus the punctuation and combining marks the page can produce. + * + * Latin-1 in full rather than only the characters in today's copy: the detail + * line renders text from the OAuth error response, so an accented character + * outside this set would swap to the system face mid-sentence. + */ +const SANS_UNICODES = [ + "U+0020-007E", // printable ASCII + "U+00A0-00FF", // Latin-1 supplement + "U+0131", // dotless i + "U+0152-0153", // OE, oe + "U+02BC", // modifier apostrophe + "U+02C6", // modifier circumflex + "U+02DA", // ring above + "U+02DC", // small tilde + "U+0300-0301", + "U+0303-0304", + "U+0308-0309", + "U+0323", // combining marks + "U+2013-2014", // en dash, em dash + "U+2018-201D", // curly quotes + "U+2026", // ellipsis + "U+2122", // trade mark +].join(","); + +/** The mono face renders only the fixed `eyebrow` label, so ASCII is enough. */ +const MONO_UNICODES = "U+0020-007E"; + +/** Weights the page uses: 400 body, 500 eyebrow, 600 headings. */ +const WEIGHT_RANGE = "400:600"; + +/** Copyright, family, subfamily, unique, full, PostScript, OFL, licence URL. */ +const NAME_IDS = "0,1,2,3,4,6,13,14"; + +/** The four features the page's Latin text needs; `*` would add about 20KB. */ +const LAYOUT_FEATURES = "ccmp,liga,kern,locl"; + +/** + * Subset and clamp the weight axis in one pass. + * + * Both steps are needed and pyftsubset cannot clamp the axis itself, so the + * whole thing runs through fontTools' Python API rather than the CLI. The + * order matters: instancing first would leave the subsetter resolving + * variations for glyphs it is about to drop. + */ +const PYTHON = ` +import sys +from fontTools.ttLib import TTFont +from fontTools.varLib import instancer +from fontTools import subset + +source, unicodes, features, name_ids, weight, out = sys.argv[1:7] +font = TTFont(source) +opts = subset.Options() +opts.flavor = None +opts.layout_features = features.split(",") +opts.hinting = False +opts.desubroutinize = True +opts.name_IDs = [int(n) for n in name_ids.split(",")] +opts.notdef_outline = True +subsetter = subset.Subsetter(options=opts) +subsetter.populate(unicodes=subset.parse_unicodes(unicodes)) +subsetter.subset(font) +lo, hi = (int(v) for v in weight.split(":")) +instancer.instantiateVariableFont( + font, {"wght": (lo, hi)}, inplace=True, updateFontNames=False +) +# fontTools stamps head.modified with the current time on save, which makes +# the output differ on every run and the committed file unreproducible. Pin it +# to the source font's own created value, which is fixed in the tarball. +font["head"].modified = font["head"].created +font.flavor = "woff2" +font.save(out) +`; + +const work = mkdtempSync(join(tmpdir(), "petdex-fonts-")); +try { + const scriptPath = join(work, "subset.py"); + writeFileSync(scriptPath, PYTHON); + + const build = (source: string, unicodes: string, name: string): string => { + const out = join(work, `${name}.woff2`); + execFileSync( + PYTHON_BIN, + [ + scriptPath, + source, + unicodes, + LAYOUT_FEATURES, + NAME_IDS, + WEIGHT_RANGE, + out, + ], + { + // fontTools walks glyph sets whose iteration order follows the hash + // seed, so the same input otherwise produces a different (equivalent) + // woff2 each run — and a diff that never settles. Pinning the seed makes + // the output reproducible. + env: { ...process.env, PYTHONHASHSEED: "0" }, + }, + ); + return readFileSync(out).toString("base64"); + }; + + // Join with ` +`: adjacent string literals are not concatenated in + // JavaScript the way they are in C, so a newline-separated list would bind + // only the first line and silently discard the rest of the font. + const wrap = (value: string) => + (value.match(/.{1,96}/g) ?? []).map((line) => ` "${line}"`).join(" +\n"); + + const target = join( + import.meta.dir, + "..", + "src", + "cli-auth", + "lib", + "callback-fonts.ts", + ); + // Keep the hand-written doc comment; replace only the two literals. + const previous = readFileSync(target, "utf8"); + const header = previous.slice(0, previous.indexOf("export const")); + + writeFileSync( + target, + `${header}export const GEIST_SANS_WOFF2_BASE64 =\n` + + `${wrap(build(SANS_SOURCE, SANS_UNICODES, "sans"))};\n\n` + + `export const GEIST_MONO_WOFF2_BASE64 =\n` + + `${wrap(build(MONO_SOURCE, MONO_UNICODES, "mono"))};\n`, + ); +} finally { + rmSync(work, { recursive: true, force: true }); +} diff --git a/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts b/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts index fe431bd43..02817fcae 100644 --- a/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts +++ b/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts @@ -179,8 +179,11 @@ describe("auth server deferred response", () => { server.respond({ kind: "success" }); const res = await response; + // `font-src data:` is the only allowance beyond the inline style and + // script: the page carries the site's two faces inline, and the listener + // is already closed by the time it renders, so it cannot fetch them. expect(res?.headers.get("content-security-policy")).toBe( - "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'", + "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; font-src data:", ); expect(res?.headers.get("x-content-type-options")).toBe("nosniff"); expect(res?.headers.get("referrer-policy")).toBe("no-referrer"); diff --git a/packages/petdex-cli/src/cli-auth/lib/auth-server.ts b/packages/petdex-cli/src/cli-auth/lib/auth-server.ts index ced4ebb35..f2cb9fe85 100644 --- a/packages/petdex-cli/src/cli-auth/lib/auth-server.ts +++ b/packages/petdex-cli/src/cli-auth/lib/auth-server.ts @@ -58,6 +58,11 @@ export interface AuthServerHandle { * which a self-contained page needs, and a wider policy here would only widen * what an injected element could reach. * + * `font-src data:` is the one addition, for the two inlined faces the page + * carries. It does not widen what an injected element could reach: a `data:` + * URI cannot make a request, and every remote font stays blocked by the + * `default-src 'none'` this is layered on. + * * `upgrade-insecure-requests` is *not* the reason, though it looks like it * should be. Loopback is a potentially-trustworthy origin, so browsers exempt * it from that directive. Measured: a subresource on `http://127.0.0.1` loads @@ -65,7 +70,7 @@ export interface AuthServerHandle { * same address is upgraded to https and fails. */ const PAGE_CSP = - "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'"; + "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; font-src data:"; function headers(html: string): Record { return { diff --git a/packages/petdex-cli/src/cli-auth/lib/callback-fonts.ts b/packages/petdex-cli/src/cli-auth/lib/callback-fonts.ts new file mode 100644 index 000000000..aad7c07fd --- /dev/null +++ b/packages/petdex-cli/src/cli-auth/lib/callback-fonts.ts @@ -0,0 +1,380 @@ +/** + * Geist, subset for the callback page, as base64 woff2. + * + * Generated, not hand-edited. Regenerate with `scripts/build-callback-fonts.ts` + * after changing the page's copy — the subset is limited to the characters the + * page can render, so a new string with a character outside Latin-1 would fall + * back to the system face mid-sentence. + * + * Source: the `geist` npm package (v1.7.2, SIL OFL 1.1). The OFL notice is + * carried in the font's own `name` table, which `--name-IDs` preserves; that is + * the licence's requirement for a bundled subset, and Next's own build drops it. + * + * Why inline at all: the server closes its listener the moment it answers, so + * the page can never fetch a font file. See `auth-server.ts`'s PAGE_CSP, which + * allows `font-src data:` and nothing else. + * + * The weight axis is clamped to 400-600 — the page's heaviest text is 600 — and + * the layout features are limited to the four the page uses. Both cuts are + * worth about half the payload; `--layout-features=*` alone would pull in + * `ss01`-`ss11`, fractions and subscripts the page never renders. + */ +export const GEIST_SANS_WOFF2_BASE64 = + "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" + + "bAYAAA=="; + +export const GEIST_MONO_WOFF2_BASE64 = + "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"; diff --git a/packages/petdex-cli/src/cli-auth/lib/callback-page.test.ts b/packages/petdex-cli/src/cli-auth/lib/callback-page.test.ts index e7ef41854..a138e4d16 100644 --- a/packages/petdex-cli/src/cli-auth/lib/callback-page.test.ts +++ b/packages/petdex-cli/src/cli-auth/lib/callback-page.test.ts @@ -313,6 +313,46 @@ describe("callback page after its script runs", () => { }); }); +describe("callback page fonts", () => { + test("inlines both faces as decodable woff2 data URIs", () => { + // The listener is closed by the time the page renders, so the page can + // never fetch a font file — the faces have to be inside the document. The + // data URI is also the one thing an import assertion would silently + // empty: `import ... with { type: "base64" }` bundles to `""` under + // `bun build` with no warning, and the page then renders in the system + // face with nothing failing. Decoding the payload is what catches that. + const html = renderCallbackPage({ kind: "success" }, "https://petdex.dev"); + const uris = html.match(/data:font\/woff2;base64,[^)]+/g) ?? []; + + expect(uris).toHaveLength(2); + for (const uri of uris) { + const bytes = Buffer.from(uri.split(",")[1], "base64"); + // `wOF2` is the woff2 magic. A truncated or emptied payload would not + // carry it, and a 72-byte one is what a broken wrap produces. + expect(bytes.subarray(0, 4).toString("latin1")).toBe("wOF2"); + expect(bytes.byteLength).toBeGreaterThan(4_000); + } + }); + + test("names the site's faces before the system fallbacks", () => { + // The stack order is what makes the CJK copy work: Geist carries no CJK + // glyphs, so those characters have to fall through to the system face. + const html = renderCallbackPage({ kind: "success" }, "https://petdex.dev"); + expect(html).toContain('font-family: "Geist", ui-sans-serif'); + expect(html).toContain('font-family: "Geist Mono", ui-monospace'); + }); + + test("declares the colour scheme before the stylesheet", () => { + // So the first frame is painted with the right background rather than + // flashing the light one while the style block is parsed. + const html = renderCallbackPage({ kind: "success" }, "https://petdex.dev"); + const meta = html.indexOf('name="color-scheme"'); + const style = html.indexOf("`, + ``, "", "", '

', From 69566f8e0474c35acf31cdf2304611c9241d70d9 Mon Sep 17 00:00:00 2001 From: Mison Date: Tue, 29 Sep 2026 13:08:27 +0800 Subject: [PATCH 16/18] docs(cli): record why the callback page follows the OS theme The page keys its dark styles off `prefers-color-scheme` rather than the site's `.dark` class, and the comment now says why that is not a shortcut: the class comes from `next-themes` state in the app's origin, which this page never visits, and no channel exists to carry it over. Checked against the products that solve the same problem. GitHub's OAuth consent page ships `data-color-mode="auto"`, which resolves to the media query for a logged-out reader; WorkOS AuthKit, whose hosted page faces the same unreachable-preference case, falls back to `prefers-color-scheme`; AWS CLI moved its SSO page to `light-dark()` in November 2025. No hosted auth product accepts a theme from the authorize URL, and Clerk rejects a `redirect_uri` carrying a query string, so smuggling one through is not an option either. Co-Authored-By: Claude Opus 5 (1M context) --- .../petdex-cli/src/cli-auth/lib/callback-page.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/petdex-cli/src/cli-auth/lib/callback-page.ts b/packages/petdex-cli/src/cli-auth/lib/callback-page.ts index d6c30ccb2..a7f8b4bca 100644 --- a/packages/petdex-cli/src/cli-auth/lib/callback-page.ts +++ b/packages/petdex-cli/src/cli-auth/lib/callback-page.ts @@ -230,6 +230,22 @@ const FONT_FACES = ` } `; +/** + * The media query, not the site's `.dark` class. + * + * The site toggles that class from `next-themes` state kept in the app's own + * origin, which this page cannot read: the login flow only ever visits + * `clerk.petdex.dev` and this loopback port, never a petdex.dev page, so the + * reader's choice there is invisible here. There is also no way to carry it + * over — no hosted auth product takes a theme from the authorize URL (Clerk + * rejects a `redirect_uri` with a query string outright), and `window.opener` + * is null for a tab opened by a system browser. + * + * Following the OS preference is what the products in this position do: + * GitHub's OAuth consent page ships `data-color-mode="auto"`, and WorkOS + * AuthKit falls back to `prefers-color-scheme` for exactly the case where the + * integrating app's stored preference is unreachable. + */ const STYLES = ` :root { color-scheme: light dark; From 6e13f812710f64f018ea20e4bd886e606b050d35 Mon Sep 17 00:00:00 2001 From: Mison Date: Tue, 29 Sep 2026 13:13:12 +0800 Subject: [PATCH 17/18] docs(cli): note the one diff font regeneration cannot avoid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regenerating the fonts from a clean environment produces a file that differs from the committed one, and the note now says why rather than leaving the next person to rediscover it: woff2's brotli compression is not byte-stable across fontTools or brotli versions, so `head.checkSumAdjustment` — a checksum over the compressed file — moves with it. Measured, not assumed: zeroing that field makes the two files byte-identical, and the glyph sets, cmap, axes and name records already match. The faces render the same; only the checksum differs. Co-Authored-By: Claude Opus 5 (1M context) --- packages/petdex-cli/scripts/build-callback-fonts.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/petdex-cli/scripts/build-callback-fonts.ts b/packages/petdex-cli/scripts/build-callback-fonts.ts index c975b1790..ead9780f9 100644 --- a/packages/petdex-cli/scripts/build-callback-fonts.ts +++ b/packages/petdex-cli/scripts/build-callback-fonts.ts @@ -108,11 +108,19 @@ instancer.instantiateVariableFont( font, {"wght": (lo, hi)}, inplace=True, updateFontNames=False ) # fontTools stamps head.modified with the current time on save, which makes -# the output differ on every run and the committed file unreproducible. Pin it -# to the source font's own created value, which is fixed in the tarball. +# the output differ on every run. Pin it to the source font's own created +# value, which is fixed in the tarball. font["head"].modified = font["head"].created font.flavor = "woff2" font.save(out) + +# One difference survives that pin and cannot be removed: woff2's brotli +# compression is not byte-stable across fontTools or brotli versions, so +# head.checkSumAdjustment - a checksum over the compressed file - moves with +# it. Everything else, glyphs included, is byte-identical, and the two files +# render the same. Expect a non-empty diff on the head table when regenerating +# a toolchain change; compare the glyph set, not the bytes, before treating it +# as a real difference. `; const work = mkdtempSync(join(tmpdir(), "petdex-fonts-")); From b8f43260576f5ac5a92cc3331fcdd26e67e8cc0d Mon Sep 17 00:00:00 2001 From: MisonL <1360962086@qq.com> Date: Fri, 9 Oct 2026 21:56:22 +0800 Subject: [PATCH 18/18] fix(cli): make the late-callback test prove its connection was accepted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test opened a raw socket, saw connect() resolve, and never used it until after respond() — but connect() only completes the TCP handshake: on Linux the server's accept can still be pending, the socket sits in the listen backlog, and closeListening() resets it (ECONNRESET, no request bytes ever read), so the assertion got "error" instead of the 409. On macOS the accept arrives just in time, which is why it passed locally and failed only on ubuntu CI. Send the first line of the request before the login is answered. That reaches an accepted connection — the state the guarantee is about — and leaves the request incomplete, so nothing parses until the second line goes out after respond(). Reproduced on Ubuntu 24.04 + Bun 1.4.2 in Docker (fail before, 3x pass after); the macOS suite still passes 231/231. Co-Authored-By: Claude Fable 5 --- .../src/cli-auth/lib/auth-server.test.ts | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts b/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts index 02817fcae..c0f2f5d88 100644 --- a/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts +++ b/packages/petdex-cli/src/cli-auth/lib/auth-server.test.ts @@ -390,13 +390,26 @@ describe("auth server while a response is held", () => { describe("auth server after the login is answered", () => { test("answers a callback that arrives on an already-open connection", async () => { - // A connection accepted before the listener closed can have its request - // parsed after `respond()` has run. Holding it would leave a socket that - // nothing can ever answer — `sendOutcome` returns early once + // A connection the server accepted before the listener closed can have its + // request parsed after `respond()` has run. Holding it would leave a socket + // that nothing can ever answer — `sendOutcome` returns early once // `responseSent` is set — so the tab would spin until the process exits. + // + // The request is sent in two pieces. A bare `connect()` is not enough: the + // TCP handshake can finish with the server never having accepted the + // socket (it sits in the listen backlog), and closing the listener then + // resets it — measured on Linux as an ECONNRESET with no bytes read. The + // first piece reaches the server through an accepted connection, which is + // the state the guarantee is about, and leaves the request incomplete so + // the handler does not run until the second piece goes out after + // `respond()`. const server = await start(); const socket = connect(server.port, "127.0.0.1"); await new Promise((resolve) => socket.on("connect", () => resolve())); + socket.write( + `GET /callback?code=two&state=${STATE} HTTP/1.1\r\nHost: 127.0.0.1\r\n`, + ); + await Bun.sleep(50); const first = beginFetch( callbackUrl(server, { code: "one", state: STATE }), @@ -410,9 +423,7 @@ describe("auth server after the login is answered", () => { socket.on("data", (chunk) => (buf += chunk)); socket.on("close", () => resolve(buf.split("\r\n")[0] ?? "")); socket.on("error", () => resolve("error")); - socket.write( - `GET /callback?code=two&state=${STATE} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n`, - ); + socket.write("Connection: close\r\n\r\n"); setTimeout(() => { socket.destroy(); resolve("no response");