Skip to content

fix(cli): publish projects through staged uploads#491

Merged
miguel-heygen merged 2 commits intomainfrom
fix/publish-staged-upload
Apr 25, 2026
Merged

fix(cli): publish projects through staged uploads#491
miguel-heygen merged 2 commits intomainfrom
fix/publish-staged-upload

Conversation

@miguel-heygen
Copy link
Copy Markdown
Collaborator

@miguel-heygen miguel-heygen commented Apr 25, 2026

Problem

hyperframes publish uploads the project ZIP through the API edge. Valid projects with binary/high-entropy media can be challenged before they reach Flask, so publish fails even when the archive is valid.

While validating the staged backend flow on devbox, the client also exposed a second contract issue: S3 PUTs must send every header signed into the presigned URL. The backend signs x-amz-server-side-encryption, so a client that only sends content-type gets SignatureDoesNotMatch from S3.

A backend review follow-up now signs the archive byte length too. The CLI already sends content_length when requesting the upload; this PR also honors content-length when it appears in X-Amz-SignedHeaders, so the PUT request does not rely on runtime-specific implicit header behavior.

Reproduction

  1. Use a project with HTML plus real binary assets, e.g. /Users/miguel07code/Downloads/apple-presentation.
  2. Run hyperframes publish --yes /Users/miguel07code/Downloads/apple-presentation against the existing direct multipart endpoint.
  3. The publish fails before backend validation. Inspecting the response shows HTTP 403 with cf-mitigated: challenge and Cloudflare HTML.
  4. Against the first staged backend draft, the upload endpoint returned a URL signed with x-amz-server-side-encryption, but the CLI PUT only sent content-type; S3 returned SignatureDoesNotMatch.

Root Cause

The CLI was sending binary ZIP bytes through the API edge instead of uploading directly to object storage. The staged path also needs to treat presigned upload headers as part of the API contract: every signed header must be sent with the S3 PUT.

What This Fixes

  • Builds the publish archive once, then requests a staged upload from /v1/hyperframes/projects/publish/upload.
  • Sends the archive byte length to the backend as content_length.
  • Uploads the ZIP directly to the returned presigned URL with backend-provided upload headers.
  • Adds defensive signed-header inference for older/incomplete staged responses:
    • x-amz-server-side-encryption: AES256 when that header is signed but missing from upload_headers
    • content-length: <archive byte length> when content-length is signed
  • Completes publish via /v1/hyperframes/projects/publish/complete.
  • Falls back to the legacy multipart endpoint when staged endpoints are not deployed yet.
  • Improves the legacy Cloudflare challenge error so it no longer looks like a generic publish failure.
  • Covers staged success, legacy fallback, and S3 upload failure with CLI tests.

Verification

Local checks

  • bunx oxfmt --check packages/cli/src/utils/publishProject.ts packages/cli/src/utils/publishProject.test.ts
  • bunx oxlint packages/cli/src/utils/publishProject.ts packages/cli/src/utils/publishProject.test.ts
  • bun run --filter @hyperframes/cli test -- src/utils/publishProject.test.ts -> 4 tests pass
  • bun run --filter @hyperframes/cli typecheck
  • git diff --check
  • pre-commit hook: lint, format, typecheck, commitlint

Devbox integration

Before the later content-length review follow-up, I verified the staged upload flow on devbox:

  • Started EF PR #35557 in an isolated devbox worktree on 127.0.0.1:8029, leaving the existing byos/local_dev stack on 8019 untouched.
  • Verified POST /v1/hyperframes/projects/publish/upload returns upload_headers with content-type and x-amz-server-side-encryption.
  • Ran the CLI through an SSH tunnel against that server:
    • HYPERFRAMES_PUBLISHED_PROJECTS_API_URL=http://127.0.0.1:18029 bun run --filter @hyperframes/cli dev publish --yes /Users/miguel07code/Downloads/apple-presentation
  • Result: publish succeeded through the staged flow. Server logs show:
    • POST /v1/hyperframes/projects/publish/upload 200 OK for apple-presentation.zip with content_length: 56888466
    • POST /v1/hyperframes/projects/publish/complete 200 OK
  • The CLI returned project id hfp_8b56d135d0cf and a claim URL.

The later signed content-length client hardening was validated with local checks only.

Notes

Copy link
Copy Markdown
Collaborator

@jrusso1020 jrusso1020 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CLI staged-upload flow looks good. Verified end-to-end against the companion EF PR #35557 running on local_dev: the CLI hit /publish/upload, PUT 56,916,833 bytes directly to the presigned S3 URL with the right upload_headers (content-type + x-amz-server-side-encryption: AES256), then /publish/complete returned https://hyperframes.dev/p/hfp_f9e6bf08c522?claim_token=…. No Cloudflare challenge — that's the win.

The defensive bits in getUploadHeaders() are nice: it parses X-Amz-SignedHeaders from the presigned URL and ensures every signed header is actually sent on the PUT, plus the SSE fallback for older staged responses that don't return explicit upload_headers. Falling back to legacy multipart on 404/405 from /publish/upload keeps the CLI compatible with envs that don't have the new endpoints deployed yet.

Local checks:

  • bun run --filter @hyperframes/cli test -- src/utils/publishProject.test.ts → 4/4 pass
  • E2E publish against local heygen_server: staged path, 200/200/200, project persisted, public URL resolves through /public

CI: Lint / Format / Typecheck / Build / Test / Test: runtime contract / Smoke / CodeQL / Perf:* / player-perf — all ✅. Tests on windows-latest and regression-shards still in flight but the changed surface is CLI utils only.

— Rames Jusso

@miguel-heygen miguel-heygen merged commit b92341e into main Apr 25, 2026
46 of 48 checks passed
@miguel-heygen miguel-heygen deleted the fix/publish-staged-upload branch April 25, 2026 15:09
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