diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 296c2a268dcf..3d80f675b81b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -16,3 +16,6 @@ docs/articles/ @stephanieelliott # Claude Code skills /.claude/skills/ @Julesssss @Expensify/pullerbear + +# Cloudflare Worker that serves the web build +/web/cloudflare-worker/ @justinpersaud @AndrewGable @roryabraham diff --git a/.github/workflows/cloudflareWorkerTests.yml b/.github/workflows/cloudflareWorkerTests.yml new file mode 100644 index 000000000000..aff7d9d745ed --- /dev/null +++ b/.github/workflows/cloudflareWorkerTests.yml @@ -0,0 +1,51 @@ +name: Cloudflare Worker tests + +on: + pull_request: + types: [opened, synchronize] + 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' + +concurrency: + group: ${{ github.ref }}-cloudflare-worker-tests + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + name: Cloudflare Worker tests + if: github.actor != 'OSBotify' + runs-on: blacksmith-2vcpu-ubuntu-2404 + defaults: + run: + working-directory: web/cloudflare-worker + env: + WRANGLER_SEND_METRICS: false + steps: + - name: Checkout + uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0 + + # The Worker is a standalone package, so this skips the root dependencies that setupNode restores. + - 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: Install dependencies + run: npm ci + + - name: Type check + run: npm run typecheck + + - name: Run tests + run: npm test 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] 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/cspell.json b/cspell.json index 232fb5466088..3c374f0fa162 100644 --- a/cspell.json +++ b/cspell.json @@ -1187,7 +1187,15 @@ "knip", "lottiefiles", "jsitooling", - "Refetched" + "Refetched", + "AASA", + "assetsignore", + "bcmap", + "HSTS", + "Miniflare", + "nosniff", + "SAMEORIGIN", + "workerd" ], "ignorePaths": [ ".github/actions/javascript/**/index.js", 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..0bd29c77a5a9 --- /dev/null +++ b/web/cloudflare-worker/.assetsignore @@ -0,0 +1,4 @@ +# Precompressed twins. 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/README.md b/web/cloudflare-worker/README.md new file mode 100644 index 000000000000..240e22d88fcd --- /dev/null +++ b/web/cloudflare-worker/README.md @@ -0,0 +1,130 @@ +# New Expensify Cloudflare Worker + +This package serves the New Expensify web build (`dist/`) from Cloudflare with [Workers Static Assets](https://developers.cloudflare.com/workers/static-assets/). + +The build and the Worker are uploaded together as one Worker version. A deploy is therefore atomic, and a rollback restores both the code and the assets. + +## What the Worker does + +Every request runs the Worker first (`run_worker_first: true`), which reads the file from the `ASSETS` binding and then: + +| Request | Response | +| --- | --- | +| Any path | Security headers: HSTS, CSP, `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Document-Policy` | +| HTML (`/`, `/index.html`, any SPA deep link) | Each `", + ' ', + ' ', + ' ', + '
', + ' ', + '', + ].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, 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); + } + cpSync(path.join(WORKER_DIR, '.assetsignore'), path.join(assetsDir, '.assetsignore')); +} + +/** + * 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?: '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, omittedFiles); + + const {rawConfig} = experimental_readRawConfig({config: path.join(WORKER_DIR, 'wrangler.jsonc')}); + const {$schema: _schema, build: _build, ...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/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/tests/worker.test.ts b/web/cloudflare-worker/tests/worker.test.ts new file mode 100644 index 000000000000..19f7a5abb043 --- /dev/null +++ b/web/cloudflare-worker/tests/worker.test.ts @@ -0,0 +1,277 @@ +import assert from 'node:assert/strict'; +import {createHash} from 'node:crypto'; +import {readFileSync} from 'node:fs'; +import path from 'node:path'; +import {after, before, describe, test} from 'node:test'; + +import createWorkerHarness from './createWorkerHarness.ts'; + +const HASHED_BUNDLE_PATH = '/main-0123456789abcdef.bundle.js'; +// Node's fetch overwrites Sec-Fetch-Mode with the request mode. Miniflare restores this header as Sec-Fetch-Mode. +const NAVIGATION_HEADERS = {'MF-Sec-Fetch-Mode': 'navigate'}; +const WEB_TEMPLATE_PATH = path.resolve(import.meta.dirname, '../../index.html'); + +/** The template's conditionals only wrap whole script tags, so its inline script bodies are byte-for-byte what the build emits. */ +function assertTemplateInlineScriptsAllowed(csp: string) { + const html = readFileSync(WEB_TEMPLATE_PATH, 'utf8'); + const inlineScripts = [...html.matchAll(/]*)>([\s\S]*?)<\/script>/g)].filter(([, attributes, body]) => !/\b(?:src|nonce)=/.test(attributes) && body.trim()); + + assert.ok(inlineScripts.length > 0, `Expected ${WEB_TEMPLATE_PATH} to contain an inline script without a nonce`); + for (const [, , body] of inlineScripts) { + const hash = `sha256-${createHash('sha256').update(body).digest('base64')}`; + assert.ok(csp.includes(`'${hash}'`), `The CSP does not allow this inline script from web/index.html (add '${hash}' to src/csp.ts):\n${body.trim().slice(0, 120)}`); + } +} + +function getHTMLNonce(html: string): string | undefined { + return /