From a4298b69564913d7a214f2b986afee24ff5cb63c Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 15:50:32 +0000 Subject: [PATCH 1/5] feat(builder): add /deploy-html endpoint for ephemeral static site deploys 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. --- services/deploy-infra/builder/src/index.ts | 231 ++++++++++++++++++- services/deploy-infra/builder/src/types.ts | 19 ++ services/deploy-infra/builder/wrangler.jsonc | 25 ++ 3 files changed, 273 insertions(+), 2 deletions(-) diff --git a/services/deploy-infra/builder/src/index.ts b/services/deploy-infra/builder/src/index.ts index 440902b5dc..2d453c2b7d 100644 --- a/services/deploy-infra/builder/src/index.ts +++ b/services/deploy-infra/builder/src/index.ts @@ -1,13 +1,26 @@ import { Hono, type Context } from 'hono'; -import type { Env, DeployRequest, DeployResponse, StatusResponse } from './types'; +import type { + Env, + DeployRequest, + DeployResponse, + StatusResponse, + HtmlDeployResponse, + HtmlDeployRecord, + DeploymentArtifacts, + DeploymentFile, +} from './types'; import { backendAuthMiddleware, createErrorHandler, createNotFoundHandler, } from '@kilocode/worker-utils'; +import { verifyKiloBearerAgainstCurrentPepper } from '@kilocode/worker-utils/kilo-token-auth'; import { CloudflareAPI } from './cloudflare-api'; +import { Deployer } from './deployer'; +import { getMimeType } from './utils'; import { validateWorkerName } from './utils'; import * as Sentry from '@sentry/cloudflare'; +import staticWorkerContent from './assets/static.worker.js'; // Import base Durable Objects import { DeploymentOrchestrator as DeploymentOrchestratorBase } from './deployment-orchestrator'; @@ -43,6 +56,177 @@ function createDurableObjectBuilderID() { type HonoEnv = { Bindings: Env }; const app = new Hono(); +const MAX_TOTAL_BYTES = 50 * 1024 * 1024; // 50 MB across all files +const DEFAULT_HOSTNAME_BASE = 'd.kiloapps.io'; +const DEFAULT_TTL_SECONDS = 24 * 60 * 60; // 24 hours +const MAX_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days +const KV_KEY_PREFIX = 'html-deploy:'; + +const FORBIDDEN_EXTENSIONS = new Set([ + 'php', + 'py', + 'rb', + 'pl', + 'sh', + 'bash', + 'cgi', + 'asp', + 'aspx', + 'jsp', + 'cfm', +]); + +function generateHtmlSlug(): string { + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + const random = crypto.getRandomValues(new Uint8Array(12)); + const suffix = Array.from(random) + .map(b => chars[b % chars.length]) + .join(''); + return `ksd-${suffix}`; +} + +function parseTtlHeader(header: string | null): number { + if (!header) return DEFAULT_TTL_SECONDS; + const seconds = parseInt(header, 10); + if (isNaN(seconds) || seconds <= 0) return DEFAULT_TTL_SECONDS; + return Math.min(seconds, MAX_TTL_SECONDS); +} + +async function parseMultipartFiles(c: Context): Promise { + const formData = await c.req.formData(); + const files: DeploymentFile[] = []; + let totalBytes = 0; + + for (const [key, value] of formData.entries()) { + if (!(value instanceof File)) continue; + + const path = key; + const buffer = Buffer.from(await value.arrayBuffer()); + + totalBytes += buffer.byteLength; + if (totalBytes > MAX_TOTAL_BYTES) { + throw new Error(`Total size exceeds the ${MAX_TOTAL_BYTES / (1024 * 1024)} MB limit`); + } + + files.push({ + path, + content: buffer, + mimeType: getMimeType(path), + }); + } + + return files; +} + +function validateStaticAssets(assets: DeploymentFile[]): string | null { + const hasIndexHtml = assets.some(a => a.path === 'index.html' || a.path.endsWith('/index.html')); + if (!hasIndexHtml) { + return 'index.html is required'; + } + + for (const file of assets) { + const ext = file.path.split('.').pop()?.toLowerCase(); + if (ext && FORBIDDEN_EXTENSIONS.has(ext)) { + return `File "${file.path}" has a disallowed extension (.${ext}) — only static files are allowed`; + } + } + + return null; +} + +// ── /deploy-html — Kilo JWT auth, no sandbox, synchronous ───────────────── + +app.post('/deploy-html', async (c: Context) => { + const authHeader = c.req.header('Authorization'); + const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null; + + const authResult = await verifyKiloBearerAgainstCurrentPepper({ + token, + nextAuthSecret: c.env.NEXTAUTH_SECRET, + workerEnv: c.env.WORKER_ENV, + connectionString: c.env.HYPERDRIVE.connectionString, + }); + + if (!authResult) { + return c.json({ error: 'Invalid or expired token' }, 401); + } + + const contentType = c.req.header('Content-Type') ?? ''; + let assets: DeploymentFile[]; + + if (contentType.includes('multipart/form-data')) { + try { + assets = await parseMultipartFiles(c); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Failed to parse multipart body'; + return c.json({ error: msg }, 400); + } + } else { + const html = await c.req.text(); + if (!html || html.length === 0) { + return c.json({ error: 'Empty body' }, 400); + } + if (html.length > MAX_TOTAL_BYTES) { + return c.json({ error: `Body exceeds the ${MAX_TOTAL_BYTES / (1024 * 1024)} MB limit` }, 400); + } + assets = [{ path: 'index.html', content: Buffer.from(html, 'utf-8'), mimeType: 'text/html' }]; + } + + if (assets.length === 0) { + return c.json({ error: 'No files provided' }, 400); + } + + const validationError = validateStaticAssets(assets); + if (validationError) { + return c.json({ error: validationError }, 400); + } + + const slug = generateHtmlSlug(); + const hostnameBase = c.env.DEPLOY_HOSTNAME_BASE || DEFAULT_HOSTNAME_BASE; + const ttlSeconds = parseTtlHeader(c.req.header('X-Expires-In') ?? null); + const expiresAt = Date.now() + ttlSeconds * 1000; + + const artifacts: DeploymentArtifacts = { + workerScript: { + path: 'index.js', + content: Buffer.from(staticWorkerContent, 'utf-8'), + mimeType: 'application/javascript+module', + }, + artifacts: [], + assets, + }; + + const cloudflareApi = new CloudflareAPI(c.env.CLOUDFLARE_ACCOUNT_ID, c.env.CLOUDFLARE_API_TOKEN); + const deployer = new Deployer(cloudflareApi); + + try { + await deployer.deploy({ + artifacts, + workerName: slug, + logger: (msg: string) => console.log(`[deploy-html ${slug}] ${msg}`), + }); + } catch (error) { + Sentry.captureException(error, { + extra: { slug, path: '/deploy-html', method: 'POST' }, + }); + const msg = error instanceof Error ? error.message : 'Unknown deployment error'; + return c.json({ error: `Deployment failed: ${msg}` }, 500); + } + + const record: HtmlDeployRecord = { slug, expiresAt }; + await c.env.HTML_DEPLOYMENTS_KV.put(`${KV_KEY_PREFIX}${slug}`, JSON.stringify(record)); + + const response: HtmlDeployResponse = { + slug, + url: `https://${slug}.${hostnameBase}`, + expires_at: new Date(expiresAt).toISOString(), + }; + + return c.json(response, 200); +}); + +// ── Backend-authenticated routes ─────────────────────────────────────────── + // Authentication middleware app.use( '*', @@ -293,7 +477,43 @@ app.onError((err, c) => { // 404 handler app.notFound(createNotFoundHandler()); -export default Sentry.withSentry((env: Env) => { +// ── Scheduled handler: auto-delete expired HTML deployments ──────────────── + +async function cleanupExpiredDeployments(env: Env): Promise { + const now = Date.now(); + const list = await env.HTML_DEPLOYMENTS_KV.list({ prefix: KV_KEY_PREFIX }); + const dispatchNamespace = 'kilo-deploy'; + const cloudflareApi = new CloudflareAPI(env.CLOUDFLARE_ACCOUNT_ID, env.CLOUDFLARE_API_TOKEN); + + for (const key of list.keys) { + const raw = await env.HTML_DEPLOYMENTS_KV.get(key.name); + if (!raw) continue; + + let record: HtmlDeployRecord; + try { + record = JSON.parse(raw) as HtmlDeployRecord; + } catch { + await env.HTML_DEPLOYMENTS_KV.delete(key.name); + continue; + } + + if (record.expiresAt <= now) { + try { + await cloudflareApi.deleteWorker(record.slug, dispatchNamespace); + console.log(`[cleanup] Deleted expired HTML deployment: ${record.slug}`); + } catch (error) { + Sentry.captureException(error, { + extra: { slug: record.slug, action: 'html-deploy-cleanup' }, + }); + const errMsg = error instanceof Error ? error.message : String(error); + console.error(`[cleanup] Failed to delete ${record.slug}: ${errMsg}`); + } + await env.HTML_DEPLOYMENTS_KV.delete(key.name); + } + } +} + +const fetchHandler = Sentry.withSentry((env: Env) => { const { id: versionId } = env.CF_VERSION_METADATA; return { @@ -303,3 +523,10 @@ export default Sentry.withSentry((env: Env) => { environment: env.ENVIRONMENT || 'production', }; }, app); + +export default { + fetch: fetchHandler, + async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise { + ctx.waitUntil(cleanupExpiredDeployments(env)); + }, +}; diff --git a/services/deploy-infra/builder/src/types.ts b/services/deploy-infra/builder/src/types.ts index 2b912ddb21..c2edc73c67 100644 --- a/services/deploy-infra/builder/src/types.ts +++ b/services/deploy-infra/builder/src/types.ts @@ -189,6 +189,14 @@ export type Env = { Sandbox: DurableObjectNamespace; DeploymentOrchestrator: DurableObjectNamespace; EventsManager: DurableObjectNamespace; + + NEXTAUTH_SECRET: SecretsStoreSecret; + HYPERDRIVE: Hyperdrive; + WORKER_ENV: string; + DEPLOY_HOSTNAME_BASE: string; + + /** KV namespace tracking ephemeral HTML deployments for auto-cleanup */ + HTML_DEPLOYMENTS_KV: KVNamespace; }; /** @@ -235,3 +243,14 @@ export type CloudflareApiResponse = { result?: T; errors?: Array<{ code: number; message: string }>; }; + +export type HtmlDeployResponse = { + slug: string; + url: string; + expires_at: string; +}; + +export type HtmlDeployRecord = { + slug: string; + expiresAt: number; +}; diff --git a/services/deploy-infra/builder/wrangler.jsonc b/services/deploy-infra/builder/wrangler.jsonc index 4110b4669b..2959887730 100644 --- a/services/deploy-infra/builder/wrangler.jsonc +++ b/services/deploy-infra/builder/wrangler.jsonc @@ -34,6 +34,31 @@ "BACKEND_EVENTS_URL": "https://app.kilo.ai/api/user-deployments/webhook", "SENTRY_DSN": "https://1f07992a2811f3e5fc4d578527ab3d64@o4509356317474816.ingest.us.sentry.io/4510379756945409", "ENVIRONMENT": "production", + "WORKER_ENV": "production", + "DEPLOY_HOSTNAME_BASE": "d.kiloapps.io", + }, + "secrets_store_secrets": [ + { + "binding": "NEXTAUTH_SECRET", + "store_id": "342a86d9e3a94da698e82d0c6e2a36f0", + "secret_name": "NEXTAUTH_SECRET_PROD", + }, + ], + "hyperdrive": [ + { + "binding": "HYPERDRIVE", + "id": "624ec80650dd414199349f4e217ddb10", + "localConnectionString": "postgres://postgres:postgres@localhost:5432/postgres", + }, + ], + "kv_namespaces": [ + { + "binding": "HTML_DEPLOYMENTS_KV", + "id": "TODO: create with `wrangler kv namespace create HTML_DEPLOYMENTS_KV` and fill in", + }, + ], + "triggers": { + "crons": ["0 * * * *"], }, "containers": [ { From 71effc9cffe40edf061d186b784960c350c7ff47 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 16:01:46 +0000 Subject: [PATCH 2/5] feat(builder): add /deploy-html endpoint for ephemeral static site deploys 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. --- services/deploy-infra/builder/src/index.ts | 71 ++++++++++++++-------- 1 file changed, 46 insertions(+), 25 deletions(-) diff --git a/services/deploy-infra/builder/src/index.ts b/services/deploy-infra/builder/src/index.ts index 2d453c2b7d..8223fcb41d 100644 --- a/services/deploy-infra/builder/src/index.ts +++ b/services/deploy-infra/builder/src/index.ts @@ -100,7 +100,14 @@ async function parseMultipartFiles(c: Context): Promise segment.startsWith('.'))) { + throw new Error(`Hidden files are not allowed: "${key}"`); + } + const buffer = Buffer.from(await value.arrayBuffer()); totalBytes += buffer.byteLength; @@ -109,9 +116,9 @@ async function parseMultipartFiles(c: Context): Promise { const now = Date.now(); - const list = await env.HTML_DEPLOYMENTS_KV.list({ prefix: KV_KEY_PREFIX }); const dispatchNamespace = 'kilo-deploy'; const cloudflareApi = new CloudflareAPI(env.CLOUDFLARE_ACCOUNT_ID, env.CLOUDFLARE_API_TOKEN); - for (const key of list.keys) { - const raw = await env.HTML_DEPLOYMENTS_KV.get(key.name); - if (!raw) continue; + let cursor: string | undefined; + do { + const list = await env.HTML_DEPLOYMENTS_KV.list({ prefix: KV_KEY_PREFIX, cursor }); - let record: HtmlDeployRecord; - try { - record = JSON.parse(raw) as HtmlDeployRecord; - } catch { - await env.HTML_DEPLOYMENTS_KV.delete(key.name); - continue; - } + for (const key of list.keys) { + const raw = await env.HTML_DEPLOYMENTS_KV.get(key.name); + if (!raw) continue; - if (record.expiresAt <= now) { + let slug: string; + let expiresAt: number; try { - await cloudflareApi.deleteWorker(record.slug, dispatchNamespace); - console.log(`[cleanup] Deleted expired HTML deployment: ${record.slug}`); - } catch (error) { - Sentry.captureException(error, { - extra: { slug: record.slug, action: 'html-deploy-cleanup' }, - }); - const errMsg = error instanceof Error ? error.message : String(error); - console.error(`[cleanup] Failed to delete ${record.slug}: ${errMsg}`); + // eslint-disable-next-line typescript-eslint/no-unsafe-assignment + const parsed: Record = JSON.parse(raw); + if (typeof parsed.slug !== 'string' || typeof parsed.expiresAt !== 'number') { + await env.HTML_DEPLOYMENTS_KV.delete(key.name); + continue; + } + slug = parsed.slug; + expiresAt = parsed.expiresAt; + } catch { + await env.HTML_DEPLOYMENTS_KV.delete(key.name); + continue; + } + + if (expiresAt <= now) { + try { + await cloudflareApi.deleteWorker(slug, dispatchNamespace); + console.log(`[cleanup] Deleted expired HTML deployment: ${slug}`); + await env.HTML_DEPLOYMENTS_KV.delete(key.name); + } catch (error) { + Sentry.captureException(error, { + extra: { slug, action: 'html-deploy-cleanup' }, + }); + const errMsg = error instanceof Error ? error.message : String(error); + console.error(`[cleanup] Failed to delete ${slug}: ${errMsg}`); + } } - await env.HTML_DEPLOYMENTS_KV.delete(key.name); } - } + + cursor = list.list_complete ? undefined : list.cursor; + } while (cursor); } const fetchHandler = Sentry.withSentry((env: Env) => { From 2d9874e4a2590128b651b90fe1f17bc7b550322e Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 16:08:26 +0000 Subject: [PATCH 3/5] fix(builder): tear down worker if KV write fails after deploy 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. --- services/deploy-infra/builder/src/index.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/services/deploy-infra/builder/src/index.ts b/services/deploy-infra/builder/src/index.ts index 8223fcb41d..a3202ba744 100644 --- a/services/deploy-infra/builder/src/index.ts +++ b/services/deploy-infra/builder/src/index.ts @@ -221,7 +221,21 @@ app.post('/deploy-html', async (c: Context) => { } const record: HtmlDeployRecord = { slug, expiresAt }; - await c.env.HTML_DEPLOYMENTS_KV.put(`${KV_KEY_PREFIX}${slug}`, JSON.stringify(record)); + try { + await c.env.HTML_DEPLOYMENTS_KV.put(`${KV_KEY_PREFIX}${slug}`, JSON.stringify(record)); + } catch (kvError) { + Sentry.captureException(kvError, { + extra: { slug, action: 'html-deploy-kv-write' }, + }); + try { + await cloudflareApi.deleteWorker(slug, 'kilo-deploy'); + } catch (rollbackError) { + Sentry.captureException(rollbackError, { + extra: { slug, action: 'html-deploy-rollback' }, + }); + } + return c.json({ error: 'Failed to record deployment — site rolled back' }, 500); + } const response: HtmlDeployResponse = { slug, From 518b5aa2098d892d9d7a7b8a47be9d1c33d461e0 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 18:27:11 +0000 Subject: [PATCH 4/5] feat(builder): implement durable object registry for html deployments 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 --- pnpm-lock.yaml | 304 ++++++++++++++++-- .../deploy-infra/builder/drizzle.config.ts | 8 + .../builder/drizzle/0000_html_deployments.sql | 6 + .../builder/drizzle/meta/0000_snapshot.json | 48 +++ .../builder/drizzle/meta/_journal.json | 13 + .../builder/drizzle/migrations.ts | 28 ++ services/deploy-infra/builder/package.json | 2 + .../builder/src/db/sqlite-schema.ts | 10 + .../builder/src/html-deploy/handler.ts | 122 +++++++ .../builder/src/html-deploy/registry.ts | 97 ++++++ .../builder/src/html-deploy/slug.ts | 1 + .../builder/src/html-deploy/validator.ts | 122 +++++++ services/deploy-infra/builder/src/index.ts | 280 ++-------------- services/deploy-infra/builder/src/types.ts | 8 +- services/deploy-infra/builder/tsconfig.json | 2 +- services/deploy-infra/builder/wrangler.jsonc | 16 +- 16 files changed, 778 insertions(+), 289 deletions(-) create mode 100644 services/deploy-infra/builder/drizzle.config.ts create mode 100644 services/deploy-infra/builder/drizzle/0000_html_deployments.sql create mode 100644 services/deploy-infra/builder/drizzle/meta/0000_snapshot.json create mode 100644 services/deploy-infra/builder/drizzle/meta/_journal.json create mode 100644 services/deploy-infra/builder/drizzle/migrations.ts create mode 100644 services/deploy-infra/builder/src/db/sqlite-schema.ts create mode 100644 services/deploy-infra/builder/src/html-deploy/handler.ts create mode 100644 services/deploy-infra/builder/src/html-deploy/registry.ts create mode 100644 services/deploy-infra/builder/src/html-deploy/slug.ts create mode 100644 services/deploy-infra/builder/src/html-deploy/validator.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a27c7da5c5..c0374a4230 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1246,7 +1246,7 @@ importers: version: 0.31.9 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@25.5.0) + version: 29.7.0 typescript: specifier: 'catalog:' version: 5.9.3 @@ -1615,6 +1615,9 @@ importers: '@sentry/cloudflare': specifier: ^10.43.0 version: 10.43.0(@cloudflare/workers-types@4.20260430.1) + drizzle-orm: + specifier: ^0.45.2 + version: 0.45.2(@cloudflare/workers-types@4.20260430.1)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(bun-types@1.3.13)(pg@8.20.0) hono: specifier: ^4.12.18 version: 4.12.18 @@ -1646,6 +1649,9 @@ importers: '@typescript/native-preview': specifier: 'catalog:' version: 7.0.0-dev.20260319.1 + drizzle-kit: + specifier: 'catalog:' + version: 0.31.9 jest: specifier: ^29.7.0 version: 29.7.0(@types/node@24.12.0) @@ -1691,7 +1697,7 @@ importers: version: 7.0.0-dev.20260319.1 jest: specifier: ^30.3.0 - version: 30.3.0(@types/node@24.12.0)(esbuild-register@3.6.0(esbuild@0.27.4)) + version: 30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)) typescript: specifier: 'catalog:' version: 5.9.3 @@ -8328,6 +8334,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -17030,7 +17037,7 @@ snapshots: '@storybook/csf': 0.1.13 '@storybook/manager-api': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) '@storybook/server-webpack5': 8.5.8(@swc/core@1.15.18)(esbuild@0.27.4)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))(typescript@5.9.3) - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) ts-dedent: 2.2.0 transitivePeerDependencies: - '@rspack/core' @@ -17054,9 +17061,9 @@ snapshots: '@chromaui/rrweb-snapshot': 2.0.0-alpha.18-noAbsolute '@playwright/test': 1.58.2 '@segment/analytics-node': 2.1.3 - '@storybook/addon-essentials': 8.5.8(@types/react@19.2.14)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + '@storybook/addon-essentials': 8.5.8(@types/react@19.2.14)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) '@storybook/csf': 0.1.13 - '@storybook/manager-api': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + '@storybook/manager-api': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) '@storybook/server-webpack5': 8.5.8(esbuild@0.27.4)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))(typescript@5.9.3) storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) ts-dedent: 2.2.0 @@ -21985,7 +21992,7 @@ snapshots: '@stitches/core@1.2.8': {} - '@storybook/addon-actions@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': + '@storybook/addon-actions@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': dependencies: '@storybook/global': 5.0.0 '@types/uuid': 9.0.8 @@ -21993,20 +22000,60 @@ snapshots: polished: 4.3.1 storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) uuid: 9.0.1 + optional: true + + '@storybook/addon-actions@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': + dependencies: + '@storybook/global': 5.0.0 + '@types/uuid': 9.0.8 + dequal: 2.0.3 + polished: 4.3.1 + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) + uuid: 9.0.1 + + '@storybook/addon-backgrounds@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': + dependencies: + '@storybook/global': 5.0.0 + memoizerific: 1.11.3 + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) + ts-dedent: 2.2.0 + optional: true '@storybook/addon-backgrounds@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: '@storybook/global': 5.0.0 memoizerific: 1.11.3 + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) + ts-dedent: 2.2.0 + + '@storybook/addon-controls@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': + dependencies: + '@storybook/global': 5.0.0 + dequal: 2.0.3 storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) ts-dedent: 2.2.0 + optional: true '@storybook/addon-controls@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: '@storybook/global': 5.0.0 dequal: 2.0.3 + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) + ts-dedent: 2.2.0 + + '@storybook/addon-docs@8.5.8(@types/react@19.2.14)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': + dependencies: + '@mdx-js/react': 3.1.1(@types/react@19.2.14)(react@19.2.4) + '@storybook/blocks': 8.5.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) + '@storybook/csf-plugin': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) + '@storybook/react-dom-shim': 8.5.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) ts-dedent: 2.2.0 + transitivePeerDependencies: + - '@types/react' + optional: true '@storybook/addon-docs@8.5.8(@types/react@19.2.14)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: @@ -22016,7 +22063,7 @@ snapshots: '@storybook/react-dom-shim': 8.5.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) ts-dedent: 2.2.0 transitivePeerDependencies: - '@types/react' @@ -22034,6 +22081,23 @@ snapshots: transitivePeerDependencies: - '@types/react' + '@storybook/addon-essentials@8.5.8(@types/react@19.2.14)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': + dependencies: + '@storybook/addon-actions': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) + '@storybook/addon-backgrounds': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) + '@storybook/addon-controls': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) + '@storybook/addon-docs': 8.5.8(@types/react@19.2.14)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) + '@storybook/addon-highlight': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) + '@storybook/addon-measure': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) + '@storybook/addon-outline': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) + '@storybook/addon-toolbars': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) + '@storybook/addon-viewport': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) + ts-dedent: 2.2.0 + transitivePeerDependencies: + - '@types/react' + optional: true + '@storybook/addon-essentials@8.5.8(@types/react@19.2.14)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: '@storybook/addon-actions': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) @@ -22045,15 +22109,21 @@ snapshots: '@storybook/addon-outline': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) '@storybook/addon-toolbars': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) '@storybook/addon-viewport': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) ts-dedent: 2.2.0 transitivePeerDependencies: - '@types/react' - '@storybook/addon-highlight@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': + '@storybook/addon-highlight@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': dependencies: '@storybook/global': 5.0.0 storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) + optional: true + + '@storybook/addon-highlight@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': + dependencies: + '@storybook/global': 5.0.0 + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@storybook/addon-links@9.1.20(react@19.2.4)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': dependencies: @@ -22062,37 +22132,73 @@ snapshots: optionalDependencies: react: 19.2.4 - '@storybook/addon-measure@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': + '@storybook/addon-measure@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': dependencies: '@storybook/global': 5.0.0 storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) tiny-invariant: 1.3.3 + optional: true - '@storybook/addon-outline@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': + '@storybook/addon-measure@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': + dependencies: + '@storybook/global': 5.0.0 + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) + tiny-invariant: 1.3.3 + + '@storybook/addon-outline@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': dependencies: '@storybook/global': 5.0.0 storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) ts-dedent: 2.2.0 + optional: true + + '@storybook/addon-outline@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': + dependencies: + '@storybook/global': 5.0.0 + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) + ts-dedent: 2.2.0 '@storybook/addon-themes@9.1.20(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': dependencies: storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) ts-dedent: 2.2.0 + '@storybook/addon-toolbars@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': + dependencies: + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) + optional: true + '@storybook/addon-toolbars@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) + + '@storybook/addon-viewport@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': + dependencies: + memoizerific: 1.11.3 storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) + optional: true '@storybook/addon-viewport@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: memoizerific: 1.11.3 + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) + + '@storybook/blocks@8.5.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': + dependencies: + '@storybook/csf': 0.1.12 + '@storybook/icons': 1.6.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) + ts-dedent: 2.2.0 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optional: true '@storybook/blocks@8.5.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: '@storybook/csf': 0.1.12 '@storybook/icons': 1.6.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) ts-dedent: 2.2.0 optionalDependencies: react: 19.2.4 @@ -22114,7 +22220,7 @@ snapshots: path-browserify: 1.0.1 process: 0.11.10 semver: 7.7.4 - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) style-loader: 3.3.4(webpack@5.105.4(@swc/core@1.15.18)(esbuild@0.27.4)) terser-webpack-plugin: 5.4.0(@swc/core@1.15.18)(esbuild@0.27.4)(webpack@5.105.4(@swc/core@1.15.18)(esbuild@0.27.4)) ts-dedent: 2.2.0 @@ -22136,7 +22242,7 @@ snapshots: '@storybook/builder-webpack5@8.5.8(esbuild@0.27.4)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))(typescript@5.9.3)': dependencies: - '@storybook/core-webpack': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + '@storybook/core-webpack': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) '@types/semver': 7.7.1 browser-assert: 1.2.1 case-sensitive-paths-webpack-plugin: 2.4.0 @@ -22198,13 +22304,24 @@ snapshots: - uglify-js - webpack-cli + '@storybook/components@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': + dependencies: + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) + optional: true + '@storybook/components@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': + dependencies: + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) + + '@storybook/core-webpack@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': dependencies: storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) + ts-dedent: 2.2.0 + optional: true '@storybook/core-webpack@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) ts-dedent: 2.2.0 '@storybook/core-webpack@9.1.20(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': @@ -22212,10 +22329,16 @@ snapshots: storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) ts-dedent: 2.2.0 - '@storybook/csf-plugin@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': + '@storybook/csf-plugin@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': dependencies: storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) unplugin: 1.16.1 + optional: true + + '@storybook/csf-plugin@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': + dependencies: + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) + unplugin: 1.16.1 '@storybook/csf-plugin@9.1.20(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': dependencies: @@ -22237,9 +22360,14 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@storybook/manager-api@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': + '@storybook/manager-api@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': dependencies: storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) + optional: true + + '@storybook/manager-api@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': + dependencies: + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@storybook/nextjs@9.1.20(patch_hash=e1857649664eed8f87877c352d277c90d4af5a58d0ad931105f033c8c08165c1)(esbuild@0.27.4)(next@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))(type-fest@4.41.0)(typescript@5.9.3)(webpack-hot-middleware@2.26.1)(webpack@5.105.4(esbuild@0.27.4))': dependencies: @@ -22325,19 +22453,35 @@ snapshots: - uglify-js - webpack-cli + '@storybook/preset-server-webpack@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': + dependencies: + '@storybook/core-webpack': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) + '@storybook/global': 5.0.0 + '@storybook/server': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) + safe-identifier: 0.4.2 + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) + ts-dedent: 2.2.0 + yaml-loader: 0.8.1 + optional: true + '@storybook/preset-server-webpack@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: '@storybook/core-webpack': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) '@storybook/global': 5.0.0 '@storybook/server': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) safe-identifier: 0.4.2 - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) ts-dedent: 2.2.0 yaml-loader: 0.8.1 - '@storybook/preview-api@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': + '@storybook/preview-api@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': dependencies: storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) + optional: true + + '@storybook/preview-api@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': + dependencies: + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@storybook/react-docgen-typescript-plugin@1.0.6--canary.9.0c3f3b7.0(typescript@5.9.3)(webpack@5.105.4(esbuild@0.27.4))': dependencies: @@ -22353,11 +22497,18 @@ snapshots: transitivePeerDependencies: - supports-color - '@storybook/react-dom-shim@8.5.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': + '@storybook/react-dom-shim@8.5.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': dependencies: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) + optional: true + + '@storybook/react-dom-shim@8.5.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@storybook/react-dom-shim@9.1.20(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': dependencies: @@ -22380,7 +22531,7 @@ snapshots: '@storybook/builder-webpack5': 8.5.8(@swc/core@1.15.18)(esbuild@0.27.4)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))(typescript@5.9.3) '@storybook/preset-server-webpack': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) '@storybook/server': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@rspack/core' - '@swc/core' @@ -22392,8 +22543,8 @@ snapshots: '@storybook/server-webpack5@8.5.8(esbuild@0.27.4)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))(typescript@5.9.3)': dependencies: '@storybook/builder-webpack5': 8.5.8(esbuild@0.27.4)(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))(typescript@5.9.3) - '@storybook/preset-server-webpack': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) - '@storybook/server': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + '@storybook/preset-server-webpack': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) + '@storybook/server': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) transitivePeerDependencies: - '@rspack/core' @@ -22404,6 +22555,19 @@ snapshots: - webpack-cli optional: true + '@storybook/server@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': + dependencies: + '@storybook/components': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) + '@storybook/csf': 0.1.12 + '@storybook/global': 5.0.0 + '@storybook/manager-api': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) + '@storybook/preview-api': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) + '@storybook/theming': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4))) + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) + ts-dedent: 2.2.0 + yaml: 2.8.4 + optional: true + '@storybook/server@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': dependencies: '@storybook/components': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) @@ -22412,7 +22576,7 @@ snapshots: '@storybook/manager-api': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) '@storybook/preview-api': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) '@storybook/theming': 8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)) - storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) ts-dedent: 2.2.0 yaml: 2.8.4 @@ -22446,9 +22610,14 @@ snapshots: - supports-color - ts-node - '@storybook/theming@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': + '@storybook/theming@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)))': dependencies: storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) + optional: true + + '@storybook/theming@8.5.8(storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6))': + dependencies: + storybook: 9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@streamparser/json@0.0.22': {} @@ -26595,6 +26764,25 @@ snapshots: - babel-plugin-macros - supports-color + jest-cli@29.7.0: + dependencies: + '@jest/core': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@24.12.0) + exit: 0.1.2 + import-local: 3.2.0 + jest-config: 29.7.0(@types/node@24.12.0) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + jest-cli@29.7.0(@types/node@24.12.0): dependencies: '@jest/core': 29.7.0 @@ -26652,6 +26840,25 @@ snapshots: - supports-color - ts-node + jest-cli@30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)): + dependencies: + '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)) + '@jest/test-result': 30.3.0 + '@jest/types': 30.3.0 + chalk: 4.1.2 + exit-x: 0.2.2 + import-local: 3.2.0 + jest-config: 30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)) + jest-util: 30.3.0 + jest-validate: 30.3.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + jest-config@29.7.0(@types/node@24.12.0): dependencies: '@babel/core': 7.29.0 @@ -27266,6 +27473,18 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 + jest@29.7.0: + dependencies: + '@jest/core': 29.7.0 + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + jest@29.7.0(@types/node@24.12.0): dependencies: '@jest/core': 29.7.0 @@ -27303,6 +27522,19 @@ snapshots: - supports-color - ts-node + jest@30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)): + dependencies: + '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.27.4)) + '@jest/types': 30.3.0 + import-local: 3.2.0 + jest-cli: 30.3.0(@types/node@25.5.0)(esbuild-register@3.6.0(esbuild@0.27.4)) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + jimp-compact@0.16.1: {} jiti@2.6.1: {} @@ -30537,6 +30769,28 @@ snapshots: stoppable@1.1.0: {} + storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6): + dependencies: + '@storybook/global': 5.0.0 + '@testing-library/jest-dom': 6.9.1 + '@testing-library/user-event': 14.6.1 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@8.0.10(@types/node@24.12.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)) + '@vitest/spy': 3.2.4 + better-opn: 3.0.2 + esbuild: 0.27.4 + esbuild-register: 3.6.0(esbuild@0.27.4) + recast: 0.23.11 + semver: 7.7.4 + ws: 8.19.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - '@testing-library/dom' + - bufferutil + - msw + - supports-color + - utf-8-validate + - vite + storybook@9.1.20(bufferutil@4.1.0)(utf-8-validate@6.0.6)(vite@8.0.10(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.4)): dependencies: '@storybook/global': 5.0.0 diff --git a/services/deploy-infra/builder/drizzle.config.ts b/services/deploy-infra/builder/drizzle.config.ts new file mode 100644 index 0000000000..27cf9ffb72 --- /dev/null +++ b/services/deploy-infra/builder/drizzle.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + out: './drizzle', + schema: './src/db/sqlite-schema.ts', + dialect: 'sqlite', + driver: 'durable-sqlite', +}); diff --git a/services/deploy-infra/builder/drizzle/0000_html_deployments.sql b/services/deploy-infra/builder/drizzle/0000_html_deployments.sql new file mode 100644 index 0000000000..e8e366a630 --- /dev/null +++ b/services/deploy-infra/builder/drizzle/0000_html_deployments.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS `html_deployments` ( + `slug` text PRIMARY KEY NOT NULL, + `expires_at` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `idx_html_dep_expires_at` ON `html_deployments` (`expires_at`); diff --git a/services/deploy-infra/builder/drizzle/meta/0000_snapshot.json b/services/deploy-infra/builder/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000000..71ef41cae3 --- /dev/null +++ b/services/deploy-infra/builder/drizzle/meta/0000_snapshot.json @@ -0,0 +1,48 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "3f2a1b4c-8e7d-4c9a-b5f0-1d2e3c4a5b6f", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "html_deployments": { + "name": "html_deployments", + "columns": { + "slug": { + "name": "slug", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_html_dep_expires_at": { + "name": "idx_html_dep_expires_at", + "columns": ["expires_at"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/services/deploy-infra/builder/drizzle/meta/_journal.json b/services/deploy-infra/builder/drizzle/meta/_journal.json new file mode 100644 index 0000000000..cbf8aa7a59 --- /dev/null +++ b/services/deploy-infra/builder/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1747076668000, + "tag": "0000_html_deployments", + "breakpoints": true + } + ] +} diff --git a/services/deploy-infra/builder/drizzle/migrations.ts b/services/deploy-infra/builder/drizzle/migrations.ts new file mode 100644 index 0000000000..6c1e08d098 --- /dev/null +++ b/services/deploy-infra/builder/drizzle/migrations.ts @@ -0,0 +1,28 @@ +// Auto-generated migration barrel — do not hand-edit. +// Regenerate with: pnpm drizzle-kit generate (from the builder directory) + +const m0000 = ` +CREATE TABLE IF NOT EXISTS \`html_deployments\` ( +\t\`slug\` text PRIMARY KEY NOT NULL, +\t\`expires_at\` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS \`idx_html_dep_expires_at\` ON \`html_deployments\` (\`expires_at\`); +`; + +export default { + journal: { + version: '7', + dialect: 'sqlite', + entries: [ + { + idx: 0, + version: '6', + when: 1747076668000, + tag: '0000_html_deployments', + breakpoints: true, + }, + ], + }, + migrations: { m0000 }, +}; diff --git a/services/deploy-infra/builder/package.json b/services/deploy-infra/builder/package.json index eae7383336..374193c1ed 100644 --- a/services/deploy-infra/builder/package.json +++ b/services/deploy-infra/builder/package.json @@ -19,6 +19,7 @@ "dependencies": { "@kilocode/encryption": "workspace:*", "@kilocode/worker-utils": "workspace:*", + "drizzle-orm": "catalog:", "zod": "catalog:", "@sentry/cloudflare": "^10.43.0", "hono": "catalog:", @@ -32,6 +33,7 @@ "@types/node": ">=24 <25", "@types/tar-stream": "^3.1.4", "@typescript/native-preview": "catalog:", + "drizzle-kit": "catalog:", "jest": "^29.7.0", "ts-jest": "^29.4.6", "tsx": "^4.21.0", diff --git a/services/deploy-infra/builder/src/db/sqlite-schema.ts b/services/deploy-infra/builder/src/db/sqlite-schema.ts new file mode 100644 index 0000000000..e168600097 --- /dev/null +++ b/services/deploy-infra/builder/src/db/sqlite-schema.ts @@ -0,0 +1,10 @@ +import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core'; + +export const htmlDeployments = sqliteTable( + 'html_deployments', + { + slug: text('slug').primaryKey(), + expiresAt: integer('expires_at').notNull(), + }, + table => [index('idx_html_dep_expires_at').on(table.expiresAt)] +); diff --git a/services/deploy-infra/builder/src/html-deploy/handler.ts b/services/deploy-infra/builder/src/html-deploy/handler.ts new file mode 100644 index 0000000000..696380075b --- /dev/null +++ b/services/deploy-infra/builder/src/html-deploy/handler.ts @@ -0,0 +1,122 @@ +import type { Context } from 'hono'; +import type { HonoEnv, HtmlDeployResponse, DeploymentArtifacts } from '../types'; +import { verifyKiloBearerAgainstCurrentPepper } from '@kilocode/worker-utils/kilo-token-auth'; +import { CloudflareAPI } from '../cloudflare-api'; +import { Deployer } from '../deployer'; +import { validateStaticAssets, parseMultipartFiles, parseTtlHeader } from './validator'; +import { generateDeploymentSlug } from './slug'; +import * as Sentry from '@sentry/cloudflare'; +import staticWorkerContent from '../assets/static.worker.js'; + +const MAX_TOTAL_BYTES = 50 * 1024 * 1024; // 50 MB +const DEFAULT_HOSTNAME_BASE = 'd.kiloapps.io'; +const DEFAULT_TTL_SECONDS = 24 * 60 * 60; // 24 hours +const MAX_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days + +export async function htmlDeployHandler(c: Context): Promise { + const authHeader = c.req.header('Authorization'); + const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null; + + const authResult = await verifyKiloBearerAgainstCurrentPepper({ + token, + nextAuthSecret: c.env.NEXTAUTH_SECRET, + workerEnv: c.env.WORKER_ENV, + connectionString: c.env.HYPERDRIVE.connectionString, + }); + + if (!authResult) { + return c.json({ error: 'Invalid or expired token' }, 401); + } + + const contentType = c.req.header('Content-Type') ?? ''; + let assets; + + if (contentType.includes('multipart/form-data')) { + try { + assets = await parseMultipartFiles(c); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Failed to parse multipart body'; + return c.json({ error: msg }, 400); + } + } else { + const html = await c.req.text(); + if (!html || html.length === 0) { + return c.json({ error: 'Empty body' }, 400); + } + if (html.length > MAX_TOTAL_BYTES) { + return c.json({ error: `Body exceeds the ${MAX_TOTAL_BYTES / (1024 * 1024)} MB limit` }, 400); + } + assets = [{ path: 'index.html', content: Buffer.from(html, 'utf-8'), mimeType: 'text/html' }]; + } + + if (assets.length === 0) { + return c.json({ error: 'No files provided' }, 400); + } + + const validationError = validateStaticAssets(assets); + if (validationError) { + return c.json({ error: validationError }, 400); + } + + const slug = generateDeploymentSlug(null); + const hostnameBase = c.env.DEPLOY_HOSTNAME_BASE || DEFAULT_HOSTNAME_BASE; + const ttlSeconds = parseTtlHeader(c.req.header('X-Expires-In') ?? null, { + defaultTtl: DEFAULT_TTL_SECONDS, + maxTtl: MAX_TTL_SECONDS, + }); + const expiresAt = Date.now() + ttlSeconds * 1000; + + const artifacts: DeploymentArtifacts = { + workerScript: { + path: 'index.js', + content: Buffer.from(staticWorkerContent, 'utf-8'), + mimeType: 'application/javascript+module', + }, + artifacts: [], + assets, + }; + + const cloudflareApi = new CloudflareAPI(c.env.CLOUDFLARE_ACCOUNT_ID, c.env.CLOUDFLARE_API_TOKEN); + const deployer = new Deployer(cloudflareApi); + + try { + await deployer.deploy({ + artifacts, + workerName: slug, + logger: (msg: string) => console.log(`[deploy-html ${slug}] ${msg}`), + }); + } catch (error) { + Sentry.captureException(error, { + extra: { slug, path: '/deploy-html', method: 'POST' }, + }); + const msg = error instanceof Error ? error.message : 'Unknown deployment error'; + return c.json({ error: `Deployment failed: ${msg}` }, 500); + } + + const registryId = c.env.HtmlDeployRegistry.idFromName('singleton'); + const registry = c.env.HtmlDeployRegistry.get(registryId); + + try { + await registry.register(slug, expiresAt); + } catch (registryError) { + Sentry.captureException(registryError, { + extra: { slug, action: 'html-deploy-registry-write' }, + }); + try { + await cloudflareApi.deleteWorker(slug, 'kilo-deploy'); + } catch (rollbackError) { + Sentry.captureException(rollbackError, { + extra: { slug, action: 'html-deploy-rollback' }, + }); + } + return c.json({ error: 'Failed to record deployment — site rolled back' }, 500); + } + + const response: HtmlDeployResponse = { + slug, + url: `https://${slug}.${hostnameBase}`, + expires_at: new Date(expiresAt).toISOString(), + }; + + return c.json(response, 200); +} diff --git a/services/deploy-infra/builder/src/html-deploy/registry.ts b/services/deploy-infra/builder/src/html-deploy/registry.ts new file mode 100644 index 0000000000..0cb036ea9a --- /dev/null +++ b/services/deploy-infra/builder/src/html-deploy/registry.ts @@ -0,0 +1,97 @@ +/** + * HtmlDeployRegistry — singleton Durable Object that tracks ephemeral HTML deployments. + * + * Replaces the KV-based tracking approach with a SQLite-backed registry that: + * - Stores deployment records with their expiry timestamp + * - Uses DO alarms to trigger cleanup exactly when the next deployment expires + * - Exposes an RPC method so the hourly cron can trigger cleanup as a safety net + */ + +import { DurableObject } from 'cloudflare:workers'; +import { drizzle } from 'drizzle-orm/durable-sqlite'; +import type { DrizzleSqliteDODatabase } from 'drizzle-orm/durable-sqlite'; +import { migrate } from 'drizzle-orm/durable-sqlite/migrator'; +import { eq, lte, asc } from 'drizzle-orm'; +import migrations from '../../drizzle/migrations'; +import { htmlDeployments } from '../db/sqlite-schema'; +import type { Env } from '../types'; +import { CloudflareAPI } from '../cloudflare-api'; +import * as Sentry from '@sentry/cloudflare'; + +const DISPATCH_NAMESPACE = 'kilo-deploy'; + +export class HtmlDeployRegistry extends DurableObject { + private db: DrizzleSqliteDODatabase; + + constructor(ctx: DurableObjectState, env: Env) { + super(ctx, env); + this.db = drizzle(ctx.storage, { logger: false }); + void ctx.blockConcurrencyWhile(() => migrate(this.db, migrations)); + } + + /** Record a new deployment and arm (or advance) the cleanup alarm. */ + async register(slug: string, expiresAt: number): Promise { + await this.db.insert(htmlDeployments).values({ slug, expiresAt }); + + const currentAlarm = await this.ctx.storage.getAlarm(); + if (currentAlarm === null || expiresAt < currentAlarm) { + await this.ctx.storage.setAlarm(expiresAt); + } + } + + /** Remove a single deployment record (e.g. on manual delete). */ + async deleteDeployment(slug: string): Promise { + await this.db.delete(htmlDeployments).where(eq(htmlDeployments.slug, slug)); + } + + /** + * Delete all expired deployments. + * Called by the alarm and also exposed as an RPC for the hourly cron safety net. + */ + async triggerCleanup(): Promise { + await this.cleanupExpiredAt(Date.now()); + } + + async alarm(): Promise { + await this.cleanupExpiredAt(Date.now()); + + // Re-arm for the next soonest expiry, if any remain. + const [next] = await this.db + .select({ expiresAt: htmlDeployments.expiresAt }) + .from(htmlDeployments) + .orderBy(asc(htmlDeployments.expiresAt)) + .limit(1); + + if (next) { + await this.ctx.storage.setAlarm(next.expiresAt); + } + } + + private async cleanupExpiredAt(now: number): Promise { + const expired = await this.db + .select({ slug: htmlDeployments.slug }) + .from(htmlDeployments) + .where(lte(htmlDeployments.expiresAt, now)); + + if (expired.length === 0) return; + + const cloudflareApi = new CloudflareAPI( + this.env.CLOUDFLARE_ACCOUNT_ID, + this.env.CLOUDFLARE_API_TOKEN + ); + + for (const { slug } of expired) { + try { + await cloudflareApi.deleteWorker(slug, DISPATCH_NAMESPACE); + console.log(`[html-deploy-registry] Deleted expired deployment: ${slug}`); + } catch (error) { + Sentry.captureException(error, { extra: { slug, action: 'html-deploy-cleanup' } }); + console.error( + `[html-deploy-registry] Failed to delete worker ${slug}: ${error instanceof Error ? error.message : String(error)}` + ); + } + // Always remove from registry, even if CF delete fails, to avoid retrying broken workers forever. + await this.db.delete(htmlDeployments).where(eq(htmlDeployments.slug, slug)); + } + } +} diff --git a/services/deploy-infra/builder/src/html-deploy/slug.ts b/services/deploy-infra/builder/src/html-deploy/slug.ts new file mode 100644 index 0000000000..90b7c4a6a3 --- /dev/null +++ b/services/deploy-infra/builder/src/html-deploy/slug.ts @@ -0,0 +1 @@ +export { generateDeploymentSlug } from '../../../../../apps/web/src/lib/user-deployments/slug-generator'; diff --git a/services/deploy-infra/builder/src/html-deploy/validator.ts b/services/deploy-infra/builder/src/html-deploy/validator.ts new file mode 100644 index 0000000000..fccf755609 --- /dev/null +++ b/services/deploy-infra/builder/src/html-deploy/validator.ts @@ -0,0 +1,122 @@ +import type { Context } from 'hono'; +import type { HonoEnv } from '../types'; +import type { DeploymentFile } from '../types'; +import { getMimeType } from '../utils'; + +const MAX_TOTAL_BYTES = 50 * 1024 * 1024; // 50 MB across all files + +// Allowlist of permitted static file extensions. +// Any file whose extension is not in this set is rejected. +const ALLOWED_EXTENSIONS = new Set([ + // markup + 'html', + 'htm', + 'xhtml', + // styles + 'css', + // scripts (compiled/bundled — no server-side execution) + 'js', + 'mjs', + 'cjs', + // data / config + 'json', + 'xml', + 'yaml', + 'yml', + 'toml', + 'csv', + 'txt', + 'md', + 'markdown', + // images + 'jpg', + 'jpeg', + 'png', + 'gif', + 'svg', + 'webp', + 'avif', + 'ico', + 'bmp', + 'tiff', + 'tif', + // fonts + 'woff', + 'woff2', + 'ttf', + 'otf', + 'eot', + // video / audio + 'mp4', + 'webm', + 'mp3', + 'wav', + 'ogg', + 'oga', + 'ogv', + // wasm + 'wasm', + // source maps + 'map', + // other + 'pdf', +]); + +export function validateStaticAssets(assets: DeploymentFile[]): string | null { + const hasIndexHtml = assets.some(a => a.path === 'index.html' || a.path.endsWith('/index.html')); + if (!hasIndexHtml) { + return 'index.html is required'; + } + + for (const file of assets) { + const ext = file.path.split('.').pop()?.toLowerCase(); + if (ext && !ALLOWED_EXTENSIONS.has(ext)) { + return `File "${file.path}" has a disallowed extension (.${ext}) — only static files are allowed`; + } + } + + return null; +} + +export async function parseMultipartFiles(c: Context): Promise { + const formData = await c.req.formData(); + const files: DeploymentFile[] = []; + let totalBytes = 0; + + for (const [key, value] of formData.entries()) { + if (!(value instanceof File)) continue; + + const normalizedPath = key.replace(/\\/g, '/'); + if (normalizedPath.includes('..') || normalizedPath.startsWith('/')) { + throw new Error(`Invalid file path: ${key}`); + } + if (normalizedPath.split('/').some(segment => segment.startsWith('.'))) { + throw new Error(`Hidden files are not allowed: "${key}"`); + } + + const buffer = Buffer.from(await value.arrayBuffer()); + + totalBytes += buffer.byteLength; + if (totalBytes > MAX_TOTAL_BYTES) { + throw new Error(`Total size exceeds the ${MAX_TOTAL_BYTES / (1024 * 1024)} MB limit`); + } + + files.push({ + path: normalizedPath, + content: buffer, + mimeType: getMimeType(normalizedPath), + }); + } + + return files; +} + +export function parseTtlHeader( + header: string | null, + defaults: { defaultTtl: number; maxTtl: number } +): number { + if (!header) return defaults.defaultTtl; + const seconds = parseInt(header, 10); + if (isNaN(seconds) || seconds <= 0) return defaults.defaultTtl; + return Math.min(seconds, defaults.maxTtl); +} diff --git a/services/deploy-infra/builder/src/index.ts b/services/deploy-infra/builder/src/index.ts index a3202ba744..f8bc2f8320 100644 --- a/services/deploy-infra/builder/src/index.ts +++ b/services/deploy-infra/builder/src/index.ts @@ -1,30 +1,19 @@ import { Hono, type Context } from 'hono'; -import type { - Env, - DeployRequest, - DeployResponse, - StatusResponse, - HtmlDeployResponse, - HtmlDeployRecord, - DeploymentArtifacts, - DeploymentFile, -} from './types'; +import type { Env, HonoEnv, DeployRequest, DeployResponse, StatusResponse } from './types'; import { backendAuthMiddleware, createErrorHandler, createNotFoundHandler, } from '@kilocode/worker-utils'; -import { verifyKiloBearerAgainstCurrentPepper } from '@kilocode/worker-utils/kilo-token-auth'; import { CloudflareAPI } from './cloudflare-api'; -import { Deployer } from './deployer'; -import { getMimeType } from './utils'; import { validateWorkerName } from './utils'; import * as Sentry from '@sentry/cloudflare'; -import staticWorkerContent from './assets/static.worker.js'; // Import base Durable Objects import { DeploymentOrchestrator as DeploymentOrchestratorBase } from './deployment-orchestrator'; import { EventsManager as EventsManagerBase } from './events-manager'; +import { HtmlDeployRegistry as HtmlDeployRegistryBase } from './html-deploy/registry'; +import { htmlDeployHandler } from './html-deploy/handler'; export { Sandbox } from '@cloudflare/sandbox'; // Export Sentry-instrumented Durable Objects @@ -48,203 +37,26 @@ export const EventsManager = Sentry.instrumentDurableObjectWithSentry( EventsManagerBase ); +export const HtmlDeployRegistry = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + release: env.CF_VERSION_METADATA.id, + sendDefaultPii: true, + environment: env.ENVIRONMENT || 'production', + }), + HtmlDeployRegistryBase +); + function createDurableObjectBuilderID() { return crypto.randomUUID(); } // Create Hono app with Env type -type HonoEnv = { Bindings: Env }; const app = new Hono(); -const MAX_TOTAL_BYTES = 50 * 1024 * 1024; // 50 MB across all files -const DEFAULT_HOSTNAME_BASE = 'd.kiloapps.io'; -const DEFAULT_TTL_SECONDS = 24 * 60 * 60; // 24 hours -const MAX_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days -const KV_KEY_PREFIX = 'html-deploy:'; - -const FORBIDDEN_EXTENSIONS = new Set([ - 'php', - 'py', - 'rb', - 'pl', - 'sh', - 'bash', - 'cgi', - 'asp', - 'aspx', - 'jsp', - 'cfm', -]); - -function generateHtmlSlug(): string { - const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; - const random = crypto.getRandomValues(new Uint8Array(12)); - const suffix = Array.from(random) - .map(b => chars[b % chars.length]) - .join(''); - return `ksd-${suffix}`; -} - -function parseTtlHeader(header: string | null): number { - if (!header) return DEFAULT_TTL_SECONDS; - const seconds = parseInt(header, 10); - if (isNaN(seconds) || seconds <= 0) return DEFAULT_TTL_SECONDS; - return Math.min(seconds, MAX_TTL_SECONDS); -} - -async function parseMultipartFiles(c: Context): Promise { - const formData = await c.req.formData(); - const files: DeploymentFile[] = []; - let totalBytes = 0; - - for (const [key, value] of formData.entries()) { - if (!(value instanceof File)) continue; - - const normalizedPath = key.replace(/\\/g, '/'); - if (normalizedPath.includes('..') || normalizedPath.startsWith('/')) { - throw new Error(`Invalid file path: ${key}`); - } - if (normalizedPath.split('/').some(segment => segment.startsWith('.'))) { - throw new Error(`Hidden files are not allowed: "${key}"`); - } - - const buffer = Buffer.from(await value.arrayBuffer()); - - totalBytes += buffer.byteLength; - if (totalBytes > MAX_TOTAL_BYTES) { - throw new Error(`Total size exceeds the ${MAX_TOTAL_BYTES / (1024 * 1024)} MB limit`); - } - - files.push({ - path: normalizedPath, - content: buffer, - mimeType: getMimeType(normalizedPath), - }); - } - - return files; -} - -function validateStaticAssets(assets: DeploymentFile[]): string | null { - const hasIndexHtml = assets.some(a => a.path === 'index.html' || a.path.endsWith('/index.html')); - if (!hasIndexHtml) { - return 'index.html is required'; - } - - for (const file of assets) { - const ext = file.path.split('.').pop()?.toLowerCase(); - if (ext && FORBIDDEN_EXTENSIONS.has(ext)) { - return `File "${file.path}" has a disallowed extension (.${ext}) — only static files are allowed`; - } - } - - return null; -} - // ── /deploy-html — Kilo JWT auth, no sandbox, synchronous ───────────────── -app.post('/deploy-html', async (c: Context) => { - const authHeader = c.req.header('Authorization'); - const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null; - - const authResult = await verifyKiloBearerAgainstCurrentPepper({ - token, - nextAuthSecret: c.env.NEXTAUTH_SECRET, - workerEnv: c.env.WORKER_ENV, - connectionString: c.env.HYPERDRIVE.connectionString, - }); - - if (!authResult) { - return c.json({ error: 'Invalid or expired token' }, 401); - } - - const contentType = c.req.header('Content-Type') ?? ''; - let assets: DeploymentFile[]; - - if (contentType.includes('multipart/form-data')) { - try { - assets = await parseMultipartFiles(c); - } catch (err) { - const msg = err instanceof Error ? err.message : 'Failed to parse multipart body'; - return c.json({ error: msg }, 400); - } - } else { - const html = await c.req.text(); - if (!html || html.length === 0) { - return c.json({ error: 'Empty body' }, 400); - } - if (html.length > MAX_TOTAL_BYTES) { - return c.json({ error: `Body exceeds the ${MAX_TOTAL_BYTES / (1024 * 1024)} MB limit` }, 400); - } - assets = [{ path: 'index.html', content: Buffer.from(html, 'utf-8'), mimeType: 'text/html' }]; - } - - if (assets.length === 0) { - return c.json({ error: 'No files provided' }, 400); - } - - const validationError = validateStaticAssets(assets); - if (validationError) { - return c.json({ error: validationError }, 400); - } - - const slug = generateHtmlSlug(); - const hostnameBase = c.env.DEPLOY_HOSTNAME_BASE || DEFAULT_HOSTNAME_BASE; - const ttlSeconds = parseTtlHeader(c.req.header('X-Expires-In') ?? null); - const expiresAt = Date.now() + ttlSeconds * 1000; - - const artifacts: DeploymentArtifacts = { - workerScript: { - path: 'index.js', - content: Buffer.from(staticWorkerContent, 'utf-8'), - mimeType: 'application/javascript+module', - }, - artifacts: [], - assets, - }; - - const cloudflareApi = new CloudflareAPI(c.env.CLOUDFLARE_ACCOUNT_ID, c.env.CLOUDFLARE_API_TOKEN); - const deployer = new Deployer(cloudflareApi); - - try { - await deployer.deploy({ - artifacts, - workerName: slug, - logger: (msg: string) => console.log(`[deploy-html ${slug}] ${msg}`), - }); - } catch (error) { - Sentry.captureException(error, { - extra: { slug, path: '/deploy-html', method: 'POST' }, - }); - const msg = error instanceof Error ? error.message : 'Unknown deployment error'; - return c.json({ error: `Deployment failed: ${msg}` }, 500); - } - - const record: HtmlDeployRecord = { slug, expiresAt }; - try { - await c.env.HTML_DEPLOYMENTS_KV.put(`${KV_KEY_PREFIX}${slug}`, JSON.stringify(record)); - } catch (kvError) { - Sentry.captureException(kvError, { - extra: { slug, action: 'html-deploy-kv-write' }, - }); - try { - await cloudflareApi.deleteWorker(slug, 'kilo-deploy'); - } catch (rollbackError) { - Sentry.captureException(rollbackError, { - extra: { slug, action: 'html-deploy-rollback' }, - }); - } - return c.json({ error: 'Failed to record deployment — site rolled back' }, 500); - } - - const response: HtmlDeployResponse = { - slug, - url: `https://${slug}.${hostnameBase}`, - expires_at: new Date(expiresAt).toISOString(), - }; - - return c.json(response, 200); -}); +app.post('/deploy-html', htmlDeployHandler); // ── Backend-authenticated routes ─────────────────────────────────────────── @@ -498,56 +310,6 @@ app.onError((err, c) => { // 404 handler app.notFound(createNotFoundHandler()); -// ── Scheduled handler: auto-delete expired HTML deployments ──────────────── - -async function cleanupExpiredDeployments(env: Env): Promise { - const now = Date.now(); - const dispatchNamespace = 'kilo-deploy'; - const cloudflareApi = new CloudflareAPI(env.CLOUDFLARE_ACCOUNT_ID, env.CLOUDFLARE_API_TOKEN); - - let cursor: string | undefined; - do { - const list = await env.HTML_DEPLOYMENTS_KV.list({ prefix: KV_KEY_PREFIX, cursor }); - - for (const key of list.keys) { - const raw = await env.HTML_DEPLOYMENTS_KV.get(key.name); - if (!raw) continue; - - let slug: string; - let expiresAt: number; - try { - // eslint-disable-next-line typescript-eslint/no-unsafe-assignment - const parsed: Record = JSON.parse(raw); - if (typeof parsed.slug !== 'string' || typeof parsed.expiresAt !== 'number') { - await env.HTML_DEPLOYMENTS_KV.delete(key.name); - continue; - } - slug = parsed.slug; - expiresAt = parsed.expiresAt; - } catch { - await env.HTML_DEPLOYMENTS_KV.delete(key.name); - continue; - } - - if (expiresAt <= now) { - try { - await cloudflareApi.deleteWorker(slug, dispatchNamespace); - console.log(`[cleanup] Deleted expired HTML deployment: ${slug}`); - await env.HTML_DEPLOYMENTS_KV.delete(key.name); - } catch (error) { - Sentry.captureException(error, { - extra: { slug, action: 'html-deploy-cleanup' }, - }); - const errMsg = error instanceof Error ? error.message : String(error); - console.error(`[cleanup] Failed to delete ${slug}: ${errMsg}`); - } - } - } - - cursor = list.list_complete ? undefined : list.cursor; - } while (cursor); -} - const fetchHandler = Sentry.withSentry((env: Env) => { const { id: versionId } = env.CF_VERSION_METADATA; @@ -561,7 +323,17 @@ const fetchHandler = Sentry.withSentry((env: Env) => { export default { fetch: fetchHandler, - async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise { - ctx.waitUntil(cleanupExpiredDeployments(env)); + + // ── Scheduled handler: hourly safety-net cleanup ──────────────────────── + // The HtmlDeployRegistry DO handles cleanup via alarms at exact expiry times. + // This cron is a fallback in case an alarm is missed. + async scheduled( + _controller: ScheduledController, + env: Env, + ctx: ExecutionContext + ): Promise { + const id = env.HtmlDeployRegistry.idFromName('singleton'); + const registry = env.HtmlDeployRegistry.get(id); + ctx.waitUntil(registry.triggerCleanup()); }, }; diff --git a/services/deploy-infra/builder/src/types.ts b/services/deploy-infra/builder/src/types.ts index c2edc73c67..db51b86bd9 100644 --- a/services/deploy-infra/builder/src/types.ts +++ b/services/deploy-infra/builder/src/types.ts @@ -6,6 +6,7 @@ import { z } from 'zod'; import type { Sandbox } from '@cloudflare/sandbox'; import type { DeploymentOrchestrator } from './deployment-orchestrator'; import type { EventsManager } from './events-manager'; +import type { HtmlDeployRegistry } from './html-deploy/registry'; // Import and re-export shared types from backend import type { @@ -195,10 +196,13 @@ export type Env = { WORKER_ENV: string; DEPLOY_HOSTNAME_BASE: string; - /** KV namespace tracking ephemeral HTML deployments for auto-cleanup */ - HTML_DEPLOYMENTS_KV: KVNamespace; + /** Singleton DO registry tracking ephemeral HTML deployments for alarm-driven cleanup */ + HtmlDeployRegistry: DurableObjectNamespace; }; +/** Hono app environment with Worker bindings. */ +export type HonoEnv = { Bindings: Env }; + /** * Request body for POST /deploy */ diff --git a/services/deploy-infra/builder/tsconfig.json b/services/deploy-infra/builder/tsconfig.json index e69eb4fc2e..6725871805 100644 --- a/services/deploy-infra/builder/tsconfig.json +++ b/services/deploy-infra/builder/tsconfig.json @@ -11,5 +11,5 @@ "skipLibCheck": true, "noEmit": true }, - "include": ["src/**/*.ts", "scripts/**/*.ts"] + "include": ["src/**/*.ts", "scripts/**/*.ts", "drizzle/**/*.ts"] } diff --git a/services/deploy-infra/builder/wrangler.jsonc b/services/deploy-infra/builder/wrangler.jsonc index 2959887730..04c3872814 100644 --- a/services/deploy-infra/builder/wrangler.jsonc +++ b/services/deploy-infra/builder/wrangler.jsonc @@ -18,7 +18,7 @@ "rules": [ { "type": "Text", - "globs": ["**/*.worker.js"], + "globs": ["**/*.worker.js", "**/*.sql"], "fallthrough": true, }, ], @@ -51,12 +51,6 @@ "localConnectionString": "postgres://postgres:postgres@localhost:5432/postgres", }, ], - "kv_namespaces": [ - { - "binding": "HTML_DEPLOYMENTS_KV", - "id": "TODO: create with `wrangler kv namespace create HTML_DEPLOYMENTS_KV` and fill in", - }, - ], "triggers": { "crons": ["0 * * * *"], }, @@ -82,6 +76,10 @@ "class_name": "Sandbox", "name": "Sandbox", }, + { + "name": "HtmlDeployRegistry", + "class_name": "HtmlDeployRegistry", + }, ], }, "migrations": [ @@ -89,5 +87,9 @@ "tag": "v1", "new_sqlite_classes": ["Sandbox", "DeploymentOrchestrator", "EventsManager"], }, + { + "tag": "v2", + "new_sqlite_classes": ["HtmlDeployRegistry"], + }, ], } From a39719da0e9b1f7820ca1cc8aedda87957f3cb08 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 18:38:25 +0000 Subject: [PATCH 5/5] refactor(builder): reduce max html deployment size limit 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. --- services/deploy-infra/builder/src/html-deploy/handler.ts | 2 +- services/deploy-infra/builder/src/html-deploy/validator.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/services/deploy-infra/builder/src/html-deploy/handler.ts b/services/deploy-infra/builder/src/html-deploy/handler.ts index 696380075b..6faa25bdb0 100644 --- a/services/deploy-infra/builder/src/html-deploy/handler.ts +++ b/services/deploy-infra/builder/src/html-deploy/handler.ts @@ -8,7 +8,7 @@ import { generateDeploymentSlug } from './slug'; import * as Sentry from '@sentry/cloudflare'; import staticWorkerContent from '../assets/static.worker.js'; -const MAX_TOTAL_BYTES = 50 * 1024 * 1024; // 50 MB +const MAX_TOTAL_BYTES = 10 * 1024 * 1024; // 10 MB const DEFAULT_HOSTNAME_BASE = 'd.kiloapps.io'; const DEFAULT_TTL_SECONDS = 24 * 60 * 60; // 24 hours const MAX_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days diff --git a/services/deploy-infra/builder/src/html-deploy/validator.ts b/services/deploy-infra/builder/src/html-deploy/validator.ts index fccf755609..3a6b19844a 100644 --- a/services/deploy-infra/builder/src/html-deploy/validator.ts +++ b/services/deploy-infra/builder/src/html-deploy/validator.ts @@ -3,7 +3,7 @@ import type { HonoEnv } from '../types'; import type { DeploymentFile } from '../types'; import { getMimeType } from '../utils'; -const MAX_TOTAL_BYTES = 50 * 1024 * 1024; // 50 MB across all files +const MAX_TOTAL_BYTES = 10 * 1024 * 1024; // 10 MB across all files // Allowlist of permitted static file extensions. // Any file whose extension is not in this set is rejected.