import { defineCommand } from "citty" import { Hono } from "hono" import { serve } from "@hono/node-server" import { bodyLimit } from "hono/body-limit" import * as fs from "node:fs" import * as path from "node:path" import { randomBytes, timingSafeEqual } from "node:crypto" import { fork, spawn, type ChildProcess } from "node:child_process" import * as net from "node:net" import { openControlDb, DEFAULT_QUOTA, ANONYMOUS_QUOTA, type ControlDb, type UserRow } from "../host/control-db.js" import { cgroupLimitsFor, probeCapsuleCgroup, joinManagerCgroup, applyCapsuleCgroup, removeCapsuleCgroup, readCapsuleCgroupUsage, } from "../host/cgroup.js" import { cpuFractionOverInterval, scoreCpuAbuse, type CpuSample } from "../host/abuse.js" import { resolveFsIsolationMode, bwrapAvailable, buildBwrapArgv, type CapsuleFsIsolationMode, } from "../host/capsule-fs-sandbox.js" import { findPackageJsonLifecycleScripts } from "../host/package-json-validation.js" import { verifyTurnstile } from "../host/turnstile.js" import { selectIdleDeploys } from "../host/idle.js" import { createHash } from "node:crypto" import { buildForDeploy } from "../runtime.js" import { buildClient } from "../bundler.js" import { ideHtml } from "../ide/built.js" import { dashboardHtml } from "../dashboard/built.js" import { marked } from "marked" import Database from "better-sqlite3" // Curated docs catalog. Hand-written titles + summaries beat anything derivable // from the markdown's H1 — the index page becomes navigation copy, not a file // listing. Order = sidebar order. Slug = URL slug = filename minus `.md`. const DOCS_CATALOG: ReadonlyArray<{ slug: string; title: string; summary: string }> = [ { slug: "cli-reference", title: "CLI reference", summary: "Every pond subcommand: what it does, when to reach for it, the flags that matter.", }, { slug: "api-reference", title: "Server API", summary: "The pond/server surface — capsule, query, mutation, table, types, ctx.db / ctx.ai / ctx.blob.", }, { slug: "client-reference", title: "Client API", summary: "The pond/client surface — useQuery, useMutation, useAuth, plus the Preact runtime.", }, { slug: "mcp", title: "MCP server", summary: "Drive pond from Claude Code / Cursor / any MCP client. Tools for deploys, source, logs, env.", }, { slug: "operations", title: "Operations", summary: "Going from pond host on a laptop to a public service. The launch runbook.", }, ] as const const DOCS_SLUG_RE = /^[a-z0-9_-]+$/ function htmlEscape(s: string): string { return s.replace(/[&<>"']/g, (c) => { if (c === "&") return "&" if (c === "<") return "<" if (c === ">") return ">" if (c === '"') return """ return "'" }) } function slugifyHeading(text: string): string { return text .toLowerCase() .replace(/<[^>]+>/g, "") .replace(/[^\w\s-]/g, "") .trim() .replace(/\s+/g, "-") .slice(0, 80) } // Render markdown to HTML with stable heading IDs and a custom code-block // wrapper. We deliberately skip syntax highlighting — the design language // established in 0.3.0 ("don't fill everything with rainbow colors, prefer // wireframe over fill") implies mono blocks with a thin border, no theming. function renderMarkdown(md: string): string { const renderer = new marked.Renderer() const usedIds = new Map() renderer.heading = ({ tokens, depth }) => { const text = tokens.map((t) => ("text" in t ? (t.text as string) : "")).join("") const base = slugifyHeading(text) || `section-${depth}` const seen = usedIds.get(base) ?? 0 usedIds.set(base, seen + 1) const id = seen === 0 ? base : `${base}-${seen}` const inner = marked.parseInline(text) as string return `${inner}\n` } return marked.parse(md, { gfm: true, breaks: false, async: false, renderer, }) as string } const SOURCE_FILE_LIMIT = 200 const SOURCE_TOTAL_LIMIT = 4 * 1024 * 1024 const SOURCE_PATH_RE = /^(server|client|shared)\/[a-zA-Z0-9_./-]+$|^package\.json$/ function validateSourceFiles( input: unknown, ): { ok: true; files: Record } | { ok: false; error: string } { if (!input || typeof input !== "object") return { ok: false, error: "sourceFiles must be an object" } const entries = Object.entries(input as Record) if (entries.length === 0) return { ok: false, error: "sourceFiles is empty" } if (entries.length > SOURCE_FILE_LIMIT) return { ok: false, error: `Too many files (max ${SOURCE_FILE_LIMIT})` } let total = 0 const out: Record = {} let hasServerEntry = false for (const [rel, content] of entries) { if (typeof content !== "string") return { ok: false, error: `File ${rel} is not a string` } if (rel.includes("..") || rel.startsWith("/") || rel.includes("\\")) { return { ok: false, error: `Invalid path: ${rel}` } } if (!SOURCE_PATH_RE.test(rel)) { return { ok: false, error: `Path not allowed: ${rel}` } } total += Buffer.byteLength(content, "utf-8") if (total > SOURCE_TOTAL_LIMIT) return { ok: false, error: `Source exceeds ${SOURCE_TOTAL_LIMIT} bytes` } if (rel === "package.json") { // Refuse uploads that define npm lifecycle scripts. They'd run as RCE // on `npm install` for anyone who later forks the deploy. const lifecycle = findPackageJsonLifecycleScripts(content) if (!lifecycle.ok) { return { ok: false, error: `package.json defines npm lifecycle script(s) ${lifecycle.offending.join(", ")} which are not allowed on hosted deploys (pond builds via esbuild, not npm install)`, } } } out[rel] = content if (rel === "server/index.ts") hasServerEntry = true } if (!hasServerEntry) return { ok: false, error: "server/index.ts is required" } return { ok: true, files: out } } function writeSourceTree(deployDir: string, files: Record): void { const sourceDir = path.join(deployDir, "source") fs.rmSync(sourceDir, { recursive: true, force: true }) fs.mkdirSync(sourceDir, { recursive: true }) for (const [rel, content] of Object.entries(files)) { const abs = path.join(sourceDir, rel) fs.mkdirSync(path.dirname(abs), { recursive: true }) fs.writeFileSync(abs, content) } } async function buildDeployFromSource(deployDir: string): Promise<{ bundleBytes: number bundleHash: string meta: { isPublic: boolean; isStatic: boolean; title?: string; description?: string } }> { const sourceDir = path.join(deployDir, "source") const serverFile = path.join(sourceDir, "server", "index.ts") const clientFile = path.join(sourceDir, "client", "index.tsx") const outfile = path.join(deployDir, "deploy-bundle.mjs") await buildForDeploy(serverFile, sourceDir, outfile) let clientBuilt = false if (fs.existsSync(clientFile)) { const html = await buildClient(clientFile) fs.writeFileSync(path.join(deployDir, "client.html"), html) clientBuilt = true } else { const stale = path.join(deployDir, "client.html") if (fs.existsSync(stale)) fs.rmSync(stale) } const bundleBuf = fs.readFileSync(outfile) const bundleHash = createHash("sha256").update(bundleBuf).digest("hex") const meta = extractCapsuleMeta(serverFile) // A static deploy is served as its client.html with no worker, so a client // is mandatory — reject the meaningless "static, no client" combination at // build time rather than shipping a deploy that 404s on every request. if (meta.isStatic && !clientBuilt) { throw new Error("A static capsule (static: true) requires a client/index.tsx") } return { bundleBytes: bundleBuf.length, bundleHash, meta } } // Replace JS/TS string literals and comments with a same-length blank so // downstream regex scans don't false-positive on tokens that appear in // strings or comments — e.g. a doc comment that mentions `public: true` or // a regex literal that quotes another capsule's metadata. Lengths are // preserved so source-position semantics (line/column) don't drift if a // caller ever needs them. // // This is a deliberately small lexer — it handles single/double/backtick // strings (incl. template-string interpolations as opaque strings, which // is over-zealous but safe for the gallery-publication decision), `// line // comments`, and `/* block */` comments. Regex literals are NOT handled — // they're rare in capsule source and the cost of false-stripping a regex // is just a missed isPublic detection (failed-closed for the public flag). export function stripJsStringsAndComments(src: string): string { let out = "" const blank = (n: number) => " ".repeat(n) let i = 0 const n = src.length while (i < n) { const c = src[i] const next = src[i + 1] // // line comment if (c === "/" && next === "/") { const nl = src.indexOf("\n", i + 2) const end = nl === -1 ? n : nl out += blank(end - i) i = end continue } // /* block comment */ if (c === "/" && next === "*") { const close = src.indexOf("*/", i + 2) const end = close === -1 ? n : close + 2 // preserve newlines so line numbers don't shift for (let k = i; k < end; k++) out += src[k] === "\n" ? "\n" : " " i = end continue } // string literal (single, double, backtick) if (c === '"' || c === "'" || c === "`") { const quote = c out += " " i++ while (i < n) { const cc = src[i] if (cc === "\\" && i + 1 < n) { // preserve newlines for any newline introduced by an escape; the // simple `out += " "` keeps length the same out += " " i += 2 continue } if (cc === quote) { out += " " i++ break } out += cc === "\n" ? "\n" : " " i++ } continue } out += c i++ } return out } // Locate the object literal passed to the FIRST `capsule(` call and return its // [start, end) byte range (the span between the outer `{` and its matching `}`, // inclusive). Operates on string/comment-stripped source so the brace match // only sees real code braces. Because stripJsStringsAndComments preserves // character offsets 1:1, the same range can be sliced out of the RAW source. // Returns null when there's no `capsule({ … })` call. function capsuleArgRange(scanSrc: string): { start: number; end: number } | null { const call = scanSrc.match(/\bcapsule\s*\(/) if (!call || call.index === undefined) return null let i = call.index + call[0].length while (i < scanSrc.length && scanSrc[i] !== "{") { // Only whitespace may sit between `capsule(` and its object arg; anything // else means this isn't the `capsule({ … })` form we statically parse. if (!/\s/.test(scanSrc[i])) return null i++ } if (scanSrc[i] !== "{") return null const start = i let depth = 0 for (; i < scanSrc.length; i++) { const ch = scanSrc[i] if (ch === "{") depth++ else if (ch === "}") { depth-- if (depth === 0) return { start, end: i + 1 } } } return null } // Best-effort static parse of the capsule({ public, title, description }) call. // We deliberately don't import the bundle to read these — that would execute // arbitrary code on the host. The scan is confined to the `capsule({ … })` // argument object (see capsuleArgRange) so an unrelated `public: true` / // `title:` elsewhere in the file — a config object, a helper, a fixture — // can't unintentionally flip the deploy public and expose its source via // /gallery and /api/public-deploys/:id/source. Strings/comments are stripped // before the brace scan so a `}` inside a string can't end the range early. function extractCapsuleMeta(serverFile: string): { isPublic: boolean isStatic: boolean title?: string description?: string } { if (!fs.existsSync(serverFile)) return { isPublic: false, isStatic: false } const rawSrc = fs.readFileSync(serverFile, "utf-8") const scanSrc = stripJsStringsAndComments(rawSrc) const range = capsuleArgRange(scanSrc) if (!range) return { isPublic: false, isStatic: false } const scanArg = scanSrc.slice(range.start, range.end) const isPublic = /\bpublic\s*:\s*true\b/.test(scanArg) const isStatic = /\bstatic\s*:\s*true\b/.test(scanArg) // Title and description are matched on the ORIGINAL source (their values live // inside string literals, which the stripper blanks out) — but only within // the SAME capsule-arg range, so a stray `title:` outside the call is ignored. const rawArg = rawSrc.slice(range.start, range.end) const titleMatch = rawArg.match(/\btitle\s*:\s*(["'`])([^"'`]{1,200})\1/) const descMatch = rawArg.match(/\bdescription\s*:\s*(["'`])([^"'`]{1,500})\1/) return { isPublic, isStatic, title: titleMatch?.[2], description: descMatch?.[2], } } interface HostedDeployRecord { deployId: string // sha256 hex of the deploy's claim token. The plaintext is generated at // create time, returned to the client ONCE, then discarded — only the // hash is persisted. This is what `authorizeDeployMutation` and the // claim endpoint compare against. Pre-0.3.10 records have plaintext // `claimToken` instead; `readRecord` migrates them in place. claimTokenHash: string appPort: number url: string apiUrl: string publicInspect: boolean createdAt: string updatedAt: string claimedAt?: string bootError?: string // ISO timestamp set when the worker exhausted its restart budget (crash-loop). // Machine-readable companion to bootError so listings/alerting can detect a // crash-looped capsule without regex-matching free text. Cleared on the next // successful boot; gates the one-alert-per-episode webhook. crashLoopedAt?: string // Capsule-declared metadata. Populated from a regex scan of server/index.ts // on each successful build — see extractCapsuleMeta(). Absent on records // that haven't been rebuilt since this field was introduced. isPublic?: boolean // When true, the deploy is served as a pure static site (client.html only) // and never boots a capsule worker. Set from extractCapsuleMeta() on build. isStatic?: boolean title?: string description?: string // Build metadata. Persisted on each successful build so the IDE's // diagnostics panel can render `✓ Built · 17.4 KB` on first mount instead // of "No build yet" (the previous in-tab-only state). Absent on records // that haven't been rebuilt since this field was introduced. bundleBytes?: number bundleHash?: string lastBuiltAt?: string lastBuildDurationMs?: number } // Pre-migration shape. Used only by `readRecord` to detect & upgrade // records written before claim-token-hashing was introduced. interface LegacyDeployRecord { deployId: string claimToken?: string claimTokenHash?: string [key: string]: unknown } function sha256Hex(s: string): string { return createHash("sha256").update(s).digest("hex") } const MAX_BUNDLE_BYTES = 64 * 1024 * 1024 const MAX_ENV_BYTES = 64 * 1024 const MAX_ENV_ENTRIES = 256 const MAX_ENV_VALUE_CHARS = 1024 const RESERVED_SUBDOMAINS = new Set(["api", "admin", "docs", "www", "app", "health"]) const SUBDOMAIN_LABEL_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/ const HEX_DEPLOY_ID_RE = /^[a-f0-9]{16}$/ const MAX_DOMAINS_PER_USER = 50 function dirSize(dir: string): number { let total = 0 if (!fs.existsSync(dir)) return 0 const stack: string[] = [dir] while (stack.length > 0) { const cur = stack.pop() as string let entries: fs.Dirent[] try { entries = fs.readdirSync(cur, { withFileTypes: true }) } catch { continue } for (const e of entries) { const p = path.join(cur, e.name) if (e.isDirectory()) { stack.push(p) } else if (e.isFile()) { try { total += fs.statSync(p).size } catch { // ignore } } } } return total } function safeEqual(a: string, b: string): boolean { const ab = Buffer.from(a) const bb = Buffer.from(b) if (ab.length !== bb.length) return false return timingSafeEqual(ab, bb) } function bearer(header: string | undefined): string | null { if (!header) return null const m = header.match(/^Bearer\s+(.+)$/i) return m ? m[1].trim() : null } function validateEnvText(text: string): { ok: true } | { ok: false; error: string } { if (Buffer.byteLength(text, "utf8") > MAX_ENV_BYTES) { return { ok: false, error: `envText exceeds ${MAX_ENV_BYTES} bytes` } } const parsed = parseEnvText(text) const keys = Object.keys(parsed) if (keys.length > MAX_ENV_ENTRIES) { return { ok: false, error: `envText exceeds ${MAX_ENV_ENTRIES} entries` } } for (const k of keys) { if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) { return { ok: false, error: `invalid env key: ${k}` } } if ((parsed[k] ?? "").length > MAX_ENV_VALUE_CHARS) { return { ok: false, error: `env value for ${k} exceeds ${MAX_ENV_VALUE_CHARS} chars` } } } return { ok: true } } function parseEnvText(text: string): Record { const out: Record = {} for (const line of text.split("\n")) { const trimmed = line.trim() if (!trimmed || trimmed.startsWith("#")) continue const eqIdx = trimmed.indexOf("=") if (eqIdx === -1) continue out[trimmed.slice(0, eqIdx).trim()] = trimmed.slice(eqIdx + 1).trim() } return out } function serializeEnv(entries: Record): string { return ( Object.keys(entries) .sort() .map((k) => `${k}=${entries[k] ?? ""}`) .join("\n") + "\n" ) } // Remove a deploy directory, tolerating Windows file locks. A worker that was // just killed (e.g. a failed boot) can still hold its bundle/SQLite handle for // a few ms, so a bare fs.rmSync throws EBUSY/EPERM and the dir leaks. The // built-in retry options wait for the OS to release the handle. On POSIX this // is a no-op (open files unlink cleanly), so behavior there is unchanged. function removeDeployDir(dir: string): void { fs.rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) } function parseDuration(s: string): number { const m = /^(\d+)(ms|s|m|h|d)$/.exec(s.trim()) if (!m) throw new Error(`invalid duration: ${s}`) const n = parseInt(m[1], 10) switch (m[2]) { case "ms": return n case "s": return n * 1000 case "m": return n * 60 * 1000 case "h": return n * 60 * 60 * 1000 case "d": return n * 24 * 60 * 60 * 1000 default: throw new Error(`invalid duration unit: ${m[2]}`) } } function formatHumanDuration(ms: number): string { const s = Math.round(ms / 1000) if (s % 86400 === 0) return `${s / 86400} day${s / 86400 === 1 ? "" : "s"}` if (s % 3600 === 0) return `${s / 3600} hour${s / 3600 === 1 ? "" : "s"}` if (s % 60 === 0) return `${s / 60} minute${s / 60 === 1 ? "" : "s"}` return `${s} second${s === 1 ? "" : "s"}` } export const hostCommand = defineCommand({ meta: { name: "host", description: "Start the Pond hosted control plane", }, args: { port: { type: "string", default: "8787", }, host: { type: "string", description: "Interface to bind (default 127.0.0.1)", default: "127.0.0.1", }, "public-host": { type: "string", description: "Hostname used in returned deploy URLs (default localhost)", default: "localhost", }, "public-base-url": { type: "string", description: "Full external base URL incl. scheme (e.g. https://pond.example.com). When set, deploy URLs use this " + "scheme/host/port instead of http://:. Use behind a TLS-terminating proxy.", default: "", }, "abuse-email": { type: "string", description: "Contact email shown on the abuse / security pages (e.g. abuse@example.com)", default: "", }, "data-dir": { type: "string", default: ".pond-host", }, "anonymous-deploys": { type: "boolean", description: "Allow unauthenticated POST /api/deploys (Lakebed-style)", default: true, }, "anonymous-grace": { type: "string", description: "How long before an unclaimed deploy's worker is terminated (e.g. 1h, 30m, 60s)", default: "1h", }, "anonymous-retention": { type: "string", description: "How long before a terminated unclaimed deploy is deleted from disk", default: "7d", }, "anonymous-rate-per-hour": { type: "string", description: "Max anonymous POST /api/deploys per IP per rolling hour", default: "5", }, "max-active-capsules": { type: "string", description: "Hard ceiling on concurrently-running capsules. A NEW POST /api/deploys is refused with HTTP 503 once the " + "box already holds this many live capsules; existing capsules keep serving, claiming, and redeploying. " + "This is the admission-control backstop that stops a deploy flood from forking past the container mem_limit " + "into an OOM cascade. 0 = unlimited (prior behavior). Size it to your mem_limit — see deploy/.env.example. " + "Also POND_MAX_ACTIVE_CAPSULES.", default: "0", }, "anon-requests-per-day": { type: "string", description: "Per-anonymous-deploy daily request cap (every proxied HTTP request counts). Over the cap returns 429 " + "until the next UTC day. Claiming a deploy lifts the cap. 0 = unlimited. Also POND_ANON_REQUESTS_PER_DAY.", default: "10000", }, "anon-mutations-per-day": { type: "string", description: "Per-anonymous-deploy daily mutation cap (POST /api/mutation/*). Over the cap returns 429 until the next " + "UTC day. Claiming a deploy lifts the cap. 0 = unlimited. Also POND_ANON_MUTATIONS_PER_DAY.", default: "1000", }, "turnstile-secret": { type: "string", description: "Cloudflare Turnstile secret. When set, anonymous POST /api/deploys must carry a verified Turnstile " + "token (x-pond-turnstile-token header or turnstileToken body field). Unset = no challenge. Also POND_TURNSTILE_SECRET.", default: "", }, "trust-proxy": { type: "boolean", description: "Read client IP from x-forwarded-for (also POND_TRUST_PROXY_HEADERS=1)", default: false, }, "capsule-cgroup-root": { type: "string", description: "Path to a delegated cgroup v2 subtree (e.g. /sys/fs/cgroup/pond.slice/capsules). When set and valid, " + "each capsule runs in its own child cgroup with cpu/memory/pids limits. Also POND_CAPSULE_CGROUP_ROOT.", default: "", }, "capsule-egress": { type: "string", description: "Outbound network policy for ALL capsules (uniform, regardless of claim status): " + "'open' (legacy: only anonymous-unclaimed capsules are network-restricted; claimed capsules have full network), " + "'sealed' (no capsule may make outbound connections), or " + "'proxy' (capsules reach only their per-deploy allowlisted hosts via the egress proxy — NOT YET WIRED end-to-end; " + "the host refuses to start in this mode, use 'sealed' for now). " + "'proxy'/'sealed' REQUIRE the OS egress firewall (deploy/capsule-egress.nft) to be the real boundary. Also POND_CAPSULE_EGRESS.", default: "open", }, "egress-proxy-port": { type: "string", description: "Loopback port for the allowlisting egress proxy when --capsule-egress=proxy. Also POND_EGRESS_PROXY_PORT.", default: "8788", }, "capsule-fs-isolation": { type: "string", description: "Per-capsule OS filesystem isolation: 'off' (default; rely on the Node --permission model only) or " + "'bwrap' (Linux: confine each capsule worker in a bubblewrap mount namespace that exposes only its own " + "deploy dir rw + the pond runtime ro, so control.db and sibling deploys are physically unreachable). " + "'bwrap' REQUIRES Linux + bubblewrap installed; the host refuses to start otherwise rather than running " + "unconfined. Also POND_CAPSULE_FS_ISOLATION.", default: "off", }, "capsule-idle-timeout": { type: "string", description: "Scale-to-zero: stop a capsule's worker after this much inactivity (e.g. 15m, 30m; 0 = never sleep). The " + "next request re-boots it on demand, so idle deploys hold no memory and host cost tracks active usage " + "instead of total deploy count. When set > 0, deploys also boot lazily on first request rather than all " + "at startup. Also POND_CAPSULE_IDLE_TIMEOUT.", default: "0", }, "alert-webhook": { type: "string", description: "URL to POST a JSON alert to when the host takes action against a capsule (crash-loop budget exhausted, " + "disk-quota auto-stop, CPU-abuse auto-stop). Fire-and-forget; alerts also always go to stderr. Also POND_ALERT_WEBHOOK.", }, "capsule-cpu-kill-percent": { type: "string", description: "Auto-kill a capsule that sustains at/above this percent of one CPU core (100 = a full core) for " + "--capsule-cpu-kill-sweeps consecutive 60s sweeps. Requires --capsule-cgroup-root (CPU usage is read from " + "the capsule cgroup). 0 = off (default). Also POND_CAPSULE_CPU_KILL_PERCENT.", default: "0", }, "capsule-cpu-kill-sweeps": { type: "string", description: "Consecutive 60s sweeps a capsule must stay over --capsule-cpu-kill-percent before it is auto-killed " + "(a single spike never trips it). Default 3. Also POND_CAPSULE_CPU_KILL_SWEEPS.", default: "3", }, }, async run({ args }) { // Honor the platform-provided PORT env (Railway, Heroku-style PaaS) first so // the host binds the port the ingress proxy probes; fall back to --port then // the default. Without this a PaaS healthcheck can't reach the control plane. const port = parseInt(process.env.PORT ?? (typeof args.port === "string" ? args.port : "8787"), 10) const hostname = typeof args.host === "string" && args.host ? args.host : "127.0.0.1" const publicHost = process.env.POND_PUBLIC_HOST ?? (typeof args["public-host"] === "string" && args["public-host"] ? args["public-host"] : "localhost") const publicBaseUrlRaw = process.env.POND_PUBLIC_BASE_URL ?? (typeof args["public-base-url"] === "string" ? args["public-base-url"] : "") let publicBaseUrl: URL | null = null if (publicBaseUrlRaw) { try { publicBaseUrl = new URL(publicBaseUrlRaw.replace(/\/$/, "")) } catch { console.error(`[pond host] invalid --public-base-url: ${publicBaseUrlRaw}`) process.exit(1) } } const abuseEmail = process.env.POND_ABUSE_EMAIL ?? (typeof args["abuse-email"] === "string" ? args["abuse-email"] : "") ?? "" const alertWebhook = process.env.POND_ALERT_WEBHOOK ?? (typeof args["alert-webhook"] === "string" ? args["alert-webhook"] : "") ?? "" const cpuKillPercent = Math.max( 0, parseInt( process.env.POND_CAPSULE_CPU_KILL_PERCENT ?? (typeof args["capsule-cpu-kill-percent"] === "string" ? args["capsule-cpu-kill-percent"] : "0"), 10, ) || 0, ) const cpuKillSweeps = Math.max( 1, parseInt( process.env.POND_CAPSULE_CPU_KILL_SWEEPS ?? (typeof args["capsule-cpu-kill-sweeps"] === "string" ? args["capsule-cpu-kill-sweeps"] : "3"), 10, ) || 3, ) const dataDir = path.resolve(process.cwd(), typeof args["data-dir"] === "string" ? args["data-dir"] : ".pond-host") const deploysDir = path.join(dataDir, "deploys") const tokenFile = path.join(dataDir, "host-token") const apiUrl = `http://${hostname}:${port}` const runningChildren = new Map() // Crash-recovery state. A worker that exits unexpectedly is respawned with // backoff, but only RESTART_MAX times inside a rolling RESTART_WINDOW_MS so // a crash-looping capsule can't peg the CPU for its neighbours. The budget // resets on any user-initiated (re)deploy — see forkDeploy's `auto` flag. const restartState = new Map() const inFlightBoots = new Map>() // Scale-to-zero bookkeeping. lastActivityAt: last proxied request (or boot) // per resident deploy. liveSockets: open WebSocket connections per deploy, so // the idle reaper never sleeps a capsule mid-stream. Both are advisory — a // lost entry at worst sleeps a capsule early, and the next request re-wakes it. const lastActivityAt = new Map() const liveSockets = new Map() // Last on-disk size measured per resident deploy by the disk watchdog (every // 60s). Cached so the /metrics endpoint can report per-capsule disk without // re-walking every deploy dir on each scrape. const capsuleDiskBytes = new Map() // CPU-abuse watchdog bookkeeping (only used when --capsule-cpu-kill-percent // is set and cgroups are active). prevCpuSample: last cumulative CPU reading // per deploy; abuseCpuStreak: consecutive sweeps over the threshold. const prevCpuSample = new Map() const abuseCpuStreak = new Map() let shuttingDown = false const SWEEP_INTERVAL_MS = 60_000 const RESTART_MAX = 5 const RESTART_WINDOW_MS = 60_000 const RESTART_BACKOFF_MS = [500, 1000, 2000, 5000, 10000] const workerPath = path.resolve(import.meta.dirname, "../host/deploy-worker.js") const pondSrcDir = path.resolve(import.meta.dirname, "..") const pondNodeModulesDir = path.resolve(import.meta.dirname, "../../node_modules") const pondDocsDir = path.resolve(import.meta.dirname, "../../docs") const anonymousEnabled = args["anonymous-deploys"] !== false const graceStr = process.env.POND_ANONYMOUS_CLEANUP_GRACE ?? (typeof args["anonymous-grace"] === "string" ? args["anonymous-grace"] : "1h") const retentionStr = process.env.POND_ANONYMOUS_CLEANUP_RETENTION ?? (typeof args["anonymous-retention"] === "string" ? args["anonymous-retention"] : "7d") const anonymousGraceMs = parseDuration(graceStr) const anonymousRetentionMs = parseDuration(retentionStr) const anonymousRateLimit = parseInt( typeof args["anonymous-rate-per-hour"] === "string" ? args["anonymous-rate-per-hour"] : "5", 10, ) const maxActiveCapsules = Math.max( 0, parseInt( process.env.POND_MAX_ACTIVE_CAPSULES ?? (typeof args["max-active-capsules"] === "string" ? args["max-active-capsules"] : "0"), 10, ) || 0, ) // Scale-to-zero idle timeout. "0"/"" disables (parseDuration requires a unit, // so the bare-zero case is handled here). > 0 enables idle eviction + lazy boot. const idleTimeoutRaw = process.env.POND_CAPSULE_IDLE_TIMEOUT ?? (typeof args["capsule-idle-timeout"] === "string" ? args["capsule-idle-timeout"] : "0") const capsuleIdleTimeoutMs = idleTimeoutRaw === "0" || idleTimeoutRaw === "" ? 0 : parseDuration(idleTimeoutRaw) const anonRequestsPerDay = Math.max( 0, parseInt( process.env.POND_ANON_REQUESTS_PER_DAY ?? (typeof args["anon-requests-per-day"] === "string" ? args["anon-requests-per-day"] : "10000"), 10, ) || 0, ) const anonMutationsPerDay = Math.max( 0, parseInt( process.env.POND_ANON_MUTATIONS_PER_DAY ?? (typeof args["anon-mutations-per-day"] === "string" ? args["anon-mutations-per-day"] : "1000"), 10, ) || 0, ) const turnstileSecret = process.env.POND_TURNSTILE_SECRET ?? (typeof args["turnstile-secret"] === "string" ? args["turnstile-secret"] : "") const trustProxy = process.env.POND_TRUST_PROXY_HEADERS === "1" || args["trust-proxy"] === true // Uniform capsule egress policy (applies to ALL capsules regardless of // claim status). See the --capsule-egress flag. const egressModeRaw = ( process.env.POND_CAPSULE_EGRESS ?? (typeof args["capsule-egress"] === "string" ? args["capsule-egress"] : "open") ).toLowerCase() if (!["open", "sealed", "proxy"].includes(egressModeRaw)) { console.error(`[pond host] invalid --capsule-egress: ${egressModeRaw} (expected open|sealed|proxy)`) process.exit(1) } if (egressModeRaw === "proxy") { // The egress proxy, its per-deploy allowlist API, and the worker-side // ProxyAgent (which needs the `undici` dependency) are staged but not yet // wired end-to-end; enabling this blindly would silently seal capsules. // The OS firewall + proxy module + control-db allowlist are in place — // see deploy/HARDENING.md for the remaining rollout steps. console.error( "[pond host] --capsule-egress=proxy is not yet wired end-to-end (worker ProxyAgent pending). " + "Use 'open' or 'sealed' for now; see deploy/HARDENING.md.", ) process.exit(1) } const egressMode = egressModeRaw as "open" | "sealed" const nodeMajor = parseInt((process.versions.node ?? "0").split(".")[0], 10) const sandboxAvailable = nodeMajor >= 22 && fs.existsSync(pondSrcDir) && fs.existsSync(pondNodeModulesDir) if (!sandboxAvailable && anonymousEnabled) { console.log( `[pond host] Node ${process.versions.node} — permission model disabled. Upgrade to Node 22+ for anonymous deploy sandboxing.`, ) } // Per-capsule OS filesystem isolation (bubblewrap). Fail CLOSED: if 'bwrap' // is requested but the platform/binary can't provide it, refuse to start // rather than silently booting capsules with only the JS --permission // boundary — the same "never fail open" rule the egress proxy follows. const fsIsolationMode: CapsuleFsIsolationMode = resolveFsIsolationMode( process.env.POND_CAPSULE_FS_ISOLATION ?? (typeof args["capsule-fs-isolation"] === "string" ? args["capsule-fs-isolation"] : "off"), ) if (fsIsolationMode === "bwrap" && !bwrapAvailable()) { console.error( process.platform !== "linux" ? `[pond host] --capsule-fs-isolation=bwrap requires Linux (got ${process.platform}). Refusing to start unconfined.` : "[pond host] --capsule-fs-isolation=bwrap requested but `bwrap` (bubblewrap) is not available on PATH. Install bubblewrap or unset the flag. Refusing to start unconfined.", ) process.exit(1) } // Per-capsule cgroup v2 isolation. Validated once at startup; null disables // it (non-Linux, no delegation) and capsules fall back to the heap cap only. const capsuleCgroupRootRaw = process.env.POND_CAPSULE_CGROUP_ROOT ?? (typeof args["capsule-cgroup-root"] === "string" ? args["capsule-cgroup-root"] : "") const capsuleCgroupRoot = probeCapsuleCgroup(capsuleCgroupRootRaw || null) if (capsuleCgroupRootRaw && !capsuleCgroupRoot) { console.log( `[pond host] capsule cgroup isolation requested (${capsuleCgroupRootRaw}) but the path is not a delegated cgroup v2 subtree with cpu/memory/pids — running without per-capsule CPU/memory caps.`, ) } if (capsuleCgroupRoot && !joinManagerCgroup(capsuleCgroupRoot)) { console.log( `[pond host] note: could not move the manager into ${capsuleCgroupRoot}/manager — per-capsule limits may not bind unless this process is cgroup-delegated (see deploy/setup-capsule-isolation.sh).`, ) } // Operator alerting. Host-side events where the control plane takes action // against a capsule (crash-loop budget exhausted, disk-quota auto-stop) are // surfaced to stderr AND, when --alert-webhook is set, POSTed as JSON so an // operator is paged instead of having to tail `docker logs`. Fire-and-forget: // alerting must never block a request path or take the host down with a dead // alert sink. function alertOperator(event: string, detail: Record): void { console.error(`[pond host] ALERT ${event}: ${JSON.stringify(detail)}`) if (!alertWebhook) return void fetch(alertWebhook, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ event, host: publicHost, ts: new Date().toISOString(), ...detail }), }).catch(() => { // best-effort: a down/slow alert sink must not affect the control plane }) } fs.mkdirSync(deploysDir, { recursive: true }) const controlDb: ControlDb = openControlDb(dataDir) const ANON_DEPLOY_RATE_SCOPE = "anon_deploy_per_ip" const ANON_DEPLOY_RATE_WINDOW_MS = 60 * 60 * 1000 function rateAllow(ip: string): boolean { return controlDb.rateAllow(ANON_DEPLOY_RATE_SCOPE, ip, ANON_DEPLOY_RATE_WINDOW_MS, anonymousRateLimit) } // Global admission control. Each live deploy is a resident child process, so // total memory scales with the number of concurrent capsules; past the // container mem_limit the kernel OOM killer can take the control plane down // with it. atCapacity() sheds NEW capsule creation with a 503 before that // happens — existing capsules (serve/claim/redeploy/crash-respawn) are never // blocked. `pendingBoots` reserves a slot SYNCHRONOUSLY at admission so a // burst of concurrent POST /api/deploys can't all pass the check during each // other's `await build` and collectively overshoot the ceiling. 0 = off. let pendingBoots = 0 function atCapacity(): boolean { return maxActiveCapsules > 0 && runningChildren.size + pendingBoots >= maxActiveCapsules } let hostToken = process.env.POND_HOST_TOKEN ?? "" let hostTokenGenerated = false if (!hostToken) { if (fs.existsSync(tokenFile)) { hostToken = fs.readFileSync(tokenFile, "utf-8").trim() } else { hostToken = randomBytes(32).toString("hex") fs.writeFileSync(tokenFile, hostToken, { mode: 0o600 }) hostTokenGenerated = true } } function urlFor(deployId: string): string { if (publicBaseUrl) { const portPart = publicBaseUrl.port ? `:${publicBaseUrl.port}` : "" return `${publicBaseUrl.protocol}//${deployId}.${publicBaseUrl.hostname}${portPart}` } return `http://${deployId}.${publicHost}:${port}` } function urlForCustomDomain(subdomain: string): string { if (publicBaseUrl) { const portPart = publicBaseUrl.port ? `:${publicBaseUrl.port}` : "" return `${publicBaseUrl.protocol}//${subdomain}.${publicBaseUrl.hostname}${portPart}` } return `http://${subdomain}.${publicHost}:${port}` } // The external hostname (no scheme/port). Used to detect "bare-domain" // requests (the landing / abuse / security pages) vs subdomain requests // (proxy to a deploy). const externalHost = (publicBaseUrl?.hostname ?? publicHost).toLowerCase() function deployDirFor(deployId: string) { return path.join(deploysDir, deployId) } function metaFileFor(deployId: string) { return path.join(deployDirFor(deployId), "deploy.json") } function envFileFor(deployId: string) { return path.join(deployDirFor(deployId), ".env.pond.server") } function readRecord(deployId: string): HostedDeployRecord | null { if (!/^[a-f0-9]+$/i.test(deployId)) return null const file = metaFileFor(deployId) if (!fs.existsSync(file)) return null const raw = JSON.parse(fs.readFileSync(file, "utf-8")) as LegacyDeployRecord // Migration: pre-0.3.10 records have plaintext `claimToken` and no // `claimTokenHash`. Compute the hash, drop the plaintext, rewrite once. // After migration the file no longer holds a usable claim token; a // backup leak only yields the hash. if (typeof raw.claimToken === "string" && typeof raw.claimTokenHash !== "string") { raw.claimTokenHash = sha256Hex(raw.claimToken) delete raw.claimToken try { fs.writeFileSync(file, JSON.stringify(raw, null, 2)) } catch { // best-effort; the in-memory record still has the hash for this // request, and the next successful read will retry } } return raw as unknown as HostedDeployRecord } // In-memory cache for the unauthenticated public-deploys listing. // Declared BEFORE writeRecord because writeRecord calls // invalidatePublicListing() and the boot loop calls writeRecord during // module/run init — if `publicListingCache` is declared later in the // function body, that path hits a TDZ ReferenceError. The handler that // reads/writes the cache lives much further down with the rest of the // public-deploys route; it closes over these bindings. let publicListingCache: { body: { deploys: unknown[] }; expiresAt: number } | null = null const PUBLIC_LISTING_TTL_MS = 10_000 function invalidatePublicListing() { publicListingCache = null } function writeRecord(record: HostedDeployRecord) { const dir = deployDirFor(record.deployId) fs.mkdirSync(dir, { recursive: true }) // Defense in depth: even if a caller built a record with a stray // `claimToken` field, strip it before persisting. Only the hash // belongs on disk. const sanitized = { ...(record as unknown as Record) } delete sanitized.claimToken fs.writeFileSync(metaFileFor(record.deployId), JSON.stringify(sanitized, null, 2)) // Any record change can flip visibility (publish/unpublish/title/etc.) // — drop the public-listing cache so the next GET reflects reality. invalidatePublicListing() } function readEnv(deployId: string): Record { const file = envFileFor(deployId) if (!fs.existsSync(file)) return {} return parseEnvText(fs.readFileSync(file, "utf-8")) } function writeEnv(deployId: string, entries: Record) { const file = envFileFor(deployId) fs.mkdirSync(path.dirname(file), { recursive: true }) fs.writeFileSync(file, serializeEnv(entries), { mode: 0o600 }) } function scopedEnvFor(_record: HostedDeployRecord): NodeJS.ProcessEnv { return { PATH: process.env.PATH ?? "", NODE_ENV: process.env.NODE_ENV ?? "production", HOME: process.env.HOME ?? "", } } async function stopDeploy(deployId: string) { const entry = runningChildren.get(deployId) if (!entry) return const { child } = entry runningChildren.delete(deployId) lastActivityAt.delete(deployId) capsuleDiskBytes.delete(deployId) prevCpuSample.delete(deployId) abuseCpuStreak.delete(deployId) if (child.exitCode !== null || child.signalCode !== null) return const exited = new Promise((resolve) => child.once("exit", () => resolve())) try { child.send({ type: "shutdown" }) } catch { child.kill("SIGKILL") return } const timer = setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL") }, 5000) timer.unref() await exited clearTimeout(timer) } async function forkDeploy(record: HostedDeployRecord, opts: { auto?: boolean } = {}): Promise { // Isolation policy is derived in ONE place (bootOptsForRecord) so every // boot path — create, claim, update, redeploy, crash-respawn — applies // the same uniform sandbox/network rules. null means the deploy should // stay down (a terminated anonymous deploy past its grace window). const policy = bootOptsForRecord(record.deployId) if (!policy) return const useSandbox = policy.useSandbox const restrictNetwork = policy.restrictNetwork // A user-initiated (re)deploy ships potentially-fixed code, so it earns a // fresh crash budget. Automatic respawns (opts.auto) must NOT reset it, // or a fast boot→crash loop would never trip RESTART_MAX. if (!opts.auto) restartState.delete(record.deployId) const dir = deployDirFor(record.deployId) const bundlePath = path.join(dir, "deploy-bundle.mjs") const clientPath = path.join(dir, "client.html") if (!fs.existsSync(bundlePath)) return await stopDeploy(record.deployId) // Resolve symlinks: the permission model checks REAL paths (macOS /tmp → // /private/tmp), and the worker uses cwd to compute its data.db location, // so cwd / bundlePath must be in real form when the sandbox is active. // bwrap also binds literal paths, so it needs the real form too. const needsRealPaths = (useSandbox && sandboxAvailable) || fsIsolationMode === "bwrap" const realDir = needsRealPaths ? fs.realpathSync(dir) : dir const realBundlePath = needsRealPaths ? fs.realpathSync(bundlePath) : bundlePath const realClientPath = fs.existsSync(clientPath) ? needsRealPaths ? fs.realpathSync(clientPath) : clientPath : undefined const quota = controlDb.getQuota(record.deployId) const execArgv = [`--max-old-space-size=${quota.maxMemoryMb}`] if (useSandbox && sandboxAvailable) { // Node 24 removed `--experimental-permission`; the stable `--permission` // is available on Node 23+. Node 22 (LTS) shipped before the rename so // we keep the experimental form there. const permissionFlag = nodeMajor >= 23 ? "--permission" : "--experimental-permission" execArgv.push( permissionFlag, `--allow-fs-read=${realDir}`, `--allow-fs-read=${fs.realpathSync(pondSrcDir)}`, `--allow-fs-read=${fs.realpathSync(pondNodeModulesDir)}`, `--allow-fs-write=${realDir}`, "--allow-addons", ) } // The IPC channel ("ipc" in stdio) carries the boot message + booted/error // replies below. fork wires it automatically; under bwrap we spawn the // bwrap wrapper with the same stdio so Node's IPC fd is inherited by the // inner node process (NODE_CHANNEL_FD rides through bwrap's env + fds). let child: ChildProcess if (fsIsolationMode === "bwrap") { const argv = buildBwrapArgv({ nodeBin: process.execPath, nodeExecArgv: execArgv, workerPath: fs.realpathSync(workerPath), deployDir: realDir, pondSrcDir: fs.realpathSync(pondSrcDir), pondNodeModulesDir: fs.realpathSync(pondNodeModulesDir), }) child = spawn(argv[0], argv.slice(1), { cwd: realDir, env: scopedEnvFor(record), stdio: ["ignore", "inherit", "inherit", "ipc"], }) } else { child = fork(workerPath, [], { cwd: realDir, env: scopedEnvFor(record), stdio: ["ignore", "inherit", "inherit", "ipc"], execArgv, }) } const deployId = record.deployId // Place the worker in its own cgroup before it does any real work, so the // bundle import and request handling are CPU/memory/pid bounded from the // start. No-op unless the host runs with a valid --capsule-cgroup-root. if (capsuleCgroupRoot && typeof child.pid === "number") { const placed = applyCapsuleCgroup(capsuleCgroupRoot, deployId, child.pid, cgroupLimitsFor(quota)) if (!placed) { console.error(`[pond host] could not place deploy ${deployId} (pid ${child.pid}) in a cgroup`) } } child.on("exit", (code, signal) => { const cur = runningChildren.get(deployId) if (cur && cur.child === child) { runningChildren.delete(deployId) if (code !== 0 && signal !== "SIGTERM" && signal !== "SIGINT") { console.error(`[pond host] deploy ${deployId} worker exited unexpectedly (code=${code}, signal=${signal})`) scheduleRespawn(deployId) } } }) try { const bootedPort = await new Promise((resolve, reject) => { const timer = setTimeout(() => { child.removeListener("message", onMessage) reject(new Error("Boot timed out after 10s")) }, 10000) const onMessage = (msg: any) => { if (msg?.type === "booted") { clearTimeout(timer) child.removeListener("message", onMessage) resolve(typeof msg.port === "number" ? msg.port : 0) } else if (msg?.type === "error") { clearTimeout(timer) child.removeListener("message", onMessage) reject(new Error(msg.message ?? "Worker reported error")) } } child.on("message", onMessage) child.once("exit", (code, signal) => { clearTimeout(timer) child.removeListener("message", onMessage) reject(new Error(`Worker exited before boot (code=${code}, signal=${signal})`)) }) child.send({ type: "boot", options: { bundlePath: realBundlePath, clientPath: realClientPath, cwd: realDir, port: 0, hostname: "127.0.0.1", inspectSecretHash: record.claimTokenHash, publicInspect: record.publicInspect, restrictNetwork, maxRestoreBytes: quota.maxDiskBytes, }, }) }) runningChildren.set(deployId, { child, port: bootedPort }) // Give a freshly-booted capsule a full idle window before it can be slept. lastActivityAt.set(deployId, Date.now()) record.appPort = bootedPort record.url = urlFor(deployId) if (record.bootError) { delete record.bootError } // A clean boot ends any crash-loop episode, re-arming the one-shot alert. if (record.crashLoopedAt) { delete record.crashLoopedAt } writeRecord(record) } catch (err: any) { // Wait for the killed worker to actually exit before returning, so the // caller's deploy-dir cleanup runs after the OS has released the worker's // file handles (bundle + data.db). On Windows those handles block rmdir // while the process lives; awaiting exit makes cleanup deterministic // rather than racing the kill. Bounded so a wedged process can't hang us. if (child.exitCode === null && child.signalCode === null) { const exited = new Promise((resolve) => child.once("exit", () => resolve())) child.kill("SIGKILL") await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 3000).unref())]) } record.bootError = err?.message ?? String(err) writeRecord(record) throw err } } // Derive the sandbox/network options a deploy should boot with from its // current claim status. Returns null when the deploy should stay down // (anonymous deploy already terminated past its grace window). function bootOptsForRecord(deployId: string): { useSandbox: boolean; restrictNetwork: boolean } | null { const anon = controlDb.findAnonymous(deployId) if (anon && anon.terminated === 1) return null const isAnonUnclaimed = anon !== null // Isolation is UNIFORM: every capsule runs in the Node permission sandbox // regardless of claim status — claiming changes ownership/quota, not the // sandbox. Network policy follows --capsule-egress: 'sealed' restricts // every capsule's outbound; 'open' (legacy) restricts only anonymous, // unclaimed capsules and leaves claimed ones with full network. return { useSandbox: true, restrictNetwork: egressMode === "sealed" ? true : isAnonUnclaimed, } } // Respawn a worker that died unexpectedly, with backoff and a hard cap so a // capsule that crashes on boot can't spin forever and starve its neighbours. function scheduleRespawn(deployId: string) { if (shuttingDown) return const record = readRecord(deployId) if (!record) return const opts = bootOptsForRecord(deployId) if (!opts) return const now = Date.now() let st = restartState.get(deployId) if (!st || now - st.windowStart > RESTART_WINDOW_MS) { st = { windowStart: now, count: 0 } restartState.set(deployId, st) } if (st.count >= RESTART_MAX) { // Alert exactly once per crash-loop episode. crashLoopedAt is the marker: // unset here means this is a fresh episode (it's cleared on the next // successful boot), so a capsule that keeps re-crashing on every on-demand // boot doesn't spam the operator. const firstAlertThisEpisode = !record.crashLoopedAt record.bootError = `Worker crashed ${RESTART_MAX}× within ${Math.round(RESTART_WINDOW_MS / 1000)}s — auto-restart paused until next deploy` record.crashLoopedAt = new Date().toISOString() writeRecord(record) console.error(`[pond host] deploy ${deployId} crash-looping — auto-restart paused`) if (firstAlertThisEpisode) { alertOperator("capsule.crash_loop", { deployId, restarts: RESTART_MAX, windowSeconds: Math.round(RESTART_WINDOW_MS / 1000), reason: record.bootError, }) } return } const delay = RESTART_BACKOFF_MS[Math.min(st.count, RESTART_BACKOFF_MS.length - 1)] st.count++ const timer = setTimeout(() => { void forkDeploy(record, { auto: true }).catch((err) => { console.error(`[pond host] respawn failed for ${deployId}: ${err?.message ?? err}`) }) }, delay) timer.unref() } // Boot a deploy on demand when a request arrives for one that isn't running // (host restarted, or it was paused). Deduped via inFlightBoots so a burst // of concurrent requests triggers a single fork. async function ensureBooted( deployId: string, ): Promise<{ child: ChildProcess; port: number } | null | "at-capacity"> { const existing = runningChildren.get(deployId) if (existing) return existing const inFlight = inFlightBoots.get(deployId) if (inFlight) return inFlight // Wake-path admission control. ensureBooted is the only lazy-wake path for a // scaled-to-zero capsule and, unlike POST /api/deploys, had no capacity gate: // a burst of traffic across many slept deploys could collectively boot past // maxActiveCapsules and trip the OOM killer. Reserve a slot synchronously via // the same pendingBoots counter the create path uses, so concurrent wakes of // DISTINCT deploys can't all clear the check during each other's await. // Same-deploy concurrency is already coalesced by inFlightBoots above. if (atCapacity()) return "at-capacity" pendingBoots++ const p = (async () => { const record = readRecord(deployId) if (!record) return null const opts = bootOptsForRecord(deployId) if (!opts) return null try { await forkDeploy(record, { auto: true }) } catch { return null } return runningChildren.get(deployId) ?? null })() inFlightBoots.set(deployId, p) try { return await p } finally { inFlightBoots.delete(deployId) pendingBoots-- } } // With scale-to-zero enabled, boot lazily: skip the eager boot-everything pass // and let the first request wake each deploy via ensureBooted, so a restart // doesn't pay for hundreds of idle workers. Otherwise boot all up front (the // historical behavior) so capsules are warm and boot errors surface at start. for (const entry of capsuleIdleTimeoutMs > 0 ? [] : fs.readdirSync(deploysDir, { withFileTypes: true })) { if (!entry.isDirectory()) continue const record = readRecord(entry.name) if (!record) continue const anon = controlDb.findAnonymous(record.deployId) if (anon && anon.terminated === 1) continue try { // forkDeploy derives the uniform isolation policy itself. await forkDeploy(record) } catch (err) { console.error(`[pond host] boot failed for ${record.deployId}:`, err) } } function runSweep() { const now = new Date().toISOString() for (const id of controlDb.listForTermination(now)) { try { stopDeploy(id) controlDb.markTerminated(id) console.log(`[pond host] anonymous deploy ${id} terminated (grace passed)`) } catch (e) { console.error(`sweep terminate ${id}:`, e) } } for (const id of controlDb.listForDeletion(now)) { try { stopDeploy(id) restartState.delete(id) if (capsuleCgroupRoot) removeCapsuleCgroup(capsuleCgroupRoot, id) removeDeployDir(deployDirFor(id)) controlDb.deleteAnonymous(id) controlDb.deleteQuota(id) console.log(`[pond host] anonymous deploy ${id} deleted (retention passed)`) } catch (e) { console.error(`sweep delete ${id}:`, e) } } // Runtime disk watchdog. The per-deploy quota is enforced at deploy/build // time, but a running capsule can keep writing (blobs, data.db growth) // until it fills the shared /data volume and takes every neighbour down. // The cgroup caps cpu/memory/pids but NOT disk, so we poll here: a capsule // whose dir exceeds its disk quota is stopped (not respawned — see the // SIGTERM-clean exit handler) and flagged, freeing the volume. for (const id of [...runningChildren.keys()]) { try { const maxDiskBytes = controlDb.getQuota(id).maxDiskBytes if (!Number.isFinite(maxDiskBytes) || maxDiskBytes <= 0) continue const used = dirSize(deployDirFor(id)) capsuleDiskBytes.set(id, used) if (used > maxDiskBytes) { void stopDeploy(id) restartState.delete(id) const record = readRecord(id) if (record) { record.bootError = `Disk usage ${used} exceeds quota ${maxDiskBytes} — capsule stopped. Reduce on-disk data and redeploy.` writeRecord(record) } console.error(`[pond host] deploy ${id} over disk quota (${used} > ${maxDiskBytes}) — stopped`) alertOperator("capsule.disk_quota_stop", { deployId: id, usedBytes: used, maxDiskBytes }) } } catch (e) { console.error(`sweep disk-watchdog ${id}:`, e) } } // Runtime CPU-abuse watchdog (opt-in). A capsule pinned at its CPU cap is a // sustained denial-of-fairness that no other limit catches. Sample each // resident capsule's cgroup CPU, score it, and auto-kill one that holds // above the threshold for cpuKillSweeps consecutive sweeps. Off unless a // kill threshold AND a cgroup root are configured (CPU usage is unreadable // without cgroups). if (cpuKillPercent > 0 && capsuleCgroupRoot) { const sweepSeconds = SWEEP_INTERVAL_MS / 1000 for (const id of [...runningChildren.keys()]) { try { const usage = readCapsuleCgroupUsage(capsuleCgroupRoot, id) if (!usage) { prevCpuSample.delete(id) abuseCpuStreak.delete(id) continue } const prev = prevCpuSample.get(id) prevCpuSample.set(id, usage) const fraction = cpuFractionOverInterval(prev, usage, sweepSeconds) const { streak, kill } = scoreCpuAbuse(fraction, abuseCpuStreak.get(id) ?? 0, cpuKillPercent, cpuKillSweeps) abuseCpuStreak.set(id, streak) if (kill) { void stopDeploy(id) restartState.delete(id) const pct = Math.round(fraction * 100) const record = readRecord(id) if (record) { record.bootError = `CPU abuse: sustained ≥${cpuKillPercent}% of a core across ${cpuKillSweeps} sweeps (last ${pct}%) — capsule stopped. Redeploy to resume.` writeRecord(record) } console.error(`[pond host] deploy ${id} CPU-abusing (${pct}% of a core) — stopped`) alertOperator("capsule.cpu_abuse_stop", { deployId: id, cpuPercent: pct, sustainedSweeps: cpuKillSweeps }) } } catch (e) { console.error(`sweep cpu-watchdog ${id}:`, e) } } } // Scale-to-zero: sleep capsules idle past the timeout. stopDeploy removes // the child from runningChildren before its exit handler runs, so the clean // exit is NOT treated as a crash and no respawn is scheduled; the next // request re-boots the deploy via ensureBooted. Capsules with an open // socket or a boot in flight are skipped (see selectIdleDeploys). if (capsuleIdleTimeoutMs > 0) { const idleIds = selectIdleDeploys({ now: Date.now(), idleTimeoutMs: capsuleIdleTimeoutMs, running: runningChildren.keys(), lastActivityAt, liveSockets, booting: inFlightBoots, }) for (const id of idleIds) { try { void stopDeploy(id) console.log( `[pond host] capsule ${id} slept after ${formatHumanDuration(capsuleIdleTimeoutMs)} idle (wakes on next request)`, ) } catch (e) { console.error(`sweep idle-evict ${id}:`, e) } } } try { controlDb.pruneRateLimits(ANON_DEPLOY_RATE_WINDOW_MS) } catch (e) { console.error("sweep prune rate_limits:", e) } try { // Daily usage counters are only read for the current UTC day; drop // everything older so the table stays one row per active deploy. controlDb.pruneDailyUsage(new Date().toISOString().slice(0, 10)) } catch (e) { console.error("sweep prune deploy_usage:", e) } } runSweep() const sweepTimer = setInterval(runSweep, SWEEP_INTERVAL_MS) sweepTimer.unref() const app = new Hono() app.use("*", async (c, next) => { const origin = c.req.header("origin") const hostHdr = (c.req.header("host") ?? "").toLowerCase() let allow = false let originHost = "" if (origin) { try { originHost = new URL(origin).host.toLowerCase() if (originHost === hostHdr) allow = true } catch { // ignore } } const headers: Record = { "strict-transport-security": "max-age=63072000; includeSubDomains; preload", "x-content-type-options": "nosniff", "referrer-policy": "strict-origin-when-cross-origin", "permissions-policy": "camera=(), microphone=(), geolocation=()", } if (allow && origin) { headers["access-control-allow-origin"] = origin headers["vary"] = "Origin" headers["access-control-allow-credentials"] = "true" headers["access-control-allow-headers"] = "content-type, authorization, x-pond-claim-token" headers["access-control-allow-methods"] = "GET, POST, PUT, DELETE, OPTIONS" } if (c.req.method === "OPTIONS") { return new Response(null, { status: 204, headers }) } await next() for (const [k, v] of Object.entries(headers)) { c.res.headers.set(k, v) } // Clickjacking policy. The control plane / dashboard must never be framed. // Deploy pages ARE framed — as scaled live previews — by the dashboard on // the apex origin, so allow only the apex to frame a deploy (not other // deploys, so tenants can't clickjack each other). The host is already the // framing authority here, so on deploy responses we drop any capsule-set // x-frame-options and merge frame-ancestors into (rather than clobber) a // capsule's own CSP. if (isBareDomainRequest(hostHdr)) { c.res.headers.set("x-frame-options", "DENY") } else { const apexOrigin = publicBaseUrl ? `${publicBaseUrl.protocol}//${publicBaseUrl.host}` : `https://${externalHost}` const frameAncestors = `frame-ancestors 'self' ${apexOrigin}` c.res.headers.delete("x-frame-options") const existingCsp = c.res.headers.get("content-security-policy") if (!existingCsp) { c.res.headers.set("content-security-policy", frameAncestors) } else if (!/frame-ancestors/i.test(existingCsp)) { c.res.headers.set("content-security-policy", `${existingCsp}; ${frameAncestors}`) } } }) app.get("/api/health", (c) => c.json({ ok: true })) // Host observability. Prometheus text exposition of per-capsule resource use // and control-plane state. Admin-gated: the sample set leaks the full deploy // inventory of a multi-tenant box, so it must never be world-readable. CPU/mem // come from each capsule's cgroup (only present when --capsule-cgroup-root is a // delegated subtree); disk is the watchdog's last measurement; the rest is // in-process bookkeeping. Samples are grouped per metric family (Prometheus // requires all samples of a family to be contiguous, with HELP/TYPE first). app.get("/metrics", (c) => { const r = requireAdmin(c) if (r instanceof Response) return r const ids = [...runningChildren.keys()] const usage = new Map>() if (capsuleCgroupRoot) for (const id of ids) usage.set(id, readCapsuleCgroupUsage(capsuleCgroupRoot, id)) const now = Date.now() const lines: string[] = [] const family = (name: string, help: string, type: string, valueFor: (id: string) => number | null) => { lines.push(`# HELP ${name} ${help}`, `# TYPE ${name} ${type}`) for (const id of ids) { const v = valueFor(id) if (v !== null) lines.push(`${name}{deploy="${id}"} ${v}`) } } lines.push( "# HELP pond_host_capsules_active Resident capsule workers", "# TYPE pond_host_capsules_active gauge", `pond_host_capsules_active ${runningChildren.size}`, "# HELP pond_host_capsules_max Configured max concurrent capsules (0=unlimited)", "# TYPE pond_host_capsules_max gauge", `pond_host_capsules_max ${maxActiveCapsules}`, ) family("pond_capsule_up", "1 while the capsule worker is resident", "gauge", () => 1) family( "pond_capsule_disk_bytes", "Last measured on-disk size of the deploy dir", "gauge", (id) => capsuleDiskBytes.get(id) ?? dirSize(deployDirFor(id)), ) family( "pond_capsule_restarts", "Auto-restarts in the current crash-loop window", "gauge", (id) => restartState.get(id)?.count ?? 0, ) family("pond_capsule_open_sockets", "Open WebSocket connections", "gauge", (id) => liveSockets.get(id) ?? 0) family("pond_capsule_idle_seconds", "Seconds since the last proxied request", "gauge", (id) => { const last = lastActivityAt.get(id) return typeof last === "number" ? Math.round((now - last) / 1000) : null }) family("pond_capsule_cpu_seconds_total", "Cumulative capsule CPU seconds (cgroup)", "counter", (id) => { const u = usage.get(id) return u ? u.cpuUsageSeconds : null }) family("pond_capsule_memory_bytes", "Current capsule memory charged by the cgroup", "gauge", (id) => { const u = usage.get(id) return u ? u.memoryBytes : null }) return new Response(lines.join("\n") + "\n", { status: 200, headers: { "content-type": "text/plain; version=0.0.4; charset=utf-8" }, }) }) // Automated, consistent control-DB backup for an operator cron. VACUUM INTO a // temp snapshot, stream it, delete the temp. Admin-gated — this is the entire // user/ownership/quota/audit database. Restore stays an offline operation // (stop host, validate the snapshot with validateSqliteFile, swap, restart). app.get("/api/admin/control-db/backup", (c) => { const r = requireAdmin(c) if (r instanceof Response) return r const tmp = path.join(dataDir, `control-backup-${Date.now()}-${process.pid}.db`) try { controlDb.backup(tmp) const buf = fs.readFileSync(tmp) audit(actorFor(r.user, r.viaHostToken), "control_db.backup", { metadata: { bytes: buf.length } }) return new Response(buf as any, { status: 200, headers: { "content-type": "application/x-sqlite3", "content-disposition": `attachment; filename="pond-control-${new Date().toISOString().slice(0, 10)}.db"`, }, }) } finally { try { fs.unlinkSync(tmp) } catch { // best effort } } }) function isHostToken(token: string): boolean { return safeEqual(token, hostToken) } function authUser(c: any): UserRow | null { const provided = bearer(c.req.header("authorization")) if (!provided) return null const user = controlDb.findUserByTokenHash(controlDb.hashToken(provided)) return user } function requireUser(c: any): { user: UserRow } | Response { const user = authUser(c) if (!user) return c.json({ error: "Unauthorized" }, 401) return { user } } function requireAdmin(c: any): { user: UserRow | null; viaHostToken: boolean } | Response { const provided = bearer(c.req.header("authorization")) if (!provided) return c.json({ error: "Unauthorized" }, 401) if (isHostToken(provided)) return { user: null, viaHostToken: true } const user = controlDb.findUserByTokenHash(controlDb.hashToken(provided)) if (!user || user.isAdmin !== 1) return c.json({ error: "Forbidden" }, 403) return { user, viaHostToken: false } } function clientIp(c: any): string { if (trustProxy) { // Prefer CF-Connecting-IP: Cloudflare OVERWRITES this header with the // real client IP on every request, so a client can't spoof it the way // they can prepend a bogus X-Forwarded-For entry. Only fall back to the // first XFF hop when the request didn't come through Cloudflare. const cf = c.req.header("cf-connecting-ip") if (cf && cf.trim()) return cf.trim() const xff = c.req.header("x-forwarded-for") if (xff) { const first = xff.split(",")[0]?.trim() if (first) return first } } try { const inc = c.env?.incoming const ip = inc?.socket?.remoteAddress if (typeof ip === "string" && ip) return ip } catch { // fall through } return "unknown" } function actorFor(user: UserRow | null, viaHostToken: boolean, anonymous = false): string { if (viaHostToken) return "__host__" if (anonymous) return "__anonymous__" return user?.id ?? "__unknown__" } function audit( actor: string, action: string, opts: { targetDeployId?: string; targetUserId?: string; metadata?: Record } = {}, ) { try { controlDb.appendAudit({ actor, action, targetDeployId: opts.targetDeployId, targetUserId: opts.targetUserId, metadata: opts.metadata, }) } catch (e) { console.error("[pond host] audit append failed:", e) } } function authorizeDeployMutation( c: any, record: HostedDeployRecord, ): { kind: "claim" } | { kind: "user"; user: UserRow } | Response { const claim = c.req.header("x-pond-claim-token") ?? "" if (claim && safeEqual(sha256Hex(claim), record.claimTokenHash)) { return { kind: "claim" } } const provided = bearer(c.req.header("authorization")) if (!provided) return c.json({ error: "Unauthorized" }, 401) if (isHostToken(provided)) { // host token gives admin powers return { kind: "user", user: { id: "__host__", username: "__host__", tokenHash: "", isAdmin: 1, createdAt: "" }, } } const user = controlDb.findUserByTokenHash(controlDb.hashToken(provided)) if (!user) return c.json({ error: "Unauthorized" }, 401) const ownerId = controlDb.getDeployOwner(record.deployId) if (user.isAdmin !== 1 && ownerId !== user.id) { return c.json({ error: "Forbidden" }, 403) } return { kind: "user", user } } // ---- USERS ---- app.post("/api/users", async (c) => { // First user bootstrap requires the host token; subsequent users require admin (host token or admin user). const provided = bearer(c.req.header("authorization")) if (!provided) return c.json({ error: "Unauthorized" }, 401) const hasAny = controlDb.hasAnyUser() if (!hasAny) { if (!isHostToken(provided)) return c.json({ error: "Unauthorized" }, 401) } else { if (!isHostToken(provided)) { const u = controlDb.findUserByTokenHash(controlDb.hashToken(provided)) if (!u || u.isAdmin !== 1) return c.json({ error: "Forbidden" }, 403) } } const body = (await c.req.json().catch(() => ({}))) as { username?: unknown; isAdmin?: unknown } if (typeof body.username !== "string" || !/^[a-z0-9_-]{1,32}$/i.test(body.username)) { return c.json({ error: "username must match /^[a-z0-9_-]{1,32}$/i" }, 400) } if (controlDb.findUserByUsername(body.username)) { return c.json({ error: "username taken" }, 409) } // First user is forced admin; otherwise honour isAdmin flag (default false). const isAdmin = !hasAny ? true : Boolean(body.isAdmin) const { user, token } = controlDb.createUser(body.username, isAdmin) const viaHostToken = isHostToken(provided) const actorUser = viaHostToken ? null : controlDb.findUserByTokenHash(controlDb.hashToken(provided)) audit(actorFor(actorUser, viaHostToken), "user.create", { targetUserId: user.id, metadata: { username: user.username, isAdmin, bootstrap: !hasAny }, }) return c.json({ userId: user.id, username: user.username, isAdmin: user.isAdmin === 1, token }, 201) }) app.get("/api/users/me", (c) => { const r = requireUser(c) if (r instanceof Response) return r return c.json({ userId: r.user.id, username: r.user.username, isAdmin: r.user.isAdmin === 1 }) }) app.post("/api/users/me/rotate-token", (c) => { const r = requireUser(c) if (r instanceof Response) return r const token = controlDb.rotateUserToken(r.user.id) audit(actorFor(r.user, false), "user.rotate_token", { targetUserId: r.user.id }) return c.json({ token }) }) // ---- AUDIT LOG ---- app.get("/api/audit", (c) => { const r = requireAdmin(c) if (r instanceof Response) return r const limitRaw = c.req.query("limit") const sinceTs = c.req.query("sinceTs") const limit = limitRaw ? parseInt(limitRaw, 10) : 100 const rows = controlDb.listAudit({ limit: Number.isFinite(limit) ? limit : 100, sinceTs: typeof sinceTs === "string" && sinceTs.length > 0 ? sinceTs : undefined, }) const entries = rows.map((row) => ({ id: row.id, ts: row.ts, actor: row.actor, action: row.action, targetDeployId: row.targetDeployId, targetUserId: row.targetUserId, metadata: row.metadata ? JSON.parse(row.metadata) : null, })) return c.json({ entries }) }) // ---- DEPLOYS ---- app.get("/api/deploys", (c) => { const r = requireUser(c) if (r instanceof Response) return r const ids = r.user.isAdmin === 1 ? fs .readdirSync(deploysDir, { withFileTypes: true }) .filter((e) => e.isDirectory()) .map((e) => e.name) : controlDb.listDeployIdsForUser(r.user.id) const records = ids .map((id) => readRecord(id)) .filter((rec): rec is HostedDeployRecord => rec !== null) .map((rec) => { const anon = controlDb.findAnonymous(rec.deployId) // Custom subdomains added via `pond domains add` aren't on the // record — they live in control-db. Surface them so the dashboard // can prefer the friendly URL over the hash one. const domains = controlDb.listDomainsForDeploy(rec.deployId).map((d) => urlForCustomDomain(d.subdomain)) return { deployId: rec.deployId, url: rec.url, apiUrl: rec.apiUrl, publicInspect: rec.publicInspect, createdAt: rec.createdAt, updatedAt: rec.updatedAt, claimedAt: rec.claimedAt, ownerId: controlDb.getDeployOwner(rec.deployId), anonymous: anon !== null, terminatesAt: anon?.terminatesAt, expiresAt: anon?.expiresAt, terminated: anon?.terminated === 1, title: rec.title, description: rec.description, isPublic: rec.isPublic === true, domains, } }) return c.json({ deploys: records }) }) app.post( "/api/deploys", bodyLimit({ maxSize: MAX_BUNDLE_BYTES, onError: (c) => c.json({ error: "Payload too large" }, 413) }), async (c) => { const providedAuth = bearer(c.req.header("authorization")) let user: UserRow | null = null if (providedAuth) { user = controlDb.findUserByTokenHash(controlDb.hashToken(providedAuth)) if (!user) return c.json({ error: "Unauthorized" }, 401) } const isAnonymous = user === null if (isAnonymous && !anonymousEnabled) { return c.json({ error: "Anonymous deploys disabled" }, 401) } if (isAnonymous) { const ip = clientIp(c) if (!rateAllow(ip)) { return new Response(JSON.stringify({ error: "Rate limit exceeded for anonymous deploys" }), { status: 429, headers: { "content-type": "application/json", "retry-after": "3600" }, }) } } // Global capacity gate (applies to anonymous AND authenticated creates): // shed load with a clean 503 instead of forking past mem_limit. Only the // creation of a NET-NEW capsule is gated — redeploy/claim/update of an // existing deploy reuses its slot and is unaffected. if (atCapacity()) { return new Response(JSON.stringify({ error: "Service unavailable" }), { status: 503, headers: { "content-type": "application/json", "retry-after": "30" }, }) } pendingBoots++ try { const quotaTemplate = isAnonymous ? ANONYMOUS_QUOTA : DEFAULT_QUOTA const body = (await c.req.json().catch(() => null)) as { sourceFiles?: unknown publicInspect?: unknown turnstileToken?: unknown } | null if (!body) return c.json({ error: "Invalid JSON body" }, 400) // Human/bot challenge for anonymous deploys. No-op unless the operator // configured a Turnstile secret; authenticated deploys are never // challenged. Token may ride in a header or the JSON body. if (isAnonymous && turnstileSecret) { const headerToken = c.req.header("x-pond-turnstile-token") ?? "" const bodyToken = typeof body.turnstileToken === "string" ? body.turnstileToken : "" const token = headerToken || bodyToken const verdict = await verifyTurnstile(turnstileSecret, token, clientIp(c)) if (!verdict.ok) { return c.json({ error: "Turnstile verification failed", errorCodes: verdict.errorCodes }, 403) } } const validated = validateSourceFiles(body.sourceFiles) if (!validated.ok) return c.json({ error: validated.error }, 400) const deployId = randomBytes(8).toString("hex") const claimToken = randomBytes(32).toString("hex") const dir = deployDirFor(deployId) fs.mkdirSync(dir, { recursive: true }) writeSourceTree(dir, validated.files) const createStart = Date.now() let buildResult: { bundleBytes: number bundleHash: string meta: { isPublic: boolean; isStatic: boolean; title?: string; description?: string } } try { buildResult = await buildDeployFromSource(dir) } catch (err: any) { removeDeployDir(dir) return c.json({ error: `Build failed: ${err?.message ?? err}` }, 400) } if (buildResult.bundleBytes > quotaTemplate.maxBundleBytes) { removeDeployDir(dir) return c.json( { error: `Bundle exceeds ${isAnonymous ? "anonymous" : "default"} per-deploy quota (${quotaTemplate.maxBundleBytes} bytes)`, }, 413, ) } const sizeAfter = dirSize(dir) if (sizeAfter > quotaTemplate.maxDiskBytes) { removeDeployDir(dir) return c.json({ error: `Disk usage ${sizeAfter} exceeds quota ${quotaTemplate.maxDiskBytes}` }, 413) } const nowIso = new Date().toISOString() const record: HostedDeployRecord = { deployId, // Persist only the hash. Plaintext `claimToken` returned to the // client below as a one-time disclosure. claimTokenHash: sha256Hex(claimToken), appPort: 0, url: urlFor(deployId), apiUrl, publicInspect: Boolean(body.publicInspect), createdAt: nowIso, updatedAt: nowIso, isPublic: buildResult.meta.isPublic, isStatic: buildResult.meta.isStatic, title: buildResult.meta.title, description: buildResult.meta.description, bundleBytes: buildResult.bundleBytes, bundleHash: buildResult.bundleHash, lastBuiltAt: nowIso, lastBuildDurationMs: Date.now() - createStart, } writeRecord(record) let extra: { terminatesAt?: string; expiresAt?: string } = {} if (isAnonymous) { controlDb.setQuota(deployId, ANONYMOUS_QUOTA) const { terminatesAt, expiresAt } = controlDb.createAnonymous( deployId, claimToken, anonymousGraceMs, anonymousRetentionMs, ) extra = { terminatesAt, expiresAt } } else { controlDb.setDeployOwner(deployId, user!.id) } try { // Static deploys are served straight from disk (client.html) and // never boot a worker — skip the fork entirely so they consume no // capsule slot. forkDeploy derives the uniform isolation policy // itself (the anon row, if any, was just written above). if (!record.isStatic) await forkDeploy(record) } catch (err: any) { try { removeDeployDir(dir) } catch {} if (isAnonymous) { controlDb.deleteAnonymous(deployId) } else { controlDb.deleteDeployOwner(deployId) } controlDb.deleteQuota(deployId) audit(actorFor(user, false, isAnonymous), "deploy.create_failed", { targetDeployId: deployId, metadata: { anonymous: isAnonymous, bundleBytes: buildResult.bundleBytes, error: String(err?.message ?? err), }, }) return c.json({ error: `Boot failed: ${err?.message ?? err}`, deployId }, 500) } audit(actorFor(user, false, isAnonymous), "deploy.create", { targetDeployId: deployId, targetUserId: user?.id, metadata: { anonymous: isAnonymous, bundleBytes: buildResult.bundleBytes }, }) return c.json( { ...record, // One-time disclosure of the plaintext claim token. Subsequent // reads of the record only see the hash. claimToken, ...extra, bundleHash: buildResult.bundleHash, bundleBytes: buildResult.bundleBytes, }, 201, ) } finally { // Release the reserved slot. On success the capsule is now counted in // runningChildren; on any failure path above it never became resident. pendingBoots-- } }, ) app.put( "/api/deploys/:deployId", bodyLimit({ maxSize: MAX_BUNDLE_BYTES, onError: (c) => c.json({ error: "Payload too large" }, 413) }), async (c) => { const deployId = c.req.param("deployId") const record = readRecord(deployId) if (!record) return c.json({ error: "Not found" }, 404) if (controlDb.findAnonymous(deployId)) { return c.json({ error: "Anonymous deploys cannot be updated — claim first" }, 403) } const auth = authorizeDeployMutation(c, record) if (auth instanceof Response) return auth const body = (await c.req.json().catch(() => null)) as { sourceFiles?: unknown publicInspect?: unknown envText?: unknown } | null if (!body) return c.json({ error: "Invalid JSON body" }, 400) const validated = validateSourceFiles(body.sourceFiles) if (!validated.ok) return c.json({ error: validated.error }, 400) const quota = controlDb.getQuota(deployId) const dir = deployDirFor(deployId) writeSourceTree(dir, validated.files) const updateStart = Date.now() let buildResult: { bundleBytes: number bundleHash: string meta: { isPublic: boolean; isStatic: boolean; title?: string; description?: string } } try { buildResult = await buildDeployFromSource(dir) } catch (err: any) { return c.json({ error: `Build failed: ${err?.message ?? err}` }, 400) } if (buildResult.bundleBytes > quota.maxBundleBytes) { return c.json({ error: `Bundle exceeds per-deploy quota (${quota.maxBundleBytes} bytes)` }, 413) } if (typeof body.envText === "string") { const v = validateEnvText(body.envText) if (!v.ok) return c.json({ error: v.error }, 413) fs.writeFileSync(path.join(dir, ".env.pond.server"), body.envText, { mode: 0o600 }) } const sizeAfter = dirSize(dir) if (sizeAfter > quota.maxDiskBytes) { return c.json({ error: `Disk usage ${sizeAfter} exceeds quota ${quota.maxDiskBytes}` }, 413) } const updateNow = new Date().toISOString() record.publicInspect = Boolean(body.publicInspect) record.updatedAt = updateNow record.isPublic = buildResult.meta.isPublic record.isStatic = buildResult.meta.isStatic record.title = buildResult.meta.title record.description = buildResult.meta.description record.bundleBytes = buildResult.bundleBytes record.bundleHash = buildResult.bundleHash record.lastBuiltAt = updateNow record.lastBuildDurationMs = Date.now() - updateStart writeRecord(record) try { // Static deploys serve from disk with no worker. If a redeploy flips // an existing (resident) deploy to static, stop its worker; otherwise // (re)boot the worker as usual. if (record.isStatic) { await stopDeploy(deployId) } else { await forkDeploy(record) } } catch (err: any) { return c.json({ error: `Boot failed: ${err?.message ?? err}` }, 500) } const actor = auth.kind === "claim" ? "__claim_token__" : actorFor(auth.user, false) audit(actor, "deploy.update", { targetDeployId: deployId, metadata: { bundleBytes: buildResult.bundleBytes, envChanged: typeof body.envText === "string" }, }) return c.json({ ...record, bundleHash: buildResult.bundleHash, bundleBytes: buildResult.bundleBytes }) }, ) app.post("/api/deploys/:deployId/claim", async (c) => { const deployId = c.req.param("deployId") const record = readRecord(deployId) if (!record) return c.json({ error: "Not found" }, 404) const body = (await c.req.json().catch(() => ({}))) as { claimToken?: unknown signup?: unknown envText?: unknown } if (typeof body.claimToken !== "string") { return c.json({ error: "claimToken required" }, 400) } const anon = controlDb.findAnonymous(deployId) const tokenMatchesRecord = safeEqual(sha256Hex(body.claimToken), record.claimTokenHash) const tokenMatchesAnon = anon ? controlDb.verifyAnonymousClaim(deployId, body.claimToken) : false if (!tokenMatchesRecord && !tokenMatchesAnon) { return c.json({ error: "Forbidden" }, 403) } // Resolve user let user: UserRow | null = null let createdCredential: { username: string; token: string } | null = null const signup = body.signup as { username?: unknown } | undefined const bearerToken = bearer(c.req.header("authorization")) if (signup && typeof signup.username === "string") { if (!anon) { return c.json({ error: "signup only allowed for unclaimed anonymous deploys" }, 400) } const baseName = signup.username if (!/^[a-z0-9_-]{1,29}$/i.test(baseName)) { return c.json({ error: "username must match /^[a-z0-9_-]{1,29}$/i" }, 400) } // Try base, then base-2 ... base-99. let chosen: string | null = null if (!controlDb.findUserByUsername(baseName)) { chosen = baseName } else { for (let n = 2; n <= 99; n++) { const candidate = `${baseName}-${n}` if (!controlDb.findUserByUsername(candidate)) { chosen = candidate break } } } if (!chosen) { return c.json({ error: "username taken (tried -2..-99)" }, 409) } // Never grant admin via self-service claim/signup. Admin bootstrap goes // through POST /api/users, which requires the host token for the first // user — minting an admin here would let the first anonymous claimer on // a fresh host take over. const created = controlDb.createUser(chosen, false) user = created.user createdCredential = { username: created.user.username, token: created.token } } else if (bearerToken) { user = controlDb.findUserByTokenHash(controlDb.hashToken(bearerToken)) if (!user) return c.json({ error: "Unauthorized" }, 401) } else { return c.json({ error: "Provide signup or Authorization" }, 400) } if (anon) { controlDb.promoteAnonymous(deployId, user.id) // First claim of an anonymous deploy — also reset the quota row so the // claimed deploy gets the larger DEFAULT_QUOTA instead of inheriting // ANONYMOUS_QUOTA forever. (Bug pre-0.3.9: claimed-from-anon deploys // were stuck at 16MB bundle / 128MB disk / 128MB memory.) controlDb.setQuota(deployId, DEFAULT_QUOTA) } else { // Re-claim of an ALREADY-OWNED deploy. The claim token alone is NOT // sufficient to transfer ownership — anyone with a copy of `.pond/ // deploy.json`, an IDE link with `#token=…`, or the persisted // `localStorage` value could otherwise dispossess the current owner // silently. Allow only the existing owner / an admin / the host // token to perform this no-op reattachment. const currentOwnerId = controlDb.getDeployOwner(deployId) const isCurrentOwner = currentOwnerId !== null && currentOwnerId === user.id if (!isCurrentOwner && user.isAdmin !== 1) { audit(actorFor(user, false), "deploy.claim_denied", { targetDeployId: deployId, targetUserId: user.id, metadata: { reason: "not_current_owner_or_admin" }, }) return c.json({ error: "Deploy already owned by another account" }, 403) } controlDb.setDeployOwner(deployId, user.id) } if (typeof body.envText === "string") { const v = validateEnvText(body.envText) if (!v.ok) return c.json({ error: v.error }, 413) fs.writeFileSync(path.join(deployDirFor(deployId), ".env.pond.server"), body.envText, { mode: 0o600 }) } // Rotate the claim token on every successful claim. The token that // authorized this claim may have leaked (IDE links with #token=…, // .pond/deploy.json, persisted localStorage). authorizeDeployMutation // still accepts the claim token for mutate/delete, so a stale copy would // otherwise stay a live write/delete credential after ownership. Minting // a fresh token here invalidates every leaked copy of the old one; the // legitimate caller persists the new value we return below. const rotatedClaimToken = randomBytes(32).toString("hex") record.claimTokenHash = sha256Hex(rotatedClaimToken) record.claimedAt = record.claimedAt ?? new Date().toISOString() record.updatedAt = new Date().toISOString() writeRecord(record) try { await forkDeploy(record) } catch (err: any) { return c.json({ error: `Boot failed: ${err?.message ?? err}` }, 500) } audit(actorFor(user, false), "deploy.claim", { targetDeployId: deployId, targetUserId: user.id, metadata: { fromAnonymous: anon !== null, signedUp: createdCredential !== null, }, }) const resp: Record = { ...record, // Return the freshly rotated plaintext claim token so the client can // persist it. The server stores only the hash; the old token the // client sent in body.claimToken is now dead. claimToken: rotatedClaimToken, } if (createdCredential) resp.user = createdCredential return c.json(resp) }) app.post("/api/deploys/:deployId/rotate-claim-token", async (c) => { const deployId = c.req.param("deployId") const record = readRecord(deployId) if (!record) return c.json({ error: "Not found" }, 404) const r = requireUser(c) if (r instanceof Response) return r const ownerId = controlDb.getDeployOwner(deployId) if (r.user.isAdmin !== 1 && ownerId !== r.user.id) { return c.json({ error: "Forbidden" }, 403) } const newToken = randomBytes(32).toString("hex") record.claimTokenHash = sha256Hex(newToken) record.updatedAt = new Date().toISOString() writeRecord(record) try { await forkDeploy(record) } catch (err: any) { return c.json({ error: `Boot failed: ${err?.message ?? err}` }, 500) } audit(actorFor(r.user, false), "deploy.rotate_claim_token", { targetDeployId: deployId }) return c.json({ deployId, claimToken: newToken }) }) app.delete("/api/deploys/:deployId", async (c) => { const deployId = c.req.param("deployId") const record = readRecord(deployId) if (!record) return c.json({ error: "Not found" }, 404) const auth = authorizeDeployMutation(c, record) if (auth instanceof Response) return auth await stopDeploy(deployId) restartState.delete(deployId) if (capsuleCgroupRoot) removeCapsuleCgroup(capsuleCgroupRoot, deployId) removeDeployDir(deployDirFor(deployId)) controlDb.deleteDeployOwner(deployId) controlDb.deleteAnonymous(deployId) controlDb.deleteQuota(deployId) controlDb.removeDomainsForDeploy(deployId) invalidatePublicListing() const actor = auth.kind === "claim" ? "__claim_token__" : actorFor(auth.user, false) audit(actor, "deploy.delete", { targetDeployId: deployId }) return c.json({ ok: true }) }) // Operator kill switch. Mirrors the sweep's terminate path (stopDeploy + // markTerminated) but on demand, gated by the host token / an admin user — // backs `pond admin terminate `. Anonymous deploys are marked // terminated so they stay down (bootOptsForRecord returns null) and the // retention sweep still deletes them; non-anonymous deploys are stopped // (the operator can DELETE them outright if they want the bytes gone too). app.post("/api/admin/deploys/:deployId/terminate", async (c) => { const deployId = c.req.param("deployId") const record = readRecord(deployId) if (!record) return c.json({ error: "Not found" }, 404) const r = requireAdmin(c) if (r instanceof Response) return r await stopDeploy(deployId) restartState.delete(deployId) const anonymous = controlDb.findAnonymous(deployId) !== null if (anonymous) controlDb.markTerminated(deployId) invalidatePublicListing() audit(actorFor(r.user, r.viaHostToken), "deploy.terminate", { targetDeployId: deployId, metadata: { anonymous }, }) return c.json({ ok: true, deployId, anonymous, terminated: true }) }) app.put("/api/deploys/:deployId/quota", async (c) => { const deployId = c.req.param("deployId") const record = readRecord(deployId) if (!record) return c.json({ error: "Not found" }, 404) const r = requireAdmin(c) if (r instanceof Response) return r const body = (await c.req.json().catch(() => null)) as { maxBundleBytes?: unknown maxDiskBytes?: unknown maxMemoryMb?: unknown maxCpuPercent?: unknown } | null if (!body) return c.json({ error: "Invalid JSON body" }, 400) if ( body.maxBundleBytes === undefined && body.maxDiskBytes === undefined && body.maxMemoryMb === undefined && body.maxCpuPercent === undefined ) { return c.json({ error: "At least one of maxBundleBytes/maxDiskBytes/maxMemoryMb/maxCpuPercent required" }, 400) } const patch: { maxBundleBytes?: number; maxDiskBytes?: number; maxMemoryMb?: number; maxCpuPercent?: number } = {} if (body.maxBundleBytes !== undefined) { if (typeof body.maxBundleBytes !== "number" || body.maxBundleBytes <= 0) { return c.json({ error: "maxBundleBytes must be positive number" }, 400) } patch.maxBundleBytes = body.maxBundleBytes } if (body.maxDiskBytes !== undefined) { if (typeof body.maxDiskBytes !== "number" || body.maxDiskBytes <= 0) { return c.json({ error: "maxDiskBytes must be positive number" }, 400) } patch.maxDiskBytes = body.maxDiskBytes } if (body.maxMemoryMb !== undefined) { if (typeof body.maxMemoryMb !== "number" || body.maxMemoryMb <= 0) { return c.json({ error: "maxMemoryMb must be positive number" }, 400) } patch.maxMemoryMb = body.maxMemoryMb } if (body.maxCpuPercent !== undefined) { if (typeof body.maxCpuPercent !== "number" || body.maxCpuPercent <= 0) { return c.json({ error: "maxCpuPercent must be positive number" }, 400) } patch.maxCpuPercent = body.maxCpuPercent } const prev = controlDb.getQuota(deployId) const next = controlDb.setQuota(deployId, patch) if (next.maxMemoryMb !== prev.maxMemoryMb || next.maxCpuPercent !== prev.maxCpuPercent) { try { await forkDeploy(record) } catch (err: any) { return c.json({ error: `Re-fork failed: ${err?.message ?? err}`, quota: next }, 500) } } audit(actorFor(r.user, r.viaHostToken), "deploy.quota_update", { targetDeployId: deployId, metadata: { patch, prev, next }, }) return c.json({ quota: next }) }) app.get("/api/deploys/:deployId/quota", (c) => { const deployId = c.req.param("deployId") const record = readRecord(deployId) if (!record) return c.json({ error: "Not found" }, 404) const r = requireUser(c) if (r instanceof Response) return r const ownerId = controlDb.getDeployOwner(deployId) if (r.user.isAdmin !== 1 && ownerId !== r.user.id) { return c.json({ error: "Forbidden" }, 403) } return c.json({ quota: controlDb.getQuota(deployId) }) }) // ---- ENV CRUD ---- function requireDeployOwner(c: any, deployId: string): { record: HostedDeployRecord } | Response { const record = readRecord(deployId) if (!record) return c.json({ error: "Not found" }, 404) if (controlDb.findAnonymous(deployId)) { return c.json({ error: "Anonymous deploys cannot manage env — claim first" }, 403) } const r = requireUser(c) if (r instanceof Response) return r const ownerId = controlDb.getDeployOwner(deployId) if (r.user.isAdmin !== 1 && ownerId !== r.user.id) { return c.json({ error: "Forbidden" }, 403) } return { record } } app.get("/api/deploys/:deployId/env", (c) => { const deployId = c.req.param("deployId") const r = requireDeployOwner(c, deployId) if (r instanceof Response) return r const entries = readEnv(deployId) return c.json({ entries }) }) // Bearer-authed proxy for the deploy's recent log buffer. Deploy workers // write to `/.pond/logs.ndjson` (cwd is the deploy dir — see // `forkDeploy`). The worker also exposes `GET /__pond/logs` as an SSE // stream, but that endpoint is gated on `x-pond-claim-token`, which only // the deploying machine ever sees in plaintext. MCP tools authenticate // with the account bearer, so they cannot reach the worker directly — // hence this control-plane proxy that owner-checks via bearer and reads // the same on-disk replay buffer. app.get("/api/deploys/:deployId/logs", (c) => { const deployId = c.req.param("deployId") const r = requireDeployOwner(c, deployId) if (r instanceof Response) return r const raw = c.req.query("limit") const parsed = raw === undefined ? 100 : Number.parseInt(raw, 10) const limit = Number.isFinite(parsed) ? Math.min(Math.max(parsed, 1), 500) : 100 const logFile = path.join(deployDirFor(deployId), ".pond", "logs.ndjson") if (!fs.existsSync(logFile)) return c.json({ entries: [] }) const text = fs.readFileSync(logFile, "utf-8") const lines = text.split("\n").filter((l) => l.length > 0) const start = Math.max(0, lines.length - limit) const entries: unknown[] = [] for (let i = start; i < lines.length; i++) { try { entries.push(JSON.parse(lines[i])) } catch { // skip malformed lines (rotation race, partial write) } } return c.json({ entries }) }) // Inspect a hosted capsule's DB schema + source layout. Opens the // capsule's SQLite file read-only so it's safe while the worker is // running (WAL mode allows concurrent readers). Used by the dashboard // detail page to render an Outline / KPI overview without a CORS hop // through the capsule itself. app.get("/api/deploys/:deployId/inspect", (c) => { const deployId = c.req.param("deployId") const r = requireDeployOwner(c, deployId) if (r instanceof Response) return r const dir = deployDirFor(deployId) const dbPath = path.join(dir, ".pond", "data.db") const sourceDir = path.join(dir, "source") type TableInfo = { name: string; rowCount: number; columns: number } const tables: TableInfo[] = [] let dbBytes = 0 let dbOpenError: string | undefined if (fs.existsSync(dbPath)) { try { dbBytes = fs.statSync(dbPath).size } catch { // best-effort } let db: Database.Database | undefined try { db = new Database(dbPath, { readonly: true, fileMustExist: true }) const rows = db .prepare( "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_pond_%' ORDER BY name ASC", ) .all() as Array<{ name: string }> for (const { name } of rows) { // Identifier validation: sqlite_master `name` is the table's // declared identifier. Reject anything outside the safe alnum/_ // set before interpolating into the COUNT(*) statement. if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) continue let rowCount = 0 let columns = 0 try { const c = db.prepare(`SELECT COUNT(*) AS n FROM "${name}"`).get() as { n: number } rowCount = Number(c?.n ?? 0) } catch { // table might be locked or schema is unusual — surface zero } try { const cols = db.prepare(`PRAGMA table_info("${name}")`).all() as unknown[] columns = cols.length } catch { // best-effort } tables.push({ name, rowCount, columns }) } } catch (err) { dbOpenError = err instanceof Error ? err.message : String(err) } finally { try { db?.close() } catch { // ignore close errors on a read-only handle } } } let sourceFileCount = 0 const countFiles = (rel: string) => { const abs = path.join(sourceDir, rel) if (!fs.existsSync(abs)) return const stat = fs.statSync(abs) if (stat.isFile()) { sourceFileCount += 1 return } if (stat.isDirectory()) { for (const entry of fs.readdirSync(abs)) { if (entry === "node_modules" || entry.startsWith(".")) continue countFiles(path.join(rel, entry)) } } } try { countFiles("") } catch { // best-effort; partial counts still useful } const totalRows = tables.reduce((acc, t) => acc + t.rowCount, 0) return c.json({ deployId, dbBytes, dbOpenError, tableCount: tables.length, totalRows, tables, sourceFileCount, }) }) // Toggle a capsule's public-inspect flag without redeploying its source. // Owner-only. Triggers forkDeploy because the worker reads the flag from // its spawn args — changing it without restart would leave the running // worker with stale state. app.patch("/api/deploys/:deployId/visibility", async (c) => { const deployId = c.req.param("deployId") const r = requireDeployOwner(c, deployId) if (r instanceof Response) return r const body = (await c.req.json().catch(() => null)) as { publicInspect?: unknown } | null if (!body || typeof body.publicInspect !== "boolean") { return c.json({ error: "body must be { publicInspect: boolean }" }, 400) } r.record.publicInspect = body.publicInspect r.record.updatedAt = new Date().toISOString() writeRecord(r.record) try { await forkDeploy(r.record) } catch (err: any) { return c.json({ error: `Boot failed: ${err?.message ?? err}` }, 500) } const actor = authUser(c) if (actor) { audit(actorFor(actor, false), "deploy.visibility_update", { targetDeployId: deployId, metadata: { publicInspect: r.record.publicInspect }, }) } return c.json({ deployId, publicInspect: r.record.publicInspect }) }) // Sample recent rows from a named table in the capsule's DB. Used by the // dashboard Overview "Recent activity" panel and by the Tables tab for a // drill-down preview. Read-only WAL-safe open; identifier whitelisted // before interpolation. Ordering: prefer the table's `id` column DESC if // present, else fall back to the intrinsic `rowid` DESC (which is also // present on WITHOUT ROWID tables for our practical schemas — but if the // PRAGMA reports no `rowid` available we surface a friendly error rather // than throwing). app.get("/api/deploys/:deployId/inspect/table/:name", (c) => { const deployId = c.req.param("deployId") const name = c.req.param("name") const r = requireDeployOwner(c, deployId) if (r instanceof Response) return r if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { return c.json({ error: "invalid table name" }, 400) } const rawLimit = Number.parseInt(c.req.query("limit") ?? "", 10) const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(rawLimit, 1), 100) : 20 const dir = deployDirFor(deployId) const dbPath = path.join(dir, ".pond", "data.db") if (!fs.existsSync(dbPath)) return c.json({ error: "capsule has no database yet" }, 404) let db: Database.Database | undefined try { db = new Database(dbPath, { readonly: true, fileMustExist: true }) const exists = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(name) as | { name: string } | undefined if (!exists) return c.json({ error: `unknown table: ${name}` }, 404) const cols = db.prepare(`PRAGMA table_info("${name}")`).all() as Array<{ name: string; type: string }> const hasIdColumn = cols.some((col) => col.name === "id") // sqlite_master.sql is the table's CREATE statement. WITHOUT ROWID // tables can't be ordered by rowid; detect by substring match (the // statement always normalises to lowercase "without rowid" via // sqlite's parser when stored back) and skip ordering rather than // erroring out on a malformed query. const createSql = ( db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get(name) as | { sql: string } | undefined )?.sql ?? "" const isWithoutRowid = /without\s+rowid/i.test(createSql) let rows: unknown[] = [] if (hasIdColumn) { rows = db.prepare(`SELECT * FROM "${name}" ORDER BY id DESC LIMIT ?`).all(limit) } else if (!isWithoutRowid) { rows = db.prepare(`SELECT *, rowid AS __rowid FROM "${name}" ORDER BY rowid DESC LIMIT ?`).all(limit) } else { // No id and no rowid — return rows in whatever order sqlite gives. rows = db.prepare(`SELECT * FROM "${name}" LIMIT ?`).all(limit) } const total = (db.prepare(`SELECT COUNT(*) AS n FROM "${name}"`).get() as { n: number }).n return c.json({ name, columns: cols.map((col) => ({ name: col.name, type: col.type })), rows, rowCount: Number(total ?? 0), orderBy: hasIdColumn ? "id DESC" : !isWithoutRowid ? "rowid DESC" : "natural", }) } catch (err) { return c.json({ error: err instanceof Error ? err.message : String(err) }, 500) } finally { try { db?.close() } catch { // ignore close errors on read-only handle } } }) app.put("/api/deploys/:deployId/env", async (c) => { const deployId = c.req.param("deployId") const r = requireDeployOwner(c, deployId) if (r instanceof Response) return r const body = (await c.req.json().catch(() => ({}))) as { entries?: unknown } if (!body.entries || typeof body.entries !== "object" || Array.isArray(body.entries)) { return c.json({ error: "entries object required" }, 400) } const incoming = body.entries as Record const merged = readEnv(deployId) for (const [k, v] of Object.entries(incoming)) { if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k)) { return c.json({ error: `invalid key: ${k}` }, 400) } if (typeof v !== "string") { return c.json({ error: `value for ${k} must be string` }, 400) } if (v.length > MAX_ENV_VALUE_CHARS) { return c.json({ error: `value for ${k} exceeds ${MAX_ENV_VALUE_CHARS} chars` }, 413) } merged[k] = v } if (Object.keys(merged).length > MAX_ENV_ENTRIES) { return c.json({ error: `merged env exceeds ${MAX_ENV_ENTRIES} entries` }, 413) } const serializedBytes = Buffer.byteLength(serializeEnv(merged), "utf8") if (serializedBytes > MAX_ENV_BYTES) { return c.json({ error: `merged env exceeds ${MAX_ENV_BYTES} bytes` }, 413) } writeEnv(deployId, merged) const quota = controlDb.getQuota(deployId) const sizeAfter = dirSize(deployDirFor(deployId)) if (sizeAfter > quota.maxDiskBytes) { return c.json({ error: `Disk usage ${sizeAfter} exceeds quota ${quota.maxDiskBytes}` }, 413) } r.record.updatedAt = new Date().toISOString() writeRecord(r.record) try { await forkDeploy(r.record) } catch (err: any) { return c.json({ error: `Boot failed: ${err?.message ?? err}` }, 500) } const envActor = authUser(c) if (envActor) { audit(actorFor(envActor, false), "deploy.env_update", { targetDeployId: deployId, metadata: { keys: Object.keys(incoming) }, }) } return c.json({ entries: merged }) }) app.delete("/api/deploys/:deployId/env/:key", async (c) => { const deployId = c.req.param("deployId") const key = c.req.param("key") const r = requireDeployOwner(c, deployId) if (r instanceof Response) return r const entries = readEnv(deployId) if (!(key in entries)) return c.json({ entries }) delete entries[key] writeEnv(deployId, entries) r.record.updatedAt = new Date().toISOString() writeRecord(r.record) try { await forkDeploy(r.record) } catch (err: any) { return c.json({ error: `Boot failed: ${err?.message ?? err}` }, 500) } const envActor = authUser(c) if (envActor) { audit(actorFor(envActor, false), "deploy.env_delete", { targetDeployId: deployId, metadata: { key }, }) } return c.json({ entries }) }) // ---- SOURCE FILES (IDE) ---- const FILE_PATH_RE = /^(?:(server|client|shared)\/[a-zA-Z0-9_./-]+|package\.json)$/ const MAX_FILE_BYTES = 1 * 1024 * 1024 function safeSourcePath(deployId: string, requested: string): string | null { if (!requested || requested.includes("\\") || requested.startsWith("/")) return null const segments = requested.split("/") if (segments.some((s) => s === ".." || s === "." || s === "")) return null if (!FILE_PATH_RE.test(requested)) return null const sourceDir = path.join(deployDirFor(deployId), "source") const abs = path.join(sourceDir, requested) // belt + suspenders: ensure resolved path is still inside sourceDir if (!abs.startsWith(sourceDir + path.sep) && abs !== sourceDir) return null return abs } function fileSubpath(c: any, deployId: string): string | null { const prefix = `/api/deploys/${deployId}/files/` const url = new URL(c.req.url) let decoded: string try { decoded = decodeURIComponent(url.pathname) } catch { return null } if (!decoded.startsWith(prefix)) return null return decoded.slice(prefix.length) } function listSourceTree(deployId: string): { path: string; size: number; mtime: string }[] { const sourceDir = path.join(deployDirFor(deployId), "source") if (!fs.existsSync(sourceDir)) return [] const out: { path: string; size: number; mtime: string }[] = [] const walk = (rel: string) => { const abs = path.join(sourceDir, rel) const stat = fs.statSync(abs) if (stat.isFile()) { out.push({ path: rel, size: stat.size, mtime: stat.mtime.toISOString() }) return } if (stat.isDirectory()) { for (const entry of fs.readdirSync(abs).sort()) walk(rel ? `${rel}/${entry}` : entry) } } for (const entry of fs.readdirSync(sourceDir).sort()) walk(entry) return out } function authorizeFileOp(c: any, deployId: string): { record: HostedDeployRecord } | Response { const record = readRecord(deployId) if (!record) return c.json({ error: "Not found" }, 404) if (controlDb.findAnonymous(deployId)) { return c.json({ error: "Anonymous deploys cannot be edited — claim first" }, 403) } const auth = authorizeDeployMutation(c, record) if (auth instanceof Response) return auth return { record } } app.get("/api/deploys/:deployId/files", (c) => { const deployId = c.req.param("deployId") const r = authorizeFileOp(c, deployId) if (r instanceof Response) return r return c.json({ files: listSourceTree(deployId) }) }) app.get("/api/deploys/:deployId/files/*", (c) => { const deployId = c.req.param("deployId") const r = authorizeFileOp(c, deployId) if (r instanceof Response) return r const sub = fileSubpath(c, deployId) if (!sub) return c.json({ error: "Invalid path" }, 400) const abs = safeSourcePath(deployId, sub) if (!abs) return c.json({ error: "Path not allowed" }, 400) if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return c.json({ error: "Not found" }, 404) return c.body(fs.readFileSync(abs), 200, { "content-type": "text/plain; charset=utf-8" }) }) app.put( "/api/deploys/:deployId/files/*", bodyLimit({ maxSize: MAX_FILE_BYTES, onError: (c) => c.json({ error: "File too large" }, 413) }), async (c) => { const deployId = c.req.param("deployId") const r = authorizeFileOp(c, deployId) if (r instanceof Response) return r const sub = fileSubpath(c, deployId) if (!sub) return c.json({ error: "Invalid path" }, 400) const abs = safeSourcePath(deployId, sub) if (!abs) return c.json({ error: "Path not allowed" }, 400) const body = await c.req.text() if (Buffer.byteLength(body, "utf-8") > MAX_FILE_BYTES) { return c.json({ error: `File exceeds ${MAX_FILE_BYTES} bytes` }, 413) } // Same rule as the bulk source endpoint: package.json can't define // npm lifecycle scripts (preinstall/install/postinstall/prepare/ // postprepare). Caught here too because IDE single-file PUT bypasses // validateSourceFiles entirely. if (sub === "package.json") { const lifecycle = findPackageJsonLifecycleScripts(body) if (!lifecycle.ok) { return c.json( { error: `package.json defines npm lifecycle script(s) ${lifecycle.offending.join(", ")} which are not allowed on hosted deploys`, }, 400, ) } } fs.mkdirSync(path.dirname(abs), { recursive: true }) fs.writeFileSync(abs, body) r.record.updatedAt = new Date().toISOString() writeRecord(r.record) const fileActor = authUser(c) audit(fileActor ? actorFor(fileActor, false) : "__claim_token__", "deploy.file_write", { targetDeployId: deployId, metadata: { path: sub, bytes: Buffer.byteLength(body, "utf-8") }, }) return c.json({ ok: true, path: sub, size: Buffer.byteLength(body, "utf-8") }) }, ) app.delete("/api/deploys/:deployId/files/*", (c) => { const deployId = c.req.param("deployId") const r = authorizeFileOp(c, deployId) if (r instanceof Response) return r const sub = fileSubpath(c, deployId) if (!sub) return c.json({ error: "Invalid path" }, 400) if (sub === "server/index.ts" || sub === "package.json") { return c.json({ error: "Cannot delete required file" }, 400) } const abs = safeSourcePath(deployId, sub) if (!abs) return c.json({ error: "Path not allowed" }, 400) if (!fs.existsSync(abs)) return c.json({ ok: true }) fs.rmSync(abs, { force: true }) r.record.updatedAt = new Date().toISOString() writeRecord(r.record) const fileActor = authUser(c) audit(fileActor ? actorFor(fileActor, false) : "__claim_token__", "deploy.file_delete", { targetDeployId: deployId, metadata: { path: sub }, }) return c.json({ ok: true }) }) app.post("/api/deploys/:deployId/build", async (c) => { const deployId = c.req.param("deployId") const r = authorizeFileOp(c, deployId) if (r instanceof Response) return r const dir = deployDirFor(deployId) if (!fs.existsSync(path.join(dir, "source", "server", "index.ts"))) { return c.json({ ok: false, errors: [{ text: "source/server/index.ts missing on host" }] }, 200) } const start = Date.now() let result: { bundleBytes: number bundleHash: string meta: { isPublic: boolean; isStatic: boolean; title?: string; description?: string } } try { result = await buildDeployFromSource(dir) } catch (err: any) { // esbuild throws a BuildFailure with .errors[] on compile error const messages: { file?: string; line?: number; column?: number; text: string }[] = [] const raw = Array.isArray(err?.errors) ? err.errors : null if (raw) { for (const m of raw) { messages.push({ file: m?.location?.file, line: m?.location?.line, column: m?.location?.column, text: String(m?.text ?? "unknown error"), }) } } else { messages.push({ text: String(err?.message ?? err) }) } return c.json({ ok: false, errors: messages, durationMs: Date.now() - start }, 200) } const quota = controlDb.getQuota(deployId) if (result.bundleBytes > quota.maxBundleBytes) { return c.json( { ok: false, errors: [{ text: `Bundle exceeds per-deploy quota (${quota.maxBundleBytes} bytes)` }] }, 200, ) } const buildNow = new Date().toISOString() const buildDuration = Date.now() - start r.record.updatedAt = buildNow r.record.isPublic = result.meta.isPublic r.record.title = result.meta.title r.record.description = result.meta.description r.record.bundleBytes = result.bundleBytes r.record.bundleHash = result.bundleHash r.record.lastBuiltAt = buildNow r.record.lastBuildDurationMs = buildDuration writeRecord(r.record) try { await forkDeploy(r.record) } catch (err: any) { return c.json({ ok: false, errors: [{ text: `Boot failed: ${err?.message ?? err}` }] }, 200) } const buildActor = authUser(c) audit(buildActor ? actorFor(buildActor, false) : "__claim_token__", "deploy.rebuild", { targetDeployId: deployId, metadata: { bundleBytes: result.bundleBytes, durationMs: buildDuration }, }) return c.json({ ok: true, bundleBytes: result.bundleBytes, bundleHash: result.bundleHash, durationMs: buildDuration, }) }) app.post("/api/deploys/:deployId/files/move", async (c) => { const deployId = c.req.param("deployId") const r = authorizeFileOp(c, deployId) if (r instanceof Response) return r const body = (await c.req.json().catch(() => null)) as { from?: unknown; to?: unknown } | null if (!body || typeof body.from !== "string" || typeof body.to !== "string") { return c.json({ error: "from + to required" }, 400) } if (body.from === "server/index.ts" || body.from === "package.json") { return c.json({ error: "Cannot move required file" }, 400) } const fromAbs = safeSourcePath(deployId, body.from) const toAbs = safeSourcePath(deployId, body.to) if (!fromAbs || !toAbs) return c.json({ error: "Path not allowed" }, 400) if (!fs.existsSync(fromAbs)) return c.json({ error: "Source not found" }, 404) if (fs.existsSync(toAbs)) return c.json({ error: "Destination exists" }, 409) fs.mkdirSync(path.dirname(toAbs), { recursive: true }) fs.renameSync(fromAbs, toAbs) r.record.updatedAt = new Date().toISOString() writeRecord(r.record) const fileActor = authUser(c) audit(fileActor ? actorFor(fileActor, false) : "__claim_token__", "deploy.file_move", { targetDeployId: deployId, metadata: { from: body.from, to: body.to }, }) return c.json({ ok: true }) }) // ---- PUBLIC GALLERY + FORK ---- // Unauthenticated. Lists deploys whose capsule declared `public: true` in // its last build. Anonymous deploys are excluded — there's no "owner" to // attribute them to, and they have a short retention window. // publicListingCache + invalidatePublicListing are declared up top (next // to writeRecord) — see comment there. The handler below closes over // those bindings. app.get("/api/public-deploys", (c) => { const now = Date.now() if (publicListingCache && publicListingCache.expiresAt > now) { return c.json(publicListingCache.body) } const out: Array<{ deployId: string url: string title?: string description?: string createdAt: string }> = [] const ids = fs .readdirSync(deploysDir, { withFileTypes: true }) .filter((e) => e.isDirectory()) .map((e) => e.name) for (const id of ids) { const rec = readRecord(id) if (!rec?.isPublic) continue if (controlDb.findAnonymous(id)) continue out.push({ deployId: rec.deployId, url: rec.url, title: rec.title, description: rec.description, createdAt: rec.createdAt, }) } out.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)) const body = { deploys: out } publicListingCache = { body, expiresAt: now + PUBLIC_LISTING_TTL_MS } return c.json(body) }) // Returns all source files for a public capsule as { path: content } so // `pond fork` can scaffold a copy. Owner-private deploys 404 here even // if they exist — non-public capsules don't show up in any listing and // their source is unreachable through this surface. app.get("/api/public-deploys/:deployId/source", (c) => { const deployId = c.req.param("deployId") const rec = readRecord(deployId) if (!rec?.isPublic) return c.json({ error: "Not found" }, 404) const sourceDir = path.join(deployDirFor(deployId), "source") if (!fs.existsSync(sourceDir)) return c.json({ error: "Not found" }, 404) const files: Record = {} const walk = (rel: string) => { const abs = path.join(sourceDir, rel) const stat = fs.statSync(abs) if (stat.isDirectory()) { for (const entry of fs.readdirSync(abs).sort()) walk(rel ? `${rel}/${entry}` : entry) } else if (stat.isFile()) { // 1 MB per file ceiling — same as the IDE if (stat.size > 1 * 1024 * 1024) return files[rel] = fs.readFileSync(abs, "utf-8") } } for (const entry of fs.readdirSync(sourceDir).sort()) walk(entry) return c.json({ deployId, title: rec.title, description: rec.description, files, }) }) // ---- CUSTOM DOMAINS ---- app.get("/api/domains", (c) => { const r = requireUser(c) if (r instanceof Response) return r const rows = r.user.isAdmin === 1 ? fs .readdirSync(deploysDir, { withFileTypes: true }) .filter((e) => e.isDirectory()) .flatMap((e) => controlDb .listDomainsForDeploy(e.name) .map((d) => ({ subdomain: d.subdomain, deployId: e.name, createdAt: d.createdAt })), ) : controlDb.listDomainsForUser(r.user.id) return c.json({ domains: rows }) }) app.post("/api/domains", async (c) => { const r = requireUser(c) if (r instanceof Response) return r const body = (await c.req.json().catch(() => ({}))) as { subdomain?: unknown; deployId?: unknown } if (typeof body.subdomain !== "string" || typeof body.deployId !== "string") { return c.json({ error: "subdomain and deployId required" }, 400) } const sub = body.subdomain if (!SUBDOMAIN_LABEL_RE.test(sub) || sub.length > 63) { return c.json( { error: "invalid subdomain (DNS label rules: a-z, 0-9, hyphens; max 63; no leading/trailing hyphen)" }, 400, ) } if (RESERVED_SUBDOMAINS.has(sub)) { return c.json({ error: `subdomain "${sub}" is reserved` }, 400) } if (HEX_DEPLOY_ID_RE.test(sub)) { return c.json({ error: "subdomain may not be a 16-char hex string (collides with deployId routing)" }, 400) } const record = readRecord(body.deployId) if (!record) return c.json({ error: "Not found" }, 404) const ownerId = controlDb.getDeployOwner(body.deployId) if (r.user.isAdmin !== 1 && ownerId !== r.user.id) { return c.json({ error: "Forbidden" }, 403) } if (r.user.isAdmin !== 1 && controlDb.countDomainsForUser(r.user.id) >= MAX_DOMAINS_PER_USER) { return c.json({ error: `domain limit reached (${MAX_DOMAINS_PER_USER} per user)` }, 429) } try { controlDb.addDomain(sub, body.deployId) } catch (err: any) { if (String(err?.code ?? "").includes("SQLITE_CONSTRAINT")) { return c.json({ error: "subdomain already taken" }, 409) } throw err } const row = controlDb.findDomain(sub) audit(actorFor(r.user, false), "domain.add", { targetDeployId: body.deployId, metadata: { subdomain: sub }, }) return c.json( { subdomain: sub, deployId: body.deployId, createdAt: row?.createdAt, url: urlForCustomDomain(sub), }, 201, ) }) app.delete("/api/domains/:subdomain", (c) => { const r = requireUser(c) if (r instanceof Response) return r const sub = c.req.param("subdomain").toLowerCase() const row = controlDb.findDomain(sub) if (!row) return c.json({ error: "Not found" }, 404) const ownerId = controlDb.getDeployOwner(row.deployId) if (r.user.isAdmin !== 1 && ownerId !== r.user.id) { return c.json({ error: "Forbidden" }, 403) } controlDb.removeDomain(sub) audit(actorFor(r.user, false), "domain.remove", { targetDeployId: row.deployId, metadata: { subdomain: sub }, }) return c.json({ ok: true }) }) const HOP_BY_HOP = new Set([ "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade", "host", ]) function deployIdFromHost(hostHeader: string | undefined): string | null { if (!hostHeader) return null const bare = hostHeader.toLowerCase().split(":")[0] const dot = bare.indexOf(".") if (dot <= 0) return null const sub = bare.slice(0, dot) if (HEX_DEPLOY_ID_RE.test(sub)) return sub const domain = controlDb.findDomain(sub) return domain?.deployId ?? null } function isBareDomainRequest(hostHeader: string | undefined): boolean { if (!hostHeader) return false const bare = hostHeader.toLowerCase().split(":")[0] return bare === externalHost || bare === `www.${externalHost}` } // Serve a static deploy: its prebuilt client.html for any navigational GET // (SPA fallback), with no worker behind it. /api/* paths have no server to // answer them on a static deploy, and non-GET methods are rejected. The // surrounding security-header middleware (CSP/HSTS/frame-ancestors) still // runs on the returned response, so static deploys get the same hardening // as proxied ones. function serveStaticDeploy(deployId: string, c: any): Response { const clientPath = path.join(deployDirFor(deployId), "client.html") if (!fs.existsSync(clientPath)) { return c.json({ error: "Not found" }, 404) } if (c.req.path.startsWith("/api/")) { return c.json({ error: "This is a static deploy — it has no server endpoints" }, 404) } if (c.req.method !== "GET" && c.req.method !== "HEAD") { return c.json({ error: "Method not allowed" }, 405) } const html = fs.readFileSync(clientPath, "utf-8") return c.html(html) } function landingHtml(): string { const installCmd = "npm install -g pondsh" // Served via jsDelivr's CDN front over the public pond-assets repo (not // raw.githubusercontent.com): correct video/mp4 content-type, week-long // edge caching, and multi-region nodes — so the landing demo survives a // launch traffic spike that would throttle raw.githubusercontent. const demoVideoUrl = "https://cdn.jsdelivr.net/gh/DevvGwardo/pond-assets@main/pond-demo.mp4" const demoPosterUrl = "https://cdn.jsdelivr.net/gh/DevvGwardo/pond-assets@main/pond-demo-poster.png" // SEO: absolute origin for canonical/OG, a descriptive title + meta // description, and SoftwareApplication structured data. const origin = publicBaseUrl ? `${publicBaseUrl.protocol}//${publicBaseUrl.host}` : `http://${publicHost}:${port}` const metaTitle = "Pond — Agent-native full-stack TypeScript, deployed anonymously" const metaDesc = "Pond runs agent-native full-stack TypeScript capsules — schema, queries, mutations, and UI in one file. Deploy anonymously in seconds, no account required." const ldJson = JSON.stringify({ "@context": "https://schema.org", "@type": "SoftwareApplication", name: "Pond", applicationCategory: "DeveloperApplication", operatingSystem: "Any", description: metaDesc, url: `${origin}/`, offers: { "@type": "Offer", price: "0", priceCurrency: "USD" }, }).replace(/ ${metaTitle}

