diff --git a/deploy/cloudflare/.gitignore b/deploy/cloudflare/.gitignore new file mode 100644 index 0000000000..bf2a55e0cd --- /dev/null +++ b/deploy/cloudflare/.gitignore @@ -0,0 +1,5 @@ +# Wrangler local state (miniflare D1/KV/DO/R2 sqlite files) — never commit. +.wrangler/ +# Local secrets for `wrangler dev`. +.dev.vars +node_modules/ diff --git a/deploy/cloudflare/README.md b/deploy/cloudflare/README.md new file mode 100644 index 0000000000..cd7245c0ea --- /dev/null +++ b/deploy/cloudflare/README.md @@ -0,0 +1,43 @@ +# Paperclip on Cloudflare + +Deploys a full Paperclip instance to Cloudflare Workers: the Worker proxies +your `*.workers.dev` origin (HTTP + WebSockets) into a +[Cloudflare Sandbox](https://developers.cloudflare.com/sandbox/) container +running `paperclipai` with embedded Postgres and the local-adapter agent CLIs +preinstalled. No custom domain required. + +```sh +pnpm install +npx wrangler login +npx wrangler deploy +npx wrangler secret put BOOTSTRAP_TOKEN # required — deployment is fail-closed until set +``` + +> ⚠️ Container disk is **ephemeral** — for any data you want to keep, set an +> external database first (`npx wrangler secret put DATABASE_URL`) and +> optionally enable the R2 attachments mount (see `wrangler.jsonc`). + +Full operator guide (prerequisites, configuration, durability, costs, +troubleshooting): **[docs/deploy/cloudflare.md](../../docs/deploy/cloudflare.md)**. + +## Layout + +| Path | Purpose | +| --- | --- | +| `src/index.ts` | Worker: boots Paperclip in the sandbox, proxies HTTP + WS | +| `src/lib.ts` | Pure helpers (unit-tested) | +| `container/Dockerfile` | Sandbox image: paperclipai + agent CLIs | +| `container/start-paperclip.sh` | Onboard-once boot script (non-root) | +| `wrangler.jsonc` | Worker + container + Durable Object config | +| `test/` | Unit tests + cross-file config consistency checks | + +This package is intentionally **not** part of the root pnpm workspace (same +pattern as `packages/plugins/sandbox-providers/*`) — its own +`pnpm-workspace.yaml` makes it a standalone single-package workspace, so the +Cloudflare toolchain never churns the root lockfile and a plain +`pnpm install` here does the right thing. + +```sh +pnpm test # vitest: lib + config invariants (Dockerfile↔SDK version pin) +pnpm typecheck +``` diff --git a/deploy/cloudflare/container/Dockerfile b/deploy/cloudflare/container/Dockerfile new file mode 100644 index 0000000000..50fb358e00 --- /dev/null +++ b/deploy/cloudflare/container/Dockerfile @@ -0,0 +1,51 @@ +# Paperclip on Cloudflare Sandbox containers. +# +# The base image tag MUST match the @cloudflare/sandbox version pinned in +# ../package.json — the SDK and the in-container runtime are versioned +# together. test/config.test.ts enforces this. +FROM docker.io/cloudflare/sandbox:0.12.4 + +# Quality-of-life tools for agent workloads (subset of the tools the root +# Dockerfile's production stage installs; the sandbox base already ships +# git, curl, python3 and node). +RUN apt-get update \ + && apt-get install -y --no-install-recommends jq ripgrep openssh-client \ + && rm -rf /var/lib/apt/lists/* + +# Paperclip (npm release) plus the local-adapter agent CLIs, mirroring the +# root Dockerfile's production stage. Versions are pinned exactly so image +# rebuilds are reproducible and supply-chain review applies to a known set +# (test/config.test.ts rejects mutable tags); bump them deliberately. +RUN npm install -g \ + paperclipai@2026.722.0 \ + @anthropic-ai/claude-code@2.1.218 \ + @openai/codex@0.145.0 \ + opencode-ai@1.18.4 \ + @google/gemini-cli@0.52.0 + +# Paperclip runtime defaults (mirrors the root Dockerfile / quadlet unit). +ENV HOST=0.0.0.0 \ + PORT=3100 \ + PAPERCLIP_HOME=/paperclip \ + PAPERCLIP_DEPLOYMENT_MODE=authenticated \ + PAPERCLIP_DEPLOYMENT_EXPOSURE=private + +# Embedded Postgres refuses to run as root, so Paperclip gets its own user. +# The uid is pinned (must match PAPERCLIP_UID in src/lib.ts — enforced by +# test/config.test.ts) so the optional R2 attachments mount can be exposed +# as owned by this user. embedded-postgres also creates symlinks inside the +# package directory on first boot, hence the chown of the installed package. +RUN useradd -m -u 4100 -s /bin/bash paperclip \ + && mkdir -p /paperclip \ + && chown -R paperclip:paperclip /paperclip \ + && chown -R paperclip:paperclip /usr/local/lib/node_modules/paperclipai + +COPY start-paperclip.sh /opt/start-paperclip.sh +RUN chmod +x /opt/start-paperclip.sh + +# Local dev (wrangler dev) requires exposed ports to be declared. +EXPOSE 3100 + +# No USER directive on purpose (trivy DS-0002): the Cloudflare Sandbox +# runtime daemon in the base image must run as root; the Paperclip workload +# itself drops to the non-root `paperclip` user in start-paperclip.sh. diff --git a/deploy/cloudflare/container/start-paperclip.sh b/deploy/cloudflare/container/start-paperclip.sh new file mode 100644 index 0000000000..b9c215a0f2 --- /dev/null +++ b/deploy/cloudflare/container/start-paperclip.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# Boots Paperclip as the non-root `paperclip` user (embedded Postgres refuses +# to run as root). Onboards non-interactively on first boot, then runs the +# server. Started by the Worker via sandbox.startProcess() with the runtime +# environment (PAPERCLIP_*, optional ANTHROPIC_API_KEY / DATABASE_URL) — +# runuser without --login preserves that environment for the child shell. +set -euo pipefail + +# Own the data dir, but skip the (optional) R2-mounted storage directory: +# s3fs rejects chown, which would abort the boot under `set -e`. The mount +# is already exposed with the paperclip uid via s3fs options (src/lib.ts). +find /paperclip -path /paperclip/instances/default/data/storage -prune \ + -o -exec chown paperclip:paperclip {} + + +# Serialize boots: concurrent Worker isolates can race ensurePaperclip() and +# start this script twice. The non-blocking lock makes every duplicate exit +# immediately instead of fighting over onboarding and port 3100. +exec flock --nonblock /paperclip/.boot.lock runuser -u paperclip -- bash -c ' + set -euo pipefail + export HOME=/home/paperclip + if [ ! -f /paperclip/instances/default/config.json ]; then + paperclipai onboard --yes --bind lan --data-dir /paperclip + fi + exec paperclipai run --data-dir /paperclip +' diff --git a/deploy/cloudflare/package.json b/deploy/cloudflare/package.json new file mode 100644 index 0000000000..48b493baaf --- /dev/null +++ b/deploy/cloudflare/package.json @@ -0,0 +1,33 @@ +{ + "name": "@paperclipai/deploy-cloudflare", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Deploy Paperclip to Cloudflare Workers with a Sandbox container", + "license": "MIT", + "homepage": "https://github.com/paperclipai/paperclip", + "bugs": { + "url": "https://github.com/paperclipai/paperclip/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/paperclipai/paperclip", + "directory": "deploy/cloudflare" + }, + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@cloudflare/sandbox": "0.12.4" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260701.0", + "@types/node": "^22.10.0", + "typescript": "^5.7.3", + "vitest": "^4.1.10", + "wrangler": "^4.113.0" + } +} diff --git a/deploy/cloudflare/pnpm-lock.yaml b/deploy/cloudflare/pnpm-lock.yaml new file mode 100644 index 0000000000..97a6630a8c --- /dev/null +++ b/deploy/cloudflare/pnpm-lock.yaml @@ -0,0 +1,1366 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + sharp: '-' + +importers: + + .: + dependencies: + '@cloudflare/sandbox': + specifier: 0.12.4 + version: 0.12.4 + devDependencies: + '@cloudflare/workers-types': + specifier: ^4.20260701.0 + version: 4.20260702.1 + '@types/node': + specifier: ^22.10.0 + version: 22.20.1 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@22.20.1)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)) + wrangler: + specifier: ^4.113.0 + version: 4.113.0(@cloudflare/workers-types@4.20260702.1) + +packages: + + '@cloudflare/containers@0.3.7': + resolution: {integrity: sha512-DM9dm3FnIBSyiSJ1FLavKwl/lk3oAmTaynCzZQ9pZR0ncRPquSxkxd8Nu2MFILxmDDsPkxKsSNEh9mHHMty4Fw==} + + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + + '@cloudflare/sandbox@0.12.4': + resolution: {integrity: sha512-0gpkd+a58Q5aGjgXvLFZSdIDAZpvh89QlaiHBpTtkDZWqHPKfyISV76/pIUzzo9ACJzVIpUO8hNv2X+LFTTiRQ==} + peerDependencies: + '@openai/agents': ^0.3.3 + '@opencode-ai/sdk': ^1.1.40 + '@xterm/xterm': '>=5.0.0' + peerDependenciesMeta: + '@openai/agents': + optional: true + '@opencode-ai/sdk': + optional: true + '@xterm/xterm': + optional: true + + '@cloudflare/unenv-preset@2.16.1': + resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/workerd-darwin-64@1.20260721.1': + resolution: {integrity: sha512-VivNMhiEdZIB4JBWxf1RMJGROErv53qmQ+dvhjA1evrCouvqRYW718VqDideU3PSV7Ythl5Df48NqZYWoaEHpQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260721.1': + resolution: {integrity: sha512-k7oye1ZiuwnnBBA2eTMduconr/ud5ZxFtRNTsYwMdmJeeeislw2+M72otrHxxvybCP7JWPPlJ38uhfajpcyhOA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20260721.1': + resolution: {integrity: sha512-hon0lW4ZQ4boAVgaw+0ZFTNS8v5MWPWvK0HZnt4tDpKYnDUviLZawtUW3KqvFmCQTipVHl1S34j3J8Eqb93hGQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260721.1': + resolution: {integrity: sha512-nAl+HRQqpX5b7xVwWcvLPZmCk8NQ2yjI0yvJTWcHiRswbMEg1ZZckVmjJUAn0PHzZARbCSyIV7v3UjM+SPRmIQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20260721.1': + resolution: {integrity: sha512-9paFG5cMTKz/CRixnEEnZbe5uvFPBFSDthxJHANfCWhUtBj49GSL1FPIokIg+Q+H8DGJEExU0lL92LtxD0lTxQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cloudflare/workers-types@4.20260702.1': + resolution: {integrity: sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.139.0': + resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + + '@rolldown/binding-android-arm64@1.1.5': + resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.5': + resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.5': + resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.5': + resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.1.5': + resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.1.5': + resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.1.5': + resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.1.5': + resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.5': + resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.5': + resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + + '@speed-highlight/core@1.2.17': + resolution: {integrity: sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + aws4fetch@1.0.20: + resolution: {integrity: sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==} + + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + + capnweb@0.8.0: + resolution: {integrity: sha512-BK/TuXUiyfLSKsmjojn70yN7oYG/JJzoURZ3tckjg5Zj2KcygPm0A5jyOlswK7SYB4f0Gh9tt+RZ132b80iLfA==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + hono@4.12.31: + resolution: {integrity: sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==} + engines: {node: '>=16.9.0'} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + miniflare@4.20260721.0: + resolution: {integrity: sha512-fBLaCxZ2i/nPH8iyLzvza0C8/sSF4sjD1ma1Skf+pkZVK0TlaW5ujHJlUHwcwR66v2JZt+Q28d4DCX/oaLG0cA==} + engines: {node: '>=22.0.0'} + hasBin: true + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.21: + resolution: {integrity: sha512-v4sDNP3fdNiWMfabO7OwOQdOX8TiQSztKyT1Wj0w+j7LDallJThJRBBBmzVGyYj0crMh7jlV4zepPkiNu9UwDQ==} + engines: {node: ^10 || ^12 || >=14} + + rolldown@1.1.5: + resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + + vite@8.1.5: + resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + workerd@1.20260721.1: + resolution: {integrity: sha512-b/DWhpV0jTudzQpLhDovcOgBz233386q+3Hbari7CLCNT9UXxjQziSTZ9yCoKdT2K3TSx5jrwlOisq8hlLWXYg==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.113.0: + resolution: {integrity: sha512-ROGzSloJv0y21It6Oc9LaruNcu1tdiQ/XzL3Jc3YkFjzXEMXzTqVhA8vQaGMTdZHTjFP0PVcwAHNgaw3gXu4wA==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^5.20260721.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + +snapshots: + + '@cloudflare/containers@0.3.7': {} + + '@cloudflare/kv-asset-handler@0.5.0': {} + + '@cloudflare/sandbox@0.12.4': + dependencies: + '@cloudflare/containers': 0.3.7 + aws4fetch: 1.0.20 + capnweb: 0.8.0 + hono: 4.12.31 + + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260721.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260721.1 + + '@cloudflare/workerd-darwin-64@1.20260721.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260721.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20260721.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20260721.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20260721.1': + optional: true + + '@cloudflare/workers-types@4.20260702.1': {} + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-project/types@0.139.0': {} + + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + + '@rolldown/binding-android-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-x64@1.1.5': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.5': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.5': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.5': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.5': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.5': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@sindresorhus/is@7.2.0': {} + + '@speed-highlight/core@1.2.17': {} + + '@standard-schema/spec@1.1.0': {} + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.5(@types/node@22.20.1)(esbuild@0.28.1) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + assertion-error@2.0.1: {} + + aws4fetch@1.0.20: {} + + blake3-wasm@2.1.5: {} + + capnweb@0.8.0: {} + + chai@6.2.2: {} + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + detect-libc@2.1.2: {} + + error-stack-parser-es@1.0.5: {} + + es-module-lexer@2.3.1: {} + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fsevents@2.3.3: + optional: true + + hono@4.12.31: {} + + kleur@4.1.5: {} + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + miniflare@4.20260721.0: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + undici: 7.28.0 + workerd: 1.20260721.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + nanoid@3.3.16: {} + + obug@2.1.4: {} + + path-to-regexp@6.3.0: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss@8.5.21: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rolldown@1.1.5: + dependencies: + '@oxc-project/types': 0.139.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.5 + '@rolldown/binding-darwin-arm64': 1.1.5 + '@rolldown/binding-darwin-x64': 1.1.5 + '@rolldown/binding-freebsd-x64': 1.1.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 + '@rolldown/binding-linux-arm64-gnu': 1.1.5 + '@rolldown/binding-linux-arm64-musl': 1.1.5 + '@rolldown/binding-linux-ppc64-gnu': 1.1.5 + '@rolldown/binding-linux-s390x-gnu': 1.1.5 + '@rolldown/binding-linux-x64-gnu': 1.1.5 + '@rolldown/binding-linux-x64-musl': 1.1.5 + '@rolldown/binding-openharmony-arm64': 1.1.5 + '@rolldown/binding-wasm32-wasi': 1.1.5 + '@rolldown/binding-win32-arm64-msvc': 1.1.5 + '@rolldown/binding-win32-x64-msvc': 1.1.5 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + + supports-color@10.2.2: {} + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.0: {} + + tslib@2.8.1: + optional: true + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + undici@7.28.0: {} + + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + + vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.21 + rolldown: 1.1.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.1 + esbuild: 0.28.1 + fsevents: 2.3.3 + + vitest@4.1.10(@types/node@22.20.1)(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@22.20.1)(esbuild@0.28.1)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.5(@types/node@22.20.1)(esbuild@0.28.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.20.1 + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + workerd@1.20260721.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260721.1 + '@cloudflare/workerd-darwin-arm64': 1.20260721.1 + '@cloudflare/workerd-linux-64': 1.20260721.1 + '@cloudflare/workerd-linux-arm64': 1.20260721.1 + '@cloudflare/workerd-windows-64': 1.20260721.1 + + wrangler@4.113.0(@cloudflare/workers-types@4.20260702.1): + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260721.1) + blake3-wasm: 2.1.5 + esbuild: 0.28.1 + miniflare: 4.20260721.0 + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260721.1 + optionalDependencies: + '@cloudflare/workers-types': 4.20260702.1 + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + ws@8.21.0: {} + + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.17 + cookie: 1.1.1 + youch-core: 0.3.3 diff --git a/deploy/cloudflare/pnpm-workspace.yaml b/deploy/cloudflare/pnpm-workspace.yaml new file mode 100644 index 0000000000..6967ff7559 --- /dev/null +++ b/deploy/cloudflare/pnpm-workspace.yaml @@ -0,0 +1,15 @@ +# Marks deploy/cloudflare as its own single-package workspace so pnpm does +# not attach it to the repo root workspace (same isolation rationale as +# packages/plugins/sandbox-providers/*): the Cloudflare toolchain +# (wrangler/workerd) never churns the root pnpm-lock.yaml. +packages: + - "." +# sharp is an optional wrangler dependency (static-asset image processing) +# that this Worker never uses; it is removed from resolution entirely +# because sharp@0.34.x carries high-severity libvips CVEs +# (GHSA-f88m-g3jw-g9cj) that trip dependency review. +overrides: + sharp: "-" +allowBuilds: + esbuild: true + workerd: true diff --git a/deploy/cloudflare/src/index.ts b/deploy/cloudflare/src/index.ts new file mode 100644 index 0000000000..02d40abcf3 --- /dev/null +++ b/deploy/cloudflare/src/index.ts @@ -0,0 +1,244 @@ +/** + * Cloudflare Worker that serves a full Paperclip instance from a Cloudflare + * Sandbox container on the Worker's own origin (works on *.workers.dev — no + * custom domain required). + * + * Request flow: + * 1. Ensure the Paperclip boot process is running in the sandbox + * (idempotent; memoized per isolate, re-checked after any failure). + * 2. WebSocket upgrades -> sandbox.wsConnect(request, 3100) + * Everything else -> sandbox.containerFetch(request, 3100) + * 3. While the container provisions / Paperclip onboards, serve a + * self-refreshing 503 status page instead of a raw error. + * + * See docs/deploy/cloudflare.md for the operator guide. + */ +import { getSandbox, type Sandbox as SandboxType } from "@cloudflare/sandbox"; +import { + ARTIFACTS_BINDING, + BOOTSTRAP_COOKIE, + BOOTSTRAP_PARAM, + PAPERCLIP_PORT, + SANDBOX_ID, + START_COMMAND, + STORAGE_MOUNT_PATH, + accessDeniedPage, + bootingResponse, + bootstrapGateMode, + buildPaperclipEnv, + decodeCookieValue, + exceedsRequestSizeLimit, + getCookie, + isOriginAllowed, + setupRequiredPage, + isMountAlreadyInUse, + isPaperclipRunning, + isTransientBootError, + isTransientBootMessage, + isWebSocketUpgrade, + storageMountOptions, +} from "./lib"; + +// Required by the Sandbox SDK: the Durable Object class backing the container, +// and the ContainerProxy entrypoint used for credential-less R2 bucket mounts +// (harmless when no bucket is configured). +export { ContainerProxy, Sandbox } from "@cloudflare/sandbox"; + +interface Env { + Sandbox: DurableObjectNamespace; + /** + * Optional R2 bucket for durable attachment storage — uncomment the + * r2_buckets block in wrangler.jsonc to enable. + */ + ARTIFACTS?: R2Bucket; + /** Optional override; defaults to the request origin (e.g. *.workers.dev). */ + PAPERCLIP_PUBLIC_URL?: string; + PAPERCLIP_DEPLOYMENT_MODE?: string; + PAPERCLIP_DEPLOYMENT_EXPOSURE?: string; + /** Secrets (wrangler secret put …); forwarded to the container when set. */ + ANTHROPIC_API_KEY?: string; + DATABASE_URL?: string; + /** + * Required before the deployment serves anything (fail-closed): every + * request must present this token (?bootstrap_token=…, which sets a + * cookie) — protects the unclaimed operator invite between first boot and + * the operator's first login. + */ + BOOTSTRAP_TOKEN?: string; + /** + * Set to "true" (wrangler.jsonc vars) after the operator account is + * claimed to open the login page to your team; Paperclip's own auth + * protects everything from then on. + */ + DISABLE_BOOTSTRAP_GATE?: string; + /** + * Comma-separated extra origins permitted to send cross-origin requests. + * Unset means same-origin only. Requests with no Origin header (CLI, agents, + * health checks) are always allowed — see isOriginAllowed. + */ + ALLOWED_ORIGINS?: string; +} + +/** + * Per-isolate memo so steady-state requests skip the listProcesses round + * trip. Reset whenever proxying fails, which also heals container restarts + * (the boot process does not survive a sandbox sleep/wake cycle). + */ +let paperclipEnsured = false; + +/** + * Shared in-flight boot so concurrent cold-start requests in one isolate + * issue a single ensure pass instead of racing startProcess. Cross-isolate + * duplicates are additionally serialized by the flock in + * container/start-paperclip.sh — duplicates exit immediately. + */ +let ensureInFlight: Promise | null = null; + +/** Constant-time comparison via digest so token checks don't leak timing. */ +async function tokensMatch(presented: string, expected: string): Promise { + const encoder = new TextEncoder(); + const [a, b] = await Promise.all([ + crypto.subtle.digest("SHA-256", encoder.encode(presented)), + crypto.subtle.digest("SHA-256", encoder.encode(expected)), + ]); + const av = new Uint8Array(a); + const bv = new Uint8Array(b); + let diff = 0; + for (let i = 0; i < av.length; i++) diff |= av[i] ^ bv[i]; + return diff === 0; +} + +/** + * Bootstrap gate, fail-closed: with no token configured the deployment + * serves only the setup page; with a token, only requests presenting it + * (query param once, cookie afterwards) reach Paperclip. Returns null when + * the request may proceed, otherwise the response to serve. + */ +async function enforceBootstrapGate(request: Request, env: Env, url: URL): Promise { + const mode = bootstrapGateMode({ + token: env.BOOTSTRAP_TOKEN, + disableGate: env.DISABLE_BOOTSTRAP_GATE, + }); + if (mode === "open") return null; + if (mode === "setup") { + return new Response(setupRequiredPage(), { + status: 403, + headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }, + }); + } + + // mode === "token": BOOTSTRAP_TOKEN is guaranteed non-empty here. + const presented = url.searchParams.get(BOOTSTRAP_PARAM); + if (presented !== null && (await tokensMatch(presented, env.BOOTSTRAP_TOKEN!))) { + // Strip the token from the URL and persist access in a cookie. + url.searchParams.delete(BOOTSTRAP_PARAM); + return new Response(null, { + status: 302, + headers: { + location: url.toString(), + "set-cookie": + `${BOOTSTRAP_COOKIE}=${encodeURIComponent(env.BOOTSTRAP_TOKEN!)}; ` + + "HttpOnly; Secure; SameSite=Lax; Path=/", + }, + }); + } + + const cookie = getCookie(request.headers.get("Cookie"), BOOTSTRAP_COOKIE); + const decoded = cookie === undefined ? undefined : decodeCookieValue(cookie); + if (decoded !== undefined && (await tokensMatch(decoded, env.BOOTSTRAP_TOKEN!))) { + return null; + } + + return new Response(accessDeniedPage(), { + status: 401, + headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }, + }); +} + +async function ensurePaperclip(sandbox: SandboxType, env: Env, requestUrl: URL): Promise { + const processes = await sandbox.listProcesses(); + if (isPaperclipRunning(processes)) return; + + // Mount durable attachment storage before Paperclip boots so the very + // first upload already lands in R2. Credential-less: the SDK routes s3fs + // traffic through the Worker's R2 binding (requires the ContainerProxy + // export above). + if (env[ARTIFACTS_BINDING]) { + try { + await sandbox.mountBucket(ARTIFACTS_BINDING, STORAGE_MOUNT_PATH, storageMountOptions()); + } catch (error) { + if (!isMountAlreadyInUse(error)) throw error; + } + } + + await sandbox.startProcess(START_COMMAND, { + env: buildPaperclipEnv({ + // origin (not a hardcoded https:// prefix) so wrangler dev's http:// + // origin round-trips correctly and auth cookies behave locally. + publicUrl: env.PAPERCLIP_PUBLIC_URL || requestUrl.origin, + deploymentMode: env.PAPERCLIP_DEPLOYMENT_MODE, + deploymentExposure: env.PAPERCLIP_DEPLOYMENT_EXPOSURE, + anthropicApiKey: env.ANTHROPIC_API_KEY, + databaseUrl: env.DATABASE_URL, + }), + }); +} + +/** + * Distinguish the Sandbox SDK's "still starting" 5xx responses from genuine + * Paperclip errors so operators see the status page, not a JSON stack trace. + */ +async function isProvisioningResponse(response: Response): Promise { + if (response.status < 500) return false; + if (!response.headers.get("content-type")?.includes("json")) return false; + const text = await response.clone().text().catch(() => ""); + return isTransientBootMessage(text); +} + +export default { + async fetch(request: Request, env: Env): Promise { + const sandbox = getSandbox(env.Sandbox, SANDBOX_ID); + const url = new URL(request.url); + + // Cheap rejections first, before the gate does any crypto or the sandbox + // is touched at all. + if (exceedsRequestSizeLimit(request.headers)) { + return new Response("Request body too large", { status: 413 }); + } + if (!isOriginAllowed(request.headers.get("Origin"), url.origin, env.ALLOWED_ORIGINS)) { + return new Response("Origin not allowed", { status: 403 }); + } + + const denied = await enforceBootstrapGate(request, env, url); + if (denied) return denied; + + try { + if (!paperclipEnsured) { + ensureInFlight ??= ensurePaperclip(sandbox, env, url).finally(() => { + ensureInFlight = null; + }); + await ensureInFlight; + paperclipEnsured = true; + } + + if (isWebSocketUpgrade(request.headers)) { + return await sandbox.wsConnect(request, PAPERCLIP_PORT); + } + + const response = await sandbox.containerFetch(request, PAPERCLIP_PORT); + if (await isProvisioningResponse(response)) { + paperclipEnsured = false; + return bootingResponse(); + } + return response; + } catch (error) { + paperclipEnsured = false; + if (isTransientBootError(error)) { + return isWebSocketUpgrade(request.headers) + ? new Response("Paperclip is starting; retry shortly", { status: 503 }) + : bootingResponse(); + } + throw error; + } + }, +} satisfies ExportedHandler; diff --git a/deploy/cloudflare/src/lib.ts b/deploy/cloudflare/src/lib.ts new file mode 100644 index 0000000000..36f97be268 --- /dev/null +++ b/deploy/cloudflare/src/lib.ts @@ -0,0 +1,339 @@ +/** + * Pure helpers for the Cloudflare Sandbox deployment Worker. + * + * Everything here is side-effect free so it can be unit tested without a + * Workers runtime (see ../test/lib.test.ts). + */ + +/** Port the Paperclip server listens on inside the sandbox container. */ +export const PAPERCLIP_PORT = 3100; + +/** + * Stable sandbox id. One deployment == one Paperclip control plane, so a + * fixed id always routes to the same Durable Object / container. + */ +export const SANDBOX_ID = "paperclip"; + +/** Boot script baked into the container image (container/Dockerfile). */ +export const START_COMMAND = "/opt/start-paperclip.sh"; + +/** + * Optional R2 binding name for durable attachment storage. When the binding + * exists, the Worker FUSE-mounts the bucket (credential-less, via the SDK's + * egress interception) at Paperclip's local-disk storage directory before + * boot, so uploaded files survive container recycling. + */ +export const ARTIFACTS_BINDING = "ARTIFACTS"; + +/** + * Paperclip's local_disk storage provider path (docs/deploy/storage.md) + * under PAPERCLIP_HOME=/paperclip. Only file uploads live here — never the + * Postgres data directory, which must not sit on a FUSE mount. + */ +export const STORAGE_MOUNT_PATH = "/paperclip/instances/default/data/storage"; + +/** + * Fixed uid/gid of the non-root `paperclip` user created in + * container/Dockerfile (useradd -u). Pinned so the s3fs mount can be owned + * by that user; test/config.test.ts enforces the pin matches the Dockerfile. + */ +export const PAPERCLIP_UID = 4100; + +/** + * s3fs options for the attachments mount: expose it as owned by the + * `paperclip` user (s3fs mounts as root and rejects chown) and allow other + * users to traverse it. The SDK's R2 defaults are applied on top. + */ +export function storageMountOptions(): { s3fsOptions: string[] } { + return { + s3fsOptions: [ + "allow_other", + `uid=${PAPERCLIP_UID}`, + `gid=${PAPERCLIP_UID}`, + "umask=0022", + ], + }; +} + +/** Benign when two isolates race to mount the same path — first one wins. */ +export function isMountAlreadyInUse(error: unknown): boolean { + return error instanceof Error && /already in use/i.test(error.message); +} + +/** Process states that mean "no longer serving" (safe to start a new one). */ +const DEAD_STATUSES = new Set(["completed", "failed", "killed", "stopped"]); + +export interface ProcessLike { + command?: string; + status?: string; +} + +/** True when the request is a WebSocket upgrade that must not be buffered. */ +export function isWebSocketUpgrade(headers: Headers): boolean { + return headers.get("Upgrade")?.toLowerCase() === "websocket"; +} + +/** Cookie set once a visitor presents the bootstrap token. */ +export const BOOTSTRAP_COOKIE = "paperclip_bootstrap"; + +/** Query parameter used to present the bootstrap token on first visit. */ +export const BOOTSTRAP_PARAM = "bootstrap_token"; + +/** Minimal cookie-header lookup (no parsing library needed for one value). */ +export function getCookie(cookieHeader: string | null, name: string): string | undefined { + if (!cookieHeader) return undefined; + for (const part of cookieHeader.split(";")) { + const eq = part.indexOf("="); + if (eq === -1) continue; + if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim(); + } + return undefined; +} + +/** + * decodeURIComponent throws URIError on a malformed escape ("%ZZ"), and the + * cookie is fully client-controlled. Raw `decodeURIComponent` in the gate meant + * one bad cookie produced an unhandled Worker exception on every request from + * that client until they cleared it. A cookie that cannot be decoded simply is + * not a valid token, so treat it as absent. + */ +export function decodeCookieValue(value: string): string | undefined { + try { + return decodeURIComponent(value); + } catch { + return undefined; + } +} + +/** + * Largest request body proxied into the sandbox, in bytes. Attachments go + * through this Worker, so the cap is generous — it exists to stop a single + * request exhausting container memory, not to police normal uploads. + */ +export const MAX_REQUEST_BYTES = 100 * 1024 * 1024; + +/** + * Reject an oversized body before it reaches the container. Only Content-Length + * is checked: a chunked upload without one is passed through, because buffering + * it here to measure it would itself be the resource exhaustion this prevents. + */ +export function exceedsRequestSizeLimit( + headers: Headers, + limit: number = MAX_REQUEST_BYTES, +): boolean { + const raw = headers.get("Content-Length"); + if (raw === null) return false; + const length = Number(raw); + return Number.isFinite(length) && length > limit; +} + +/** + * Origin allowlist for state-changing cross-origin requests. Empty or unset + * means same-origin-only, which is the safe default; the deployment is a single + * origin, so no browser client legitimately posts from anywhere else. + * + * Requests with no Origin header are allowed: non-browser clients (the CLI, + * agents, health checks) never send one, and the bootstrap gate plus + * Paperclip's own auth are what actually authenticate them. + */ +export function isOriginAllowed( + origin: string | null, + selfOrigin: string, + allowlist?: string, +): boolean { + if (origin === null) return true; + if (origin === selfOrigin) return true; + if (!allowlist) return false; + return allowlist + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean) + .includes(origin); +} + +/** + * Access-gate decision, fail-closed by default: + * - "open" — operator explicitly disabled the gate (post-claim state) + * - "setup" — no usable token configured; serve the setup page, proxy nothing + * - "token" — token configured; require it (query param once, cookie after) + * An empty-string token counts as unconfigured — it must never open the gate. + */ +export function bootstrapGateMode(options: { + token?: string; + disableGate?: string; +}): "open" | "setup" | "token" { + if (options.disableGate === "true") return "open"; + if (!options.token) return "setup"; + return "token"; +} + +/** 403 page served fail-closed until BOOTSTRAP_TOKEN is configured. */ +export function setupRequiredPage(): string { + return ` + + + + +Paperclip — setup required + + + +
+

