feat(builder): add /deploy-html endpoint for ephemeral static site deploys#3208
feat(builder): add /deploy-html endpoint for ephemeral static site deploys#3208kilo-code-bot[bot] wants to merge 5 commits into
Conversation
…ploys Accepts raw HTML (text/html) or multiple files (multipart/form-data), authenticates with Kilo user JWT, deploys synchronously via Cloudflare Assets API without spawning a sandbox, and returns a live URL. Pages auto-delete after a configurable TTL (default 24h, max 7d) via an hourly cron that scans a KV namespace and removes expired workers. Validation rejects server-side file extensions (.php, .py, .rb, etc.) and requires an index.html at the root.
|
|
||
| async function cleanupExpiredDeployments(env: Env): Promise<void> { | ||
| const now = Date.now(); | ||
| const list = await env.HTML_DEPLOYMENTS_KV.list({ prefix: KV_KEY_PREFIX }); |
There was a problem hiding this comment.
WARNING: KV list() is not paginated — deployments beyond the first 1000 keys will never be cleaned up
Cloudflare KV's list() returns at most 1000 keys per call and sets list.list_complete = false when there are more. The cleanup loop needs to iterate until list_complete is true, using cursor to fetch subsequent pages:
let cursor: string | undefined;
do {
const list = await env.HTML_DEPLOYMENTS_KV.list({ prefix: KV_KEY_PREFIX, cursor });
for (const key of list.keys) {
// ... existing cleanup logic ...
}
cursor = list.list_complete ? undefined : list.cursor;
} while (cursor);Without this, once the deployment count exceeds 1000 the oldest entries are silently skipped and the Cloudflare workers they reference are never deleted.
| for (const [key, value] of formData.entries()) { | ||
| if (!(value instanceof File)) continue; | ||
|
|
||
| const path = key; |
There was a problem hiding this comment.
WARNING: Multipart form field keys are used directly as deploy paths with no sanitisation — path traversal sequences are accepted
key (the HTML form field name) is assigned directly to path with no validation. A caller can submit a field named ../../evil or __proto__ and it will be passed straight through to validateStaticAssets and ultimately into the deployer. At minimum, paths should be checked for .. components and leading / before use:
if (key.includes('..') || key.startsWith('/')) {
throw new Error(`Invalid file path: "${key}"`);
}| } | ||
|
|
||
| const record: HtmlDeployRecord = { slug, expiresAt }; | ||
| await c.env.HTML_DEPLOYMENTS_KV.put(`${KV_KEY_PREFIX}${slug}`, JSON.stringify(record)); |
There was a problem hiding this comment.
WARNING: KV write after a successful deploy is not error-handled — a failure here orphans the deployed worker permanently
If HTML_DEPLOYMENTS_KV.put() throws (e.g. quota exceeded, transient error), the Cloudflare worker has already been deployed but no cleanup record is created. The cron will never delete it because it has no KV entry. The error should be caught, logged/reported to Sentry, and ideally the newly-deployed worker should be torn down before returning a 500.
| "kv_namespaces": [ | ||
| { | ||
| "binding": "HTML_DEPLOYMENTS_KV", | ||
| "id": "TODO: create with `wrangler kv namespace create HTML_DEPLOYMENTS_KV` and fill in", |
There was a problem hiding this comment.
CRITICAL: Placeholder string "TODO: create with ..." is not a valid KV namespace ID
Deploying with this config will fail at the Wrangler validation step (wrangler deploy). The KV namespace must be created and its real UUID filled in before this can be deployed to production.
| "id": "TODO: create with `wrangler kv namespace create HTML_DEPLOYMENTS_KV` and fill in", | |
| "id": "REPLACE_ME_WITH_REAL_KV_NAMESPACE_ID", |
Code Review SummaryStatus: 1 Issue Found | Recommendation: Address before merge Overview
✅ Fixed in latest commits
Issue Details (click to expand)CRITICAL
Other Observations (not in diff)
Files Reviewed (7 files)
Fix these issues in Kilo Cloud Reviewed by claude-4.6-sonnet-20260217 · 1,918,136 tokens |
…ploys Accepts raw HTML (text/html) or multiple files (multipart/form-data), authenticates with Kilo user JWT, deploys synchronously via Cloudflare Assets API without spawning a sandbox, and returns a live URL. Pages auto-delete after a configurable TTL (default 24h, max 7d) via an hourly cron that scans a KV namespace and removes expired workers. Validation rejects server-side file extensions (.php, .py, .rb, etc.), path traversal (..) and hidden files (.env, .htaccess, etc.), and requires an index.html at the root.
If the KV put that records the deployment for auto-cleanup fails, the deployed worker would be orphaned (never cleaned up by cron). Now we catch the KV error, attempt to delete the worker, and return a 500 error to the client.
Review responseCRITICAL: KV namespace TODO placeholder — This requires running WARNING: KV write failure — Fixed in 2d9874e. If Already fixed in earlier pushes:
|
Replace the KV-based tracking for ephemeral HTML deployments with a Durable Object registry (`HtmlDeployRegistry`). This change enables more robust, alarm-driven cleanup of deployments and improves state management. - Add `drizzle-orm` and `drizzle-kit` for database schema management - Implement `HtmlDeployRegistry` Durable Object - Add `html-deploy` handler and registry logic - Migrate `HTML_DEPLOYMENTS_KV` binding to `HtmlDeployRegistry` DO - Update wrangler configuration with new DO and migrations
Decrease the maximum allowed total bytes for HTML deployments from 50 MB to 10 MB to prevent excessive resource consumption during ephemeral static site deployments.
| constructor(ctx: DurableObjectState, env: Env) { | ||
| super(ctx, env); | ||
| this.db = drizzle(ctx.storage, { logger: false }); | ||
| void ctx.blockConcurrencyWhile(() => migrate(this.db, migrations)); |
There was a problem hiding this comment.
CRITICAL: void ctx.blockConcurrencyWhile(...) discards the Promise — migrations may not complete before the first RPC arrives
Using void here means the blockConcurrencyWhile call is fire-and-forget. Cloudflare's DO documentation requires await so that the runtime holds off all incoming requests until the callback resolves. Without await, a concurrent register() or alarm() call could execute against an uninitialized (pre-migration) database and either fail or silently corrupt state.
| void ctx.blockConcurrencyWhile(() => migrate(this.db, migrations)); | |
| await ctx.blockConcurrencyWhile(() => migrate(this.db, migrations)); |
Summary
POST /deploy-htmltokilo-deploy-builderthat accepts raw HTML (text/html) or multiple files (multipart/form-data) and deploys them as a static site synchronously via the Cloudflare Assets API — no sandbox, no Durable Object.verifyKiloBearerAgainstCurrentPepper(validates token + pepper rotation against DB).X-Expires-Inheader). An hourly cron deletes expired workers from the dispatch namespace.index.htmlexists at the root and rejects server-side file extensions (.php,.py,.rb,.sh,.asp,.aspx,.jsp,.cfm, etc.).css/styles.css)..., leading/), hidden/dot files (.env,.htaccess), and failed Worker deletes during cleanup are retried on next cron run (KV entry preserved).Verification
curl -X POST .../deploy-html -H "Authorization: Bearer $TOKEN" -F "index.html=@index.html"and confirmed the site went live athttps://ksd-xxxxx.d.kiloapps.ioVisual Changes
N/A
Reviewer Notes
/deploy-htmlroute is registered before thebackendAuthMiddleware-protected routes so it can use its own Kilo JWT auth instead of the sharedBACKEND_AUTH_TOKENwrangler kv namespace create HTML_DEPLOYMENTS_KV— the ID is a TODO placeholder inwrangler.jsonccrons: ["0 * * * *"]) paginates through all KV keys with cursor-based pagination to handle >1000 entries