Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .github/workflows/cloudflareWorkerTests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ on:
- 'web/index.html'
- '.nvmrc'
- '.github/workflows/cloudflareWorkerTests.yml'
- '.github/workflows/deployPRTestingWorker.yml'

concurrency:
group: ${{ github.ref }}-cloudflare-worker-tests
Expand Down
69 changes: 69 additions & 0 deletions .github/workflows/deployPRTestingWorker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: Deploy PR-testing Cloudflare Worker

on:
workflow_dispatch:

permissions:
contents: read

concurrency:
group: deploy-pr-testing-worker
cancel-in-progress: false

jobs:
validateActor:
if: ${{ github.ref == 'refs/heads/main' }}
runs-on: blacksmith-2vcpu-ubuntu-2404
outputs:
IS_DEPLOYER: ${{ steps.isUserDeployer.outputs.IS_DEPLOYER }}
steps:
- id: isUserDeployer
run: |
if gh api "/orgs/Expensify/teams/web-deployers/memberships/$GITHUB_ACTOR" --silent; then
echo "IS_DEPLOYER=true" >> "$GITHUB_OUTPUT"
elif gh api "/orgs/Expensify/teams/keyholders/memberships/$GITHUB_ACTOR" --silent; then
echo "IS_DEPLOYER=true" >> "$GITHUB_OUTPUT"
elif gh api "/orgs/Expensify/teams/mobile-deployers/memberships/$GITHUB_ACTOR" --silent; then
echo "IS_DEPLOYER=true" >> "$GITHUB_OUTPUT"
else
echo "IS_DEPLOYER=false" >> "$GITHUB_OUTPUT"
exit 1
fi
env:
GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_COMMIT_TOKEN }}

deploy:
needs: validateActor
if: ${{ needs.validateActor.outputs.IS_DEPLOYER == 'true' }}
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

- 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

- name: Deploy the existing PR-testing Worker
run: npx wrangler deploy --config wrangler.pr-testing.jsonc
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_WORKERS_DEPLOY_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
28 changes: 28 additions & 0 deletions web/cloudflare-worker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,31 @@ npx wrangler deploy --dry-run --env test-staging
```

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

## PR-testing Worker

[`src/prTesting.ts`](src/prTesting.ts) serves `https://<PR number>.pr-testing.expensify.com` from the existing public S3 prefix `https://ad-hoc-expensify-cash.s3.amazonaws.com/web/<PR number>/`. It shares the security headers and CSP definitions with the static-assets Worker, with PR-testing font and frame origins. It also allows the App template's inline script hashes.

This is one wildcard Worker, not a Worker per PR. Ad-hoc builds still upload to S3 in `.github/workflows/buildAdHoc.yml`; no build or hosting changes are needed. The existing non-200-to-`index.html` fallback is preserved. `/`, `/index.html`, `/service-worker.js` and fallback HTML bypass forced edge caching. HTML also drops validators and gets `Cache-Control: no-cache` so each rewritten nonce matches its CSP.

[`wrangler.pr-testing.jsonc`](wrangler.pr-testing.jsonc) is deliberately separate from the static-assets config: PR testing needs neither `dist/` nor an `ASSETS` or `PREVIOUS_BUILDS` binding. It retains the existing Worker name (`pr-testing-expensify`) and compatibility date. Its existing `*.pr-testing.expensify.com/*` route stays managed in Cloudflare, so deploys need no zone access.

### Migration and deployment

1. Grant `CLOUDFLARE_WORKERS_DEPLOY_TOKEN` access to edit the existing `pr-testing-expensify` Worker, in addition to the staging and production Workers. Keep `CLOUDFLARE_ACCOUNT_ID` in GitHub secrets.
2. Merge the App PR, then manually run **Deploy PR-testing Cloudflare Worker** (`.github/workflows/deployPRTestingWorker.yml`) on `main`. It requires membership in the web deployer, key holder or mobile deployer team, matching the old repository's deploy authorization. It runs the package's type check and tests before deploying.
3. Verify an existing PR hostname, a deep link, its service worker and two different PR builds. Rebuild one PR and check that the new build appears without stale HTML or CSP errors.
4. Only after that deployment is verified, merge the companion Cloudflare-Workers cleanup PR. Do not delete the live Worker or its wildcard route. If Workers Builds is connected to the old repository, disconnect that integration during the handoff so it cannot deploy the old code again.

The workflow is manual, like the old repository's deploy workflow; it does not redeploy this shared Worker for every ad-hoc build. A rollback before cleanup can redeploy the previous commit from Cloudflare-Workers. After cleanup, use a previous Worker version in Cloudflare to roll back.

Local verification, without credentials or uploading anything:

```bash
npm ci
npm run typecheck
npm test
npx wrangler deploy --dry-run --config wrangler.pr-testing.jsonc
```

The PR-testing tests bundle the real configuration and run it in workerd with all requests to S3 intercepted. They verify PR isolation, edge-cache options, SPA fallbacks, nonce matching, shared security headers and service-worker caching without reading live builds.
1 change: 1 addition & 0 deletions web/cloudflare-worker/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions web/cloudflare-worker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
},
"devDependencies": {
"@types/node": "24.19.0",
"miniflare": "5.20260926.1-alpha",
"typescript": "7.0.2",
"wrangler": "4.144.0"
}
Expand Down
13 changes: 11 additions & 2 deletions web/cloudflare-worker/src/csp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,18 @@ const PRODUCTION_CSP = buildCSP({
scriptSrcHashes: INLINE_SCRIPT_HASHES,
});

function getCSP(environment: Env['ENVIRONMENT'], nonce: string): string {
const csp = environment === 'staging' ? STAGING_CSP : PRODUCTION_CSP;
const PR_TESTING_CSP = buildCSP({
fontSrcHosts: ['https://*.pr-testing.expensify.com'],
frameSrcHosts: ['https://staging.expensify.com'],
scriptSrcHashes: INLINE_SCRIPT_HASHES,
});

type Environment = 'staging' | 'production' | 'pr-testing';

function getCSP(environment: Environment, nonce: string): string {
const csp = environment === 'pr-testing' ? PR_TESTING_CSP : environment === 'staging' ? STAGING_CSP : PRODUCTION_CSP;
return csp.replace(NONCE_PLACEHOLDER, `nonce-${nonce}`);
}

export {NONCE_PLACEHOLDER, getCSP};
export type {Environment};
19 changes: 2 additions & 17 deletions web/cloudflare-worker/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {getCSP, NONCE_PLACEHOLDER} from './csp';
import {NONCE_PLACEHOLDER} from './csp';
import {generateNonce, setSecurityHeaders} from './security';

/** 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]+)$/;
Expand All @@ -9,12 +10,6 @@ const STATIC_FILE_PATH = /\.(?:js|mjs|css|map|json|wasm|png|jpe?g|gif|webp|avif|
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;
}
Expand Down Expand Up @@ -42,16 +37,6 @@ function buildAssetRequest(request: Request, url: URL): Request {
return new Request(assetURL, request);
}

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');
}

function getPreviousBuildFileHeaders(object: R2Object): Headers {
const headers = new Headers({'Cache-Control': 'public, max-age=0, must-revalidate', ETag: object.httpEtag});
object.writeHttpMetadata(headers);
Expand Down
45 changes: 45 additions & 0 deletions web/cloudflare-worker/src/prTesting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/** Serves each PR's existing S3 build through the shared wildcard PR-testing Worker. */
import {NONCE_PLACEHOLDER} from './csp';
import {generateNonce, setSecurityHeaders} from './security';

// These URLs are overwritten by every ad-hoc build, unlike the content-hashed assets.
const UNVERSIONED_PATHS = new Set(['/', '/index.html', '/service-worker.js']);

export default {
async fetch(request): Promise<Response> {
const url = new URL(request.url);
const prNumber = url.hostname.split('.')[0];
const bucketURL = `https://ad-hoc-expensify-cash.s3.amazonaws.com/web/${prNumber}`;
const assetURL = bucketURL + (url.pathname === '/' ? '/index.html' : url.pathname);
let assetResponse = await fetch(assetURL, {
cf: {cacheEverything: !UNVERSIONED_PATHS.has(url.pathname)},
});

// Preserve the existing SPA fallback, including for missing assets, during the repository migration.
if (assetResponse.status !== 200) {
assetResponse = await fetch(`${bucketURL}/index.html`, {
cf: {cacheEverything: false},
});
}

const nonce = generateNonce();
const response = new Response(assetResponse.body, assetResponse);
setSecurityHeaders(response.headers, 'pr-testing', nonce);

if (!response.headers.get('Content-Type')?.startsWith('text/html')) {
return response;
}

// A cached HTML body must not be paired with a freshly generated CSP nonce.
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;
22 changes: 22 additions & 0 deletions web/cloudflare-worker/src/security.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type {Environment} from './csp';

/** Security headers and nonce generation shared by the static-assets and PR-testing Workers. */
import {getCSP} from './csp';

function generateNonce(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return btoa(String.fromCharCode(...bytes));
}

function setSecurityHeaders(headers: Headers, environment: 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 {generateNonce, setSecurityHeaders};
Loading
Loading