pond [alpha]

Agent-native full-stack TypeScript capsules. Deploy anonymously.

Watch the demo

See how it works

0:00 / 0:00

Anonymous deploys are sandboxed and may be terminated at any time. Stats · Abuse policy · Security

` } function galleryHtml(): string { // Tiny client-side renderer: hits /api/public-deploys + draws cards. // No bundler, no SPA — the page is one HTML file and 30 lines of inline JS. return ` Pond Gallery

Pond gallery

Capsules whose authors opted into capsule({ public: true }). Fork to spin up your own copy.

Loading…
` } // Shared CSS + chrome for the /docs surface. Same palette / type as // landingHtml() — black, mono accents, no rounded corners, no rainbow // code highlighting. The sidebar collapses to a top-bar
on // mobile via media query, no JS. function docsChrome(opts: { title: string; activeSlug: string | null; bodyHtml: string }): string { const navItems = DOCS_CATALOG.map( (d) => `
  • ${htmlEscape(d.title)}
  • `, ).join("\n ") return ` ${htmlEscape(opts.title)}
    ${opts.bodyHtml}
    ` } function docsIndexHtml(): string { const cards = DOCS_CATALOG.map( (d) => ` ${htmlEscape(d.slug)}

    ${htmlEscape(d.title)}

    ${htmlEscape(d.summary)}

    `, ).join("") const body = `

    pond / docs

    Everything pond can do.

    Five short references. The CLI and the runtime, the MCP server, and the operations guide for when you outgrow a laptop.

    ${cards}
    ` return docsChrome({ title: "Docs · Pond", activeSlug: null, bodyHtml: body }) } function docsPageHtml(slug: string, markdown: string): string | null { const meta = DOCS_CATALOG.find((d) => d.slug === slug) if (!meta) return null const body = renderMarkdown(markdown) return docsChrome({ title: `${meta.title} · Pond docs`, activeSlug: slug, bodyHtml: body, }) } function abuseHtml(): string { const contact = abuseEmail ? `${abuseEmail}` : "the host operator" return ` Pond — Abuse Policy

    Abuse policy

    Pond hosts arbitrary user-submitted capsules anonymously. To keep this service usable, the following are prohibited:

    • Phishing, credential harvesting, or impersonation of any third party.
    • Malware distribution, drive-by downloads, exploit kits.
    • Cryptocurrency mining, brute force / credential stuffing, or any abuse of compute resources.
    • Spam, scams, illegal content under the jurisdiction of the host operator.
    • Targeted harassment or doxing.

    Reporting abuse

    Email ${contact} with the deploy URL and a description of the issue. The host operator may take down any deploy at any time without notice. There is no SLA.

    Service limits

    Anonymous deploys are subject to generous but finite resource limits (bundle size, disk, memory, and network requests). Unclaimed deploys are terminated after a grace period and deleted after a retention period. Outbound network access is restricted for anonymous deploys.

    No warranty

    Pond is provided as-is. The host operator makes no guarantees of availability, durability, or fitness for any purpose. Do not deploy production workloads.

    ← back

    ` } // Public deployment stats. Aggregate counts only (no deploy ids / inventory), // derived from deploy.create audit events — safe to expose, unlike /metrics. function statsHtml(): string { const s = controlDb.deployStats() const fmt = (n: number) => n.toLocaleString("en-US") // Dot-matrix area chart over the last 30 days (UTC). Each column is a day; // dots are lit from the baseline up to the day's scaled height, so adjacent // columns read as an area silhouette. Surface dots glow. Server-rendered // SVG — no client JS. const COLS = 30 const ROWS = 10 const counts = new Map(s.daily.map((d) => [d.day, d.count])) const now = new Date() const series: Array<{ day: string; count: number }> = [] for (let i = COLS - 1; i >= 0; i--) { const dt = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - i)) const key = dt.toISOString().slice(0, 10) series.push({ day: key, count: counts.get(key) ?? 0 }) } const maxVal = Math.max(1, ...series.map((p) => p.count)) const cellX = 19 const cellY = 14 const padL = 16 const padT = 12 const padB = 26 const W = padL * 2 + (COLS - 1) * cellX const H = padT + (ROWS - 1) * cellY + padB const unlit: string[] = [] const litDots: string[] = [] const glow: string[] = [] series.forEach((p, c) => { const h = Math.round((p.count / maxVal) * ROWS) const cx = padL + c * cellX for (let row = 0; row < ROWS; row++) { const cy = padT + row * cellY if (row < ROWS - h) { unlit.push(``) continue } const depth = row - (ROWS - h) if (depth === 0) { glow.push(``) litDots.push(``) } else { const op = Math.max(0.3, 1 - depth * 0.08).toFixed(2) litDots.push(``) } } }) const ticks = [0, 7, 14, 21, COLS - 1] .map((c) => { const cx = padL + c * cellX return `${series[c].day.slice(5)}` }) .join("") const chart = ` ${unlit.join("")} ${glow.join("")} ${litDots.join("")} ${ticks} ` return ` Pond — Stats

    Deployments

    Capsules deployed to this Pond host. JSON

    ${fmt(s.last24h)}
    last 24h
    ${fmt(s.last7d)}
    last 7 days
    ${fmt(s.total)}
    all time

    Deploys per day last 30 days · peak ${fmt(maxVal)}/day

    ${chart}

    ← back

    ` } function securityTxt(): string { const contact = abuseEmail ? `Contact: mailto:${abuseEmail}` : "Contact: (set POND_ABUSE_EMAIL to populate this field)" const oneYearOut = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10) return `${contact} Expires: ${oneYearOut}T00:00:00.000Z Preferred-Languages: en Canonical: ${publicBaseUrl ? publicBaseUrl.toString().replace(/\/$/, "") : `http://${publicHost}:${port}`}/.well-known/security.txt ` } app.all("*", async (c) => { // Bare-domain requests (no subdomain) — serve the marketing / policy pages. if (isBareDomainRequest(c.req.header("host"))) { const url = new URL(c.req.url) if (c.req.method === "GET") { if (url.pathname === "/" || url.pathname === "") { return c.html(landingHtml()) } if (url.pathname === "/abuse") { return c.html(abuseHtml()) } if (url.pathname === "/stats") { return c.html(statsHtml()) } if (url.pathname === "/api/stats") { return c.json(controlDb.deployStats()) } if (url.pathname === "/.well-known/security.txt") { return new Response(securityTxt(), { status: 200, headers: { "content-type": "text/plain; charset=utf-8" }, }) } if (url.pathname === "/robots.txt") { return new Response("User-agent: *\nAllow: /\n", { status: 200, headers: { "content-type": "text/plain; charset=utf-8" }, }) } if (url.pathname === "/favicon.svg" || url.pathname === "/favicon.ico") { const svg = `` return new Response(svg, { status: 200, headers: { "content-type": "image/svg+xml", "cache-control": "public, max-age=86400" }, }) } if (url.pathname === "/llms.txt" || url.pathname === "/llms-full.txt") { const filename = url.pathname.slice(1) const abs = path.join(pondDocsDir, filename) if (fs.existsSync(abs)) { return new Response(fs.readFileSync(abs), { status: 200, headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "public, max-age=300", }, }) } return c.json({ error: "Not found" }, 404) } // Human-readable docs index. /docs and /docs/ both land here. if (url.pathname === "/docs" || url.pathname === "/docs/") { return c.html(docsIndexHtml()) } // Human-readable docs page. /docs/ with no .md suffix renders // the markdown to HTML. We deliberately fall through to the raw- // markdown handler below for /docs/.md so agents (and llms.txt) // keep working unchanged. const docHtmlMatch = url.pathname.match(/^\/docs\/([a-z0-9_-]+)\/?$/) if (docHtmlMatch) { const slug = docHtmlMatch[1] if (!DOCS_SLUG_RE.test(slug)) return c.json({ error: "Not found" }, 404) const abs = path.join(pondDocsDir, `${slug}.md`) if (fs.existsSync(abs) && fs.statSync(abs).isFile()) { const rendered = docsPageHtml(slug, fs.readFileSync(abs, "utf-8")) if (rendered) return c.html(rendered) } return c.json({ error: "Not found" }, 404) } const docMatch = url.pathname.match(/^\/docs\/([a-zA-Z0-9_-]+\.md)$/) if (docMatch) { const abs = path.join(pondDocsDir, docMatch[1]) if (fs.existsSync(abs) && fs.statSync(abs).isFile()) { return new Response(fs.readFileSync(abs), { status: 200, headers: { "content-type": "text/markdown; charset=utf-8", "cache-control": "public, max-age=300", }, }) } return c.json({ error: "Not found" }, 404) } if (url.pathname === "/gallery") { return c.html(galleryHtml()) } if (url.pathname === "/dashboard" || url.pathname === "/dashboard/") { const bootstrap = JSON.stringify({ publicHost }) const html = dashboardHtml.replace( "__POND_DASHBOARD__BOOTSTRAP__", `window.__POND_DASHBOARD = ${bootstrap}`, ) return c.html(html) } const ideMatch = url.pathname.match(/^\/ide\/([a-f0-9]+)\/?$/) if (ideMatch) { const deployId = ideMatch[1] const record = readRecord(deployId) if (!record) return c.json({ error: "Unknown deploy" }, 404) // Send the last successful build (if any) so the IDE's // diagnostics panel renders `✓ Built · 17.4 KB` on first mount // instead of "No build yet". Older records that pre-date these // fields simply omit `lastBuild` and the IDE falls back to the // unbuilt placeholder, which is honest for them. const lastBuild = typeof record.bundleBytes === "number" && typeof record.bundleHash === "string" && typeof record.lastBuiltAt === "string" ? { bundleBytes: record.bundleBytes, bundleHash: record.bundleHash, builtAt: record.lastBuiltAt, durationMs: record.lastBuildDurationMs ?? 0, } : null const bootstrap = JSON.stringify({ deployId, deployUrl: record.url, publicHost, lastBuild, }) const html = ideHtml.replace("__POND_IDE__BOOTSTRAP__", `window.__POND_IDE = ${bootstrap}`) return c.html(html) } } return c.json({ error: "Not found" }, 404) } const deployId = deployIdFromHost(c.req.header("host")) if (!deployId) return c.json({ error: "Not found" }, 404) // Static deploys are served as their prebuilt client.html straight from // disk — no worker, no capsule slot, exempt from capsule wake + the anon // daily quota (serving a file is not metered compute). The record read is // skipped whenever a worker is already resident, so a resident (therefore // dynamic) capsule never pays for this check on the hot path. if (!runningChildren.has(deployId)) { const rec = readRecord(deployId) if (rec?.isStatic) return serveStaticDeploy(deployId, c) } // Per-deploy daily quota — anonymous deploys only; claiming a deploy lifts // it. Checked here (before ensureBooted) so an over-quota app can't even // wake its capsule. Every proxied request counts; POST /api/mutation/* // also counts as a mutation. Owned/claimed deploys skip this entirely. if ((anonRequestsPerDay > 0 || anonMutationsPerDay > 0) && controlDb.findAnonymous(deployId)) { const isMutation = c.req.method === "POST" && c.req.path.startsWith("/api/mutation/") const day = new Date().toISOString().slice(0, 10) const verdict = controlDb.consumeDailyUsage(deployId, day, isMutation, anonRequestsPerDay, anonMutationsPerDay) if (!verdict.ok) { const now = Date.now() const nextUtcDay = Date.UTC( new Date(now).getUTCFullYear(), new Date(now).getUTCMonth(), new Date(now).getUTCDate() + 1, ) const retryAfter = Math.max(1, Math.ceil((nextUtcDay - now) / 1000)) return new Response( JSON.stringify({ error: `Anonymous daily ${verdict.kind} limit reached — claim the deploy or retry tomorrow`, }), { status: 429, headers: { "content-type": "application/json", "retry-after": String(retryAfter) } }, ) } } const woke = runningChildren.get(deployId) ?? (await ensureBooted(deployId)) // Wake-path at capacity: the deploy exists but the box is full, so shed with // a 503 instead of a misleading 404. Short retry-after — a slot frees as soon // as the idle reaper sleeps a neighbour. if (woke === "at-capacity") { return new Response(JSON.stringify({ error: "Service unavailable" }), { status: 503, headers: { "content-type": "application/json", "retry-after": "5" }, }) } const entry = woke if (!entry) return c.json({ error: "Unknown deploy" }, 404) // Mark the capsule active so the idle reaper keeps it resident. lastActivityAt.set(deployId, Date.now()) const url = new URL(c.req.url) const target = `http://127.0.0.1:${entry.port}${url.pathname}${url.search}` const headers = new Headers() c.req.raw.headers.forEach((v, k) => { if (!HOP_BY_HOP.has(k.toLowerCase())) headers.set(k, v) }) const method = c.req.method const hasBody = method !== "GET" && method !== "HEAD" const init: RequestInit & { duplex?: "half" } = { method, headers } if (hasBody) { init.body = c.req.raw.body init.duplex = "half" } let upstream: Response try { upstream = await fetch(target, init) } catch (err: any) { return c.json({ error: `Upstream error: ${err?.message ?? err}` }, 502) } const respHeaders = new Headers() upstream.headers.forEach((v, k) => { if (!HOP_BY_HOP.has(k.toLowerCase())) respHeaders.set(k, v) }) return new Response(upstream.body, { status: upstream.status, headers: respHeaders }) }) const controlServer = serve({ fetch: app.fetch, port, hostname }) // ── WebSocket upgrade proxy ───────────────────────────────────────── // The bare control plane bypasses Hono for HTTP Upgrade requests and pipes // them raw to the matching deploy's local port. This lets capsules expose // socket() handlers at /api/socket/ and have wscat / browser clients // reach them through .pond.run just like a regular request. controlServer.on("upgrade", (req, clientSocket, head) => { const hostHeader = (req.headers.host ?? "").toString() const deployId = deployIdFromHost(hostHeader) if (!deployId) { clientSocket.write("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n") clientSocket.destroy() return } // Static deploys have no worker and no socket() handlers — never boot one // for an upgrade. Only pay the record read when no worker is resident. if (!runningChildren.has(deployId) && readRecord(deployId)?.isStatic) { clientSocket.write("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n") clientSocket.destroy() return } void (async () => { // Wake a slept capsule on demand (parity with the HTTP path) so a // WebSocket-first client still reaches a scaled-to-zero deploy. const woke = runningChildren.get(deployId) ?? (await ensureBooted(deployId)) if (woke === "at-capacity") { clientSocket.write("HTTP/1.1 503 Service Unavailable\r\nRetry-After: 5\r\nConnection: close\r\n\r\n") clientSocket.destroy() return } const entry = woke if (!entry) { clientSocket.write("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n") clientSocket.destroy() return } // Count this connection so the idle reaper won't sleep the capsule while a // socket is open; stamp activity on open and again on close. lastActivityAt.set(deployId, Date.now()) liveSockets.set(deployId, (liveSockets.get(deployId) ?? 0) + 1) let released = false const releaseSocket = () => { if (released) return released = true const remaining = (liveSockets.get(deployId) ?? 1) - 1 if (remaining <= 0) liveSockets.delete(deployId) else liveSockets.set(deployId, remaining) lastActivityAt.set(deployId, Date.now()) } // Open a TCP connection to the deploy worker, re-serialize the original // upgrade request (parsed headers + any head bytes already buffered), // then full-duplex pipe both directions until either end closes. const upstream = net.connect({ host: "127.0.0.1", port: entry.port }) upstream.once("connect", () => { const lines: string[] = [`${req.method} ${req.url} HTTP/${req.httpVersion}`] for (const [name, value] of Object.entries(req.headers)) { if (value == null) continue if (Array.isArray(value)) { for (const v of value) lines.push(`${name}: ${v}`) } else { lines.push(`${name}: ${value}`) } } lines.push("", "") upstream.write(lines.join("\r\n")) if (head && head.length > 0) upstream.write(head) upstream.pipe(clientSocket) clientSocket.pipe(upstream) }) const destroyBoth = () => { try { upstream.destroy() } catch { // best effort } try { clientSocket.destroy() } catch { // best effort } } upstream.on("error", destroyBoth) clientSocket.on("error", destroyBoth) clientSocket.on("close", () => { releaseSocket() upstream.destroy() }) upstream.on("close", () => { releaseSocket() clientSocket.destroy() }) })() }) const shutdown = async () => { shuttingDown = true console.log("\n[pond host] shutting down") for (const deployId of [...runningChildren.keys()]) { await stopDeploy(deployId) } controlDb.close() controlServer.close(() => process.exit(0)) setTimeout(() => process.exit(0), 2000).unref() } process.on("SIGINT", shutdown) process.on("SIGTERM", shutdown) console.log(`\n pond host control plane running at http://${hostname}:${port}`) if (publicBaseUrl) { console.log(` public base URL: ${publicBaseUrl.toString().replace(/\/$/, "")}`) } if (!abuseEmail) { console.log(` ⚠ no --abuse-email set — security.txt / abuse page will be unhelpful for a public deploy.`) } if (hostTokenGenerated) { // First run only: surface the freshly generated token once so the operator // can capture it. On subsequent boots we never reprint it — stdout lands in // `docker logs`, and the value is persisted to the 0600 token file anyway. console.log(` host token (bootstrap / recovery, generated now): ${hostToken}`) console.log(` ^ save this — it will NOT be printed again. Also stored at ${tokenFile}.`) } else { console.log( ` host token: configured (from ${process.env.POND_HOST_TOKEN ? "POND_HOST_TOKEN" : tokenFile}) — not printed`, ) } console.log(` bootstrap first admin: pond login --api ${apiUrl} --username `) if (anonymousEnabled) { console.log( ` anonymous deploys: enabled (grace=${formatHumanDuration(anonymousGraceMs)}, retention=${formatHumanDuration(anonymousRetentionMs)}, rate=${anonymousRateLimit}/h)\n`, ) } else { console.log(" anonymous deploys: disabled\n") } if (anonRequestsPerDay > 0 || anonMutationsPerDay > 0) { console.log( ` anonymous daily quota: ${anonRequestsPerDay || "∞"} requests / ${anonMutationsPerDay || "∞"} mutations per deploy (429 over; lifted on claim)`, ) } if (maxActiveCapsules > 0) { console.log(` capacity: max ${maxActiveCapsules} concurrent capsules (new deploys past this get HTTP 503)`) } else { console.log( " ⚠ capacity: UNLIMITED concurrent capsules — a deploy flood can exceed the container mem_limit and " + "trigger the OOM killer. Set --max-active-capsules / POND_MAX_ACTIVE_CAPSULES (see deploy/.env.example).", ) } if (capsuleIdleTimeoutMs > 0) { console.log( ` scale-to-zero: capsules sleep after ${formatHumanDuration(capsuleIdleTimeoutMs)} idle and wake on demand (lazy boot on restart)`, ) } else { console.log( " scale-to-zero: off — every deploy stays resident (set --capsule-idle-timeout / POND_CAPSULE_IDLE_TIMEOUT to sleep idle capsules)", ) } if (anonymousEnabled) { console.log( turnstileSecret ? ` anonymous deploy challenge: Cloudflare Turnstile enabled (from ${process.env.POND_TURNSTILE_SECRET ? "POND_TURNSTILE_SECRET" : "--turnstile-secret"})` : " anonymous deploy challenge: none (set --turnstile-secret / POND_TURNSTILE_SECRET to require Turnstile)", ) } if (capsuleCgroupRoot) { console.log(` capsule isolation: cgroup v2 enabled at ${capsuleCgroupRoot} (per-capsule cpu/memory/pids caps)`) console.log(` capsule egress policy: ${egressMode}`) console.log( cpuKillPercent > 0 ? ` abuse auto-kill: a capsule sustaining ≥${cpuKillPercent}% of a core for ${cpuKillSweeps} sweeps is stopped` : " abuse auto-kill: off (set --capsule-cpu-kill-percent to stop sustained CPU hogs)", ) } else { console.log(" capsule isolation: cgroup limits OFF (heap cap only) — set --capsule-cgroup-root to enable") // The OS egress firewall (deploy/capsule-egress.nft) matches on capsule // cgroup membership. With no cgroup root, no capsule socket carries the // `pond` cgroup tag, so a loaded nft ruleset matches NOTHING — capsules // have unrestricted OS-level egress even though `nft -f` succeeded. Warn // loudly so an operator doesn't get a false sense of security. (In // 'sealed' mode the JS-layer block still applies, but it is bypassable by // native addons / DNS — the OS firewall is the real boundary.) console.log( ` ⚠ capsule egress policy '${egressMode}': the nft egress firewall keys on cgroup membership, ` + `so with cgroup isolation OFF any loaded capsule-egress.nft rules match NOTHING (no OS-level egress ` + `enforcement). Run deploy/setup-capsule-isolation.sh and set --capsule-cgroup-root. See deploy/HARDENING.md.`, ) } if (fsIsolationMode === "bwrap") { console.log( " capsule fs isolation: bubblewrap enabled — each capsule worker is confined to its own deploy dir " + "(rw) + pond runtime (ro); control.db and sibling deploys are not in its mount namespace.", ) } else { console.log( " capsule fs isolation: OFF (Node --permission only) — set --capsule-fs-isolation=bwrap on a Linux host " + "with bubblewrap for a kernel-enforced per-tenant filesystem boundary. See deploy/HARDENING.md.", ) } console.log( alertWebhook ? " observability: GET /metrics (admin-gated, Prometheus) · operator alerts → --alert-webhook" : " observability: GET /metrics (admin-gated, Prometheus) · operator alerts to stderr only (set --alert-webhook / POND_ALERT_WEBHOOK to page on crash-loop / disk-quota stops)", ) if (hostname === "0.0.0.0" || hostname === "::") { console.log( " ⚠ bound to all interfaces — deploying a bundle here gives the caller arbitrary code execution as this user.\n", ) } }, })