diff --git a/.changeset/short-deploy-token.md b/.changeset/short-deploy-token.md new file mode 100644 index 00000000..3a4918bd --- /dev/null +++ b/.changeset/short-deploy-token.md @@ -0,0 +1,6 @@ +--- +"@cloudhal/api": patch +"@cloudhal/cli": patch +--- + +A `CLOUDHAL_DEPLOY_TOKEN` shorter than 32 characters no longer makes every `/api` request fail with 500. Only `/api/admin` is disabled (503 `deploy_token_invalid`): the error is logged once, `/api/health` reports `problems: ["deploy_token_invalid"]`, and sign-in and Studio keep working. The Workers Builds setup steps and the Deploy button guide now say not to edit `CLOUDHAL_DEPLOY_TOKEN`, because every build replaces it. diff --git a/docs/guides/deploy-button.md b/docs/guides/deploy-button.md index c1dd8b65..bbff4aa0 100644 --- a/docs/guides/deploy-button.md +++ b/docs/guides/deploy-button.md @@ -18,6 +18,8 @@ A local `cloudhal deploy` prints a one-time setup link. In Workers Builds the bu Until an owner exists, sign-up needs that token. Once the owner exists, sign-up is closed whatever the token is. +**Do not edit `CLOUDHAL_DEPLOY_TOKEN`.** It sits next to `CLOUDHAL_SETUP_TOKEN` in the same list, but it is the deployment token, and every build replaces it. The owner account needs only `CLOUDHAL_SETUP_TOKEN`. If `CLOUDHAL_DEPLOY_TOKEN` is changed to a value shorter than 32 characters, the admin Worker switches off its deploy endpoints (`/api/admin`), and `/api/health` reports `"problems": ["deploy_token_invalid"]`. Studio, sign-in and publishing keep working. To fix it, retry the latest build or push a commit: the build sets a new token. + ## How `cloudhal deploy` behaves in Workers Builds Workers Builds sets `WORKERS_CI=1`. Every build starts from a fresh clone, with no `.cloudhal/` folder: no local site, no `secrets.json`, no `state.json`. With `WORKERS_CI=1`, `cloudhal deploy`: diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index 6a04c356..b8a52848 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -15,7 +15,7 @@ import type { ApiDeps, Env } from "./context.ts"; import { errorHandler, unauthorized } from "./errors.ts"; import { PLAYGROUND_PRINCIPAL, playgroundGuard } from "./playground.ts"; import { activity, deploymentActivity } from "./routes/activity.ts"; -import { adminRoutes } from "./routes/admin.ts"; +import { mountAdmin } from "./routes/admin-mount.ts"; import { agentKeys } from "./routes/agent-keys.ts"; import { aiRoutes } from "./routes/ai.ts"; import { analytics } from "./routes/analytics.ts"; @@ -210,19 +210,12 @@ export function createApi(deps: ApiDeps) { c.res.headers.set("cache-control", "no-store"); }); - app.get("/api/health", (c) => c.json({ ok: true })); + const problems = mountAdmin(app, deps); + app.get("/api/health", (c) => c.json(problems.length ? { ok: true, problems } : { ok: true })); if (deps.authHandler) { const handler = deps.authHandler; app.on(["GET", "POST"], "/api/auth/*", (c) => handler(c.req.raw)); } - if (deps.admin) { - if (deps.admin.deployToken.length < 32) - throw new Error("admin.deployToken must be at least 32 characters"); - app.route( - "/api/admin", - adminRoutes(deps.admin.deployToken, deps.admin.storage, deps.ai?.transport, deps.mcp?.render), - ); - } app.get("/api/me", meHandler(membership)); @@ -270,7 +263,7 @@ export function createApi(deps: ApiDeps) { mcp: Boolean(deps.mcp), activity: Boolean(deps.audit ?? deps.mcp?.audit), previewLinks: Boolean(deps.preview), - deployTransfer: Boolean(deps.admin), + deployTransfer: Boolean(deps.admin) && problems.length === 0, approvals: Boolean(deps.mcp?.approvals), elevationRequests: Boolean(deps.mcp?.approvals && deps.agentKeys), members: Boolean(deps.staff), diff --git a/packages/api/src/routes/admin-mount.ts b/packages/api/src/routes/admin-mount.ts new file mode 100644 index 00000000..f31d492d --- /dev/null +++ b/packages/api/src/routes/admin-mount.ts @@ -0,0 +1,40 @@ +/** + * Mounts `/api/admin` behind the deploy token. A deploy token shorter than 32 characters (an owner + * who edited CLOUDHAL_DEPLOY_TOKEN instead of CLOUDHAL_SETUP_TOKEN) switches off only the admin + * routes: the rest of the API, sign-in included, keeps working and `/api/health` names the problem. + */ +import type { Hono } from "hono"; +import type { ApiDeps, Env } from "../context.ts"; +import { adminRoutes } from "./admin.ts"; + +export const MIN_DEPLOY_TOKEN_LENGTH = 32; + +/** Health problems the admin mount found; empty when the admin routes are served or not wanted. */ +export type AdminProblem = "deploy_token_invalid"; + +export function mountAdmin(app: Hono, deps: ApiDeps): AdminProblem[] { + if (!deps.admin) return []; + const { deployToken, storage } = deps.admin; + if (deployToken.length >= MIN_DEPLOY_TOKEN_LENGTH) { + app.route( + "/api/admin", + adminRoutes(deployToken, storage, deps.ai?.transport, deps.mcp?.render), + ); + return []; + } + console.error( + `cloudhal: CLOUDHAL_DEPLOY_TOKEN is shorter than ${MIN_DEPLOY_TOKEN_LENGTH} characters; /api/admin is disabled ` + + "(push, pull, owner transfer). Do not edit CLOUDHAL_DEPLOY_TOKEN; deploy again to set it " + + "(Workers Builds: retry the build). The owner setup token is CLOUDHAL_SETUP_TOKEN.", + ); + app.all("/api/admin/*", (c) => + c.json( + { + error: "deploy_token_invalid", + message: `CLOUDHAL_DEPLOY_TOKEN must be at least ${MIN_DEPLOY_TOKEN_LENGTH} characters; admin endpoints are disabled`, + }, + 503, + ), + ); + return ["deploy_token_invalid"]; +} diff --git a/packages/api/test/admin.test.ts b/packages/api/test/admin.test.ts index e6f8cbbd..f3763490 100644 --- a/packages/api/test/admin.test.ts +++ b/packages/api/test/admin.test.ts @@ -3,7 +3,7 @@ import { SiteGraphStore, seedStarterSite } from "@cloudhal/core-ts"; import { makePng } from "@cloudhal/core-ts/media/fixtures"; import { MemoryBlobStore } from "@cloudhal/storage"; import { BetterSqlite3Driver } from "@cloudhal/storage/sqlite"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createApi } from "../src/app.ts"; const TOKEN = "deploy-token-for-tests-0123456789abcdef"; @@ -238,9 +238,41 @@ describe("admin bundle endpoints", () => { expect((await put(bundle.replace(site.id, "site_other0000001"))).status).toBe(400); expect((await put(bundle, "?mode=yolo")).status).toBe(400); - expect(() => - createApi({ store: a.store, admin: { deployToken: "short", storage: a } }), - ).toThrow(/32/); + }); +}); + +describe("short deploy token", () => { + it("disables only /api/admin, logs once and reports it on /api/health", async () => { + const b = await backend(); + const errors = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + const app = createApi({ store: b.store, admin: { deployToken: "short", storage: b } }); + expect(errors).toHaveBeenCalledTimes(1); + expect(String(errors.mock.calls[0]?.[0])).toMatch(/CLOUDHAL_DEPLOY_TOKEN.*32/); + + const health = await app.request("/api/health"); + expect(health.status).toBe(200); + expect(await health.json()).toEqual({ ok: true, problems: ["deploy_token_invalid"] }); + + for (const token of ["short", TOKEN]) { + const res = await app.request("/api/admin/sites", { + headers: { authorization: `Bearer ${token}` }, + }); + expect(res.status).toBe(503); + expect(((await res.json()) as { error: string }).error).toBe("deploy_token_invalid"); + } + // The rest of the API still answers (here: unauthenticated, not a crash). + expect((await app.request("/api/me")).status).toBeLessThan(500); + expect(errors).toHaveBeenCalledTimes(1); + } finally { + errors.mockRestore(); + } + }); + + it("keeps /api/health plain with a valid token", async () => { + const b = await backend(); + const app = createApi({ store: b.store, admin: { deployToken: TOKEN, storage: b } }); + expect(await (await app.request("/api/health")).json()).toEqual({ ok: true }); }); }); diff --git a/packages/cli/src/commands/deploy/workers-builds.ts b/packages/cli/src/commands/deploy/workers-builds.ts index 60dc7be2..1da13bfd 100644 --- a/packages/cli/src/commands/deploy/workers-builds.ts +++ b/packages/cli/src/commands/deploy/workers-builds.ts @@ -142,5 +142,7 @@ export function setupInstructions(adminUrl: string, adminWorker: string): string " edit CLOUDHAL_SETUP_TOKEN and set a random value of your own (32 characters or more),", ` or run \`npx wrangler secret put CLOUDHAL_SETUP_TOKEN --name ${adminWorker}\``, ` 2. open ${login}`, + "Do not edit CLOUDHAL_DEPLOY_TOKEN: every build replaces it, and a changed value switches off", + "the deploy endpoints (/api/admin). The owner account only needs CLOUDHAL_SETUP_TOKEN.", ].join("\n"); } diff --git a/scripts/size-baseline.json b/scripts/size-baseline.json index 56427416..b6922d1a 100644 --- a/scripts/size-baseline.json +++ b/scripts/size-baseline.json @@ -304,7 +304,7 @@ "params": 5 }, "packages/api/src/app.ts::createApi": { - "lines": 224 + "lines": 217 }, "packages/api/src/build-turn.ts::runBuildTurn": { "lines": 260