Skip to content

feat(cli): publish HyperFrames projects to persisted hyperframes.dev URLs#406

Closed
miguel-heygen wants to merge 3 commits intomainfrom
feat/hyperframes-project-persistence-cli
Closed

feat(cli): publish HyperFrames projects to persisted hyperframes.dev URLs#406
miguel-heygen wants to merge 3 commits intomainfrom
feat/hyperframes-project-persistence-cli

Conversation

@miguel-heygen
Copy link
Copy Markdown
Collaborator

Summary

  • replace the tunnel-based hyperframes publish flow with persisted project upload
  • preserve the EF claim token and print a claimable hyperframes.dev URL
  • add a focused publish helper test for archive creation and upload response handling

Verification

  • packages/cli/node_modules/.bin/vitest run packages/cli/src/utils/publishProject.test.ts
  • devbox e2e against EF + demo-next: publish sample project, open printed URL, claim/import into a real session

Notes

  • The old tunnel implementation is replaced by the persisted-project flow exercised on devbox.

One-command share: `hyperframes publish` starts the preview server,
opens a public HTTPS tunnel to it, and prints a URL the user can paste
into Slack / Figma / iPhone Safari / anywhere. Token-gated, read-only
by default, explicit consent before exposure, auto-rebuilds the studio
if its assets are missing so users never see a broken tunnel.

# What changed

- packages/cli/src/commands/publish.ts — new command. Consent prompt,
  mint session token, start preview through a fetch wrapper that gates
  the studio server, open the tunnel, print the URL. Ctrl-C reaps both
  children cleanly.
- packages/cli/src/utils/tunnel.ts — provider detection
  (cloudflared preferred, tuns.sh ssh fallback) and openTunnel that
  spawns the child and resolves with the first emitted public URL.
- packages/cli/src/utils/publishSecurity.ts — shared-secret token gate,
  mutation gate (read-only by default), read-only UI injection,
  interactive consent prompt.
- packages/cli/src/server/studioServer.ts — StudioAssetsMissingError
  thrown at construction if dist is missing (no lazy-500 at request
  time). Fixed the dev-fallback path: was three levels up at
  `hyperframes-oss/studio/dist`, now correctly two at
  `packages/studio/dist`.
- packages/cli/scripts/build-copy.sh — replaces the old silent
  `cp -r ../studio/dist/*`. Runs under `set -euo pipefail`, asserts
  the source index.html exists before copying, asserts the
  destination index.html exists after. A zero-file copy can no longer
  ship a broken CLI bundle.
- Root package.json — build now serializes studio first, then the
  rest in parallel. CLI dropped its nested `build:studio`; the two
  parallel `vite build` invocations were clobbering each other's
  packages/studio/dist.
- packages/cli/src/cli.ts, src/help.ts — register publish in the
  CLI, add it to the Getting Started group and root examples.
- docs/packages/cli.mdx — documents the command, flags, and the
  read-only security model.

# Tunnel

Auto-picks the first available of:

1. cloudflared — Cloudflare's free quick tunnel. Most reliable.
   `brew install cloudflared` (Linux/Windows binaries equivalent). No
   account, no keys, no config.
2. tuns.sh — zero-install ssh -R fallback. Works on every machine
   with OpenSSH.

`--provider cloudflared | tuns | auto` forces one.

# Security hardening

