Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/short-deploy-token.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions docs/guides/deploy-button.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
15 changes: 4 additions & 11 deletions packages/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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));

Expand Down Expand Up @@ -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),
Expand Down
40 changes: 40 additions & 0 deletions packages/api/src/routes/admin-mount.ts
Original file line number Diff line number Diff line change
@@ -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<Env>, 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"];
}
40 changes: 36 additions & 4 deletions packages/api/test/admin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 });
});
});

Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commands/deploy/workers-builds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}<that value>`,
"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");
}
2 changes: 1 addition & 1 deletion scripts/size-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading