Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
da5ad47
Add Cloudflare Worker package for serving NewDot web with Static Assets
justinpersaud Oct 6, 2026
a86d92e
Port NewDot CSP builder to the Static Assets Worker
justinpersaud Oct 6, 2026
4abac78
Serve NewDot web assets with security headers from the Worker
justinpersaud Oct 6, 2026
c701190
Stop stripping conditional headers from Worker asset requests
justinpersaud Oct 6, 2026
46dc51c
Add integration tests for the NewDot Static Assets Worker
justinpersaud Oct 6, 2026
c5e4718
Serve deep links with reserved characters without a redirect
justinpersaud Oct 6, 2026
7cc1334
Add a header comparison script for the NewDot Worker
justinpersaud Oct 6, 2026
8920ca0
Fix spelling and formatting in the NewDot Worker package
justinpersaud Oct 6, 2026
2f43e49
Document local development for the NewDot Worker
justinpersaud Oct 6, 2026
4797e43
Route the staging NewDot Worker to test-staging.new.expensify.com
justinpersaud Oct 7, 2026
d2e0671
Fix spelling in the NewDot Worker route docs
justinpersaud Oct 7, 2026
5793e49
Remove references to the previous hosting setup from the NewDot Worker
justinpersaud Oct 7, 2026
9f5283b
Add code owners for the NewDot Cloudflare Worker
justinpersaud Oct 7, 2026
279096a
Run the NewDot Cloudflare Worker tests in CI
justinpersaud Oct 7, 2026
cc53df8
Stop pinning exact Node and npm versions in the Worker package
justinpersaud Oct 7, 2026
e133460
Apply .assetsignore through wrangler's build command
justinpersaud Oct 7, 2026
dd3ac63
Sample Workers Logs for the NewDot Worker
justinpersaud Oct 7, 2026
4dcf881
Send real navigation requests in Worker tests
justinpersaud Oct 7, 2026
c64013b
Test hashed asset caching, file-like navigations and inline script ha…
justinpersaud Oct 7, 2026
9fb8664
Let wrangler types run without a web build
justinpersaud Oct 7, 2026
dd10971
Generate the Worker test build instead of committing fixture files
justinpersaud Oct 8, 2026
4d72379
Give test-staging.new.expensify.com its own Worker environment
justinpersaud Oct 8, 2026
e9a5c98
Return 404 when the build has no apple-app-site-association file
justinpersaud Oct 9, 2026
15db5d2
Run the Worker tests when web/index.html changes
justinpersaud Oct 9, 2026
1a16c16
Send real navigations from compareHeaders to wrangler dev
justinpersaud Oct 9, 2026
689c809
Fix README steps for swapping web builds under wrangler dev
justinpersaud Oct 9, 2026
aba8676
Deploy each staging web build to test-staging.new.expensify.com
justinpersaud Oct 9, 2026
c917dce
Document the automatic test-staging deploy
justinpersaud Oct 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -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
51 changes: 51 additions & 0 deletions .github/workflows/cloudflareWorkerTests.yml
Original file line number Diff line number Diff line change
@@ -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
48 changes: 48 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 2 additions & 0 deletions config/eslint/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
10 changes: 9 additions & 1 deletion cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -1187,7 +1187,15 @@
"knip",
"lottiefiles",
"jsitooling",
"Refetched"
"Refetched",
"AASA",
"assetsignore",
"bcmap",
"HSTS",
"Miniflare",
"nosniff",
"SAMEORIGIN",
"workerd"
],
"ignorePaths": [
".github/actions/javascript/**/index.js",
Expand Down
1 change: 1 addition & 0 deletions knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
],
Expand Down
4 changes: 4 additions & 0 deletions web/cloudflare-worker/.assetsignore
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions web/cloudflare-worker/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.wrangler/
.dev.vars*
worker-configuration.d.ts
4 changes: 4 additions & 0 deletions web/cloudflare-worker/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
engine-strict=true

