From da5ad47b33e293407a459ee0fa79fa19e98e6ab8 Mon Sep 17 00:00:00 2001 From: Justin Persaud Date: Tue, 6 Oct 2026 18:22:31 -0400 Subject: [PATCH 01/28] Add Cloudflare Worker package for serving NewDot web with Static Assets Standalone npm package (not a root workspace) so wrangler and workerd stay out of the root install. Root ESLint and Knip skip it because its types live in its own node_modules. --- config/eslint/eslint.config.mjs | 2 + knip.json | 1 + web/cloudflare-worker/.assetsignore | 4 + web/cloudflare-worker/.gitignore | 3 + web/cloudflare-worker/.npmrc | 4 + web/cloudflare-worker/package-lock.json | 1990 ++++++++++++++++++ web/cloudflare-worker/package.json | 25 + web/cloudflare-worker/scripts/prepareDist.sh | 13 + web/cloudflare-worker/tests/tsconfig.json | 16 + web/cloudflare-worker/tsconfig.json | 15 + web/cloudflare-worker/wrangler.jsonc | 38 + 11 files changed, 2111 insertions(+) create mode 100644 web/cloudflare-worker/.assetsignore create mode 100644 web/cloudflare-worker/.gitignore create mode 100644 web/cloudflare-worker/.npmrc create mode 100644 web/cloudflare-worker/package-lock.json create mode 100644 web/cloudflare-worker/package.json create mode 100755 web/cloudflare-worker/scripts/prepareDist.sh create mode 100644 web/cloudflare-worker/tests/tsconfig.json create mode 100644 web/cloudflare-worker/tsconfig.json create mode 100644 web/cloudflare-worker/wrangler.jsonc diff --git a/config/eslint/eslint.config.mjs b/config/eslint/eslint.config.mjs index 10e73863e75b..334840173206 100644 --- a/config/eslint/eslint.config.mjs +++ b/config/eslint/eslint.config.mjs @@ -846,6 +846,8 @@ const config = defineConfig([ '**/vendor', 'modules/group-ib-fp/**/*', 'web/snippets/gib.js', + // Standalone npm package with its own dependencies and typecheck (see web/cloudflare-worker/README.md) + 'web/cloudflare-worker/**/*', // Generated language files - excluded from ESLint but still type-checked 'src/languages/de.ts', 'src/languages/el.ts', diff --git a/knip.json b/knip.json index 72c09c1b8500..b659f616ef55 100644 --- a/knip.json +++ b/knip.json @@ -42,6 +42,7 @@ ".github/actions/**/index.js", "tests/perf-test/**", "web/snippets/gib.js", + "web/cloudflare-worker/**", "src/libs/actions/connections/FinancialForce.ts", "src/setup/telemetry/noopExpoUpdates.ts" ], diff --git a/web/cloudflare-worker/.assetsignore b/web/cloudflare-worker/.assetsignore new file mode 100644 index 000000000000..f868ffc70844 --- /dev/null +++ b/web/cloudflare-worker/.assetsignore @@ -0,0 +1,4 @@ +# Precompressed twins for the S3 origin. Cloudflare compresses Worker responses at the edge. +*.br +# Source maps are uploaded to Sentry at build time and must not be publicly served. +*.map diff --git a/web/cloudflare-worker/.gitignore b/web/cloudflare-worker/.gitignore new file mode 100644 index 000000000000..b2f02dedeefe --- /dev/null +++ b/web/cloudflare-worker/.gitignore @@ -0,0 +1,3 @@ +.wrangler/ +.dev.vars* +worker-configuration.d.ts diff --git a/web/cloudflare-worker/.npmrc b/web/cloudflare-worker/.npmrc new file mode 100644 index 000000000000..317d26831b83 --- /dev/null +++ b/web/cloudflare-worker/.npmrc @@ -0,0 +1,4 @@ +engine-strict=true + +# Supply chain protection: only resolve package versions published at least 7 days ago. +min-release-age=7 diff --git a/web/cloudflare-worker/package-lock.json b/web/cloudflare-worker/package-lock.json new file mode 100644 index 000000000000..963aabedb37e --- /dev/null +++ b/web/cloudflare-worker/package-lock.json @@ -0,0 +1,1990 @@ +{ + "name": "@expensify/new-expensify-cloudflare-worker", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@expensify/new-expensify-cloudflare-worker", + "version": "0.0.0", + "devDependencies": { + "@types/node": "24.19.0", + "typescript": "7.0.2", + "wrangler": "4.144.0" + }, + "engines": { + "node": "26.5.0", + "npm": "11.17.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.2.tgz", + "integrity": "sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260926.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260926.1.tgz", + "integrity": "sha512-VCIpwgJu5Dm1o+VHLclNOKIdpttEqss7oDHS5DYxgVoonM9Dxxxr1xCQvJbh6IXFuQ1DDrbm5krjte5NsTgX3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260926.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260926.1.tgz", + "integrity": "sha512-oVuLXfCnr1Xu9sBMNYrEPcFZQNMcviNcacz9l+oqQiLHc5UrVp++lpKbsuWstCfh+/c39Pp37TVeaNBLA8+ReA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260926.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260926.1.tgz", + "integrity": "sha512-BjmiDvYBVBG5248gCHSZS3yONbcm3/3qKTu9RaQA8o7jQHJfXx9JnWbhkld2KsbXnRGEGHFwpLLidIpIGtQYUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260926.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260926.1.tgz", + "integrity": "sha512-28bdfQDIFcpaKXMPisADy8BchpIsX+tEAhD+bhfEBXukFGFr8F3ouyiG3HzN3CqAh/OYvifJizzFZtptUPCy7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260926.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260926.1.tgz", + "integrity": "sha512-ehdL3ataKdEmlnW9oom6OZUR8n3XsTL/zl8+KGREpLi5sMSW0mek0PrF4wK9SX51f3Alnlu8tF8eKBg+mc3xFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@types/node": { + "version": "24.19.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.0.tgz", + "integrity": "sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "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" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/miniflare": { + "version": "5.20260926.1-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260926.1-alpha.tgz", + "integrity": "sha512-vc4lgl8YVxR2aundPnVPTIcL46UQKK+ebW7yinXNE1Y9joFS0JoakMazr7r3M8DXRvaemKS8EEmszkv/b05qUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.4", + "undici": "7.29.1", + "workerd": "1.20260926.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/workerd": { + "version": "1.20260926.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260926.1.tgz", + "integrity": "sha512-YojhsWBuZwk3Jk9OC0QfTgVbLeQOD1tK1olC9ZXAUcmeip8z4NE/xtttpxCsuTvQBRqppvy7Tkac0kxPYMjIXA==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260926.1", + "@cloudflare/workerd-darwin-arm64": "1.20260926.1", + "@cloudflare/workerd-linux-64": "1.20260926.1", + "@cloudflare/workerd-linux-arm64": "1.20260926.1", + "@cloudflare/workerd-windows-64": "1.20260926.1" + } + }, + "node_modules/wrangler": { + "version": "4.144.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.144.0.tgz", + "integrity": "sha512-4xIASysrsGBLkTuyoYUTJuhqjlnhwX90Ui8HVLElLJ9Unfqn0Hq51YuMP6GMmijbKFK/7Jmlgk6WkMZDCAklLQ==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.2", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "5.20260926.1-alpha", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260926.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260926.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "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 + } + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + } + } +} diff --git a/web/cloudflare-worker/package.json b/web/cloudflare-worker/package.json new file mode 100644 index 000000000000..1d53287e8176 --- /dev/null +++ b/web/cloudflare-worker/package.json @@ -0,0 +1,25 @@ +{ + "name": "@expensify/new-expensify-cloudflare-worker", + "version": "0.0.0", + "private": true, + "description": "Cloudflare Worker that serves the New Expensify web build with Workers Static Assets.", + "type": "module", + "scripts": { + "prepare-dist": "./scripts/prepareDist.sh", + "dev": "npm run prepare-dist && wrangler dev", + "dev:production": "npm run prepare-dist && wrangler dev --env production", + "types": "wrangler types", + "typecheck": "npm run types && tsc -p . && tsc -p tests", + "test": "node --test 'tests/**/*.test.ts'", + "compare-headers": "node scripts/compareHeaders.ts" + }, + "engines": { + "node": "26.5.0", + "npm": "11.17.0" + }, + "devDependencies": { + "@types/node": "24.19.0", + "typescript": "7.0.2", + "wrangler": "4.144.0" + } +} diff --git a/web/cloudflare-worker/scripts/prepareDist.sh b/web/cloudflare-worker/scripts/prepareDist.sh new file mode 100755 index 000000000000..0613b384d91d --- /dev/null +++ b/web/cloudflare-worker/scripts/prepareDist.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# Wrangler only reads .assetsignore from the root of the assets directory, which the web build recreates. +set -eu + +WORKER_DIR="$(cd "$(dirname "$0")/.." && pwd)" +DIST_DIR="$WORKER_DIR/../../dist" + +if [[ ! -f "$DIST_DIR/index.html" ]]; then + echo "No web build found at $DIST_DIR. Build one first (see README.md)." >&2 + exit 1 +fi + +cp "$WORKER_DIR/.assetsignore" "$DIST_DIR/.assetsignore" diff --git a/web/cloudflare-worker/tests/tsconfig.json b/web/cloudflare-worker/tests/tsconfig.json new file mode 100644 index 000000000000..026869084c68 --- /dev/null +++ b/web/cloudflare-worker/tests/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "es2024", + "lib": ["es2024"], + "module": "nodenext", + "moduleResolution": "nodenext", + "types": ["node"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true + }, + "include": [".", "../scripts"] +} diff --git a/web/cloudflare-worker/tsconfig.json b/web/cloudflare-worker/tsconfig.json new file mode 100644 index 000000000000..8e3b138af7e9 --- /dev/null +++ b/web/cloudflare-worker/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es2024", + "lib": ["es2024"], + "module": "esnext", + "moduleResolution": "bundler", + "types": [], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true, + "verbatimModuleSyntax": true + }, + "include": ["src", "worker-configuration.d.ts"] +} diff --git a/web/cloudflare-worker/wrangler.jsonc b/web/cloudflare-worker/wrangler.jsonc new file mode 100644 index 000000000000..0a3c6c83b5cc --- /dev/null +++ b/web/cloudflare-worker/wrangler.jsonc @@ -0,0 +1,38 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + // The top level only configures `wrangler dev`. Deploys always pass `--env staging` or `--env production`. + "name": "new-expensify-assets-local", + "main": "src/index.ts", + "compatibility_date": "2026-09-26", + "workers_dev": false, + "preview_urls": false, + "observability": { + "enabled": true, + }, + "assets": { + "directory": "../../dist", + "binding": "ASSETS", + "not_found_handling": "single-page-application", + // Serve exact paths like the S3 origin does, so `/index.html` is not redirected to `/`. + "html_handling": "none", + // Every response needs security headers, and HTML needs a per-request CSP nonce, including SPA navigations. + "run_worker_first": true, + }, + "vars": { + "ENVIRONMENT": "staging", + }, + "env": { + "staging": { + "name": "staging-new-expensify-assets", + "vars": { + "ENVIRONMENT": "staging", + }, + }, + "production": { + "name": "new-expensify-assets", + "vars": { + "ENVIRONMENT": "production", + }, + }, + }, +} From a86d92eb8c25ea18b0546fda445e7a1ca866d5b2 Mon Sep 17 00:00:00 2001 From: Justin Persaud Date: Tue, 6 Oct 2026 18:23:44 -0400 Subject: [PATCH 02/28] Port NewDot CSP builder to the Static Assets Worker font-src uses 'self' instead of a hard-coded host, so the same policy works on localhost and test hostnames. --- web/cloudflare-worker/src/csp.ts | 175 +++++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 web/cloudflare-worker/src/csp.ts diff --git a/web/cloudflare-worker/src/csp.ts b/web/cloudflare-worker/src/csp.ts new file mode 100644 index 000000000000..607b94c809ec --- /dev/null +++ b/web/cloudflare-worker/src/csp.ts @@ -0,0 +1,175 @@ +/** Placeholder in the CSP and in `web/index.html` script tags that the Worker replaces with a per-request nonce. */ +const NONCE_PLACEHOLDER = 'nonce-random-value'; + +type CSPOptions = { + fontSrcHosts?: string[]; + frameSrcHosts?: string[]; + scriptSrcHashes?: string[]; +}; + +/** Hashes of the inline scripts in `web/index.html` that are not nonced. */ +const INLINE_SCRIPT_HASHES = [ + 'sha256-hK550RmP3t+9myNCpVty39wCs9CDUTXjWUP30rrXvD0=', + 'sha256-JgQ1FoMqsUqLfloUqis32MBUkR9nCO7v7MjXWfmS6eY=', + 'sha256-Xr7MaaLC2xWFhQ/khbLZgUddvDCpUuEbefj9xj/9O9s=', + 'sha256-teb83afsm9CMKzhm1Dd8p0ieUMAEb4sajrZR3sH+vPE=', + 'sha256-RvmVKfUeI9jgs3Hf2cF8pJfT96fa/A0p+uUF+ea8Cnk=', + 'sha256-ZC4Ihfl+1sv3E25DQh090ITQKwffxiocyA9C1vaePKU=', +]; + +function buildCSP({fontSrcHosts = [], frameSrcHosts = [], scriptSrcHashes = []}: CSPOptions): string { + const csp: Record = { + 'default-src': ["'self'", 'https://assets.onfido.com'], + 'script-src': [ + "'self'", + `'${NONCE_PLACEHOLDER}'`, + ...scriptSrcHashes.map((hash) => `'${hash}'`), + "'unsafe-eval'", + 'https://*.convertexperiments.com', + 'https://*.sardine.ai/', + 'https://*.sentry.io', + 'https://accounts.google.com/gsi/client', + 'https://api.mapbox.com', + 'https://api.openai.com', + 'https://appleid.cdn-apple.com', + 'https://assets.onfido.com', + 'https://burp', + 'https://cdn.expensify.com', + 'https://cdn.ketchjs.com', + 'https://cdn.plaid.com', + 'https://connect.facebook.net', + 'https://edge.fullstory.com', + 'https://global.ketchcdn.com', + 'https://googletagmanager.com', + 'https://*.googletagmanager.com', + 'https://rs.fullstory.com', + 'https://sdk.onfido.com', + 'https://sentry.io', + 'https://snap.licdn.com', + 'https://tagmanager.google.com', + 'https://web-sdk.smartlook.com', + 'https://www.redditstatic.com', + 'https://www.woopra.com', + ], + 'connect-src': [ + "'self'", + 'blob: *.onfido.com', + 'data:', + 'https://*.analytics.google.com', + 'https://*.convertexperiments.com', + 'https://*.doubleclick.net', + 'https://*.google-analytics.com', + 'https://*.googleapis.com', + 'https://*.googleusercontent.com', + 'https://*.pusher.com', + 'https://*.pusherapp.com', + 'https://*.sentry.io', + 'https://*.smartlook.cloud', + 'https://*.tiles.mapbox.com', + 'https://accounts.google.com/gsi/', + 'https://alb.reddit.com', + 'https://analytics.google.com', + 'https://api.mapbox.com', + 'https://api.openai.com', + 'https://cdn.expensify.com', + 'https://cdn.ketchjs.com', + 'https://connect.facebook.net', + 'https://edge.fullstory.com', + 'https://events.mapbox.com', + 'https://global.ketchcdn.com', + 'https://google-analytics.com', + 'https://googletagmanager.com', + 'https://*.googletagmanager.com', + 'https://pixel-config.reddit.com', + 'https://rs.fullstory.com', + 'https://secure.expensify.com', + 'https://sentry.io', + 'https://snap.licdn.com', + 'https://staging-secure.expensify.com', + 'https://staging.expensify.com', + 'https://telephony.onfido.com', + 'https://www.expensify.com', + 'https://www.google.com/ccm/collect', + 'https://www.google.com/ccm/form-data/942650393', + 'https://www.google.com/measurement/', + 'https://www.google.com/pagead/1p-conversion/942650393/', + 'https://www.google.com/pagead/form-data/942650393', + 'https://www.google.com/rmkt/collect/942650393/', + 'https://www.googleadservices.com/pagead/conversion/942650393/', + 'https://www.redditstatic.com', + 'https://www.woopra.com', + 'wss://*.onfido.com', + 'wss://*.pusher.com', + 'wss://sync.onfido.com', + ], + 'img-src': [ + '*', + 'blob:', + 'data:', + 'https://*.google-analytics.com', + 'https://*.googletagmanager.com', + 'https://google-analytics.com', + 'https://googletagmanager.com', + 'https://px.ads.linkedin.com', + 'https://rs.fullstory.com', + 'https://ssl.gstatic.com', + 'https://www.facebook.com', + 'https://www.gstatic.com', + ], + 'style-src': [ + "'self'", + "'unsafe-inline'", + 'https://accounts.google.com/gsi/style', + 'https://assets.onfido.com', + 'https://fonts.googleapis.com', + 'https://googletagmanager.com', + 'https://sdk.onfido.com', + 'https://tagmanager.google.com', + ], + 'font-src': ['data:', ...fontSrcHosts, 'https://fonts.gstatic.com'], + 'frame-src': [ + "'self'", + ...frameSrcHosts, + 'https://accounts.google.com/gsi/', + 'https://app.storylane.io', + 'https://*.sardine.ai/', + 'https://cdn.plaid.com', + 'https://eu.id.group-ib.com', + 'https://expensify.navattic.com', + 'https://expensify.storylane.io', + 'https://hooks.stripe.com', + 'https://sdk.onfido.com', + 'https://secure.expensify.com', + 'https://staging.expensify.com', + 'https://td.doubleclick.net', + 'https://www.expensify.com', + 'https://www.googletagmanager.com', + ], + 'worker-src': ["'self'", 'blob:'], + 'child-src': ["'self'", 'blob:'], + 'media-src': ['blob:', 'https://assets.onfido.com', 'https://d2k5nsl2zxldvw.cloudfront.net', 'https://staging.expensify.com', 'https://www.expensify.com'], + }; + + return Object.entries(csp) + .map(([directive, sources]) => `${directive} ${sources.join(' ')}`) + .join('; '); +} + +const STAGING_CSP = buildCSP({ + fontSrcHosts: ["'self'"], + frameSrcHosts: ['new-expensify://staging.new.expensify.com'], + scriptSrcHashes: INLINE_SCRIPT_HASHES, +}); + +const PRODUCTION_CSP = buildCSP({ + fontSrcHosts: ["'self'", 'https://www.expensify.com'], + frameSrcHosts: ['new-expensify://new.expensify.com'], + scriptSrcHashes: INLINE_SCRIPT_HASHES, +}); + +function getCSP(environment: Env['ENVIRONMENT'], nonce: string): string { + const csp = environment === 'staging' ? STAGING_CSP : PRODUCTION_CSP; + return csp.replace(NONCE_PLACEHOLDER, `nonce-${nonce}`); +} + +export {NONCE_PLACEHOLDER, getCSP}; From 4abac78233949e9f6a9bb062313360e7e8be95b9 Mon Sep 17 00:00:00 2001 From: Justin Persaud Date: Tue, 6 Oct 2026 18:23:46 -0400 Subject: [PATCH 03/28] Serve NewDot web assets with security headers from the Worker Missing static files return 404 instead of the SPA shell, hashed assets are cached as immutable, and HTML is never revalidated because its CSP nonce changes per request. --- web/cloudflare-worker/src/index.ts | 90 ++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 web/cloudflare-worker/src/index.ts diff --git a/web/cloudflare-worker/src/index.ts b/web/cloudflare-worker/src/index.ts new file mode 100644 index 000000000000..70642daa78cb --- /dev/null +++ b/web/cloudflare-worker/src/index.ts @@ -0,0 +1,90 @@ +import {getCSP, NONCE_PLACEHOLDER} from './csp'; + +/** Content-hashed build output (`main-<16 hex>.bundle.js`, `main.<10 hex>.css`, ...), emitted at the root of `dist/`. */ +const HASHED_ASSET_PATH = /^\/[^/]+(?:-[0-9a-f]{16}\.bundle\.js|\.[0-9a-f]{10}\.[a-z0-9]+)$/; + +/** Paths that can only be files. A miss on one of these is a 404 rather than the SPA shell. */ +const STATIC_FILE_PATH = /\.(?:js|mjs|css|map|json|wasm|png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|otf|mp3|wav|ogg|lottie|pdf|txt|bcmap|br)$/i; + +const AASA_PATH = '/.well-known/apple-app-site-association'; +const LEGACY_AASA_PATH = '/apple-app-site-association'; + +function generateNonce(): string { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return btoa(String.fromCharCode(...bytes)); +} + +function isHTMLResponse(response: Response): boolean { + return response.headers.get('Content-Type')?.startsWith('text/html') ?? false; +} + +/** + * HTML gets a fresh nonce on every request, so a 304 would pair a cached body with a new CSP. Only plain + * static files are allowed to revalidate. + */ +function buildAssetRequest(request: Request, url: URL): Request { + const assetURL = new URL(url); + if (assetURL.pathname === LEGACY_AASA_PATH) { + assetURL.pathname = AASA_PATH; + } + + const assetRequest = new Request(assetURL, request); + if (!STATIC_FILE_PATH.test(url.pathname)) { + assetRequest.headers.delete('If-None-Match'); + assetRequest.headers.delete('If-Modified-Since'); + } + return assetRequest; +} + +function setSecurityHeaders(headers: Headers, environment: Env['ENVIRONMENT'], nonce: string) { + headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload'); + headers.set('Content-Security-Policy', getCSP(environment, nonce)); + headers.set('X-Frame-Options', 'SAMEORIGIN'); + headers.set('X-Content-Type-Options', 'nosniff'); + headers.set('Referrer-Policy', 'origin-when-cross-origin'); + // Used by react-native-release-profiler + headers.set('Document-Policy', 'js-profiling'); +} + +export default { + async fetch(request, env): Promise { + const url = new URL(request.url); + const nonce = generateNonce(); + const isNavigation = request.headers.get('Sec-Fetch-Mode') === 'navigate'; + const assetResponse = await env.ASSETS.fetch(buildAssetRequest(request, url)); + + // The SPA fallback answers every miss with index.html. A browser asking for a missing chunk needs a real 404. + if (!isNavigation && isHTMLResponse(assetResponse) && STATIC_FILE_PATH.test(url.pathname)) { + const notFound = new Response('Not Found', {status: 404, headers: {'Content-Type': 'text/plain; charset=utf-8'}}); + setSecurityHeaders(notFound.headers, env.ENVIRONMENT, nonce); + return notFound; + } + + const response = new Response(assetResponse.body, assetResponse); + setSecurityHeaders(response.headers, env.ENVIRONMENT, nonce); + + if (url.pathname === AASA_PATH || url.pathname === LEGACY_AASA_PATH) { + response.headers.set('Content-Type', 'application/json'); + } + + if (HASHED_ASSET_PATH.test(url.pathname)) { + response.headers.set('Cache-Control', 'public, max-age=31536000, immutable'); + } + + if (!isHTMLResponse(response)) { + return response; + } + + response.headers.set('Cache-Control', 'no-cache'); + response.headers.delete('ETag'); + response.headers.delete('Last-Modified'); + return new HTMLRewriter() + .on(`script[nonce="${NONCE_PLACEHOLDER}"]`, { + element(element) { + element.setAttribute('nonce', nonce); + }, + }) + .transform(response); + }, +} satisfies ExportedHandler; From c701190ce8cc4068d0bca6dacfb5fa1fa9468079 Mon Sep 17 00:00:00 2001 From: Justin Persaud Date: Tue, 6 Oct 2026 18:41:44 -0400 Subject: [PATCH 04/28] Stop stripping conditional headers from Worker asset requests HTML responses never carry ETag or Last-Modified, so browsers have no validator to send for them. --- web/cloudflare-worker/src/index.ts | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/web/cloudflare-worker/src/index.ts b/web/cloudflare-worker/src/index.ts index 70642daa78cb..ad7072adfaca 100644 --- a/web/cloudflare-worker/src/index.ts +++ b/web/cloudflare-worker/src/index.ts @@ -19,22 +19,14 @@ function isHTMLResponse(response: Response): boolean { return response.headers.get('Content-Type')?.startsWith('text/html') ?? false; } -/** - * HTML gets a fresh nonce on every request, so a 304 would pair a cached body with a new CSP. Only plain - * static files are allowed to revalidate. - */ function buildAssetRequest(request: Request, url: URL): Request { - const assetURL = new URL(url); - if (assetURL.pathname === LEGACY_AASA_PATH) { - assetURL.pathname = AASA_PATH; + if (url.pathname !== LEGACY_AASA_PATH) { + return request; } - const assetRequest = new Request(assetURL, request); - if (!STATIC_FILE_PATH.test(url.pathname)) { - assetRequest.headers.delete('If-None-Match'); - assetRequest.headers.delete('If-Modified-Since'); - } - return assetRequest; + const assetURL = new URL(url); + assetURL.pathname = AASA_PATH; + return new Request(assetURL, request); } function setSecurityHeaders(headers: Headers, environment: Env['ENVIRONMENT'], nonce: string) { @@ -76,6 +68,7 @@ export default { return response; } + // The nonce changes per request, so a 304 would pair a cached body with a CSP that no longer matches it. response.headers.set('Cache-Control', 'no-cache'); response.headers.delete('ETag'); response.headers.delete('Last-Modified'); From 46dc51c834e2244d64edb9e94be26d19ca9b912e Mon Sep 17 00:00:00 2001 From: Justin Persaud Date: Tue, 6 Oct 2026 18:41:46 -0400 Subject: [PATCH 05/28] Add integration tests for the NewDot Static Assets Worker Runs the real wrangler.jsonc in workerd via wrangler's createTestHarness against a fixture build, using node:test so the package needs no test-runner dependency. --- web/cloudflare-worker/package.json | 2 +- .../tests/createWorkerHarness.ts | 46 +++++ .../.well-known/apple-app-site-association | 1 + .../tests/fixtures/webBuild/index.html | 11 ++ .../webBuild/main-0123456789abcdef.bundle.js | 1 + .../main-0123456789abcdef.bundle.js.br | 1 + .../webBuild/merged-source-map.js.map | 1 + .../tests/fixtures/webBuild/service-worker.js | 1 + .../tests/fixtures/webBuild/version.json | 1 + web/cloudflare-worker/tests/worker.test.ts | 183 ++++++++++++++++++ 10 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 web/cloudflare-worker/tests/createWorkerHarness.ts create mode 100644 web/cloudflare-worker/tests/fixtures/webBuild/.well-known/apple-app-site-association create mode 100644 web/cloudflare-worker/tests/fixtures/webBuild/index.html create mode 100644 web/cloudflare-worker/tests/fixtures/webBuild/main-0123456789abcdef.bundle.js create mode 100644 web/cloudflare-worker/tests/fixtures/webBuild/main-0123456789abcdef.bundle.js.br create mode 100644 web/cloudflare-worker/tests/fixtures/webBuild/merged-source-map.js.map create mode 100644 web/cloudflare-worker/tests/fixtures/webBuild/service-worker.js create mode 100644 web/cloudflare-worker/tests/fixtures/webBuild/version.json create mode 100644 web/cloudflare-worker/tests/worker.test.ts diff --git a/web/cloudflare-worker/package.json b/web/cloudflare-worker/package.json index 1d53287e8176..da4dc9956f17 100644 --- a/web/cloudflare-worker/package.json +++ b/web/cloudflare-worker/package.json @@ -9,7 +9,7 @@ "dev": "npm run prepare-dist && wrangler dev", "dev:production": "npm run prepare-dist && wrangler dev --env production", "types": "wrangler types", - "typecheck": "npm run types && tsc -p . && tsc -p tests", + "typecheck": "npm run types > /dev/null && tsc -p . && tsc -p tests", "test": "node --test 'tests/**/*.test.ts'", "compare-headers": "node scripts/compareHeaders.ts" }, diff --git a/web/cloudflare-worker/tests/createWorkerHarness.ts b/web/cloudflare-worker/tests/createWorkerHarness.ts new file mode 100644 index 000000000000..b57520915e70 --- /dev/null +++ b/web/cloudflare-worker/tests/createWorkerHarness.ts @@ -0,0 +1,46 @@ +import {cpSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {createTestHarness, experimental_readRawConfig} from 'wrangler'; + +const WORKER_DIR = path.resolve(import.meta.dirname, '..'); +const FIXTURE_BUILD_DIR = path.join(import.meta.dirname, 'fixtures', 'webBuild'); + +type WorkerHarness = { + server: ReturnType; + cleanup: () => Promise; +}; + +/** + * Runs the real wrangler.jsonc against a copy of the fixture build. The harness has no option to override the + * assets directory, so this writes a temporary config that only changes `main` and `assets.directory`. + */ +function createWorkerHarness(env?: 'staging' | 'production'): WorkerHarness { + const tempDir = mkdtempSync(path.join(tmpdir(), 'new-expensify-worker-')); + const assetsDir = path.join(tempDir, 'dist'); + cpSync(FIXTURE_BUILD_DIR, assetsDir, {recursive: true}); + cpSync(path.join(WORKER_DIR, '.assetsignore'), path.join(assetsDir, '.assetsignore')); + + const {rawConfig} = experimental_readRawConfig({config: path.join(WORKER_DIR, 'wrangler.jsonc')}); + const {$schema: _schema, ...config} = rawConfig; + const configPath = path.join(tempDir, 'wrangler.json'); + writeFileSync( + configPath, + JSON.stringify({ + ...config, + main: path.join(WORKER_DIR, rawConfig.main ?? ''), + assets: {...rawConfig.assets, directory: assetsDir}, + }), + ); + + const server = createTestHarness({workers: [{configPath, env}]}); + return { + server, + cleanup: async () => { + await server.close(); + rmSync(tempDir, {recursive: true, force: true}); + }, + }; +} + +export default createWorkerHarness; diff --git a/web/cloudflare-worker/tests/fixtures/webBuild/.well-known/apple-app-site-association b/web/cloudflare-worker/tests/fixtures/webBuild/.well-known/apple-app-site-association new file mode 100644 index 000000000000..4c1e41d41a2c --- /dev/null +++ b/web/cloudflare-worker/tests/fixtures/webBuild/.well-known/apple-app-site-association @@ -0,0 +1 @@ +{"applinks":{"details":[]}} diff --git a/web/cloudflare-worker/tests/fixtures/webBuild/index.html b/web/cloudflare-worker/tests/fixtures/webBuild/index.html new file mode 100644 index 000000000000..d249aa562fa0 --- /dev/null +++ b/web/cloudflare-worker/tests/fixtures/webBuild/index.html @@ -0,0 +1,11 @@ + + + + New Expensify fixture + + + + +
+ + diff --git a/web/cloudflare-worker/tests/fixtures/webBuild/main-0123456789abcdef.bundle.js b/web/cloudflare-worker/tests/fixtures/webBuild/main-0123456789abcdef.bundle.js new file mode 100644 index 000000000000..474d9f5598a8 --- /dev/null +++ b/web/cloudflare-worker/tests/fixtures/webBuild/main-0123456789abcdef.bundle.js @@ -0,0 +1 @@ +window.fixtureBundle = true; diff --git a/web/cloudflare-worker/tests/fixtures/webBuild/main-0123456789abcdef.bundle.js.br b/web/cloudflare-worker/tests/fixtures/webBuild/main-0123456789abcdef.bundle.js.br new file mode 100644 index 000000000000..991a02f7a7c5 --- /dev/null +++ b/web/cloudflare-worker/tests/fixtures/webBuild/main-0123456789abcdef.bundle.js.br @@ -0,0 +1 @@ +compressed twin diff --git a/web/cloudflare-worker/tests/fixtures/webBuild/merged-source-map.js.map b/web/cloudflare-worker/tests/fixtures/webBuild/merged-source-map.js.map new file mode 100644 index 000000000000..12b3e15cce79 --- /dev/null +++ b/web/cloudflare-worker/tests/fixtures/webBuild/merged-source-map.js.map @@ -0,0 +1 @@ +{"version":3,"sources":[],"mappings":""} diff --git a/web/cloudflare-worker/tests/fixtures/webBuild/service-worker.js b/web/cloudflare-worker/tests/fixtures/webBuild/service-worker.js new file mode 100644 index 000000000000..59e6fb308313 --- /dev/null +++ b/web/cloudflare-worker/tests/fixtures/webBuild/service-worker.js @@ -0,0 +1 @@ +self.fixtureServiceWorker = true; diff --git a/web/cloudflare-worker/tests/fixtures/webBuild/version.json b/web/cloudflare-worker/tests/fixtures/webBuild/version.json new file mode 100644 index 000000000000..d504a2cf9033 --- /dev/null +++ b/web/cloudflare-worker/tests/fixtures/webBuild/version.json @@ -0,0 +1 @@ +{"version":"1.0.0-0"} diff --git a/web/cloudflare-worker/tests/worker.test.ts b/web/cloudflare-worker/tests/worker.test.ts new file mode 100644 index 000000000000..38482394af4a --- /dev/null +++ b/web/cloudflare-worker/tests/worker.test.ts @@ -0,0 +1,183 @@ +import assert from 'node:assert/strict'; +import {after, before, describe, test} from 'node:test'; + +import createWorkerHarness from './createWorkerHarness.ts'; + +const HASHED_BUNDLE_PATH = '/main-0123456789abcdef.bundle.js'; +const NAVIGATION_HEADERS = {'Sec-Fetch-Mode': 'navigate', 'Sec-Fetch-Dest': 'document'}; + +function getHTMLNonce(html: string): string | undefined { + return /", + ' ', + ' ', + ' ', + '
', + ' ', + '', + ].join('\n'), + 'main-0123456789abcdef.bundle.js': 'window.fixtureBundle = true;', + 'main-0123456789abcdef.bundle.js.br': 'stands in for a Brotli twin', + 'main.0123456789.css': '#root {}', + 'merged-source-map.js.map': '{"version":3,"sources":[],"mappings":""}', + 'service-worker.js': 'self.fixtureServiceWorker = true;', + 'workbox-01234567.js': 'self.fixtureWorkbox = true;', + 'version.json': '{"version":"1.0.0-0"}', + '.well-known/apple-app-site-association': '{"applinks":{"details":[]}}', +}; type WorkerHarness = { server: ReturnType; cleanup: () => Promise; }; +function writeFakeWebBuild(assetsDir: string) { + for (const [filePath, contents] of Object.entries(FAKE_WEB_BUILD)) { + const absolutePath = path.join(assetsDir, filePath); + mkdirSync(path.dirname(absolutePath), {recursive: true}); + writeFileSync(absolutePath, contents); + } + cpSync(path.join(WORKER_DIR, '.assetsignore'), path.join(assetsDir, '.assetsignore')); +} + /** - * Runs the real wrangler.jsonc against a copy of the fixture build. The harness has no option to override the - * assets directory, so this writes a temporary config that changes `main` and `assets.directory`, and copies - * .assetsignore itself in place of the `build` command. + * Runs the real wrangler.jsonc against a fake web build in a temporary directory. The harness has no option to + * override the assets directory, so this writes a temporary config that changes `main` and `assets.directory`, and + * copies .assetsignore itself in place of the `build` command. */ function createWorkerHarness(env?: 'staging' | 'production'): WorkerHarness { const tempDir = mkdtempSync(path.join(tmpdir(), 'new-expensify-worker-')); const assetsDir = path.join(tempDir, 'dist'); - cpSync(FIXTURE_BUILD_DIR, assetsDir, {recursive: true}); - cpSync(path.join(WORKER_DIR, '.assetsignore'), path.join(assetsDir, '.assetsignore')); + writeFakeWebBuild(assetsDir); const {rawConfig} = experimental_readRawConfig({config: path.join(WORKER_DIR, 'wrangler.jsonc')}); const {$schema: _schema, build: _build, ...config} = rawConfig; diff --git a/web/cloudflare-worker/tests/fixtures/webBuild/.well-known/apple-app-site-association b/web/cloudflare-worker/tests/fixtures/webBuild/.well-known/apple-app-site-association deleted file mode 100644 index 4c1e41d41a2c..000000000000 --- a/web/cloudflare-worker/tests/fixtures/webBuild/.well-known/apple-app-site-association +++ /dev/null @@ -1 +0,0 @@ -{"applinks":{"details":[]}} diff --git a/web/cloudflare-worker/tests/fixtures/webBuild/index.html b/web/cloudflare-worker/tests/fixtures/webBuild/index.html deleted file mode 100644 index d249aa562fa0..000000000000 --- a/web/cloudflare-worker/tests/fixtures/webBuild/index.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - New Expensify fixture - - - - -
- - diff --git a/web/cloudflare-worker/tests/fixtures/webBuild/main-0123456789abcdef.bundle.js b/web/cloudflare-worker/tests/fixtures/webBuild/main-0123456789abcdef.bundle.js deleted file mode 100644 index 474d9f5598a8..000000000000 --- a/web/cloudflare-worker/tests/fixtures/webBuild/main-0123456789abcdef.bundle.js +++ /dev/null @@ -1 +0,0 @@ -window.fixtureBundle = true; diff --git a/web/cloudflare-worker/tests/fixtures/webBuild/main-0123456789abcdef.bundle.js.br b/web/cloudflare-worker/tests/fixtures/webBuild/main-0123456789abcdef.bundle.js.br deleted file mode 100644 index 991a02f7a7c5..000000000000 --- a/web/cloudflare-worker/tests/fixtures/webBuild/main-0123456789abcdef.bundle.js.br +++ /dev/null @@ -1 +0,0 @@ -compressed twin diff --git a/web/cloudflare-worker/tests/fixtures/webBuild/main.0123456789.css b/web/cloudflare-worker/tests/fixtures/webBuild/main.0123456789.css deleted file mode 100644 index 978b6eb316c3..000000000000 --- a/web/cloudflare-worker/tests/fixtures/webBuild/main.0123456789.css +++ /dev/null @@ -1 +0,0 @@ -#root {} diff --git a/web/cloudflare-worker/tests/fixtures/webBuild/merged-source-map.js.map b/web/cloudflare-worker/tests/fixtures/webBuild/merged-source-map.js.map deleted file mode 100644 index 12b3e15cce79..000000000000 --- a/web/cloudflare-worker/tests/fixtures/webBuild/merged-source-map.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"sources":[],"mappings":""} diff --git a/web/cloudflare-worker/tests/fixtures/webBuild/service-worker.js b/web/cloudflare-worker/tests/fixtures/webBuild/service-worker.js deleted file mode 100644 index 59e6fb308313..000000000000 --- a/web/cloudflare-worker/tests/fixtures/webBuild/service-worker.js +++ /dev/null @@ -1 +0,0 @@ -self.fixtureServiceWorker = true; diff --git a/web/cloudflare-worker/tests/fixtures/webBuild/version.json b/web/cloudflare-worker/tests/fixtures/webBuild/version.json deleted file mode 100644 index 00d1b8d52c28..000000000000 --- a/web/cloudflare-worker/tests/fixtures/webBuild/version.json +++ /dev/null @@ -1 +0,0 @@ -{"version": "1.0.0-0"} diff --git a/web/cloudflare-worker/tests/fixtures/webBuild/workbox-01234567.js b/web/cloudflare-worker/tests/fixtures/webBuild/workbox-01234567.js deleted file mode 100644 index 8b3006fe5fae..000000000000 --- a/web/cloudflare-worker/tests/fixtures/webBuild/workbox-01234567.js +++ /dev/null @@ -1 +0,0 @@ -self.fixtureWorkbox = true; From 4d7237988c3f704db1d31dde7c06f25897a8cabf Mon Sep 17 00:00:00 2001 From: Justin Persaud Date: Thu, 8 Oct 2026 16:21:30 -0400 Subject: [PATCH 22/28] Give test-staging.new.expensify.com its own Worker environment The staging environment is reserved for staging.new.expensify.com, so it has no route until the switchover. test-staging serves a staging build with the staging CSP on its own Worker. --- web/cloudflare-worker/README.md | 21 ++++++++++++------- .../tests/createWorkerHarness.ts | 2 +- web/cloudflare-worker/tests/worker.test.ts | 21 +++++++++++++++++++ web/cloudflare-worker/wrangler.jsonc | 9 +++++++- 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/web/cloudflare-worker/README.md b/web/cloudflare-worker/README.md index 731762397b7f..1248a4be556d 100644 --- a/web/cloudflare-worker/README.md +++ b/web/cloudflare-worker/README.md @@ -105,19 +105,26 @@ The script diffs status, caching and security headers for a fixed set of paths. ## Deploying -| Environment | Worker | Route | -| --- | --- | --- | -| `staging` | `staging-new-expensify-assets` | `test-staging.new.expensify.com/*` until switchover, then `staging.new.expensify.com/*` | -| `production` | `new-expensify-assets` | None until switchover, then `new.expensify.com/*` | +| Environment | Worker | Route | CSP | +| --- | --- | --- | --- | +| `test-staging` | `test-staging-new-expensify-assets` | `test-staging.new.expensify.com/*` | Staging | +| `staging` | `staging-new-expensify-assets` | None until switchover, then `staging.new.expensify.com/*` | Staging | +| `production` | `new-expensify-assets` | None until switchover, then `new.expensify.com/*` | Production | -Deploys will run `wrangler deploy --env staging|production` from CI, with `CLOUDFLARE_ACCOUNT_ID` and a scoped `CLOUDFLARE_API_TOKEN` supplied as environment secrets. +`test-staging` is for testing a staging build on a hostname nobody else uses. Deploy it with a staging build in `dist/`: -Workers Logs are sampled per environment (`head_sampling_rate`), because the Worker runs for every asset request. +```bash +npx wrangler deploy --env test-staging +``` + +Staging and production deploys will run `wrangler deploy --env staging|production` from CI, with `CLOUDFLARE_ACCOUNT_ID` and a scoped `CLOUDFLARE_API_TOKEN` supplied as environment secrets. + +Workers Logs are sampled in `staging` and `production` (`head_sampling_rate`), because the Worker runs for every asset request. `test-staging` logs every request. To check the config without credentials or uploading anything: ```bash -npx wrangler deploy --dry-run --env staging +npx wrangler deploy --dry-run --env test-staging ``` Never commit credentials, account IDs or `.dev.vars` files here. This is a public repository. diff --git a/web/cloudflare-worker/tests/createWorkerHarness.ts b/web/cloudflare-worker/tests/createWorkerHarness.ts index 93d4929d841c..47dfca4fa21a 100644 --- a/web/cloudflare-worker/tests/createWorkerHarness.ts +++ b/web/cloudflare-worker/tests/createWorkerHarness.ts @@ -49,7 +49,7 @@ function writeFakeWebBuild(assetsDir: string) { * override the assets directory, so this writes a temporary config that changes `main` and `assets.directory`, and * copies .assetsignore itself in place of the `build` command. */ -function createWorkerHarness(env?: 'staging' | 'production'): WorkerHarness { +function createWorkerHarness(env?: 'test-staging' | 'staging' | 'production'): WorkerHarness { const tempDir = mkdtempSync(path.join(tmpdir(), 'new-expensify-worker-')); const assetsDir = path.join(tempDir, 'dist'); writeFakeWebBuild(assetsDir); diff --git a/web/cloudflare-worker/tests/worker.test.ts b/web/cloudflare-worker/tests/worker.test.ts index ace6a1b75a7d..15acd26602d4 100644 --- a/web/cloudflare-worker/tests/worker.test.ts +++ b/web/cloudflare-worker/tests/worker.test.ts @@ -207,6 +207,27 @@ describe('staging worker', () => { }); }); +describe('test-staging worker', () => { + const {server, cleanup} = createWorkerHarness('test-staging'); + + before(async () => { + await server.listen(); + }); + + after(async () => { + await cleanup(); + }); + + test('uses the staging CSP', async () => { + // Given the test-staging environment, which serves a staging build + // When any page is requested + const csp = (await server.fetch('/', {headers: NAVIGATION_HEADERS})).headers.get('Content-Security-Policy') ?? ''; + + // Then it gets the staging CSP, so a staging build behaves there as it does on staging.new.expensify.com + assert.match(csp, /new-expensify:\/\/staging\.new\.expensify\.com/); + }); +}); + describe('production worker', () => { const {server, cleanup} = createWorkerHarness('production'); diff --git a/web/cloudflare-worker/wrangler.jsonc b/web/cloudflare-worker/wrangler.jsonc index 724b14ea89d3..4a96b6d90679 100644 --- a/web/cloudflare-worker/wrangler.jsonc +++ b/web/cloudflare-worker/wrangler.jsonc @@ -27,9 +27,16 @@ "ENVIRONMENT": "staging", }, "env": { + // Serves a staging build on a hostname nobody else uses, so it can be deployed and tested independently. + "test-staging": { + "name": "test-staging-new-expensify-assets", + "routes": [{"pattern": "test-staging.new.expensify.com/*", "zone_name": "expensify.com"}], + "vars": { + "ENVIRONMENT": "staging", + }, + }, "staging": { "name": "staging-new-expensify-assets", - "routes": [{"pattern": "test-staging.new.expensify.com/*", "zone_name": "expensify.com"}], // The Worker runs for every asset, so logging every request would exceed the included Workers Logs volume. "observability": { "enabled": true, From e9a5c98fa9f0f7e0d31a8a49868b008ed1f890d6 Mon Sep 17 00:00:00 2001 From: Justin Persaud Date: Fri, 9 Oct 2026 13:19:09 -0400 Subject: [PATCH 23/28] Return 404 when the build has no apple-app-site-association file The file has no extension, so a miss fell through to the SPA shell and was then served as application/json. --- web/cloudflare-worker/src/index.ts | 5 ++-- .../tests/createWorkerHarness.ts | 9 ++++--- web/cloudflare-worker/tests/worker.test.ts | 24 +++++++++++++++++++ 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/web/cloudflare-worker/src/index.ts b/web/cloudflare-worker/src/index.ts index 59bde9bff741..015fa6612178 100644 --- a/web/cloudflare-worker/src/index.ts +++ b/web/cloudflare-worker/src/index.ts @@ -57,10 +57,11 @@ export default { const url = new URL(request.url); const nonce = generateNonce(); const isNavigation = request.headers.get('Sec-Fetch-Mode') === 'navigate'; + const isAASAPath = url.pathname === AASA_PATH || url.pathname === LEGACY_AASA_PATH; const assetResponse = await env.ASSETS.fetch(buildAssetRequest(request, url)); // The SPA fallback answers every miss with index.html. A browser asking for a missing chunk needs a real 404. - if (!isNavigation && isHTMLResponse(assetResponse) && STATIC_FILE_PATH.test(url.pathname)) { + if (isHTMLResponse(assetResponse) && (isAASAPath || (!isNavigation && STATIC_FILE_PATH.test(url.pathname)))) { const notFound = new Response('Not Found', {status: 404, headers: {'Content-Type': 'text/plain; charset=utf-8'}}); setSecurityHeaders(notFound.headers, env.ENVIRONMENT, nonce); return notFound; @@ -69,7 +70,7 @@ export default { const response = new Response(assetResponse.body, assetResponse); setSecurityHeaders(response.headers, env.ENVIRONMENT, nonce); - if (url.pathname === AASA_PATH || url.pathname === LEGACY_AASA_PATH) { + if (isAASAPath) { response.headers.set('Content-Type', 'application/json'); } diff --git a/web/cloudflare-worker/tests/createWorkerHarness.ts b/web/cloudflare-worker/tests/createWorkerHarness.ts index 47dfca4fa21a..74e6a7f39c5a 100644 --- a/web/cloudflare-worker/tests/createWorkerHarness.ts +++ b/web/cloudflare-worker/tests/createWorkerHarness.ts @@ -35,8 +35,11 @@ type WorkerHarness = { cleanup: () => Promise; }; -function writeFakeWebBuild(assetsDir: string) { +function writeFakeWebBuild(assetsDir: string, omittedFiles: string[]) { for (const [filePath, contents] of Object.entries(FAKE_WEB_BUILD)) { + if (omittedFiles.includes(filePath)) { + continue; + } const absolutePath = path.join(assetsDir, filePath); mkdirSync(path.dirname(absolutePath), {recursive: true}); writeFileSync(absolutePath, contents); @@ -49,10 +52,10 @@ function writeFakeWebBuild(assetsDir: string) { * override the assets directory, so this writes a temporary config that changes `main` and `assets.directory`, and * copies .assetsignore itself in place of the `build` command. */ -function createWorkerHarness(env?: 'test-staging' | 'staging' | 'production'): WorkerHarness { +function createWorkerHarness(env?: 'test-staging' | 'staging' | 'production', {omittedFiles = []}: {omittedFiles?: string[]} = {}): WorkerHarness { const tempDir = mkdtempSync(path.join(tmpdir(), 'new-expensify-worker-')); const assetsDir = path.join(tempDir, 'dist'); - writeFakeWebBuild(assetsDir); + writeFakeWebBuild(assetsDir, omittedFiles); const {rawConfig} = experimental_readRawConfig({config: path.join(WORKER_DIR, 'wrangler.jsonc')}); const {$schema: _schema, build: _build, ...config} = rawConfig; diff --git a/web/cloudflare-worker/tests/worker.test.ts b/web/cloudflare-worker/tests/worker.test.ts index 15acd26602d4..19f7a5abb043 100644 --- a/web/cloudflare-worker/tests/worker.test.ts +++ b/web/cloudflare-worker/tests/worker.test.ts @@ -207,6 +207,30 @@ describe('staging worker', () => { }); }); +describe('worker with a build that has no apple-app-site-association file', () => { + const {server, cleanup} = createWorkerHarness('staging', {omittedFiles: ['.well-known/apple-app-site-association']}); + + before(async () => { + await server.listen(); + }); + + after(async () => { + await cleanup(); + }); + + test('returns 404 instead of the app shell labelled as JSON', async () => { + // Given the file has no extension, so the SPA fallback would otherwise answer a miss with index.html + for (const path of ['/.well-known/apple-app-site-association', '/apple-app-site-association']) { + // When Apple's CDN fetches it + const response = await server.fetch(path); + + // Then it gets a 404, because HTML served as JSON would be read as an invalid association file + assert.equal(response.status, 404, path); + assert.doesNotMatch(response.headers.get('Content-Type') ?? '', /json|html/, path); + } + }); +}); + describe('test-staging worker', () => { const {server, cleanup} = createWorkerHarness('test-staging'); From 15db5d2ad4003460eb585c277aaec3ad249cf4d3 Mon Sep 17 00:00:00 2001 From: Justin Persaud Date: Fri, 9 Oct 2026 13:19:12 -0400 Subject: [PATCH 24/28] Run the Worker tests when web/index.html changes The tests check its inline scripts against the CSP hashes, so an edit there needs them to run. --- .github/workflows/cloudflareWorkerTests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/cloudflareWorkerTests.yml b/.github/workflows/cloudflareWorkerTests.yml index 64dabef1f21c..aff7d9d745ed 100644 --- a/.github/workflows/cloudflareWorkerTests.yml +++ b/.github/workflows/cloudflareWorkerTests.yml @@ -6,6 +6,8 @@ on: branches-ignore: [staging, production] paths: - 'web/cloudflare-worker/**' + # The tests check its inline scripts against the CSP hashes. + - 'web/index.html' - '.nvmrc' - '.github/workflows/cloudflareWorkerTests.yml' From 1a16c16e4c0713fdebc2ccb07ee16fdfeee4b366 Mon Sep 17 00:00:00 2001 From: Justin Persaud Date: Fri, 9 Oct 2026 13:19:13 -0400 Subject: [PATCH 25/28] Send real navigations from compareHeaders to wrangler dev Node's fetch overwrites Sec-Fetch-Mode, so the script's navigation checks reached a local Worker as ordinary requests. --- web/cloudflare-worker/scripts/compareHeaders.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/cloudflare-worker/scripts/compareHeaders.ts b/web/cloudflare-worker/scripts/compareHeaders.ts index 3ca6d72b857e..182f793fed92 100644 --- a/web/cloudflare-worker/scripts/compareHeaders.ts +++ b/web/cloudflare-worker/scripts/compareHeaders.ts @@ -18,7 +18,8 @@ const COMPARED_HEADERS = [ 'content-security-policy', ]; -const NAVIGATION_HEADERS = {'Sec-Fetch-Mode': 'navigate', 'Sec-Fetch-Dest': 'document'}; +// Node's fetch overwrites Sec-Fetch-Mode. `wrangler dev` restores MF-Sec-Fetch-Mode as Sec-Fetch-Mode; real hosts ignore it. +const NAVIGATION_HEADERS = {'MF-Sec-Fetch-Mode': 'navigate'}; type PathCheck = { label: string; From 689c809600ef834237c8cb867b7bf483790708ad Mon Sep 17 00:00:00 2001 From: Justin Persaud Date: Fri, 9 Oct 2026 13:19:14 -0400 Subject: [PATCH 26/28] Fix README steps for swapping web builds under wrangler dev A rebuild deletes dist/.assetsignore, so prepare-dist has to run again. Release archives now download to /tmp so they can't be committed. --- web/cloudflare-worker/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/cloudflare-worker/README.md b/web/cloudflare-worker/README.md index 1248a4be556d..0982d8af3f5e 100644 --- a/web/cloudflare-worker/README.md +++ b/web/cloudflare-worker/README.md @@ -43,11 +43,11 @@ Either build one from the App root: npm run build-staging # or `npm run build` for production ``` -Or, to skip the build, download a released one into the App root: +Or, to skip the build, download a released one and extract it in the App root. The archive goes to `/tmp` so it can't be committed: ```bash -gh release download --repo Expensify/App --pattern webBuild.tar.gz -tar -xzf webBuild.tar.gz # creates dist/ +gh release download --repo Expensify/App --pattern webBuild.tar.gz --dir /tmp --clobber +rm -rf dist && tar -xzf /tmp/webBuild.tar.gz # creates dist/ ``` ### 3. Start the Worker @@ -57,7 +57,7 @@ npm run dev # http://localhost:8787 with the staging CSP npm run dev:production # the same, with the production CSP ``` -`wrangler dev` reloads when `dist/` changes, so you can rebuild or swap builds without restarting it. +`wrangler dev` reloads when `dist/` changes. After rebuilding or swapping `dist/`, run `npm run prepare-dist` to restore `.assetsignore`, which the web build deletes. To check the CSP exactly as browsers enforce it in production, serve over HTTPS: From aba86766b18eb09c8716f689376a07ef624eb452 Mon Sep 17 00:00:00 2001 From: Justin Persaud Date: Fri, 9 Oct 2026 13:50:34 -0400 Subject: [PATCH 27/28] Deploy each staging web build to test-staging.new.expensify.com Runs alongside the S3 deploy with a token that can only edit the test-staging Worker. continue-on-error keeps a failure there from failing the staging deploy. --- .github/workflows/deploy.yml | 48 ++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0621b4cda065..69400d417946 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -770,6 +770,54 @@ jobs: env: HOST: ${{ env.DEPLOY_ENV == 'production' && vars.WEB_PRODUCTION_HOST || vars.WEB_STAGING_HOST }} + webDeployTestStaging: + name: Deploy Web to test-staging.new.expensify.com + needs: [prep, webBuild] + if: ${{ needs.webBuild.result == 'success' && needs.prep.outputs.DEPLOY_ENV == 'staging' }} + # Trial hosting for the web build, so a failure here must not fail the staging deploy. + continue-on-error: true + runs-on: blacksmith-2vcpu-ubuntu-2404 + steps: + - name: Checkout + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 + with: + ref: ${{ needs.prep.outputs.DEPLOY_SHA }} + + - name: Setup Node + # v6.1.0 + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f + with: + node-version-file: '.nvmrc' + cache: npm + cache-dependency-path: web/cloudflare-worker/package-lock.json + + - name: Download web build artifact + # v7 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + with: + name: web-build-tar-gz-artifact + path: ./ + + - name: Extract web build + run: tar -xzf "${{ needs.webBuild.outputs.TAR_FILENAME }}" + + - name: Install Cloudflare Worker dependencies + working-directory: web/cloudflare-worker + run: npm ci + + - name: Deploy to Cloudflare Workers + working-directory: web/cloudflare-worker + run: npx wrangler deploy --env test-staging + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_WORKERS_DEPLOY_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + WRANGLER_SEND_METRICS: false + + - name: Verify deploy + run: | + APP_VERSION=$(jq -r .version < package.json) + ./.github/scripts/verifyDeploy.sh https://test-staging.new.expensify.com "$APP_VERSION" + buildStorybook: name: Build storybook docs needs: [prep] From c917dcef081b004aea2267c0ab569655a1e6e56b Mon Sep 17 00:00:00 2001 From: Justin Persaud Date: Fri, 9 Oct 2026 13:50:36 -0400 Subject: [PATCH 28/28] Document the automatic test-staging deploy --- web/cloudflare-worker/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/cloudflare-worker/README.md b/web/cloudflare-worker/README.md index 0982d8af3f5e..240e22d88fcd 100644 --- a/web/cloudflare-worker/README.md +++ b/web/cloudflare-worker/README.md @@ -111,7 +111,7 @@ The script diffs status, caching and security headers for a fixed set of paths. | `staging` | `staging-new-expensify-assets` | None until switchover, then `staging.new.expensify.com/*` | Staging | | `production` | `new-expensify-assets` | None until switchover, then `new.expensify.com/*` | Production | -`test-staging` is for testing a staging build on a hostname nobody else uses. Deploy it with a staging build in `dist/`: +`test-staging` serves a staging build on a hostname nobody else uses. Every staging deploy also deploys the same build there (the `webDeployTestStaging` job in `.github/workflows/deploy.yml`), using `CLOUDFLARE_WORKERS_DEPLOY_TOKEN`, which can only edit this Worker. A failure there does not fail the staging deploy. To deploy it by hand, put a staging build in `dist/` and run: ```bash npx wrangler deploy --env test-staging