The studio server was built for localhost and everything is
unauthenticated. PUT/POST/DELETE/PATCH on /api/projects/:id/files/*
reads and overwrites the project directory. Exposing that surface
unguarded over a public URL is how you ship a "drop a keylogger in
their index.html" bug. Four composed defences:

1. Token gate. On startup, mint a 32-byte base64url token via
   crypto.randomBytes. Public URL is
   `${tunnelUrl}/?t=${token}#project/${name}`. First hit validates in
   constant time (timingSafeEqual), sets an httpOnly; Secure;
   SameSite=Lax session cookie, and 302-redirects to a clean URL so
   the token never lands in browser history or Referer. Every
   subsequent request needs the cookie. Anything else returns 404
   (deliberately, not 401/403 — do not advertise that a server is
   here). Cookie TTL: 12 h. Implemented as a fetch wrapper rather
   than a Hono middleware because the studio routes are registered
   before publish gets the app; middlewares only apply to routes
   registered after them. Wrapping fetch gates traffic at the HTTP
   boundary and sidesteps ordering entirely.
2. Mutation gate. When --allow-edit is not set, refuse writes/deletes
   on `/api/projects/:id/files/*`,
   `POST /api/projects/:id/duplicate-file`, and
   `POST /api/projects/:id/render`. Returns
   `403 { "error": "forbidden", "reason": "…re-run with --allow-edit…" }`.
   Authenticated reads still work.
3. Read-only UI enforcement. The mutation gate closes the server
   side, but the studio UI does not know about the mode. Without
   this layer, visitors would type into a Monaco editor whose saves
   silently 403. Every response carries
   `X-HF-Publish-Readonly: 1`. HTML responses from the SPA (NOT
   `/api/*`, so the composition bundle served inside the player
   iframe is untouched) get a small inline <style> + <script>
   injected before `</head>`. The style neutralises Monaco /
   CodeMirror surfaces via pointer-events: none,
   caret-color: transparent, user-select: none. The script sets
   window.__HF_PUBLISH_READONLY__ = true, renders a compact pill
   centered at the top of the viewport ("READ-ONLY · Published
   tunnel"), and monkey-patches window.fetch to short-circuit
   mutating calls client-side so the UI does not loop through
   doomed 403s.
4. Explicit consent on startup. Before the server binds, a clack
   confirm prompt spells out the exposure: token-is-a-password
   guidance, the 12 h session lifetime, and — when --allow-edit is
   set — a warning that visitors can read/write/delete project
   files. --yes / -y skips for scripts. In non-TTY environments
   without --yes, publish refuses to proceed rather than silently
   exposing.

# Auto-repair for missing studio assets

Previously, running publish against a dev checkout without a built
studio produced "Studio not found. Rebuild with: pnpm run build" on
every request — after the tunnel was open and the URL shared. We
fix it instead: createStudioServer throws
StudioAssetsMissingError at construction, publish catches it,
spawns `bun run build` in packages/studio with a spinner, retries
createStudioServer once the build succeeds. The error surface only
fires when auto-rebuild itself fails (broken global install, etc.),
and lists every path searched plus the `bun install && bun run
build` guidance. End-to-end: deleting both packages/studio/dist
and packages/cli/dist/studio and running publish now rebuilds the
studio and opens the tunnel without user intervention.

# Flags

- --port <n>               Preview port (default 3002).
- --provider <p>           cloudflared | tuns | auto.
- --allow-edit             Remote visitors can write/delete files.
                           Off by default.
- --yes / -y               Skip the consent prompt.

# Properties worth calling out

- No info leak on unauthenticated probes — random visitors see 404
  whether a path exists or not.
- Token never leaves the first URL — redirect strips it before any
  HTML is served, so no iframe Referer can leak it.
- Constant-time comparison for both token and cookie checks.
- No persisted state — token lives for the lifetime of the command,
  Ctrl-C nukes it.
- Read-only cannot be bypassed by a malicious client; the
  server-side 403 is the real stop.
- zero-file build:copy can no longer ship a broken CLI.

# Tests

- src/utils/publishSecurity.test.ts — 23 cases: token exchange,
  cookie flow, mutation-gate allow/deny matrix,
  X-HF-Publish-Readonly header, HTML-injection + no-op on
  --allow-edit / non-HTML / /api/* paths.
- src/utils/tunnel.test.ts — 7 cases for the URL extractor
  (per-provider banner parsing, cross-provider contamination
  guard) and provider picker semantics.
- src/server/studioServer.test.ts — StudioAssetsMissingError shape
  and export for downstream catch blocks.
- Full CLI suite: 144/144 pass.
- tsc --noEmit clean, tsup build clean.
@mintlify
Copy link
Copy Markdown

mintlify Bot commented Apr 22, 2026

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
hyperframes 🟢 Ready View Preview Apr 22, 2026, 5:02 AM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@miguel-heygen
Copy link
Copy Markdown
Collaborator Author

Superseded by #312, which contains the current persisted publish flow and canary routing work.

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.

1 participant