Setup required

+

This Paperclip deployment starts locked so that nobody + else can claim the operator account before you do.

+

Set a bootstrap token, then open this URL with + ?${BOOTSTRAP_PARAM}=<your token>:

+

npx wrangler secret put BOOTSTRAP_TOKEN

+

After you claim the operator account you can open the deployment to your + team by setting DISABLE_BOOTSTRAP_GATE to "true" + in wrangler.jsonc and redeploying.

+
+ +`; +} + +/** 401 page shown while the deployment is gated by BOOTSTRAP_TOKEN. */ +export function accessDeniedPage(): string { + return ` + + + + +Paperclip — access restricted + + + +
+

Access restricted

+

This Paperclip deployment is gated by a bootstrap token.

+

Open the URL with ?${BOOTSTRAP_PARAM}=<your token> — + the value you set with wrangler secret put BOOTSTRAP_TOKEN.

+

Once the operator account is claimed, the operator can remove the gate + with wrangler secret delete BOOTSTRAP_TOKEN.

+
+ +`; +} + +/** True when a live Paperclip boot process already exists in the sandbox. */ +export function isPaperclipRunning(processes: ProcessLike[]): boolean { + return processes.some( + (p) => (p.command ?? "").includes(START_COMMAND) && !DEAD_STATUSES.has(p.status ?? "") + ); +} + +export interface PaperclipEnvOptions { + /** Public origin the instance is reachable at, e.g. https://x.workers.dev */ + publicUrl: string; + deploymentMode?: string; + deploymentExposure?: string; + anthropicApiKey?: string; + databaseUrl?: string; +} + +/** + * Environment passed to the Paperclip boot process. Secrets are only + * forwarded when actually configured so the container env stays minimal. + */ +export function buildPaperclipEnv(options: PaperclipEnvOptions): Record { + const env: Record = { + HOST: "0.0.0.0", + PORT: String(PAPERCLIP_PORT), + PAPERCLIP_HOME: "/paperclip", + PAPERCLIP_DEPLOYMENT_MODE: options.deploymentMode ?? "authenticated", + PAPERCLIP_DEPLOYMENT_EXPOSURE: options.deploymentExposure ?? "private", + PAPERCLIP_PUBLIC_URL: options.publicUrl, + }; + if (options.anthropicApiKey) env.ANTHROPIC_API_KEY = options.anthropicApiKey; + if (options.databaseUrl) env.DATABASE_URL = options.databaseUrl; + return env; +} + +/** + * Matches the Sandbox SDK's own transient startup errors: the container VM is + * still provisioning, or the port is not accepting connections yet (Paperclip + * onboards its database on first boot, which takes a minute or two). + * Deliberately specific to SDK wording so genuine Paperclip 5xx responses are + * never mistaken for boot noise. + */ +const TRANSIENT_BOOT_PATTERNS = [ + /currently provisioning/i, + /no container instance/i, + /container.*(?:not running|starting|is starting)/i, + /connection refused/i, + /port.*not (?:ready|mapped|found)/i, + /network connection lost/i, + /timed out.*(?:port|start|container)/i, +]; + +export function isTransientBootMessage(message: string): boolean { + return TRANSIENT_BOOT_PATTERNS.some((pattern) => pattern.test(message)); +} + +export function isTransientBootError(error: unknown): boolean { + return error instanceof Error && isTransientBootMessage(error.message); +} + +/** + * Self-refreshing status page served while the container provisions and + * Paperclip onboards. Inline styles only — nothing else is reachable yet. + */ +export function bootingPage(): string { + return ` + + + + + +Paperclip is starting… + + + +
+
+

