Skip to content

Upload index.html with the assets, and keep error bodies that aren't JSON - #637

Merged
netanelgilad merged 2 commits into
mainfrom
claude/sharp-gates-7ykx7g
Sep 23, 2026
Merged

netanelgilad merged 2 commits into
mainfrom
claude/sharp-gates-7ykx7g

Conversation

@netanelgilad

@netanelgilad netanelgilad commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

Note

Description

A static site deploy used to send the built index.html as a file part on the finalize request. Cloudflare's managed WAF inspects that body, and one app's markup contained a documentation comment with a backticked shell command — so every publish was answered 403 at the edge, 54/54 over a week, before the request ever reached the backend. This PR routes the entry point through the same presigned S3 PUT path as every other asset, so finalize carries no user-authored content at all, and separately makes ApiError.fromHttpError keep a non-JSON error body instead of discarding it — which is what made the original failure take a week to diagnose.

Related Issue

Requires the server-side counterpart base44-dev/apper#25992. Without it the server leaves index_html_staged unset and this client behaves exactly as before.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Refactoring (no functional changes)
  • Other (please describe):

Changes Made

feat(site): upload index.html with the assets instead of at finalize

  • CreateDeploymentResponseSchema reads a new index_html_staged field, .optional().default(false) — an older server leaves it unset and the client keeps sending the bytes, so the release needs no coordination with a backend deploy in either direction.
  • FinalizePayload becomes an exported discriminated union (worker / static-inline / static-staged). The previous "indexHtml" in payload shape could not express "send nothing", and Record<string, never> types loosely inside a union.
  • deployment.ts splits the old readIndexHtml into requireStaticEntryPoint (validation, still run before create so a build that cannot be completed fails before any upload work) and resolveStaticCompletion (reads the bytes only when finalize will actually carry them).
  • index.html itself goes up through the existing uploadPresignedAsset path with no changes — the server signs it to a staging key and copies it into the build at finalize; the client does not need to know the key.

fix(errors): keep an error response body that is not JSON

  • ApiError.fromHttpError previously fell back to ky's message, which names only the status and URL, so an opaque 403 or 502 read identically whoever emitted it.
  • Adds readResponseText(): reads a clone() so the caller's body stays unconsumed, never throws (it runs on a path already reporting a failure), and truncates at 500 chars so an HTML error page cannot flood a log.
  • When the body is empty, the message reports the content-type instead — an empty body is itself a fingerprint of the layer that answered.

Testing

  • I have tested these changes locally
  • I have added/updated tests as needed
  • All tests pass (npm test)

New coverage:

  • static_site_deployments.spec.ts — a new "index.html staged through the presigned PUTs" suite asserting the entry point is PUT with the other assets (unsigned, byte-identical to the fixture) and that finalizeRequests[0] is [], i.e. no user HTML in a request body of ours. The existing legacy-path cases stay as backward-compat coverage.
  • errors.spec.ts — three cases: an HTML body naming its emitter, an empty body reporting its content type, and truncation of a 2000-char body. One existing assertion changed: expect(apiError.responseBody).toBeUndefined() on a non-JSON body was pinning the exact defect.
  • TestAPIServer.ts — the create-response type gains the optional index_html_staged flag.

Checklist

  • My code follows the project's style guidelines
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation (if applicable)
  • My changes generate no new warnings
  • I have updated docs/ (AGENTS.md) if I made architectural changes

Additional Notes

Why "all tests pass" is unchecked: the reported run is 843 passed with 2 failures in dev.spec.ts, which reproduce identically on the untouched baseline (confirmed by stashing and re-running). bun run typecheck and bun run lint are clean.

Reproduction (differential probe against production on a non-existent route, so the trigger is the body and not the path):

body result
benign HTML 404 from FastAPI — reached origin
the app's index.html 403, server: cloudflare, <title>Blocked</title>
Run `curl -s https://api.example.com/health` to check. 403
curl -s https://x.com | grep (no backtick) ok

No retry could help — the bytes were the trigger, and nothing about the app was wrong.

Not fixed here: the full-stack arm still POSTs worker modules through finalize, and a JSDoc comment or help string containing a backticked command plus a URL in a .js module part hits the same WAF rule (verified against production). A WAF exception scoped to the finalize path is still wanted — it is the only thing covering that arm, and it unblocks the affected app today rather than after a release plus sandbox-image pickup.


🤖 Generated by Claude | 2026-09-23 04:58 UTC | 845faaa

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCz2yqUy1qj82YAsLXsoLt
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YCz2yqUy1qj82YAsLXsoLt
@github-actions

Copy link
Copy Markdown
Contributor

🚀 Package Preview Available!


Install this PR's preview build with npm:

npm i @base44-preview/cli@0.1.16-pr.637.845faaa

Prefer not to change any import paths? Install using npm alias so your code still imports base44:

npm i "base44@npm:@base44-preview/cli@0.1.16-pr.637.845faaa"

Or add it to your package.json dependencies:

{
  "dependencies": {
    "base44": "npm:@base44-preview/cli@0.1.16-pr.637.845faaa"
  }
}

Preview published to npm registry — try new features instantly!

@netanelgilad
netanelgilad merged commit 86addd7 into main Sep 23, 2026
15 checks passed
@netanelgilad
netanelgilad deleted the claude/sharp-gates-7ykx7g branch September 23, 2026 08:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants