From 28e9406dc1375b2c68cedbd9feee1d762e100047 Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Sun, 8 Nov 2026 01:24:53 +0300 Subject: [PATCH] feat(cli): self-host runtime options for containers (#143) `cloudhal studio --host ` (CLOUDHAL_HOST) picks the address it listens on, `--base-url ` (CLOUDHAL_BASE_URL) the public origin for Better Auth's baseURL, the API's trusted origins and the printed URL, and `--behind-proxy` (CLOUDHAL_BEHIND_PROXY=1) makes `serveNode` take each request's scheme and host from X-Forwarded-Proto/Host (new `trustProxy`, `forwardedUrl`, `forwardedRequest` in @cloudhal/api/node). `--init-if-empty` (CLOUDHAL_INIT=) scaffolds the project through `init` when the folder has no cloudhal.json, so a container starts on an empty volume. `--host` now takes an address for `dev` too; alone it still means every interface. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com> --- .changeset/selfhost-runtime.md | 6 + packages/api/src/node.ts | 55 ++++- packages/api/test/node-proxy.test.ts | 95 +++++++ packages/cli/src/cli.ts | 35 ++- packages/cli/src/cli/commands-local.ts | 10 +- packages/cli/src/cli/help.ts | 13 +- packages/cli/src/commands/astro.ts | 5 +- packages/cli/src/commands/studio-options.ts | 161 ++++++++++++ packages/cli/src/commands/studio.ts | 48 +++- packages/cli/test/studio-selfhost.test.ts | 259 ++++++++++++++++++++ 10 files changed, 665 insertions(+), 22 deletions(-) create mode 100644 .changeset/selfhost-runtime.md create mode 100644 packages/api/test/node-proxy.test.ts create mode 100644 packages/cli/src/commands/studio-options.ts create mode 100644 packages/cli/test/studio-selfhost.test.ts diff --git a/.changeset/selfhost-runtime.md b/.changeset/selfhost-runtime.md new file mode 100644 index 00000000..51232b96 --- /dev/null +++ b/.changeset/selfhost-runtime.md @@ -0,0 +1,6 @@ +--- +"@cloudhal/cli": patch +"@cloudhal/api": patch +--- + +Self-hosting (#143): `cloudhal studio` can be reached from beyond the machine it runs on. `--host ` (or `CLOUDHAL_HOST`) is the address it listens on (default `127.0.0.1`; `0.0.0.0` in a container), `--base-url ` (or `CLOUDHAL_BASE_URL`) the public origin behind a reverse proxy — Better Auth's `baseURL` (its session cookies are `Secure` for https), a trusted origin for the API's CSRF check, and the URL the command prints — and `--behind-proxy` (or `CLOUDHAL_BEHIND_PROXY=1`) makes the server take each request's scheme and host from the proxy's `X-Forwarded-Proto` / `X-Forwarded-Host` (`serveNode`'s new `trustProxy`, with `forwardedUrl` / `forwardedRequest` in `@cloudhal/api/node`); without the flag those headers are ignored, as before. `--init-if-empty` (or `CLOUDHAL_INIT=`, with `--starter`, `--name` / `CLOUDHAL_SITE_NAME`) creates the project the way `cloudhal init` does when the folder has no `cloudhal.json`, so a container starts on an empty volume; a folder with other files is refused, as `init` refuses it. `studio()` returns the bound `port` next to `url`. `cloudhal dev --host` now also takes an address (`--host []`; alone it still means every interface). diff --git a/packages/api/src/node.ts b/packages/api/src/node.ts index 13b2213c..5fccefc0 100644 --- a/packages/api/src/node.ts +++ b/packages/api/src/node.ts @@ -1,17 +1,68 @@ import { type ServerType, serve } from "@hono/node-server"; import type { CloudhalApi } from "./app.ts"; +export interface ServeNodeOptions { + readonly port: number; + /** Address to listen on (default `127.0.0.1`; `0.0.0.0` for every interface, e.g. a container). */ + readonly hostname?: string | undefined; + /** + * Behind a reverse proxy that terminates TLS: take the request's scheme and host from + * `X-Forwarded-Proto` / `X-Forwarded-Host`, so the API's own origin (its CSRF and MCP origin + * checks, upload tickets, links) is the public one. Only when the proxy is the only way in: + * anyone who can reach the port directly can then claim any origin. + */ + readonly trustProxy?: boolean | undefined; +} + /** Serves the API on Node. Resolves once listening; returns the server for shutdown. */ export function serveNode( app: CloudhalApi, - options: { port: number; hostname?: string }, + options: ServeNodeOptions, ): Promise<{ server: ServerType; port: number }> { + const fetch: typeof app.fetch = options.trustProxy + ? (request, ...rest) => app.fetch(forwardedRequest(request), ...rest) + : app.fetch; return new Promise((resolve) => { const server = serve( - { fetch: app.fetch, port: options.port, hostname: options.hostname ?? "127.0.0.1" }, + { fetch, port: options.port, hostname: options.hostname ?? "127.0.0.1" }, (info) => { resolve({ server, port: info.port }); }, ); }); } + +/** A host name or IP, optionally with a port, as a proxy writes it (no path, no userinfo). */ +const FORWARDED_HOST = /^(\[[0-9a-fA-F:.]+\]|[A-Za-z0-9.-]+)(:\d{1,5})?$/; + +/** + * The URL a request had at the proxy, from `X-Forwarded-Proto` and `X-Forwarded-Host` (the first + * value of each; a proxy chain appends). Unchanged when neither header says anything usable. + */ +export function forwardedUrl(url: string, headers: Headers): string { + const proto = headers.get("x-forwarded-proto")?.split(",")[0]?.trim().toLowerCase(); + const host = headers.get("x-forwarded-host")?.split(",")[0]?.trim(); + const u = new URL(url); + if (proto === "http" || proto === "https") u.protocol = `${proto}:`; + if (host && FORWARDED_HOST.test(host)) { + u.host = host; + // the setter keeps the old port when the new host names none + if (!/:\d+$/.test(host)) u.port = ""; + } + return u.href; +} + +/** The same request at its forwarded URL (the body streams through untouched). */ +export function forwardedRequest(request: Request): Request { + const url = forwardedUrl(request.url, request.headers); + if (url === request.url) return request; + const body = request.method === "GET" || request.method === "HEAD" ? null : request.body; + return new Request(url, { + method: request.method, + headers: request.headers, + body, + signal: request.signal, + // a streamed body needs this (Node's fetch); not in the DOM typing + ...(body ? { duplex: "half" } : {}), + } as RequestInit); +} diff --git a/packages/api/test/node-proxy.test.ts b/packages/api/test/node-proxy.test.ts new file mode 100644 index 00000000..e4f50b00 --- /dev/null +++ b/packages/api/test/node-proxy.test.ts @@ -0,0 +1,95 @@ +/** + * `serveNode` behind a reverse proxy (self-hosting, #143): with `trustProxy` the request the app + * sees carries the scheme and host the proxy forwarded, so the API's own origin is the public one; + * without it the headers are ignored. + */ +import { Hono } from "hono"; +import { afterAll, describe, expect, it } from "vitest"; +import type { CloudhalApi } from "../src/app.ts"; +import { forwardedUrl, serveNode } from "../src/node.ts"; + +describe("forwardedUrl", () => { + const h = (init: Record) => new Headers(init); + + it("takes scheme and host from the proxy headers", () => { + expect( + forwardedUrl( + "http://127.0.0.1:8787/api/me?x=1", + h({ "x-forwarded-proto": "https", "x-forwarded-host": "studio.example.com" }), + ), + ).toBe("https://studio.example.com/api/me?x=1"); + }); + + it("uses the first value of a proxy chain and keeps a forwarded port", () => { + expect( + forwardedUrl( + "http://127.0.0.1:8787/", + h({ "x-forwarded-proto": "https, http", "x-forwarded-host": "a.example:8443, b.example" }), + ), + ).toBe("https://a.example:8443/"); + }); + + it("changes only what a header says, and ignores values that are not a scheme or a host", () => { + expect(forwardedUrl("http://studio.example.com/x", h({ "x-forwarded-proto": "https" }))).toBe( + "https://studio.example.com/x", + ); + expect(forwardedUrl("http://127.0.0.1:8787/x", h({ "x-forwarded-host": "[::1]:9000" }))).toBe( + "http://[::1]:9000/x", + ); + expect( + forwardedUrl( + "http://127.0.0.1:8787/x", + h({ "x-forwarded-proto": "javascript", "x-forwarded-host": "evil.example/path" }), + ), + ).toBe("http://127.0.0.1:8787/x"); + expect(forwardedUrl("http://127.0.0.1:8787/x", h({}))).toBe("http://127.0.0.1:8787/x"); + }); +}); + +describe("serveNode trustProxy", () => { + const app = new Hono(); + app.all("*", async (c) => + c.json({ + url: c.req.url, + method: c.req.method, + body: c.req.method === "POST" ? await c.req.text() : null, + }), + ); + const servers: { server: { close(cb: () => void): void } }[] = []; + afterAll(async () => { + for (const s of servers) await new Promise((r) => s.server.close(() => r())); + }); + const start = async (trustProxy: boolean) => { + const s = await serveNode(app as unknown as CloudhalApi, { port: 0, trustProxy }); + servers.push(s); + return `http://127.0.0.1:${s.port}`; + }; + const forwarded = { "x-forwarded-proto": "https", "x-forwarded-host": "studio.example.com" }; + + it("rewrites the request URL from the forwarded headers, body included", async () => { + const base = await start(true); + const get = (await (await fetch(`${base}/api/x?y=1`, { headers: forwarded })).json()) as { + url: string; + }; + expect(get.url).toBe("https://studio.example.com/api/x?y=1"); + const post = (await ( + await fetch(`${base}/api/x`, { method: "POST", headers: forwarded, body: "hello" }) + ).json()) as { url: string; body: string }; + expect(post).toEqual({ + url: "https://studio.example.com/api/x", + method: "POST", + body: "hello", + }); + // nothing forwarded: the request is as it came + const plain = (await (await fetch(`${base}/api/x`)).json()) as { url: string }; + expect(plain.url).toBe(`${base}/api/x`); + }); + + it("ignores the headers without trustProxy", async () => { + const base = await start(false); + const r = (await (await fetch(`${base}/api/x`, { headers: forwarded })).json()) as { + url: string; + }; + expect(r.url).toBe(`${base}/api/x`); + }); +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index d4d464ef..cdac732b 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -13,7 +13,10 @@ export const OPTIONS = { name: { type: "string" }, starter: { type: "string" }, port: { type: "string" }, - host: { type: "boolean" }, + host: { type: "string" }, + "base-url": { type: "string" }, + "behind-proxy": { type: "boolean" }, + "init-if-empty": { type: "boolean" }, label: { type: "string" }, publish: { type: "boolean" }, snapshot: { type: "string" }, @@ -84,8 +87,36 @@ const ONLY_FOR: Readonly< "no-media": { commands: ["export"] }, "latest-only": { commands: ["export"] }, "cancel-schedules": { commands: ["push"] }, + "base-url": { commands: ["studio"] }, + "behind-proxy": { commands: ["studio"] }, + "init-if-empty": { commands: ["studio"] }, }; +/** + * Flags whose value may be left out (`cloudhal dev --host`, as before `--host` took an address): + * the value they then stand for. `parseArgs` has no optional values, so a bare flag — at the end, + * or followed by another flag — gets it filled in first. + */ +const OPTIONAL_VALUE: Readonly> = { host: "0.0.0.0" }; + +export function fillOptionalValues(argv: string[]): string[] { + const out: string[] = []; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] as string; + out.push(arg); + if (arg === "--") { + out.push(...argv.slice(i + 1)); + break; + } + const flag = arg.startsWith("--") ? arg.slice(2) : undefined; + const fill = flag && Object.hasOwn(OPTIONAL_VALUE, flag) ? OPTIONAL_VALUE[flag] : undefined; + if (fill === undefined) continue; + const next = argv[i + 1]; + if (next === undefined || next.startsWith("-")) out.push(fill); + } + return out; +} + function refuseForeignFlags(command: string, values: Record): void { for (const [flag, only] of Object.entries(ONLY_FOR)) if (values[flag] !== undefined && !only.commands.includes(command)) @@ -105,7 +136,7 @@ export const COMMANDS: Record = { export async function main(argv: string[]): Promise { const { values, positionals } = parseArgs({ - args: argv, + args: fillOptionalValues(argv), allowPositionals: true, options: OPTIONS, }); diff --git a/packages/cli/src/cli/commands-local.ts b/packages/cli/src/cli/commands-local.ts index 14a4ee59..bf11a1a2 100644 --- a/packages/cli/src/cli/commands-local.ts +++ b/packages/cli/src/cli/commands-local.ts @@ -31,7 +31,7 @@ const devCommand: CliCommand = async (_rest, values, { projectDir }) => { const server = await dev({ projectDir, ...(values.port ? { port: Number(values.port) } : {}), - ...(values.host ? { host: true } : {}), + ...(values.host ? { host: values.host } : {}), }); if (server.url) console.log(`cloudhal dev → ${server.url}`); await untilSignal(); @@ -69,6 +69,14 @@ const studioCommand: CliCommand = async (_rest, values, { projectDir }) => { projectDir, ...(values.port ? { port: Number(values.port) } : {}), ...(values["no-auth"] ? { noAuth: true } : {}), + // Self-hosting (#143); the environment fills what the flags leave out (studio-options.ts). + ...(values.host ? { host: values.host } : {}), + ...(values["base-url"] ? { baseUrl: values["base-url"] } : {}), + ...(values["behind-proxy"] ? { behindProxy: true } : {}), + ...(values["init-if-empty"] ? { initIfEmpty: true } : {}), + ...(values.starter ? { starter: values.starter } : {}), + ...(values.name ? { name: values.name } : {}), + log: (l) => console.log(` ${l}`), }); console.log(`cloudhal studio → ${s.url}`); await untilSignal(); diff --git a/packages/cli/src/cli/help.ts b/packages/cli/src/cli/help.ts index 6439aa76..74193008 100644 --- a/packages/cli/src/cli/help.ts +++ b/packages/cli/src/cli/help.ts @@ -3,10 +3,19 @@ export const HELP = `cloudhal — agent-native sites Usage: cloudhal init [--name ] [--starter blog] create a site project with a seeded local store - cloudhal dev [--port ] [--host] live preview (drafts visible) + cloudhal dev [--port ] [--host []] live preview (drafts visible) cloudhal snapshot [--label ] [--publish] freeze the current graph (optionally publish) cloudhal build [--snapshot ] static HTML from the published/latest snapshot - cloudhal studio [--port ] [--no-auth] API + Studio UI for this project (default :8787) + cloudhal studio [--port ] [--host []] [--base-url ] [--behind-proxy] [--no-auth] + [--init-if-empty [--starter blog] [--name ]] + API + Studio UI for this project (default :8787); + self-hosting: --host 0.0.0.0 (CLOUDHAL_HOST) listens + beyond this machine, --base-url (CLOUDHAL_BASE_URL) + is the public origin behind a reverse proxy, + --behind-proxy (CLOUDHAL_BEHIND_PROXY=1) trusts its + X-Forwarded-Proto/Host, --init-if-empty + (CLOUDHAL_INIT=) creates the project first + when the folder has no cloudhal.json cloudhal mcp [--mode plan|manual|edits|auto|bypass] [--scopes a,b] [--skills ] [--preview-url ] MCP server over stdio for this project cloudhal deploy [--name ] [--dry-run] [--jurisdiction eu] [--scheduler cron|alarm] [--plan free|paid] diff --git a/packages/cli/src/commands/astro.ts b/packages/cli/src/commands/astro.ts index b35278ba..33a2fd21 100644 --- a/packages/cli/src/commands/astro.ts +++ b/packages/cli/src/commands/astro.ts @@ -7,7 +7,8 @@ import { resolveBuildSnapshot } from "./snapshot.ts"; export interface DevOptions { readonly projectDir: string; readonly port?: number; - readonly host?: boolean; + /** `true` (every interface) or an address to listen on; default: this machine only. */ + readonly host?: boolean | string; /** `false` skips the scheduler's Node ticker (RFC-0008 §8.4). */ readonly scheduler?: boolean; } @@ -27,7 +28,7 @@ export async function dev( logLevel: "info", server: { ...(options.port !== undefined ? { port: options.port } : {}), - ...(options.host ? { host: true } : {}), + ...(options.host ? { host: options.host } : {}), }, }); const addr = server.address; diff --git a/packages/cli/src/commands/studio-options.ts b/packages/cli/src/commands/studio-options.ts new file mode 100644 index 00000000..575a7e3e --- /dev/null +++ b/packages/cli/src/commands/studio-options.ts @@ -0,0 +1,161 @@ +/** + * How `cloudhal studio` is reached when it does not run on the author's own machine (self-hosting, + * #143): the address it listens on, the public origin behind a reverse proxy, whether the proxy's + * `X-Forwarded-*` headers are trusted, and the first run on an empty folder (a container's volume). + * Flags win over the environment: `CLOUDHAL_HOST`, `CLOUDHAL_BASE_URL`, `CLOUDHAL_BEHIND_PROXY`, + * `CLOUDHAL_INIT` (a starter name), `CLOUDHAL_SITE_NAME`. + */ +import { access } from "node:fs/promises"; +import path from "node:path"; +import { CONFIG_FILE } from "../config.ts"; +import { init } from "./init.ts"; + +type Env = Readonly>; + +/** The flags `cloudhal studio` takes for where and how it is served. */ +export interface StudioServerFlags { + readonly port?: number; + /** `--host`: the address to listen on (default `127.0.0.1`; `0.0.0.0` for every interface). */ + readonly host?: string; + /** `--base-url`: the public origin (scheme, host, port), e.g. `https://studio.example.com`. */ + readonly baseUrl?: string; + /** `--behind-proxy`: trust `X-Forwarded-Proto` / `X-Forwarded-Host`. */ + readonly behindProxy?: boolean; +} + +/** What `studio()` serves with: flags, then the environment, then the defaults. */ +export interface StudioServer { + readonly port: number; + readonly hostname: string | undefined; + /** The public origin, without a trailing slash; undefined means `http://localhost:`. */ + readonly baseUrl: string | undefined; + readonly behindProxy: boolean; +} + +const DEFAULT_PORT = 8787; +const ON = new Set(["1", "true", "yes", "on"]); + +function envFlag(value: string | undefined): boolean { + return value !== undefined && ON.has(value.trim().toLowerCase()); +} + +function nonEmpty(value: string | undefined): string | undefined { + const v = value?.trim(); + return v ? v : undefined; +} + +/** + * `--base-url` / `CLOUDHAL_BASE_URL` as an origin: http or https, host, optional port; a path, + * query or fragment is refused (Studio is served at the root, and Better Auth's `baseURL` is an + * origin). + */ +export function parseBaseUrl(value: string): string { + let url: URL; + try { + url = new URL(value.trim()); + } catch { + throw new Error( + `--base-url must be an absolute URL such as https://studio.example.com (got "${value}")`, + ); + } + if (url.protocol !== "http:" && url.protocol !== "https:") + throw new Error(`--base-url must be an http or https URL (got "${value}")`); + if (url.pathname !== "/" || url.search || url.hash || url.username || url.password) + throw new Error(`--base-url takes an origin only, without a path (got "${value}")`); + return url.origin; +} + +/** Resolves the server options from flags and the environment. */ +export function resolveStudioServer( + flags: StudioServerFlags, + env: Env = process.env, +): StudioServer { + const baseUrl = flags.baseUrl ?? nonEmpty(env.CLOUDHAL_BASE_URL); + return { + port: flags.port ?? DEFAULT_PORT, + hostname: nonEmpty(flags.host) ?? nonEmpty(env.CLOUDHAL_HOST), + baseUrl: baseUrl === undefined ? undefined : parseBaseUrl(baseUrl), + behindProxy: flags.behindProxy ?? envFlag(env.CLOUDHAL_BEHIND_PROXY), + }; +} + +/** Where to open Studio: the public origin when there is one, else this machine. */ +export function studioUrl(server: StudioServer, port: number): string { + if (server.baseUrl) return `${server.baseUrl}/`; + const h = server.hostname; + const host = + h === undefined || h === "127.0.0.1" || h === "0.0.0.0" || h === "::" || h === "::1" + ? "localhost" + : h.includes(":") + ? `[${h}]` + : h; + return `http://${host}:${port}/`; +} + +/** + * Better Auth's `baseURL` (the origin browsers use; its cookies are `Secure` for https) and the + * origins the API accepts state-changing requests from: the Studio dev server, the public origin + * (so a proxy that keeps the request on http still passes the API's CSRF check), and + * `CLOUDHAL_TRUSTED_ORIGINS`. + */ +export function studioAuthOrigins( + server: StudioServer, + env: Env = process.env, +): { baseURL: string; trustedOrigins: string[] } { + const baseURL = server.baseUrl ?? `http://localhost:${server.port}`; + const trustedOrigins = [ + // The Studio dev server (vite, :5173) proxies /api here during Studio development. + "http://localhost:5173", + ...(server.baseUrl ? [server.baseUrl] : []), + ...(env.CLOUDHAL_TRUSTED_ORIGINS?.split(",") ?? []), + ]; + return { + baseURL, + trustedOrigins: [...new Set(trustedOrigins.map((o) => o.trim()))].filter(Boolean), + }; +} + +/** `--init-if-empty` and its `--starter` / `--name`; from the environment, `CLOUDHAL_INIT=`. */ +export interface StudioInitFlags { + readonly initIfEmpty?: boolean; + readonly starter?: string; + readonly name?: string; +} + +/** The first-run scaffold to perform, or undefined when none was asked for. */ +export function resolveStudioInit( + flags: StudioInitFlags, + env: Env = process.env, +): { starter: string; name: string | undefined } | undefined { + const envStarter = nonEmpty(env.CLOUDHAL_INIT); + if (!flags.initIfEmpty && !envStarter) return undefined; + return { + starter: flags.starter ?? envStarter ?? "blog", + name: flags.name ?? nonEmpty(env.CLOUDHAL_SITE_NAME), + }; +} + +/** + * Creates the project the way `cloudhal init` does when the folder has no `cloudhal.json` (a + * container starting on an empty volume). A folder that has other files but no config is left + * alone: `init` refuses it, so a volume mounted at the wrong path is never written over. + * Returns whether a project was created. + */ +export async function initIfEmpty( + projectDir: string, + options: { starter: string; name?: string | undefined; log?: (line: string) => void }, +): Promise { + try { + await access(path.join(projectDir, CONFIG_FILE)); + return false; + } catch { + // no project yet + } + await init({ + dir: projectDir, + starter: options.starter, + ...(options.name ? { name: options.name } : {}), + ...(options.log ? { log: options.log } : {}), + }); + return true; +} diff --git a/packages/cli/src/commands/studio.ts b/packages/cli/src/commands/studio.ts index 268107af..1164f7e5 100644 --- a/packages/cli/src/commands/studio.ts +++ b/packages/cli/src/commands/studio.ts @@ -15,10 +15,20 @@ import { localComments } from "./comments-local.ts"; import { terminalMailSender } from "./mail-local.ts"; import { resolveSkillsDir } from "./mcp.ts"; import { automationsEnabled, startLocalScheduler } from "./scheduler-local.ts"; +import { + initIfEmpty, + resolveStudioInit, + resolveStudioServer, + type StudioInitFlags, + type StudioServerFlags, + studioAuthOrigins, + studioUrl, +} from "./studio-options.ts"; -export interface StudioOptions { +export interface StudioOptions extends StudioServerFlags, StudioInitFlags { readonly projectDir: string; - readonly port?: number; + /** Progress lines (the first-run scaffold); default: nothing. */ + readonly log?: (line: string) => void; /** Directory of a built Studio (`apps/studio/dist`). Defaults to the monorepo build when present. */ readonly studioDist?: string; /** Skip authentication (local scripts only). */ @@ -51,7 +61,15 @@ export function isApiPath(pathname: string): boolean { */ export async function studio( options: StudioOptions, -): Promise<{ url: string; stop(): Promise }> { +): Promise<{ url: string; port: number; stop(): Promise }> { + // Self-hosting (#143): where to listen, the public origin, the proxy headers, the first run. + const server = resolveStudioServer(options); + const scaffold = resolveStudioInit(options); + if (scaffold) { + const log = options.log ?? (() => {}); + if (await initIfEmpty(options.projectDir, { ...scaffold, log })) + log(`created a new "${scaffold.starter}" project in ${options.projectDir}`); + } const cfg = await readProjectConfig(options.projectDir); // The theme's block types (RFC-0015 §6) join the built-ins, and its design settings // (RFC-0017 §1) check `Site.theme.config`: validated, offered, listed. @@ -84,6 +102,7 @@ export async function studio( // RFC-0008 §11.1: the broken-link sweep's 404 rows ...(analytics.feature().enabled ? { analytics } : {}), }); + const origins = studioAuthOrigins(server); let deps: Parameters[0] = { store: local.store, analytics, @@ -102,11 +121,8 @@ export async function studio( // RFC-0016: Workers AI over REST when the environment has an account id and a token (never // stored); the settings are kept either way. ai: { store: new AiStore(local.sql), ...localAiTransport() }, - // The Studio dev server (vite, :5173) proxies /api here during Studio development. - trustedOrigins: [ - "http://localhost:5173", - ...(process.env.CLOUDHAL_TRUSTED_ORIGINS?.split(",") ?? []), - ], + // The Studio dev server, the public origin and CLOUDHAL_TRUSTED_ORIGINS (studio-options.ts). + trustedOrigins: origins.trustedOrigins, // Without auth anyone who can reach the port could write; answer localhost names only. ...(options.noAuth ? { allowedHosts: ["localhost", "127.0.0.1", "[::1]"] } : {}), mcp: { @@ -123,9 +139,10 @@ export async function studio( const { createAuthFor } = await import("@cloudhal/api/auth/node"); const auth = await createAuthFor({ dataDir: local.dataDir, - baseURL: `http://localhost:${options.port ?? 8787}`, + // The public origin when there is one (its cookies are `Secure` behind https). + baseURL: origins.baseURL, // Better Auth checks Origin on browser POSTs itself; the Studio dev server must pass too. - trustedOrigins: deps.trustedOrigins ?? [], + trustedOrigins: origins.trustedOrigins, }); deps = { ...deps, @@ -150,13 +167,18 @@ export async function studio( app.get("*", (c, next) => (isApiPath(c.req.path) ? next() : spa(c, next))); } - const { server, port } = await serveNode(app, { port: options.port ?? 8787 }); + const listening = await serveNode(app, { + port: server.port, + hostname: server.hostname, + trustProxy: server.behindProxy, + }); return { - url: `http://localhost:${port}/`, + url: studioUrl(server, listening.port), + port: listening.port, stop: async () => { stopRollups(); ticker?.stop(); - await new Promise((resolve) => server.close(() => resolve())); + await new Promise((resolve) => listening.server.close(() => resolve())); await local.close(); }, }; diff --git a/packages/cli/test/studio-selfhost.test.ts b/packages/cli/test/studio-selfhost.test.ts new file mode 100644 index 00000000..60b5c516 --- /dev/null +++ b/packages/cli/test/studio-selfhost.test.ts @@ -0,0 +1,259 @@ +/** + * `cloudhal studio` where it does not run on the author's machine (self-hosting, #143): `--host`, + * `--base-url`, `--behind-proxy` and `--init-if-empty`, their environment variables, and what they + * change for Better Auth behind an https reverse proxy. + */ +import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { fillOptionalValues } from "../src/cli.ts"; +import { + parseBaseUrl, + resolveStudioInit, + resolveStudioServer, + studioAuthOrigins, + studioUrl, +} from "../src/commands/studio-options.ts"; +import { studio } from "../src/index.ts"; + +describe("studio server options", () => { + it("flags win over the environment, which fills what they leave out", () => { + const env = { + CLOUDHAL_HOST: "0.0.0.0", + CLOUDHAL_BASE_URL: "https://env.example/", + CLOUDHAL_BEHIND_PROXY: "true", + }; + expect(resolveStudioServer({}, env)).toEqual({ + port: 8787, + hostname: "0.0.0.0", + baseUrl: "https://env.example", + behindProxy: true, + }); + expect( + resolveStudioServer( + { port: 9000, host: "10.0.0.5", baseUrl: "http://flag.example:9000", behindProxy: false }, + env, + ), + ).toEqual({ + port: 9000, + hostname: "10.0.0.5", + baseUrl: "http://flag.example:9000", + behindProxy: false, + }); + expect(resolveStudioServer({}, { CLOUDHAL_HOST: " ", CLOUDHAL_BEHIND_PROXY: "0" })).toEqual({ + port: 8787, + hostname: undefined, + baseUrl: undefined, + behindProxy: false, + }); + }); + + it("takes an origin for --base-url and refuses anything else", () => { + expect(parseBaseUrl(" https://studio.example.com/ ")).toBe("https://studio.example.com"); + expect(parseBaseUrl("http://localhost:8787")).toBe("http://localhost:8787"); + expect(() => parseBaseUrl("studio.example.com")).toThrow(/absolute URL/); + expect(() => parseBaseUrl("ftp://studio.example.com")).toThrow(/http or https/); + expect(() => parseBaseUrl("https://studio.example.com/studio")).toThrow(/origin only/); + expect(() => parseBaseUrl("https://studio.example.com/?x=1")).toThrow(/origin only/); + }); + + it("prints the public origin, else this machine", () => { + const base = { port: 8787, behindProxy: false }; + expect(studioUrl({ ...base, hostname: undefined, baseUrl: undefined }, 8787)).toBe( + "http://localhost:8787/", + ); + expect(studioUrl({ ...base, hostname: "0.0.0.0", baseUrl: undefined }, 1234)).toBe( + "http://localhost:1234/", + ); + expect(studioUrl({ ...base, hostname: "10.0.0.5", baseUrl: undefined }, 8787)).toBe( + "http://10.0.0.5:8787/", + ); + expect(studioUrl({ ...base, hostname: "fe80::1", baseUrl: undefined }, 8787)).toBe( + "http://[fe80::1]:8787/", + ); + expect( + studioUrl({ ...base, hostname: "0.0.0.0", baseUrl: "https://studio.example.com" }, 8787), + ).toBe("https://studio.example.com/"); + }); + + it("gives Better Auth the public origin and trusts it for the API's CSRF check", () => { + const local = studioAuthOrigins( + { port: 8787, hostname: undefined, baseUrl: undefined, behindProxy: false }, + {}, + ); + expect(local).toEqual({ + baseURL: "http://localhost:8787", + trustedOrigins: ["http://localhost:5173"], + }); + const hosted = studioAuthOrigins( + { port: 8787, hostname: "0.0.0.0", baseUrl: "https://studio.example.com", behindProxy: true }, + { CLOUDHAL_TRUSTED_ORIGINS: "https://other.example, https://studio.example.com" }, + ); + expect(hosted).toEqual({ + baseURL: "https://studio.example.com", + trustedOrigins: [ + "http://localhost:5173", + "https://studio.example.com", + "https://other.example", + ], + }); + }); + + it("scaffolds on --init-if-empty or CLOUDHAL_INIT, with the starter and name given", () => { + expect(resolveStudioInit({}, {})).toBeUndefined(); + expect(resolveStudioInit({ starter: "blog" }, {})).toBeUndefined(); + expect(resolveStudioInit({ initIfEmpty: true }, {})).toEqual({ + starter: "blog", + name: undefined, + }); + expect(resolveStudioInit({}, { CLOUDHAL_INIT: "blog", CLOUDHAL_SITE_NAME: "Notes" })).toEqual({ + starter: "blog", + name: "Notes", + }); + expect( + resolveStudioInit( + { initIfEmpty: true, starter: "docs", name: "Flag" }, + { CLOUDHAL_INIT: "blog" }, + ), + ).toEqual({ starter: "docs", name: "Flag" }); + }); + + it("fills a bare --host (every interface) so parseArgs sees a value", () => { + expect(fillOptionalValues(["dev", "--host"])).toEqual(["dev", "--host", "0.0.0.0"]); + expect(fillOptionalValues(["studio", "--host", "--port", "1"])).toEqual([ + "studio", + "--host", + "0.0.0.0", + "--port", + "1", + ]); + expect(fillOptionalValues(["studio", "--host", "10.0.0.5"])).toEqual([ + "studio", + "--host", + "10.0.0.5", + ]); + expect(fillOptionalValues(["studio", "--host=::"])).toEqual(["studio", "--host=::"]); + expect(fillOptionalValues(["x", "--", "--host"])).toEqual(["x", "--", "--host"]); + }); +}); + +describe("cloudhal studio on an empty folder and behind a proxy", () => { + let tmp: string; + beforeAll(async () => { + tmp = await mkdtemp(path.join(os.tmpdir(), "cloudhal-studio-selfhost-")); + }); + afterAll(async () => { + await rm(tmp, { recursive: true, force: true }); + }); + + it("--init-if-empty creates the project once, then serves it", async () => { + const projectDir = path.join(tmp, "data"); + await mkdir(projectDir); + const lines: string[] = []; + const s = await studio({ + projectDir, + noAuth: true, + port: 0, + initIfEmpty: true, + name: "Volume Site", + log: (l) => lines.push(l), + }); + try { + const cfg = JSON.parse(await readFile(path.join(projectDir, "cloudhal.json"), "utf8")) as { + siteId: string; + }; + expect(cfg.siteId).toMatch(/\S/); + expect(lines.some((l) => l.includes('created a new "blog" project'))).toBe(true); + expect((await fetch(`${s.url}api/health`)).status).toBe(200); + const sites = (await (await fetch(`${s.url}api/sites`)).json()) as { + id: string; + name: string; + }[]; + expect(sites.map((x) => [x.id, x.name])).toEqual([[cfg.siteId, "Volume Site"]]); + await s.stop(); + // a second start keeps the project + const again = await studio({ projectDir, noAuth: true, port: 0, initIfEmpty: true }); + try { + const cfg2 = JSON.parse(await readFile(path.join(projectDir, "cloudhal.json"), "utf8")) as { + siteId: string; + }; + expect(cfg2.siteId).toBe(cfg.siteId); + } finally { + await again.stop(); + } + } catch (e) { + await s.stop().catch(() => {}); + throw e; + } + }); + + it("refuses a folder that has files but no project, and does nothing without the flag", async () => { + const other = path.join(tmp, "other"); + await mkdir(other); + await writeFile(path.join(other, "notes.txt"), "mine"); + await expect( + studio({ projectDir: other, noAuth: true, port: 0, initIfEmpty: true }), + ).rejects.toThrow(/is not empty/); + const empty = path.join(tmp, "empty"); + await mkdir(empty); + await expect(studio({ projectDir: empty, noAuth: true, port: 0 })).rejects.toThrow(); + await expect(access(path.join(empty, "cloudhal.json"))).rejects.toThrow(); + }); + + it("signs the first owner in behind an https proxy with Secure cookies", async () => { + const projectDir = path.join(tmp, "proxied"); + await mkdir(projectDir); + const s = await studio({ + projectDir, + port: 0, + host: "127.0.0.1", + baseUrl: "https://studio.example.test", + behindProxy: true, + initIfEmpty: true, + }); + try { + expect(s.url).toBe("https://studio.example.test/"); + const base = `http://127.0.0.1:${s.port}`; + const proxied = { + origin: "https://studio.example.test", + "x-forwarded-proto": "https", + "x-forwarded-host": "studio.example.test", + "content-type": "application/json", + }; + const signUp = await fetch(`${base}/api/auth/sign-up/email`, { + method: "POST", + headers: proxied, + body: JSON.stringify({ + name: "Olive Owner", + email: "owner@example.test", + password: "correct horse battery staple", + }), + }); + expect(signUp.status, await signUp.clone().text()).toBe(200); + const cookies = signUp.headers.getSetCookie(); + const session = cookies.find((c) => c.startsWith("__Secure-better-auth.session_token=")); + expect(session, cookies.join("\n")).toMatch(/;\s*Secure/i); + const cookie = (session as string).split(";")[0] as string; + const me = await fetch(`${base}/api/me`, { headers: { ...proxied, cookie } }); + expect(me.status).toBe(200); + expect(await me.json()).toMatchObject({ role: "owner" }); + // a state-changing call from the public origin passes the API's own CSRF check + const key = await fetch(`${base}/api/agent-keys`, { + method: "POST", + headers: { ...proxied, cookie }, + body: JSON.stringify({ name: "ci" }), + }); + expect(key.status, await key.clone().text()).toBe(201); + // a browser on another origin does not + const foreign = await fetch(`${base}/api/agent-keys`, { + method: "POST", + headers: { ...proxied, cookie, origin: "https://evil.example" }, + body: JSON.stringify({ name: "no" }), + }); + expect(foreign.status).toBe(403); + } finally { + await s.stop(); + } + }); +});