From 69438b755f0c281db8fe4879ceb270511a229d15 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 12:20:10 +0000 Subject: [PATCH 1/2] fix(errors): keep an error response body that is not JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ApiError.fromHttpError parsed the body as JSON and, when that threw, fell back to ky's own message — which names only the status and the URL. An opaque 403 or 502 therefore read identically whoever emitted it, and the server's own words were discarded before anyone saw them. This matters where it is hardest to reproduce: the sandbox publish path only ever surfaces this string, through the --json envelope, into a log. A run that failed with `Request failed with status code 403 Forbidden: POST .../finalize` gave no way to tell an app-level rejection from a proxy in front of the app, because the bytes that would have said so were already gone. Keep them instead, truncated so an HTML error page cannot flood the log, and when there are none report the content type — an empty body is itself a fingerprint of the layer that answered. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YCz2yqUy1qj82YAsLXsoLt --- packages/cli/src/core/errors.ts | 35 +++++++++++- packages/cli/tests/core/errors.spec.ts | 73 ++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/core/errors.ts b/packages/cli/src/core/errors.ts index a93d1bf97..b57ab3705 100644 --- a/packages/cli/src/core/errors.ts +++ b/packages/cli/src/core/errors.ts @@ -288,6 +288,28 @@ export class DependencyNotFoundError extends UserError { // System Errors // ============================================================================ +/** Keeps an unparseable error body loggable without dumping a whole HTML page. */ +const MAX_RESPONSE_TEXT_CHARS = 500; + +/** + * The response body as text, for a response whose body would not parse as JSON. + * Reads a clone so the caller's own body stays unconsumed, and never throws — + * this runs on a path that is already reporting a failure. + */ +async function readResponseText( + response: Response, +): Promise { + try { + const text = (await response.clone().text()).trim(); + if (!text) return undefined; + return text.length > MAX_RESPONSE_TEXT_CHARS + ? `${text.slice(0, MAX_RESPONSE_TEXT_CHARS)}…` + : text; + } catch { + return undefined; + } +} + interface ApiErrorOptions extends CLIErrorOptions { statusCode?: number; requestUrl?: string; @@ -357,7 +379,18 @@ export class ApiError extends SystemError { details = parseErrorDetails(parsedData.extra_data); } } catch { - message = error.message; + // A non-JSON body is the one case where the server's own words are + // lost: ky's message names only the status and URL, so an opaque 403 + // or 502 reads identically whoever emitted it. Keep the bytes (and, + // when there are none, the content type) — they identify the layer + // that answered, which JSON from our API would have named outright. + const body = await readResponseText(error.response); + responseBody = body; + message = body + ? `${error.message} — ${body}` + : `${error.message} — empty body, content-type ${ + error.response.headers.get("content-type") ?? "absent" + }`; } const statusCode = ApiError.normalizeStatusCode( diff --git a/packages/cli/tests/core/errors.spec.ts b/packages/cli/tests/core/errors.spec.ts index d14924f4d..737348aee 100644 --- a/packages/cli/tests/core/errors.spec.ts +++ b/packages/cli/tests/core/errors.spec.ts @@ -255,7 +255,80 @@ describe("SystemError subclasses", () => { expect(apiError.statusCode).toBe(500); expect(apiError.requestUrl).toBe("https://api.base44.com/v1/deploy"); expect(apiError.requestMethod).toBe("POST"); + expect(apiError.responseBody).toBe("Internal Server Error"); + expect(apiError.message).toContain("Internal Server Error"); + }); + + it("ApiError.fromHttpError keeps a non-JSON body that names the emitting layer", async () => { + const { HTTPError } = await import("ky"); + const response = new Response( + "403 Forbidden — nginx", + { + status: 403, + statusText: "Forbidden", + headers: { "content-type": "text/html" }, + }, + ); + const request = new Request( + "https://app.base44.com/api/apps/a1/deployments/d1/finalize", + { + method: "POST", + }, + ); + + const apiError = await ApiError.fromHttpError( + new HTTPError(response, request, {} as never), + "finalizing deployment", + ); + + expect(apiError.statusCode).toBe(403); + expect(apiError.message).toContain("403 Forbidden — nginx"); + }); + + it("ApiError.fromHttpError reports the content type when the body is empty", async () => { + const { HTTPError } = await import("ky"); + const response = new Response("", { + status: 403, + statusText: "Forbidden", + headers: { "content-type": "text/plain" }, + }); + const request = new Request( + "https://app.base44.com/api/apps/a1/deployments", + { + method: "POST", + }, + ); + + const apiError = await ApiError.fromHttpError( + new HTTPError(response, request, {} as never), + "creating deployment", + ); + expect(apiError.responseBody).toBeUndefined(); + expect(apiError.message).toContain("empty body"); + expect(apiError.message).toContain("text/plain"); + }); + + it("ApiError.fromHttpError truncates a long non-JSON body", async () => { + const { HTTPError } = await import("ky"); + const response = new Response("x".repeat(2000), { + status: 502, + statusText: "Bad Gateway", + }); + const request = new Request( + "https://app.base44.com/api/apps/a1/deployments", + { + method: "POST", + }, + ); + + const apiError = await ApiError.fromHttpError( + new HTTPError(response, request, {} as never), + "creating deployment", + ); + + expect(apiError.message).toContain("…"); + expect(apiError.message.length).toBeLessThan(800); }); it("ApiError.fromHttpError handles plain Error", async () => { From c2f58ab6e2dd8f5c4f822131590f408d378894a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 04:56:30 +0000 Subject: [PATCH 2/2] feat(site): upload index.html with the assets instead of at finalize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A static deploy sent the built index.html as a file part on the finalize request. Cloudflare's WAF inspects that body, and a managed command-injection rule matches a backtick followed by a shell command with arguments — so an app whose markup contains, say, a documentation comment with `curl -s https://… ` was answered 403 at the edge, before the request reached us. No response body we could parse, no application log, and every publish of that commit failed the same way. Nothing about the app was wrong, and no retry could help: the bytes themselves were the trigger. Every other asset already goes straight to S3 through a presigned PUT, where nothing inspects it. The entry point now goes the same way, to a staging key the server copies into the build at finalize, so finalize carries no user-authored content at all. index.html stays the sentinel. It is still written last, by the server, only once every declared asset is present — the ordering that stops a partial build from reading as complete, and stops create short-circuiting a commit that never finished. A server that has not shipped this leaves `index_html_staged` unset, and the client keeps sending the bytes, so a CLI release need not be coordinated with a backend deploy in either direction. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YCz2yqUy1qj82YAsLXsoLt --- packages/cli/src/core/site/api.ts | 19 +++--- packages/cli/src/core/site/deployment.ts | 52 +++++++++++----- packages/cli/src/core/site/schema.ts | 5 ++ .../tests/cli/static_site_deployments.spec.ts | 59 ++++++++++++++++++- .../cli/tests/cli/testkit/TestAPIServer.ts | 3 + 5 files changed, 111 insertions(+), 27 deletions(-) diff --git a/packages/cli/src/core/site/api.ts b/packages/cli/src/core/site/api.ts index ab004967d..4071d9c10 100644 --- a/packages/cli/src/core/site/api.ts +++ b/packages/cli/src/core/site/api.ts @@ -88,14 +88,15 @@ export async function createDeployment( } /** - * What completes a deployment — the one thing the two kinds of build send - * differently. A build that produced a worker completes with its modules and - * the asset completion token; a plain static build completes with the - * `index.html` sentinel, which is the whole form and carries no payload part. + * What completes a deployment. A worker build sends its modules and the asset + * completion token. A static build sends nothing at all against a current + * server — its entry point went up through the presigned PUTs and finalize + * copies it into place — and the `index.html` bytes against an older one. */ -type FinalizePayload = - | { modules: WorkerModule[]; completionJwt: string | null } - | { indexHtml: Uint8Array }; +export type FinalizePayload = + | { kind: "worker"; modules: WorkerModule[]; completionJwt: string | null } + | { kind: "static-inline"; indexHtml: Uint8Array } + | { kind: "static-staged" }; export async function finalizeDeployment( deploymentId: string, @@ -104,12 +105,12 @@ export async function finalizeDeployment( ): Promise { const formData = new FormData(); - if ("indexHtml" in payload) { + if (payload.kind === "static-inline") { formData.append( "index.html", new File([payload.indexHtml], "index.html", { type: "text/html" }), ); - } else { + } else if (payload.kind === "worker") { formData.append( "payload", JSON.stringify({ completion_jwt: payload.completionJwt }), diff --git a/packages/cli/src/core/site/deployment.ts b/packages/cli/src/core/site/deployment.ts index a73e3104e..d7f354618 100644 --- a/packages/cli/src/core/site/deployment.ts +++ b/packages/cli/src/core/site/deployment.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { InvalidInputError } from "@/core/errors.js"; import { getAppContext } from "@/core/project/app-config.js"; import { pathExists } from "@/core/utils/fs.js"; +import type { FinalizePayload } from "./api.js"; import { createDeployment, finalizeDeployment } from "./api.js"; import { buildAssetManifest } from "./manifest.js"; import { collectModules } from "./modules.js"; @@ -28,9 +29,6 @@ interface WorkerBuild { assetsDir: string | null; } -/** What completes the deployment at finalize. */ -type Completion = { modules: WorkerModule[] } | { indexHtml: Uint8Array }; - const NO_ASSETS: AssetManifestResult = { manifest: {}, filesByHash: new Map() }; const DEPLOYMENTS_API_ENV = "BASE44_DEPLOYMENTS_API"; @@ -81,11 +79,11 @@ export async function deployToDeployments(options: { ? await buildAssetManifest(assetsDir, getAppContext().id) : NO_ASSETS; - // Resolved before the create call so a build that cannot be completed fails - // before any upload work. - const completion: Completion = worker - ? { modules: worker.modules } - : { indexHtml: await readIndexHtml(assetsDir, assets) }; + // Checked before the create call so a build that cannot be completed fails + // before any upload work — the bytes are only read if finalize carries them. + if (!worker) { + requireStaticEntryPoint(assetsDir, assets); + } const created = await createDeployment({ git_hash: gitHash, @@ -99,13 +97,17 @@ export async function deployToDeployments(options: { { concurrency, progress }, ); - if ("modules" in completion) { + const completion: FinalizePayload = worker + ? { kind: "worker", modules: worker.modules, completionJwt } + : await resolveStaticCompletion(assetsDir, assets, created.indexHtmlStaged); + + if (completion.kind === "worker") { progress?.onWorker?.({ moduleCount: completion.modules.length }); } const finalized = await finalizeDeployment( created.deploymentId, created.sessionId, - "modules" in completion ? { ...completion, completionJwt } : completion, + completion, ); return { deploymentId: finalized.deploymentId, gitHash }; @@ -140,20 +142,38 @@ async function resolveWorkerBuild( } /** - * Finalize carries these bytes by contract when no worker completes the - * deployment, so a build with no index.html at its root is broken — or the - * configured outputDirectory points at the wrong place. + * A static build is addressed by its entry point, so one without an index.html + * at its root is broken — or the configured outputDirectory points at the wrong + * place. Checked against the manifest, which is already built. */ -async function readIndexHtml( +function requireStaticEntryPoint( assetsDir: string | null, assets: AssetManifestResult, -): Promise { +): string { if (!assetsDir || !assets.manifest["/index.html"]) { throw new InvalidInputError( `No index.html found in "${assetsDir ?? "the site output directory"}" — a static site needs one at the output directory root.`, ); } - return new Uint8Array(await readFile(join(assetsDir, "index.html"))); + return assetsDir; +} + +/** + * How a static build completes. A current server stages the entry point with + * the other presigned uploads and copies it in, so finalize sends nothing; + * older ones still expect the bytes in the request body. + */ +async function resolveStaticCompletion( + assetsDir: string | null, + assets: AssetManifestResult, + indexHtmlStaged: boolean, +): Promise { + if (indexHtmlStaged) return { kind: "static-staged" }; + const dir = requireStaticEntryPoint(assetsDir, assets); + return { + kind: "static-inline", + indexHtml: new Uint8Array(await readFile(join(dir, "index.html"))), + }; } /** diff --git a/packages/cli/src/core/site/schema.ts b/packages/cli/src/core/site/schema.ts index 97fd7d4d4..f0883f759 100644 --- a/packages/cli/src/core/site/schema.ts +++ b/packages/cli/src/core/site/schema.ts @@ -140,6 +140,9 @@ export const CreateDeploymentResponseSchema = z ]) .nullable() .optional(), + // Absent on a server predating staged entry points, where finalize is still + // the only thing that writes index.html. + index_html_staged: z.boolean().optional().default(false), }) .transform( ( @@ -148,9 +151,11 @@ export const CreateDeploymentResponseSchema = z deploymentId: string; sessionId: string; assetUploads: CfAssetUploads | S3AssetUploads | null; + indexHtmlStaged: boolean; } => ({ deploymentId: data.deployment_id, sessionId: data.session_id, + indexHtmlStaged: data.index_html_staged, assetUploads: data.asset_uploads == null ? null diff --git a/packages/cli/tests/cli/static_site_deployments.spec.ts b/packages/cli/tests/cli/static_site_deployments.spec.ts index 9d66d2fc5..46f81e35d 100644 --- a/packages/cli/tests/cli/static_site_deployments.spec.ts +++ b/packages/cli/tests/cli/static_site_deployments.spec.ts @@ -15,11 +15,12 @@ const SESSION_ID = "3f9a1c07b8e44d2f"; const SIGNED_CONTENT_TYPES: Record = { "/main.js": "application/javascript", "/styles.css": "text/css", + "/index.html": "text/html", }; /** Byte counts the server signs into the URLs (from the real fixture files). */ const FIXTURE_SIZES: Record = Object.fromEntries( - ["/main.js", "/styles.css"].map((path) => [ + ["/main.js", "/styles.css", "/index.html"].map((path) => [ path, readFileSync(join(fixture("with-site"), "site-output", path.slice(1))) .length, @@ -36,10 +37,11 @@ describe("site deploy command (static site through the deployments API, env-gate const t = setupCLITests(); /** The s3 create arm: presigned PUT targets for the requested paths. */ - function mockStaticCreate(uploadPaths: string[]) { + function mockStaticCreate(uploadPaths: string[], indexHtmlStaged = false) { t.api.mockDeploymentCreate({ deployment_id: DEPLOYMENT_ID, session_id: SESSION_ID, + ...(indexHtmlStaged ? { index_html_staged: true } : {}), asset_uploads: uploadPaths.length === 0 ? null @@ -301,3 +303,56 @@ describe("site deploy command (static site through the deployments API, env-gate expect(t.api.presignedUploadRequests).toHaveLength(0); }); }); + +describe("site deploy — index.html staged through the presigned PUTs", () => { + const t = setupCLITests(); + + async function readSiteFile(name: string): Promise { + return await readFile(join(fixture("with-site"), "site-output", name)); + } + + function mockStagedCreate(uploadPaths: string[]) { + t.api.mockDeploymentCreate({ + deployment_id: DEPLOYMENT_ID, + session_id: SESSION_ID, + index_html_staged: true, + asset_uploads: { + type: "s3" as const, + uploads: uploadPaths.map((path) => ({ + path, + content_type: `${SIGNED_CONTENT_TYPES[path]}; charset=utf-8`, + content_length: FIXTURE_SIZES[path], + url: `${t.api.baseUrl}/presigned${path}`, + })), + }, + }); + for (const path of uploadPaths) { + t.api.mockPresignedUpload(path); + } + } + + it("PUTs index.html with the other assets and finalizes with an empty form", async () => { + await t.givenLoggedInWithProject(fixture("with-site")); + t.givenEnv({ BASE44_DEPLOYMENTS_API: "1" }); + mockStagedCreate(["/index.html", "/main.js", "/styles.css"]); + t.api.mockDeploymentFinalize({ deployment_id: DEPLOYMENT_ID }); + + const result = await t.run("site", "deploy", "-y", "--git-hash", GIT_HASH); + + t.expectResult(result).toSucceed(); + + // The entry point goes up the same way as every other asset — the server + // signed it to a staging key, so nothing here has to know that. + const byPath = new Map( + t.api.presignedUploadRequests.map((r) => [r.path, r]), + ); + const index = byPath.get("/index.html"); + expect(index?.data.equals(await readSiteFile("index.html"))).toBe(true); + expect(index?.authorization).toBeUndefined(); + + // The point of the change: no user-authored HTML in a request body of ours. + expect(t.api.finalizeRequests).toHaveLength(1); + expect(t.api.finalizeRequests[0]).toEqual([]); + expect(t.api.finalizeQueries[0]).toEqual({ session_id: SESSION_ID }); + }); +}); diff --git a/packages/cli/tests/cli/testkit/TestAPIServer.ts b/packages/cli/tests/cli/testkit/TestAPIServer.ts index 0fc704cce..83123a305 100644 --- a/packages/cli/tests/cli/testkit/TestAPIServer.ts +++ b/packages/cli/tests/cli/testkit/TestAPIServer.ts @@ -252,6 +252,9 @@ interface DeploymentCreateResponse { }>; } | null; + /** True once the server stages index.html with the presigned PUTs, so + * finalize carries no file parts at all for a static build. */ + index_html_staged?: boolean; } interface DeploymentFinalizeResponse {