From 339e49dfec6beb37ea35770bd43145798e53a299 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 18 Apr 2026 02:52:12 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat(cli):=20hyperframes=20publish=20?= =?UTF-8?q?=E2=80=94=20share=20projects=20via=20a=20public=20URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. - 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. 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. 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