# Supply chain protection: only resolve package versions published at least 7 days ago.
min-release-age=7
130 changes: 130 additions & 0 deletions web/cloudflare-worker/README.md
Original file line number Diff line number Diff line change
@@ -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 `<script nonce="nonce-random-value">` gets a per-request nonce that matches the CSP. `Cache-Control: no-cache`, with no `ETag` or `Last-Modified`, since a 304 would pair a cached body with a new nonce |
| Content-hashed files (`main-<16 hex>.bundle.js`, `name.<10 hex>.css`) | `Cache-Control: public, max-age=31536000, immutable` |
| Files with fixed names (`version.json`, `service-worker.js`, `manifest.json`, ...) | Default `public, max-age=0, must-revalidate`, with an `ETag` |
| A file path that does not exist (`/main-<old hash>.bundle.js`) | `404`, so a stale tab gets a `ChunkLoadError` and recovers instead of parsing HTML as JavaScript |
| Any other path that does not exist (`/r/123`) | The app shell (`index.html`) with `200` |
| `/.well-known/apple-app-site-association` and `/apple-app-site-association` | The same file with `Content-Type: application/json` |

[`.assetsignore`](.assetsignore) keeps the Brotli twins (`*.br`) and source maps (`*.map`) out of the upload. Wrangler's `build` command (`scripts/prepareDist.sh`) copies it into `dist/` before every `wrangler dev` and `wrangler deploy`, so always run wrangler from this directory.

The CSP lives in [`src/csp.ts`](src/csp.ts). The `ENVIRONMENT` variable in [`wrangler.jsonc`](wrangler.jsonc) selects the staging or production policy.

## Running it locally

`wrangler dev` runs the Worker in workerd, the same runtime Cloudflare uses, and serves `dist/` exactly as a deploy would. No Cloudflare account or credentials are needed.

### 1. Install

This package has its own dependencies. They are not installed by the root `npm install`.

```bash
cd web/cloudflare-worker
npm ci
```

### 2. Get a web build in `dist/`

Either build one from the App root:

```bash
npm run build-staging # or `npm run build` for production
```

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 <version> --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

```bash
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. 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:

```bash
npm run dev -- --local-protocol https
```

Over plain HTTP, a few third-party scripts that load protocol-relative URLs (Convert, Group-IB) request `http://` resources that the HTTPS-only CSP blocks. Over HTTPS there should be no CSP violations in the console.

> [!WARNING]
> The Worker sends `Strict-Transport-Security`. Open the HTTPS session in a throwaway browser profile, so `localhost` is not pinned to HTTPS for your other local projects.

### What you can't test locally

The API at `www.expensify.com`, and Google Sign-In, only accept requests from known origins. The app loads to the sign-in page, but signing in fails with CORS errors. Everything the Worker is responsible for can be tested locally; signing in is verified on a deployed test hostname.

## Checking the behavior

### Automated tests

```bash
npm test # integration tests: the real wrangler.jsonc in workerd, against a fake web build
npm run typecheck # regenerates worker-configuration.d.ts with `wrangler types`, then type-checks src/ and tests/
```

### Comparing headers with a live host

```bash
npm run compare-headers -- https://staging.new.expensify.com http://localhost:8787
npm run compare-headers -- https://new.expensify.com http://localhost:8787 # run `npm run dev:production` first
```

The script diffs status, caching and security headers for a fixed set of paths. Nonces are masked, and CSP differences are reported per directive.

### Manual checks in a browser

1. **CSP:** Open the app and check the console for CSP violations (over HTTPS, see above).
2. **Deep links:** Open a deep link such as `/settings/profile` directly and reload it.
3. **Service worker:** Check that it registers. In DevTools > Application > Service workers, its source is `/service-worker.js`.
4. **A deploy with a tab left open:**
1. Open the app on one build.
2. Replace `dist/` with a newer build and run `npm run prepare-dist`.
3. Navigate to a page that lazy-loads code.
4. Verify that the old chunk request returns `404`, the app reloads onto the new build, and `/version.json` reports the new version.

## Deploying

| 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 |

`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
```

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 test-staging
```

Never commit credentials, account IDs or `.dev.vars` files here. This is a public repository.
Loading
Loading