Paperclip is starting

+

The sandbox container is provisioning and Paperclip is onboarding its database.

+

First boot takes a minute or two. This page refreshes automatically.

+
+ +`; +} + +/** 503 + Retry-After so health checkers and browsers both behave. */ +export function bootingResponse(): Response { + return new Response(bootingPage(), { + status: 503, + headers: { + "content-type": "text/html; charset=utf-8", + "retry-after": "15", + "cache-control": "no-store", + }, + }); +} diff --git a/deploy/cloudflare/test/config.test.ts b/deploy/cloudflare/test/config.test.ts new file mode 100644 index 0000000000..e3346b38ae --- /dev/null +++ b/deploy/cloudflare/test/config.test.ts @@ -0,0 +1,96 @@ +/** + * Consistency checks across wrangler.jsonc, the container Dockerfile and + * package.json. These encode the deployment's cross-file invariants — most + * importantly the Sandbox SDK requirement that the base image tag match the + * @cloudflare/sandbox package version exactly. + */ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { PAPERCLIP_PORT, PAPERCLIP_UID, STORAGE_MOUNT_PATH } from "../src/lib"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); + +function read(path: string): string { + return readFileSync(join(root, path), "utf8"); +} + +/** Minimal JSONC parser: strips // and /* *\/ comments outside strings. */ +function parseJsonc(text: string): any { + const stripped = text.replace( + /"(?:[^"\\]|\\.)*"|\/\/[^\n]*|\/\*[\s\S]*?\*\//g, + (match) => (match.startsWith('"') ? match : "") + ); + return JSON.parse(stripped); +} + +const wrangler = parseJsonc(read("wrangler.jsonc")); +const pkg = JSON.parse(read("package.json")); +const dockerfile = read("container/Dockerfile"); + +describe("wrangler.jsonc", () => { + it("wires the Sandbox container, DO binding and migration to one class", () => { + const containerClass = wrangler.containers[0].class_name; + expect(containerClass).toBe("Sandbox"); + expect(wrangler.durable_objects.bindings[0].class_name).toBe(containerClass); + expect(wrangler.migrations[0].new_sqlite_classes).toContain(containerClass); + }); + + it("uses the container Dockerfile as the image", () => { + expect(wrangler.containers[0].image).toBe("./container/Dockerfile"); + }); + + it("enables nodejs_compat (required by the Sandbox SDK)", () => { + expect(wrangler.compatibility_flags).toContain("nodejs_compat"); + }); + + it("carries no account- or zone-specific configuration", () => { + const raw = read("wrangler.jsonc"); + expect(wrangler.account_id).toBeUndefined(); + expect(wrangler.routes).toBeUndefined(); + expect(raw).not.toMatch(/account_id/); + }); + + it("defaults to private exposure (embedded Postgres requirement)", () => { + expect(wrangler.vars.PAPERCLIP_DEPLOYMENT_EXPOSURE).toBe("private"); + }); +}); + +describe("container image", () => { + it("pins the base image to the exact @cloudflare/sandbox version", () => { + const sdkVersion = pkg.dependencies["@cloudflare/sandbox"]; + // Must be an exact pin — the SDK and in-container runtime version together. + expect(sdkVersion).toMatch(/^\d+\.\d+\.\d+$/); + expect(dockerfile).toContain(`FROM docker.io/cloudflare/sandbox:${sdkVersion}`); + }); + + it("exposes the Paperclip port for local dev", () => { + expect(dockerfile).toContain(`EXPOSE ${PAPERCLIP_PORT}`); + }); + + it("installs Paperclip and the agent CLIs with exact version pins", () => { + expect(dockerfile).toMatch(/paperclipai@\d+\.\d+\.\d+/); + expect(dockerfile).toMatch(/@anthropic-ai\/claude-code@\d+\.\d+\.\d+/); + // Mutable tags make image rebuilds non-reproducible and un-reviewable. + expect(dockerfile).not.toContain("@latest"); + }); + + it("pins the paperclip uid the R2 mount options rely on", () => { + expect(dockerfile).toContain(`useradd -m -u ${PAPERCLIP_UID} `); + }); +}); + +describe("boot script", () => { + const script = read("container/start-paperclip.sh"); + + it("excludes the R2 storage mount from the ownership pass", () => { + // s3fs rejects chown; a bare `chown -R /paperclip` would abort the boot. + expect(script).toContain(`-path ${STORAGE_MOUNT_PATH} -prune`); + expect(script).not.toMatch(/chown -R paperclip:paperclip \/paperclip\s*$/m); + }); + + it("serializes duplicate boots with a non-blocking lock", () => { + expect(script).toContain("flock --nonblock"); + }); +}); diff --git a/deploy/cloudflare/test/lib.test.ts b/deploy/cloudflare/test/lib.test.ts new file mode 100644 index 0000000000..597bd2a5bf --- /dev/null +++ b/deploy/cloudflare/test/lib.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from "vitest"; +import { + BOOTSTRAP_PARAM, + PAPERCLIP_PORT, + PAPERCLIP_UID, + START_COMMAND, + STORAGE_MOUNT_PATH, + accessDeniedPage, + MAX_REQUEST_BYTES, + bootstrapGateMode, + decodeCookieValue, + exceedsRequestSizeLimit, + getCookie, + isOriginAllowed, + setupRequiredPage, + bootingPage, + bootingResponse, + buildPaperclipEnv, + isMountAlreadyInUse, + isPaperclipRunning, + isTransientBootError, + isTransientBootMessage, + isWebSocketUpgrade, + storageMountOptions, +} from "../src/lib"; + +describe("isWebSocketUpgrade", () => { + it("detects a standard upgrade request", () => { + const headers = new Headers({ Upgrade: "websocket", Connection: "Upgrade" }); + expect(isWebSocketUpgrade(headers)).toBe(true); + }); + + it("is case-insensitive", () => { + expect(isWebSocketUpgrade(new Headers({ Upgrade: "WebSocket" }))).toBe(true); + }); + + it("rejects plain requests and non-websocket upgrades", () => { + expect(isWebSocketUpgrade(new Headers())).toBe(false); + expect(isWebSocketUpgrade(new Headers({ Upgrade: "h2c" }))).toBe(false); + }); +}); + +describe("isPaperclipRunning", () => { + it("finds a live boot process", () => { + expect( + isPaperclipRunning([{ command: START_COMMAND, status: "running" }]) + ).toBe(true); + expect( + isPaperclipRunning([{ command: `bash ${START_COMMAND}`, status: "starting" }]) + ).toBe(true); + }); + + it("ignores dead processes so a restart can happen", () => { + for (const status of ["completed", "failed", "killed", "stopped"]) { + expect(isPaperclipRunning([{ command: START_COMMAND, status }])).toBe(false); + } + }); + + it("ignores unrelated processes and empty lists", () => { + expect(isPaperclipRunning([])).toBe(false); + expect(isPaperclipRunning([{ command: "sleep 1", status: "running" }])).toBe(false); + expect(isPaperclipRunning([{}])).toBe(false); + }); +}); + +describe("buildPaperclipEnv", () => { + it("produces the baseline environment", () => { + const env = buildPaperclipEnv({ publicUrl: "https://example.workers.dev" }); + expect(env).toEqual({ + HOST: "0.0.0.0", + PORT: String(PAPERCLIP_PORT), + PAPERCLIP_HOME: "/paperclip", + PAPERCLIP_DEPLOYMENT_MODE: "authenticated", + PAPERCLIP_DEPLOYMENT_EXPOSURE: "private", + PAPERCLIP_PUBLIC_URL: "https://example.workers.dev", + }); + }); + + it("only forwards secrets that are actually set", () => { + const bare = buildPaperclipEnv({ publicUrl: "https://x.dev" }); + expect(bare).not.toHaveProperty("ANTHROPIC_API_KEY"); + expect(bare).not.toHaveProperty("DATABASE_URL"); + + const withSecrets = buildPaperclipEnv({ + publicUrl: "https://x.dev", + anthropicApiKey: "test-key", + databaseUrl: "postgres://example", + }); + expect(withSecrets.ANTHROPIC_API_KEY).toBe("test-key"); + expect(withSecrets.DATABASE_URL).toBe("postgres://example"); + }); + + it("honors mode/exposure overrides", () => { + const env = buildPaperclipEnv({ + publicUrl: "https://x.dev", + deploymentMode: "authenticated", + deploymentExposure: "public", + }); + expect(env.PAPERCLIP_DEPLOYMENT_EXPOSURE).toBe("public"); + }); +}); + +describe("transient boot detection", () => { + it("matches the Sandbox SDK's startup errors", () => { + for (const message of [ + "Container is currently provisioning. This can take several minutes on first deployment.", + "no container instance available", + "connection refused", + "Port 3100 not ready", + "Network connection lost", + ]) { + expect(isTransientBootMessage(message), message).toBe(true); + expect(isTransientBootError(new Error(message)), message).toBe(true); + } + }); + + it("does not swallow genuine application errors", () => { + for (const message of [ + "Internal Server Error", + "database migration failed", + "TypeError: cannot read properties of undefined", + ]) { + expect(isTransientBootMessage(message), message).toBe(false); + } + expect(isTransientBootError("connection refused")).toBe(false); // non-Error + }); +}); + +describe("bootstrapGateMode", () => { + it("fails closed when no token is configured", () => { + expect(bootstrapGateMode({})).toBe("setup"); + expect(bootstrapGateMode({ token: undefined })).toBe("setup"); + }); + + it("treats an empty-string token as unconfigured, never open", () => { + expect(bootstrapGateMode({ token: "" })).toBe("setup"); + }); + + it("requires the token when one is configured", () => { + expect(bootstrapGateMode({ token: "s3cret" })).toBe("token"); + }); + + it("only opens on the explicit literal opt-out", () => { + expect(bootstrapGateMode({ disableGate: "true" })).toBe("open"); + expect(bootstrapGateMode({ token: "s3cret", disableGate: "true" })).toBe("open"); + expect(bootstrapGateMode({ disableGate: "TRUE" })).toBe("setup"); + expect(bootstrapGateMode({ disableGate: "1" })).toBe("setup"); + }); + + it("setup page tells the operator how to configure the gate", () => { + const html = setupRequiredPage(); + expect(html).toContain("BOOTSTRAP_TOKEN"); + expect(html).toContain("DISABLE_BOOTSTRAP_GATE"); + }); +}); + +describe("bootstrap gate helpers", () => { + it("extracts a single cookie value", () => { + expect(getCookie("a=1; paperclip_bootstrap=tok; b=2", "paperclip_bootstrap")).toBe("tok"); + expect(getCookie("paperclip_bootstrap=tok", "paperclip_bootstrap")).toBe("tok"); + }); + + it("returns undefined for missing header, missing cookie, or name prefixes", () => { + expect(getCookie(null, "paperclip_bootstrap")).toBeUndefined(); + expect(getCookie("other=1", "paperclip_bootstrap")).toBeUndefined(); + expect(getCookie("xpaperclip_bootstrap=evil", "paperclip_bootstrap")).toBeUndefined(); + }); + + it("treats an undecodable cookie as absent rather than throwing", () => { + // decodeURIComponent("%ZZ") throws URIError, and the gate runs before the + // Worker's try/catch — so a raw decode turned one malformed client cookie + // into an unhandled exception on every subsequent request from that client. + expect(() => decodeCookieValue("%ZZ")).not.toThrow(); + expect(decodeCookieValue("%ZZ")).toBeUndefined(); + expect(decodeCookieValue("%E0%A4%A")).toBeUndefined(); + expect(decodeCookieValue("plain")).toBe("plain"); + expect(decodeCookieValue("a%20b")).toBe("a b"); + }); + + it("rejects only bodies declaring more than the cap", () => { + const h = (v?: string) => new Headers(v === undefined ? {} : { "Content-Length": v }); + expect(exceedsRequestSizeLimit(h())).toBe(false); + expect(exceedsRequestSizeLimit(h(String(MAX_REQUEST_BYTES)))).toBe(false); + expect(exceedsRequestSizeLimit(h(String(MAX_REQUEST_BYTES + 1)))).toBe(true); + // A non-numeric Content-Length is not evidence of an oversized body. + expect(exceedsRequestSizeLimit(h("not-a-number"))).toBe(false); + }); + + it("allows same-origin and header-less callers, blocks other origins", () => { + const self = "https://paperclip.example.workers.dev"; + // CLI, agents and health checks send no Origin at all. + expect(isOriginAllowed(null, self)).toBe(true); + expect(isOriginAllowed(self, self)).toBe(true); + expect(isOriginAllowed("https://evil.example", self)).toBe(false); + expect(isOriginAllowed("https://ok.example", self, "https://ok.example")).toBe(true); + expect(isOriginAllowed("https://ok.example", self, " https://a.test , https://ok.example ")).toBe( + true, + ); + expect(isOriginAllowed("https://evil.example", self, "https://ok.example")).toBe(false); + }); + + it("access-denied page names the param and secret", () => { + const html = accessDeniedPage(); + expect(html).toContain(BOOTSTRAP_PARAM); + expect(html).toContain("BOOTSTRAP_TOKEN"); + }); +}); + +describe("storage mount", () => { + it("targets Paperclip's local_disk storage path, never the DB dir", () => { + expect(STORAGE_MOUNT_PATH).toBe("/paperclip/instances/default/data/storage"); + expect(STORAGE_MOUNT_PATH).not.toContain("postgres"); + }); + + it("exposes the mount as the paperclip user", () => { + const { s3fsOptions } = storageMountOptions(); + expect(s3fsOptions).toContain(`uid=${PAPERCLIP_UID}`); + expect(s3fsOptions).toContain(`gid=${PAPERCLIP_UID}`); + expect(s3fsOptions).toContain("allow_other"); + }); + + it("recognizes the benign already-mounted race", () => { + expect(isMountAlreadyInUse(new Error("Mount path already in use: /x"))).toBe(true); + expect(isMountAlreadyInUse(new Error("S3FS mount command failed"))).toBe(false); + expect(isMountAlreadyInUse("already in use")).toBe(false); // non-Error + }); +}); + +describe("booting page", () => { + it("self-refreshes and explains what is happening", () => { + const html = bootingPage(); + expect(html).toContain('http-equiv="refresh"'); + expect(html).toContain("Paperclip is starting"); + }); + + it("responds 503 with Retry-After and no caching", () => { + const response = bootingResponse(); + expect(response.status).toBe(503); + expect(response.headers.get("retry-after")).toBe("15"); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get("content-type")).toContain("text/html"); + }); +}); diff --git a/deploy/cloudflare/tsconfig.json b/deploy/cloudflare/tsconfig.json new file mode 100644 index 0000000000..27c4518429 --- /dev/null +++ b/deploy/cloudflare/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es2022", + "lib": ["es2022"], + "module": "es2022", + "moduleResolution": "bundler", + "types": ["@cloudflare/workers-types", "node"], + "strict": true, + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src", "test", "vitest.config.ts"] +} diff --git a/deploy/cloudflare/vitest.config.ts b/deploy/cloudflare/vitest.config.ts new file mode 100644 index 0000000000..ed8bf7739b --- /dev/null +++ b/deploy/cloudflare/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["test/**/*.test.ts"], + }, +}); diff --git a/deploy/cloudflare/wrangler.jsonc b/deploy/cloudflare/wrangler.jsonc new file mode 100644 index 0000000000..309a523553 --- /dev/null +++ b/deploy/cloudflare/wrangler.jsonc @@ -0,0 +1,48 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "paperclip-sandbox", + "main": "src/index.ts", + "compatibility_date": "2026-08-29", + "compatibility_flags": ["nodejs_compat"], + "containers": [ + { + "class_name": "Sandbox", + "image": "./container/Dockerfile", + // Paperclip control plane + embedded Postgres + agent CLI processes. + // Shrink at your own risk; embedded Postgres alone wants real memory. + "instance_type": { "vcpu": 2, "memory_mib": 8192, "disk_mb": 10240 }, + // One control plane per deployment — a fixed sandbox id routes every + // request to the same instance, so extra instances would sit idle. + "max_instances": 1, + "name": "paperclip-sandbox" + } + ], + "durable_objects": { + "bindings": [{ "class_name": "Sandbox", "name": "Sandbox" }] + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Sandbox"] }], + // Optional (experimental): durable attachment storage. Paperclip's upload + // directory is FUSE-mounted from this bucket (credential-less, via the + // Worker binding), so attachments survive container recycling. To enable: + // 1. npx wrangler r2 bucket create paperclip-attachments + // 2. Uncomment the block below and redeploy. + // See docs/deploy/cloudflare.md#durable-attachments-via-r2-optional-experimental. + // "r2_buckets": [ + // { "binding": "ARTIFACTS", "bucket_name": "paperclip-attachments" } + // ], + "vars": { + // Defaults follow docs/deploy/deployment-modes.md. Embedded Postgres + // currently requires "private" exposure; switch to "public" only with an + // external DATABASE_URL (see docs/deploy/cloudflare.md#data-durability). + "PAPERCLIP_DEPLOYMENT_MODE": "authenticated", + "PAPERCLIP_DEPLOYMENT_EXPOSURE": "private", + // Leave empty to default to the request origin (your workers.dev URL). + "PAPERCLIP_PUBLIC_URL": "", + // The deployment starts fail-closed: it serves nothing until you set the + // BOOTSTRAP_TOKEN secret (protects the unclaimed operator invite). After + // claiming the operator account, flip this to "true" to open the login + // page to your team — Paperclip's own auth takes over from there. + "DISABLE_BOOTSTRAP_GATE": "false" + }, + "observability": { "enabled": true } +} diff --git a/docs/deploy/cloudflare.md b/docs/deploy/cloudflare.md new file mode 100644 index 0000000000..c871b24e47 --- /dev/null +++ b/docs/deploy/cloudflare.md @@ -0,0 +1,164 @@ +--- +title: Cloudflare +summary: Run Paperclip on Cloudflare Workers with a Sandbox container +--- + +Deploy a full Paperclip instance to Cloudflare: a Worker proxies your +`*.workers.dev` origin into a [Cloudflare Sandbox](https://developers.cloudflare.com/sandbox/) +container that runs `paperclipai` (npm release) with embedded Postgres and the +local-adapter agent CLIs (Claude Code, Codex, OpenCode, Gemini) preinstalled. + +No custom domain is required — HTTP and WebSocket traffic are proxied on the +Worker's own origin. + +## ⚠️ Data Durability + +**Sandbox container disk is ephemeral.** When the container sleeps after +inactivity or is replaced by a deploy, the embedded Postgres data directory is +wiped — companies, agents, issues, everything. + +Treat the default configuration as an **evaluation deployment**. For anything +you want to keep, point Paperclip at an external Postgres before onboarding: + +```sh +npx wrangler secret put DATABASE_URL +# e.g. postgres://USER:PASSWORD@HOST:5432/paperclip (Neon, Supabase, RDS, …) +``` + +The Worker forwards `DATABASE_URL` into the container and Paperclip uses it +instead of embedded Postgres. + +## Durable Attachments via R2 (Optional, Experimental) + +> **Experimental:** this mount path is newer than the rest of the deployment +> and has not yet been validated on a live deployment. The default (no R2 +> binding) is unaffected. + +Uploaded files (issue attachments, images) can survive container recycling +without any Paperclip configuration: the Worker FUSE-mounts an R2 bucket at +Paperclip's [local-disk storage directory](/deploy/storage) before boot, +credential-less, through the Worker's own R2 binding. + +```sh +npx wrangler r2 bucket create paperclip-attachments +# then uncomment the r2_buckets block in wrangler.jsonc and redeploy +``` + +Notes: + +- Only the attachments directory is mounted. The Postgres data directory + stays on container disk **by design** — databases must not run on FUSE + mounts. Durable attachments complement, not replace, `DATABASE_URL`. +- In `wrangler dev` the SDK syncs the directory through the R2 binding + instead of s3fs; behavior is equivalent for testing. +- Alternatively, Paperclip's own `s3` storage provider can talk to R2 + directly via its S3-compatible API (`paperclipai configure --section + storage`) — the mount is just the zero-config path. + +## Prerequisites + +- A Cloudflare account on the **Workers Paid** plan (containers are not + available on the free tier) +- [Docker](https://docs.docker.com/get-docker/) running locally (wrangler + builds the container image and, for `wrangler dev`, runs it) +- Node.js 22+ (required by the pinned wrangler toolchain) and pnpm + +## Deploy + +```sh +cd deploy/cloudflare +pnpm install +npx wrangler login +npx wrangler deploy + +# required before anything is served: gate the deployment until you claim +# the operator account — any value you choose, e.g. `openssl rand -hex 16` +npx wrangler secret put BOOTSTRAP_TOKEN + +# optional: give in-container agents an API key +npx wrangler secret put ANTHROPIC_API_KEY +``` + +Then open +`https://paperclip-sandbox..workers.dev/?bootstrap_token=`. +The **first request** provisions the container and onboards Paperclip +(a minute or two) — you'll see a self-refreshing status page until the app is +up. + +Paperclip boots in `authenticated` mode with a pending bootstrap invite, and +**the first visitor to reach the app can claim the operator account**. The +deployment is therefore **fail-closed**: until `BOOTSTRAP_TOKEN` is set, the +Worker serves only a setup page, and with the token set, requests must +present it (query param once; cookie afterwards) or receive a 401 — nothing +reaches Paperclip either way. + +After you claim the operator account, open the deployment to your team by +setting `DISABLE_BOOTSTRAP_GATE` to `"true"` in `wrangler.jsonc` and +redeploying — from that point Paperclip's own login protects everything. + +## Configuration + +Set via `vars` in `wrangler.jsonc` or `wrangler secret put`: + +| Name | Kind | Default | Purpose | +| --- | --- | --- | --- | +| `PAPERCLIP_PUBLIC_URL` | var | request origin | Public URL Paperclip advertises | +| `PAPERCLIP_DEPLOYMENT_MODE` | var | `authenticated` | See [Deployment Modes](/deploy/deployment-modes) | +| `PAPERCLIP_DEPLOYMENT_EXPOSURE` | var | `private` | Embedded Postgres currently requires `private`; use `public` only with an external `DATABASE_URL` | +| `BOOTSTRAP_TOKEN` | secret | — (required) | Fail-closed gate: nothing is served until set (see Deploy) | +| `DISABLE_BOOTSTRAP_GATE` | var | `"false"` | Set `"true"` after claiming the operator account to open team logins | +| `ANTHROPIC_API_KEY` | secret | — | Forwarded to in-container agent CLIs | +| `DATABASE_URL` | secret | — | External Postgres (strongly recommended, see above) | +| `ARTIFACTS` | R2 binding | — | Durable attachment storage (see above) | + +Container sizing lives in `wrangler.jsonc` (`instance_type`, default +2 vCPU / 8 GiB / 10 GB). Embedded Postgres plus concurrent agent processes +want real memory; shrink with care. + +## How It Works + +- `src/index.ts` — Worker entry. On each request it idempotently ensures the + Paperclip boot process is running (`sandbox.startProcess`), then proxies: + WebSocket upgrades via `sandbox.wsConnect(request, 3100)`, everything else + via `sandbox.containerFetch(request, 3100)`. Same-origin proxying keeps + Paperclip's cookies and live-update WebSockets on one host. +- `container/Dockerfile` — extends `cloudflare/sandbox` (tag pinned to the + `@cloudflare/sandbox` package version; enforced by `test/config.test.ts`), + installs `paperclipai` and the agent CLIs. +- `container/start-paperclip.sh` — onboards once + (`paperclipai onboard --yes --bind lan`), then `paperclipai run`, as a + non-root user (embedded Postgres refuses root). + +## Local Development + +```sh +cd deploy/cloudflare +pnpm install +pnpm dev # wrangler dev — builds and runs the container via Docker +``` + +Run the unit and config-consistency tests: + +```sh +pnpm test +pnpm typecheck +``` + +## Troubleshooting + +- **Status page loops for more than ~5 minutes** — check `npx wrangler tail` + for container/start errors. First-ever deploys can also spend a few minutes + provisioning container capacity. +- **`Container is currently provisioning`** in logs is normal on first boot. +- **Everything reset after idling** — that's the ephemerality caveat above; + configure `DATABASE_URL`. +- **Inspect the container directly** — `npx wrangler dev` locally, then + `docker exec` into the running container; or add temporary debug output to + `start-paperclip.sh`. + +## Cost Notes + +You pay for Worker requests plus container runtime (vCPU-seconds, memory, +disk) while the sandbox is awake. The container sleeps after inactivity; +with embedded Postgres that also means data loss (see above), which is the +other reason to use an external database. diff --git a/docs/docs.json b/docs/docs.json index 8538840176..6f98ebb24c 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -84,6 +84,7 @@ "deploy/local-development", "deploy/tailscale-private-access", "deploy/docker", + "deploy/cloudflare", "deploy/deployment-modes", "deploy/database", "deploy/secrets",