{"id":"69dbce074e5bc2f6ca3c5755","title":"2026 04 12 Secrets Deployment And Cloudflare Config","path":"how-to/technical-articles/2026-04-12-secrets-deployment-and-cloudflare-config","contentMarkdown":"# 09-Jul-2026 — Secrets, Deployment, Integration Worker, Fly Tokens, and Cloudflare Configuration [#114](https://github.com/nbarrett/ngx-ramblers/issues/114) [#125](https://github.com/nbarrett/ngx-ramblers/issues/125) [#304](https://github.com/nbarrett/ngx-ramblers/issues/304)\n\n_____\n\nThis article explains how secrets flow through NGX Ramblers — from storage, through the deployment pipeline, to runtime use by the backend and frontend. It covers the three-tier secret storage model, the Fly.io deployment process, the shared integration worker (browser automation, HTML fetch, Flickr scraping, document conversion, migration jobs and Serenity walks uploads, with report upload and lifecycle audit events), the per-environment Fly API token encrypted into the `FLY_CONFIG` bundle (powering machine restart and memory-diagnostics metrics), and how Cloudflare email routing configuration reaches managed environments.\n\n## Overview\n\nNGX Ramblers uses a layered approach to secret management. Secrets live in three places, with a clearly-defined precedence: CI reads from MongoDB, local development reads from `server/.env` and `non-vcs/secrets/` files, and GitHub Secrets carry only the handful of bootstrap values the deploy script itself needs.\n\nA shared integration worker runs as a separate Fly.io app alongside the origin environments. Its secrets follow the same DB-first model but are synced to GitHub as a separate secret (`INTEGRATION_WORKER_SECRETS_JSON`) alongside the main `CONFIGS_JSON`.\n\n```mermaid\nflowchart LR\n    GH@{ icon: \"logos:github-icon\", label: \"GitHub Secrets\", pos: \"b\", h: 48 }\n    DB@{ icon: \"ngx:mongodb\", label: \"MongoDB Atlas\", pos: \"b\", h: 48 }\n    LOCAL@{ icon: \"ngx:file\", label: \"Local Files\", pos: \"b\", h: 48 }\n\n    GH --- DB --- LOCAL\n```\n\n| Tier | Location | Used By | Contents |\n|------|----------|---------|----------|\n| **GitHub Secrets** | GitHub Actions | CI/CD pipelines | `CONFIGS_JSON`, `INTEGRATION_WORKER_SECRETS_JSON`, `FLY_API_TOKEN`, `ADMIN_MONGODB_URI`, Docker credentials |\n| **MongoDB Atlas** | `config` collection | Runtime + deployment | Per-environment secrets, integration worker config, per-environment Fly token, Cloudflare config, AWS credentials, MongoDB URIs |\n| **Local Files** | `server/.env`, `non-vcs/secrets/` | Local development only | Developer overrides and per-app `.env` files, all git-ignored |\n\n## Deployment Flow\n\nWhen code is pushed to `main`, or a manual deployment is triggered, the following sequence occurs:\n\n```mermaid\nflowchart LR\n    GHA@{ icon: \"logos:github-actions\", label: \"GitHub Actions\", pos: \"b\", h: 48 }\n    TS@{ icon: \"logos:typescript-icon\", label: \"Deploy Script\", pos: \"b\", h: 48 }\n    QUERY@{ icon: \"ngx:mongodb\", label: \"Query MongoDB\", pos: \"b\", h: 48 }\n    DEPLOY@{ icon: \"logos:fly-icon\", label: \"Deploy to Fly.io\", pos: \"b\", h: 48 }\n\n    GHA -->|\"Run deploy\"| TS\n    TS -->|\"ADMIN_MONGODB_URI set\"| QUERY\n    QUERY --> MERGE[\"Encrypt & merge secrets\"]\n    MERGE --> IMPORT[\"flyctl secrets import\"]\n    IMPORT --> DEPLOY\n```\n\n### How the deploy runner reads from MongoDB\n\nThe GitHub Actions runner opens a short-lived connection to the staging MongoDB cluster during each deploy. The connection string lives in the GitHub secret `ADMIN_MONGODB_URI`; the deploy script aliases it to `MONGODB_URI` and uses it to read the `ENVIRONMENTS` config document from the `config` collection. Per-environment secrets never pass through `CONFIGS_JSON` — they travel directly from MongoDB into `flyctl secrets import` on the target Fly app.\n\n### Secret Building Priority\n\nDuring deployment, `buildSecretsFromDatabaseConfig()` merges secrets in a specific order. Later values override earlier ones:\n\n1. **Global secrets** from `globalConfig.secrets`\n2. **Global AWS config** from `globalConfig.aws`\n3. **Per-environment AWS config** (overrides global if present)\n4. **MongoDB URI** built from `envConfig.mongo` credentials\n5. **`NGX_LITE`** flag derived from `envConfig.ngxLite`\n6. **Per-environment secrets** from `envConfig.secrets`\n7. **Integration worker callback base URL** — defaulted to `https://<appName>.fly.dev` when not already set\n8. **Fly config** — `{apiToken, appName}` encrypted with AES-256-GCM and stored as `FLY_CONFIG` (from `envConfig.flyio.apiKey`)\n9. **Cloudflare config** — encrypted with AES-256-GCM and stored as `CLOUDFLARE_CONFIG`\n\n### Database vs File Import\n\nThe deploy script has exactly two modes in `importSecrets()`:\n\n- **CI mode** (the only mode exercised on a GitHub runner): `ADMIN_MONGODB_URI` is always set, so `importSecretsFromDatabase()` runs. The runner has no access to `non-vcs/secrets/` — those files are git-ignored and simply don't exist on the runner, so the file path can never execute.\n- **Local-developer mode**: when a developer runs `deploy-to-environments.ts` from a laptop without `ADMIN_MONGODB_URI` set, `importSecretsFromFile()` reads `non-vcs/secrets/secrets.<appName>.env` from disk. This is a convenience for local testing of the deploy script itself — it is not a production fallback.\n\n```mermaid\nflowchart LR\n    START@{ icon: \"ngx:ramblers\", label: \"deploy script\", pos: \"b\", h: 48 }\n    QUERY@{ icon: \"ngx:mongodb\", label: \"Query MongoDB\", pos: \"b\", h: 48 }\n    FILE@{ icon: \"ngx:file\", label: \"Read non-vcs/secrets/*.env\", pos: \"b\", h: 48 }\n    IMPORT@{ icon: \"logos:fly-icon\", label: \"flyctl secrets import\", pos: \"b\", h: 48 }\n\n    START --> CI{\"ADMIN_MONGODB_URI set?\"}\n    CI -->|\"Yes (CI)\"| QUERY\n    QUERY --> IMPORT\n    CI -->|\"No (local dev)\"| FILE\n    FILE --> IMPORT\n```\n\nIf `importSecretsFromDatabase()` returns empty for an environment, the CI run simply continues without importing — Fly keeps whatever secrets are already on the app. There is no \"file fallback\" on the runner because there are no files.\n\n## Integration Worker Deployment\n\nThe integration worker is a general machine-to-machine worker. It runs Serenity browser automation for Ramblers walks uploads, but also handles synchronous browser operations (HTML fetch, Flickr album scraping, migration scrapes), image resizing, and document conversion. It is deployed as a separate Fly.io app (`ngx-ramblers-integration-worker`) that shares the same Docker image as the origin but runs `npm run worker-server` (which starts `integration-worker-server`) instead of `npm run server`.\n\nThe worker exposes several route groups, all under `/api/integration-worker`:\n\n| Route group | Purpose |\n|-------------|---------|\n| `/jobs`, `/progress`, `/result` | Ramblers walks upload jobs (queued Serenity runs with signed progress and result callbacks) |\n| `/browser/html-fetch`, `/browser/flickr-user-albums` | Synchronous headless-browser HTML fetch and Flickr album scraping |\n| `/migration/*` | Legacy-site migration scrapes |\n| `/resize/*` | Image resize jobs |\n| `/document-conversion/convert` | Document conversion |\n\nThe walks-upload flow below is the most involved of these and is documented in detail; the other route groups follow the same signed request/response pattern.\n\n```mermaid\nflowchart LR\n    PUSH@{ icon: \"logos:github-icon\", label: \"Push to main\", pos: \"b\", h: 48 }\n    BUILD@{ icon: \"logos:docker-icon\", label: \"Build image\", pos: \"b\", h: 48 }\n    ORIGIN@{ icon: \"logos:fly-icon\", label: \"Deploy origin\", pos: \"b\", h: 48 }\n    FILTER[\"Worker change detection\"]\n    WORKER@{ icon: \"logos:fly-icon\", label: \"Deploy worker\", pos: \"b\", h: 48 }\n\n    PUSH --> BUILD\n    BUILD --> ORIGIN\n    BUILD --> FILTER\n    FILTER -->|\"Runtime changes in worker graph\"| WORKER\n    FILTER -->|\"No change\"| SKIP[\"Skip — keep warm\"]\n```\n\n### How Worker Deployment Works\n\n- The main build workflow (`build-push-and-deploy-ngx-ramblers-docker-image.yml`) runs a **Detect worker-relevant changes** step (`server/deploy/detect-worker-changes.ts`) that decides whether the worker needs redeploying\n- When worker-relevant runtime changes are found, the workflow builds and pushes a `ngx-ramblers:integration-worker-<run_number>` image and a conditional **Deploy Ramblers upload worker** step runs `server/deploy/deploy-integration-worker.ts` with that image tag\n- When nothing worker-relevant changed, the build and deploy steps are skipped entirely — the worker stays warm with no restart\n- After a successful worker deploy, the workflow advances a `worker-deployed` git ref (force-pushed to `HEAD`) so the next run can diff against the last thing actually deployed to the worker, not merely the previous commit\n- A standalone `workflow_dispatch`-only workflow (`build-and-deploy-integration-worker.yml`) exists as a manual escape hatch for redeploying a chosen image tag\n\n### Worker Change Detection\n\nRather than a static path glob, `detect-worker-changes.ts` walks the worker's real dependency graph. It runs `madge` from the worker entry point (`server/lib/ramblers/integration-worker-server.ts`) to resolve every `.ts` file the worker actually imports, then unions that set with a fixed list of infrastructure files. A change is only treated as worker-relevant if it falls inside that set **and** changes the emitted JavaScript — type-only edits (identical transpiled output) are ignored.\n\nThe dependency graph plus the following explicit infrastructure files make up the detection scope:\n\n| Category | Paths |\n|----------|-------|\n| Worker dependency graph | Every `.ts` file reachable from `server/lib/ramblers/integration-worker-server.ts` (resolved at build time via `madge`) |\n| Container + Fly config | `Dockerfile`, `fly.integration-worker.toml` |\n| Server packages | `server/package.json`, `server/package-lock.json`, `server/playwright.config.ts` |\n| Deploy + detection scripts | `server/deploy/deploy-integration-worker.ts`, `server/deploy/detect-worker-changes.ts` |\n| Workflows | `.github/workflows/build-push-and-deploy-ngx-ramblers-docker-image.yml`, `.github/workflows/build-and-deploy-integration-worker.yml` |\n\nChanges are compared against the `worker-deployed` ref when it exists, otherwise against `HEAD~1`.\n\n### Routing Uploads to the Worker\n\nEnvironments route uploads to the worker by setting `INTEGRATION_WORKER_URL` in their per-environment secrets. When set, the dispatcher sends signed job requests to the worker instead of spawning Serenity locally. The worker exposes three walks-upload endpoints under `/api/integration-worker`:\n\n| Endpoint | Direction | Purpose |\n|----------|-----------|---------|\n| `POST /jobs` | Origin → Worker | Submit a signed, AES-encrypted job. Worker runs it immediately or queues behind the active job, returning `{queued, queuePosition, activeJobId}` |\n| `POST /progress` | Worker → Origin | Stream per-line stdout events, parsed test steps, and lifecycle markers back to the origin |\n| `POST /result` | Worker → Origin | Announce completion, exit status, and the S3 report key prefix |\n\n```mermaid\nflowchart LR\n    subgraph origin[\"Origin (e.g. EKWG)\"]\n        DISPATCH@{ icon: \"ngx:express\", label: \"Dispatcher\", pos: \"b\", h: 48 }\n    end\n    subgraph worker[\"Worker\"]\n        RECEIVE@{ icon: \"ngx:express\", label: \"Job receiver\", pos: \"b\", h: 48 }\n        SERENITY[\"Serenity spawn\"]\n    end\n\n    DISPATCH -->|\"POST /jobs — HMAC signed, AES-encrypted credentials\"| RECEIVE\n    RECEIVE --> SERENITY\n    SERENITY -->|\"POST /progress & /result — signed callbacks\"| DISPATCH\n\n    style origin fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n    style worker fill:#EEE8F5,stroke:#AB9BC8,stroke-width:2px,rx:12,ry:12,color:#404143\n```\n\nCredentials are AES-encrypted in the job request and decrypted on the worker side. The worker never connects to MongoDB — `Login.performAs` short-circuits its Mongo lookup when `RAMBLERS_USERNAME` and `RAMBLERS_PASSWORD` are set in the environment (they always are on the worker, because the runner writes them from the decrypted payload before spawning Serenity).\n\n### Serenity Report Upload to S3\n\nEvery upload run — success or failure — uploads the complete Serenity HTML report to the origin's own S3 bucket so that operators can open the detailed test output from the Walk Export UI.\n\n```mermaid\nflowchart LR\n    subgraph worker[\"Worker\"]\n        RUN[\"Serenity run\"] --> GEN[\"Report generated at target/site/serenity\"]\n        GEN --> UP@{ icon: \"ngx:aws\", label: \"uploadSerenityReportToS3\", pos: \"b\", h: 48 }\n    end\n    subgraph origin[\"Origin\"]\n        RESULT@{ icon: \"ngx:express\", label: \"POST /result\", pos: \"b\", h: 48 }\n        AUDIT@{ icon: \"ngx:mongodb\", label: \"recordReportLocation\", pos: \"b\", h: 48 }\n        WS[\"WebSocket broadcast\"]\n        UI@{ icon: \"ngx:ramblers\", label: \"View Report button\", pos: \"b\", h: 48 }\n    end\n    S3@{ icon: \"ngx:aws\", label: \"S3 ramblers-upload-reports/<csvFileName>/\", pos: \"b\", h: 48 }\n\n    UP --> S3\n    UP -->|\"reportBucket + reportKeyPrefix\"| RESULT\n    RESULT --> AUDIT\n    AUDIT --> WS --> UI\n    UI -->|\"/api/aws/s3/.../index.html\"| S3\n\n    style origin fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n    style worker fill:#EEE8F5,stroke:#AB9BC8,stroke-width:2px,rx:12,ry:12,color:#404143\n```\n\nEnd-to-end flow:\n\n1. Origin's dispatcher computes `reportKeyPrefix = ramblers-upload-reports/<csvFileName without .csv>` and sends it, together with AES-encrypted S3 credentials (reusing the environment's own `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`) in the `/jobs` request.\n2. After the Serenity subprocess exits, `finishJob()` calls `uploadSerenityReportToS3()` which walks `target/site/serenity` and uploads every file via `uploadDirectoryToS3`, using the centralised `contentTypeForExtension` / `fileContentTypeMappings` helpers in `projects/ngx-ramblers/src/app/models/content-metadata.model.ts` (shared with the origin's AWS utilities).\n3. The worker then posts `/result` with `reportBucket` and `reportKeyPrefix` populated.\n4. The origin's `recordReportLocation` persists the prefix onto the audit session and broadcasts an audit row over the WebSocket so the Walk Export UI renders a **View Report** button next to **Back To Walks Admin**.\n5. The UI fetches the report through the existing `/api/aws/s3/.../index.html` proxy — no public bucket access, no extra credentials.\n\n### Lifecycle Audit Events\n\nBeyond the per-step `SUCCESS` / `TEST_STEP` rows parsed from stdout, the worker emits additional lifecycle markers via the `IntegrationWorkerEventType.LIFECYCLE` type. The origin's `recordLifecycleEvent` writes each one as a `SUMMARY` audit row and pushes it over the WebSocket:\n\n| Marker | When |\n|--------|------|\n| `Scenario execution complete` | First time the Serenity report writer prints `[report]` to stderr |\n| `Serenity report generation starting` | Immediately after, before the report writer runs |\n| `Serenity report generation complete, uploading to S3` | After Serenity exits, before `uploadSerenityReportToS3` is invoked |\n| `Report upload to S3 complete` | After the report directory has been uploaded to S3, before the final result callback |\n\nThese make the \"silent\" stretch between the last scenario step and the final result callback visible in the audit log.\n\n### Local Developer Ergonomics\n\n`ngx-cli local dev` honours four worker-related overrides in `server/.env`:\n\n- `INTEGRATION_WORKER_URL`\n- `INTEGRATION_WORKER_CALLBACK_BASE_URL`\n- `INTEGRATION_WORKER_SHARED_SECRET`\n- `INTEGRATION_WORKER_ENCRYPTION_KEY`\n\nThe `local.ts` command reads these values via `readServerEnvValue` and applies them on top of the secrets resolved from the database (see `SERVER_ENV_OVERRIDE_KEYS`). A developer can therefore flip between a locally-running worker container and the Fly-hosted worker purely by editing `.env`, without touching the database or redeploying anything.\n\n## Worker Secret Management\n\nWorker configuration lives in the `uploadWorker` field of the environments config document — alongside `environments[]`, not inside it. This ensures \"deploy all environments\" never touches the worker.\n\n```mermaid\nflowchart LR\n    UI@{ icon: \"ngx:ramblers\", label: \"Admin UI\", pos: \"b\", h: 48 }\n    DB@{ icon: \"ngx:mongodb\", label: \"MongoDB config\", pos: \"b\", h: 48 }\n    PUSH[\"Push to GitHub\"]\n    GH1@{ icon: \"logos:github-icon\", label: \"CONFIGS_JSON\", pos: \"b\", h: 48 }\n    GH2@{ icon: \"logos:github-icon\", label: \"INTEGRATION_WORKER_SECRETS_JSON\", pos: \"b\", h: 48 }\n\n    UI -->|\"Save\"| DB\n    DB --> PUSH\n    PUSH --> GH1\n    PUSH --> GH2\n```\n\n| Config location | What it holds | Managed via |\n|----------------|---------------|-------------|\n| `value.uploadWorker` in environments config | App name, HMAC secret, AES key, memory, scale, org-scoped Fly `apiKey` | Global Settings tab in admin UI |\n| `value.environments[].secrets` per routing env | `INTEGRATION_WORKER_URL`, `_SHARED_SECRET`, `_ENCRYPTION_KEY` | Per-environment settings in admin UI |\n| GitHub secret `INTEGRATION_WORKER_SECRETS_JSON` | JSON blob with HMAC + AES keys | \"Push to GitHub\" button (synced alongside `CONFIGS_JSON`) |\n| GitHub variable `INTEGRATION_WORKER_APP_NAME` | Fly app name | \"Push to GitHub\" button |\n\n### Key Rotation\n\nTo rotate the worker's HMAC or AES key:\n\n1. Edit the key in the **Global Settings** tab (Upload Worker Configuration section)\n2. Update the matching key in every routing environment's secrets (currently EKWG only)\n3. Click **Save Configuration**\n4. Go to **GitHub Secrets** tab and click **Push to GitHub** — this updates both `CONFIGS_JSON` and `INTEGRATION_WORKER_SECRETS_JSON` atomically\n5. Redeploy the worker and the routing environment(s)\n\n## Cloudflare Configuration\n\nCloudflare configuration deserves special attention because it contains a sensitive API token that must never reach the frontend, yet the frontend needs certain non-sensitive fields to render the committee email routing UI.\n\n### Encryption at Deploy Time\n\nDuring deployment, the Cloudflare config is encrypted using AES-256-GCM before being stored as a Fly.io secret:\n\n```mermaid\nflowchart LR\n    DBCF@{ icon: \"ngx:mongodb\", label: \"MongoDB config\", pos: \"b\", h: 48 }\n    ENVVAR@{ icon: \"logos:fly-icon\", label: \"CLOUDFLARE_CONFIG\", pos: \"b\", h: 48 }\n\n    DBCF --> ENCRYPT[\"AES-256-GCM encrypt\"] --> ENVVAR\n```\n\nThe encrypted payload structure is:\n\n| Bytes | Content |\n|-------|---------|\n| 0-12 | Initialisation vector (IV) |\n| 12-28 | Authentication tag |\n| 28+ | Ciphertext |\n\n### Runtime: Backend to Frontend\n\nAt runtime, the backend decrypts `CLOUDFLARE_CONFIG` and uses the full config (including `apiToken`) for Cloudflare API calls. The frontend only receives non-sensitive fields:\n\n```mermaid\nflowchart LR\n    subgraph backend[\"Backend\"]\n        DECRYPT[\"Decrypt\"] --> FULL[\"Full config\"]\n        FULL --> API@{ icon: \"ngx:cloudflare\", label: \"Cloudflare API\", pos: \"b\", h: 48 }\n    end\n\n    subgraph frontend[\"Frontend\"]\n        SAFE[\"Safe config\"] --> UI@{ icon: \"ngx:ramblers\", label: \"Committee UI\", pos: \"b\", h: 48 }\n    end\n\n    FULL -->|\"Non-sensitive only\"| SAFE\n    UI -->|\"Via backend API\"| FULL\n```\n\n### Non-Sensitive Config Fields\n\nThe `/api/cloudflare/email-routing/config` endpoint returns only:\n\n| Field | Type | Purpose |\n|-------|------|---------|\n| `configured` | `boolean` | Whether Cloudflare is set up for this environment |\n| `accountId` | `string` | Used to build Cloudflare dashboard links |\n| `zoneId` | `string` | Used to build Cloudflare dashboard links |\n| `baseDomain` | `string` | The domain for email routing (e.g. `nwkramblers.org.uk`) |\n| `ownsZone` | `boolean` | Whether this site owns its Cloudflare zone or is a subdomain of a shared zone (drives the apex-vs-subdomain inbox setup) |\n| `zoneName` | `string` | The resolved Cloudflare zone name for the domain |\n\nThe `apiToken` is **never** sent to the frontend. All Cloudflare operations go through authenticated backend endpoints.\n\n## Fly API Token & the `FLY_CONFIG` Bundle\n\nThe Fly API token follows the identical encrypted-bundle pattern as the Cloudflare config. Each environment carries one org-scoped Fly API token in `flyio.apiKey` (alongside `flyio.appName`) in the `ENVIRONMENTS` config document in MongoDB. That single token does everything Fly-related for the environment: it authenticates the deploy (`flyctl deploy` and `flyctl secrets import`), it restarts a stuck machine from the NGX admin UI, and it reads Fly's Prometheus metrics for the Memory Diagnostics charts.\n\n### Encryption at Deploy Time\n\nDuring deployment, `buildSecretsFromDatabaseConfig()` encrypts `{apiToken, appName}` into a `FLY_CONFIG` Fly secret using AES-256-GCM keyed by `ENVIRONMENT_SETUP_API_KEY` — the same mechanism, same encryption key, and same payload layout used for `CLOUDFLARE_CONFIG`.\n\n```mermaid\nflowchart LR\n    DBFLY@{ icon: \"ngx:mongodb\", label: \"flyio.apiKey + appName\", pos: \"b\", h: 48 }\n    ENVVAR@{ icon: \"logos:fly-icon\", label: \"FLY_CONFIG\", pos: \"b\", h: 48 }\n\n    DBFLY --> ENCRYPT[\"AES-256-GCM encrypt\"] --> ENVVAR\n```\n\n### Runtime Token Resolution\n\nAt runtime, `flyRuntimeConfig()` resolves the token in a fixed precedence order and stops at the first source that yields both a token and an app name:\n\n1. **Fly-injected environment variables** — Fly sets `FLY_APP_NAME` and `FLY_MACHINE_ID` on every deployed machine; combined with a `FLY_API_TOKEN` env var this is the fast path\n2. **Decrypted `FLY_CONFIG` bundle** — decrypted with `ENVIRONMENT_SETUP_API_KEY`, this is how deployed machines normally obtain the token\n3. **Environments config in the database** — matched on the Mongo database name, used as a last-resort fallback\n\n```mermaid\nflowchart LR\n    START@{ icon: \"logos:fly-icon\", label: \"flyRuntimeConfig()\", pos: \"b\", h: 48 }\n    ENV@{ icon: \"logos:fly-icon\", label: \"FLY_* env vars\", pos: \"b\", h: 48 }\n    BUNDLE@{ icon: \"logos:fly-icon\", label: \"Decrypt FLY_CONFIG\", pos: \"b\", h: 48 }\n    DB@{ icon: \"ngx:mongodb\", label: \"Environments config\", pos: \"b\", h: 48 }\n    TOKEN[\"Resolved token + appName\"]\n\n    START --> ENV\n    ENV -->|\"token + appName present\"| TOKEN\n    ENV -->|\"missing\"| BUNDLE\n    BUNDLE -->|\"decrypted\"| TOKEN\n    BUNDLE -->|\"absent\"| DB\n    DB --> TOKEN\n```\n\n### What the Token Powers\n\n| Use | Where | Notes |\n|-----|-------|-------|\n| Deploy + `flyctl secrets import` | CI deploy runner | The per-environment `apiKey` is loaded into `FLY_API_TOKEN` for that environment's `flyctl` commands |\n| Restart a stuck machine | Admin UI (#304) | `restartCurrentMachine()` calls the Fly Machines API with the resolved token |\n| Read memory / metric history | Memory Diagnostics charts (#304) | `fly-metrics` queries Fly's Prometheus API with the resolved token |\n\nAn org-scoped token is required for the metrics path: an app-scoped token can manage and restart the app but returns 403 from Fly's Prometheus API.\n\nOriginally five environments carried a separate app-scoped deploy token plus a distinct org-scoped `metricsToken`. On 9 Jul 2026 this was consolidated to a single org-scoped `apiKey` per environment and the `metricsToken` field was removed, so one token now covers deploy, restart and metrics.\n\n## Required Environment Variables\n\n### Origin Environments\n\n| Variable | Required | Purpose |\n|----------|----------|---------|\n| `AUTH_SECRET` | Yes | JWT authentication secret |\n| `AWS_ACCESS_KEY_ID` | Yes | S3 file storage (also reused to upload Serenity reports) |\n| `AWS_SECRET_ACCESS_KEY` | Yes | S3 file storage |\n| `AWS_REGION` | Yes | S3 region (e.g. `eu-west-2`) |\n| `AWS_BUCKET` | Yes | S3 bucket name |\n| `MONGODB_URI` | Yes | Database connection string |\n| `CLOUDFLARE_CONFIG` | No | Encrypted Cloudflare config (enables email routing) |\n| `FLY_CONFIG` | No | Encrypted per-environment Fly token + app name (enables machine restart + metrics) |\n| `ENVIRONMENT_SETUP_API_KEY` | No | Encryption key for both `CLOUDFLARE_CONFIG` and `FLY_CONFIG` |\n| `INTEGRATION_WORKER_URL` | No | Worker URL (enables remote upload routing) |\n| `INTEGRATION_WORKER_SHARED_SECRET` | If worker URL set | HMAC signing key for worker requests |\n| `INTEGRATION_WORKER_ENCRYPTION_KEY` | If worker URL set | AES key for credential encryption |\n\n### Worker App\n\n| Variable | Required | Purpose |\n|----------|----------|---------|\n| `INTEGRATION_WORKER_SHARED_SECRET` | Yes | HMAC signature validation |\n| `INTEGRATION_WORKER_ENCRYPTION_KEY` | Yes | Credential + report-credential decryption |\n| `CHROME_BIN` | Built-in | Chrome path (baked into Docker image via `ENV`) |\n| `CHROMEDRIVER_PATH` | Built-in | Chromedriver path (baked into Docker image via `ENV`) |\n\nThe worker deliberately has no `MONGODB_URI`. `RAMBLERS_USERNAME` and `RAMBLERS_PASSWORD` are set by the runner from the decrypted job payload immediately before Serenity spawns, so `Login.performAs` takes its env-var fast path and never attempts a Mongo lookup.\n\n### Deploy-Only Variables\n\n| Variable | Required | Purpose |\n|----------|----------|---------|\n| `FLY_API_TOKEN` | Deploy only | GitHub secret — the deploy runner's ambient Fly authentication. Distinct from the per-environment `flyio.apiKey`, which the deploy loads into `FLY_API_TOKEN` per environment and which is also baked into that environment's `FLY_CONFIG` secret |\n| `ADMIN_MONGODB_URI` | Deploy only | Staging database for secrets import |\n| `INTEGRATION_WORKER_SECRETS_JSON` | Deploy only | GitHub secret — worker HMAC + AES keys as JSON |\n| `INTEGRATION_WORKER_APP_NAME` | Deploy only | GitHub variable — worker Fly app name |\n\n## Issue History\n\nThe secrets and deployment infrastructure was built across several issues:\n\n```mermaid\nflowchart BT\n    I146@{ icon: \"logos:github-icon\", label: \"#146 Env Setup\", pos: \"b\", h: 48 }\n    I156@{ icon: \"ngx:cloudflare\", label: \"#156 DNS/SSL\", pos: \"b\", h: 48 }\n    I125@{ icon: \"ngx:cloudflare\", label: \"#125 Email Routing\", pos: \"b\", h: 48 }\n    I82[\"#82 Brevo Auth\"]\n    I130[\"#130 Replies Rejected\"]\n    I114@{ icon: \"logos:fly-icon\", label: \"#114 Integration Worker\", pos: \"b\", h: 48 }\n    I304@{ icon: \"logos:fly-icon\", label: \"#304 Machine Restart & Metrics\", pos: \"b\", h: 48 }\n    TODAY@{ icon: \"logos:fly-icon\", label: \"Today: Worker, Fly Tokens & Secrets\", pos: \"b\", h: 48 }\n\n    I146 -->|\"secrets\"| I125\n    I146 -->|\"config\"| I156\n    I156 -->|\"Cloudflare\"| I125\n    I156 -->|\"DNS\"| I82\n    I130 -->|\"motivated\"| I125\n    I125 --> TODAY\n    I82 --> TODAY\n    I114 -->|\"worker + reports + lifecycle\"| TODAY\n    I304 -->|\"Fly token + restart + metrics\"| TODAY\n```\n\n| Issue | Title | Key Contribution |\n|-------|-------|-----------------|\n| [#146](https://github.com/nbarrett/ngx-ramblers/issues/146) | Automated environment creation | Database-first secrets loading, GitHub secrets sync, global AWS |\n| [#156](https://github.com/nbarrett/ngx-ramblers/issues/156) | DNS/SSL automation | Cloudflare DNS and certificate management in environment setup |\n| [#130](https://github.com/nbarrett/ngx-ramblers/issues/130) | Email replies rejected | The original problem that drove Cloudflare email routing work |\n| [#125](https://github.com/nbarrett/ngx-ramblers/issues/125) | Cloudflare email routing | Committee email forwarding, worker scripts, email logs UI |\n| [#82](https://github.com/nbarrett/ngx-ramblers/issues/82) | Brevo domain authentication | DKIM/SPF DNS records via Cloudflare for email deliverability |\n| [#114](https://github.com/nbarrett/ngx-ramblers/issues/114) | Distributed integration worker | Shared worker (Serenity uploads, browser fetch, Flickr scraping, migration, resize, document conversion), queued job execution, dependency-graph deploy detection, S3 Serenity report upload, lifecycle audit events, env-var login short-circuit, `.env` overrides for local dev |\n| [#304](https://github.com/nbarrett/ngx-ramblers/issues/304) | Machine restart & memory diagnostics | Per-environment org-scoped Fly token encrypted into `FLY_CONFIG`, admin restart of a stuck Fly machine, Fly Prometheus metric-history charts, consolidation of deploy + metrics tokens into one `apiKey` |\n\n## Why No Code Changes Were Needed (Original Cloudflare Work)\n\nThe application code already handled Cloudflare gracefully — showing a warning when config was missing and enabling features when config was present. The missing piece was the deployment pipeline: managed environments had no `CLOUDFLARE_CONFIG` secret because the deploy script did not import secrets from the database. Adding `importSecretsFromDatabase()` to the deploy flow was the final link in the chain.\n\n## What Changed for the Integration Worker\n\nThe integration worker required changes across several layers:\n\n- **Model**: `UploadWorkerConfig` added to `EnvironmentsConfig` as a sibling to `environments[]`, keeping it out of the deploy-all matrix; `IntegrationWorkerEventType.LIFECYCLE` added for non-stdout audit markers\n- **Worker runner**: Serenity subprocess uploads `target/site/serenity` to `s3://<envBucket>/ramblers-upload-reports/<csvFileName>/` via `uploadSerenityReportToS3`, and emits lifecycle progress events around report generation and upload\n- **Origin audit notifier**: new `recordLifecycleEvent` and `recordReportLocation` helpers persist the extra rows and broadcast the report location over the WebSocket so the UI can surface a \"View Report\" link\n- **Admin UI**: New section in the Global Settings tab for worker app name, org-scoped Fly `apiKey`, HMAC secret, AES key, memory, and scale; Walk Export UI renders the report link against the selected session\n- **GitHub sync**: the \"Push to GitHub\" action writes `INTEGRATION_WORKER_SECRETS_JSON` alongside `CONFIGS_JSON` when worker config exists\n- **CI workflow**: a dependency-graph deploy step (`detect-worker-changes.ts`) piggybacked on the main build, reusing the same Docker image with a different Fly config (`fly.integration-worker.toml`) via `deploy-integration-worker.ts`\n- **Content-type mapping**: `contentTypeForExtension` + `fileContentTypeMappings` centralised in `projects/ngx-ramblers/src/app/models/content-metadata.model.ts` and shared between the S3 report uploader and `aws-utils`\n- **Local dev**: `ngx-cli local dev` reads four worker env vars from `server/.env` so a developer can flip between a local worker and the Fly worker without redeploying\n","contentHtml":"<h1>09-Jul-2026 — Secrets, Deployment, Integration Worker, Fly Tokens, and Cloudflare Configuration <a href=\"https://github.com/nbarrett/ngx-ramblers/issues/114\">#114</a> <a href=\"https://github.com/nbarrett/ngx-ramblers/issues/125\">#125</a> <a href=\"https://github.com/nbarrett/ngx-ramblers/issues/304\">#304</a></h1>\n<hr>\n<p>This article explains how secrets flow through NGX Ramblers — from storage, through the deployment pipeline, to runtime use by the backend and frontend. It covers the three-tier secret storage model, the Fly.io deployment process, the shared integration worker (browser automation, HTML fetch, Flickr scraping, document conversion, migration jobs and Serenity walks uploads, with report upload and lifecycle audit events), the per-environment Fly API token encrypted into the <code>FLY_CONFIG</code> bundle (powering machine restart and memory-diagnostics metrics), and how Cloudflare email routing configuration reaches managed environments.</p>\n<h2>Overview</h2>\n<p>NGX Ramblers uses a layered approach to secret management. Secrets live in three places, with a clearly-defined precedence: CI reads from MongoDB, local development reads from <code>server/.env</code> and <code>non-vcs/secrets/</code> files, and GitHub Secrets carry only the handful of bootstrap values the deploy script itself needs.</p>\n<p>A shared integration worker runs as a separate Fly.io app alongside the origin environments. Its secrets follow the same DB-first model but are synced to GitHub as a separate secret (<code>INTEGRATION_WORKER_SECRETS_JSON</code>) alongside the main <code>CONFIGS_JSON</code>.</p>\n<pre><code class=\"language-mermaid\">flowchart LR\n    GH@{ icon: &quot;logos:github-icon&quot;, label: &quot;GitHub Secrets&quot;, pos: &quot;b&quot;, h: 48 }\n    DB@{ icon: &quot;ngx:mongodb&quot;, label: &quot;MongoDB Atlas&quot;, pos: &quot;b&quot;, h: 48 }\n    LOCAL@{ icon: &quot;ngx:file&quot;, label: &quot;Local Files&quot;, pos: &quot;b&quot;, h: 48 }\n\n    GH --- DB --- LOCAL\n</code></pre>\n<table>\n<thead>\n<tr>\n<th>Tier</th>\n<th>Location</th>\n<th>Used By</th>\n<th>Contents</th>\n</tr>\n</thead>\n<tbody><tr>\n<td><strong>GitHub Secrets</strong></td>\n<td>GitHub Actions</td>\n<td>CI/CD pipelines</td>\n<td><code>CONFIGS_JSON</code>, <code>INTEGRATION_WORKER_SECRETS_JSON</code>, <code>FLY_API_TOKEN</code>, <code>ADMIN_MONGODB_URI</code>, Docker credentials</td>\n</tr>\n<tr>\n<td><strong>MongoDB Atlas</strong></td>\n<td><code>config</code> collection</td>\n<td>Runtime + deployment</td>\n<td>Per-environment secrets, integration worker config, per-environment Fly token, Cloudflare config, AWS credentials, MongoDB URIs</td>\n</tr>\n<tr>\n<td><strong>Local Files</strong></td>\n<td><code>server/.env</code>, <code>non-vcs/secrets/</code></td>\n<td>Local development only</td>\n<td>Developer overrides and per-app <code>.env</code> files, all git-ignored</td>\n</tr>\n</tbody></table>\n<h2>Deployment Flow</h2>\n<p>When code is pushed to <code>main</code>, or a manual deployment is triggered, the following sequence occurs:</p>\n<pre><code class=\"language-mermaid\">flowchart LR\n    GHA@{ icon: &quot;logos:github-actions&quot;, label: &quot;GitHub Actions&quot;, pos: &quot;b&quot;, h: 48 }\n    TS@{ icon: &quot;logos:typescript-icon&quot;, label: &quot;Deploy Script&quot;, pos: &quot;b&quot;, h: 48 }\n    QUERY@{ icon: &quot;ngx:mongodb&quot;, label: &quot;Query MongoDB&quot;, pos: &quot;b&quot;, h: 48 }\n    DEPLOY@{ icon: &quot;logos:fly-icon&quot;, label: &quot;Deploy to Fly.io&quot;, pos: &quot;b&quot;, h: 48 }\n\n    GHA --&gt;|&quot;Run deploy&quot;| TS\n    TS --&gt;|&quot;ADMIN_MONGODB_URI set&quot;| QUERY\n    QUERY --&gt; MERGE[&quot;Encrypt &amp; merge secrets&quot;]\n    MERGE --&gt; IMPORT[&quot;flyctl secrets import&quot;]\n    IMPORT --&gt; DEPLOY\n</code></pre>\n<h3>How the deploy runner reads from MongoDB</h3>\n<p>The GitHub Actions runner opens a short-lived connection to the staging MongoDB cluster during each deploy. The connection string lives in the GitHub secret <code>ADMIN_MONGODB_URI</code>; the deploy script aliases it to <code>MONGODB_URI</code> and uses it to read the <code>ENVIRONMENTS</code> config document from the <code>config</code> collection. Per-environment secrets never pass through <code>CONFIGS_JSON</code> — they travel directly from MongoDB into <code>flyctl secrets import</code> on the target Fly app.</p>\n<h3>Secret Building Priority</h3>\n<p>During deployment, <code>buildSecretsFromDatabaseConfig()</code> merges secrets in a specific order. Later values override earlier ones:</p>\n<ol>\n<li><strong>Global secrets</strong> from <code>globalConfig.secrets</code></li>\n<li><strong>Global AWS config</strong> from <code>globalConfig.aws</code></li>\n<li><strong>Per-environment AWS config</strong> (overrides global if present)</li>\n<li><strong>MongoDB URI</strong> built from <code>envConfig.mongo</code> credentials</li>\n<li><strong><code>NGX_LITE</code></strong> flag derived from <code>envConfig.ngxLite</code></li>\n<li><strong>Per-environment secrets</strong> from <code>envConfig.secrets</code></li>\n<li><strong>Integration worker callback base URL</strong> — defaulted to <code>https://&lt;appName&gt;.fly.dev</code> when not already set</li>\n<li><strong>Fly config</strong> — <code>{apiToken, appName}</code> encrypted with AES-256-GCM and stored as <code>FLY_CONFIG</code> (from <code>envConfig.flyio.apiKey</code>)</li>\n<li><strong>Cloudflare config</strong> — encrypted with AES-256-GCM and stored as <code>CLOUDFLARE_CONFIG</code></li>\n</ol>\n<h3>Database vs File Import</h3>\n<p>The deploy script has exactly two modes in <code>importSecrets()</code>:</p>\n<ul>\n<li><strong>CI mode</strong> (the only mode exercised on a GitHub runner): <code>ADMIN_MONGODB_URI</code> is always set, so <code>importSecretsFromDatabase()</code> runs. The runner has no access to <code>non-vcs/secrets/</code> — those files are git-ignored and simply don&#39;t exist on the runner, so the file path can never execute.</li>\n<li><strong>Local-developer mode</strong>: when a developer runs <code>deploy-to-environments.ts</code> from a laptop without <code>ADMIN_MONGODB_URI</code> set, <code>importSecretsFromFile()</code> reads <code>non-vcs/secrets/secrets.&lt;appName&gt;.env</code> from disk. This is a convenience for local testing of the deploy script itself — it is not a production fallback.</li>\n</ul>\n<pre><code class=\"language-mermaid\">flowchart LR\n    START@{ icon: &quot;ngx:ramblers&quot;, label: &quot;deploy script&quot;, pos: &quot;b&quot;, h: 48 }\n    QUERY@{ icon: &quot;ngx:mongodb&quot;, label: &quot;Query MongoDB&quot;, pos: &quot;b&quot;, h: 48 }\n    FILE@{ icon: &quot;ngx:file&quot;, label: &quot;Read non-vcs/secrets/*.env&quot;, pos: &quot;b&quot;, h: 48 }\n    IMPORT@{ icon: &quot;logos:fly-icon&quot;, label: &quot;flyctl secrets import&quot;, pos: &quot;b&quot;, h: 48 }\n\n    START --&gt; CI{&quot;ADMIN_MONGODB_URI set?&quot;}\n    CI --&gt;|&quot;Yes (CI)&quot;| QUERY\n    QUERY --&gt; IMPORT\n    CI --&gt;|&quot;No (local dev)&quot;| FILE\n    FILE --&gt; IMPORT\n</code></pre>\n<p>If <code>importSecretsFromDatabase()</code> returns empty for an environment, the CI run simply continues without importing — Fly keeps whatever secrets are already on the app. There is no &quot;file fallback&quot; on the runner because there are no files.</p>\n<h2>Integration Worker Deployment</h2>\n<p>The integration worker is a general machine-to-machine worker. It runs Serenity browser automation for Ramblers walks uploads, but also handles synchronous browser operations (HTML fetch, Flickr album scraping, migration scrapes), image resizing, and document conversion. It is deployed as a separate Fly.io app (<code>ngx-ramblers-integration-worker</code>) that shares the same Docker image as the origin but runs <code>npm run worker-server</code> (which starts <code>integration-worker-server</code>) instead of <code>npm run server</code>.</p>\n<p>The worker exposes several route groups, all under <code>/api/integration-worker</code>:</p>\n<table>\n<thead>\n<tr>\n<th>Route group</th>\n<th>Purpose</th>\n</tr>\n</thead>\n<tbody><tr>\n<td><code>/jobs</code>, <code>/progress</code>, <code>/result</code></td>\n<td>Ramblers walks upload jobs (queued Serenity runs with signed progress and result callbacks)</td>\n</tr>\n<tr>\n<td><code>/browser/html-fetch</code>, <code>/browser/flickr-user-albums</code></td>\n<td>Synchronous headless-browser HTML fetch and Flickr album scraping</td>\n</tr>\n<tr>\n<td><code>/migration/*</code></td>\n<td>Legacy-site migration scrapes</td>\n</tr>\n<tr>\n<td><code>/resize/*</code></td>\n<td>Image resize jobs</td>\n</tr>\n<tr>\n<td><code>/document-conversion/convert</code></td>\n<td>Document conversion</td>\n</tr>\n</tbody></table>\n<p>The walks-upload flow below is the most involved of these and is documented in detail; the other route groups follow the same signed request/response pattern.</p>\n<pre><code class=\"language-mermaid\">flowchart LR\n    PUSH@{ icon: &quot;logos:github-icon&quot;, label: &quot;Push to main&quot;, pos: &quot;b&quot;, h: 48 }\n    BUILD@{ icon: &quot;logos:docker-icon&quot;, label: &quot;Build image&quot;, pos: &quot;b&quot;, h: 48 }\n    ORIGIN@{ icon: &quot;logos:fly-icon&quot;, label: &quot;Deploy origin&quot;, pos: &quot;b&quot;, h: 48 }\n    FILTER[&quot;Worker change detection&quot;]\n    WORKER@{ icon: &quot;logos:fly-icon&quot;, label: &quot;Deploy worker&quot;, pos: &quot;b&quot;, h: 48 }\n\n    PUSH --&gt; BUILD\n    BUILD --&gt; ORIGIN\n    BUILD --&gt; FILTER\n    FILTER --&gt;|&quot;Runtime changes in worker graph&quot;| WORKER\n    FILTER --&gt;|&quot;No change&quot;| SKIP[&quot;Skip — keep warm&quot;]\n</code></pre>\n<h3>How Worker Deployment Works</h3>\n<ul>\n<li>The main build workflow (<code>build-push-and-deploy-ngx-ramblers-docker-image.yml</code>) runs a <strong>Detect worker-relevant changes</strong> step (<code>server/deploy/detect-worker-changes.ts</code>) that decides whether the worker needs redeploying</li>\n<li>When worker-relevant runtime changes are found, the workflow builds and pushes a <code>ngx-ramblers:integration-worker-&lt;run_number&gt;</code> image and a conditional <strong>Deploy Ramblers upload worker</strong> step runs <code>server/deploy/deploy-integration-worker.ts</code> with that image tag</li>\n<li>When nothing worker-relevant changed, the build and deploy steps are skipped entirely — the worker stays warm with no restart</li>\n<li>After a successful worker deploy, the workflow advances a <code>worker-deployed</code> git ref (force-pushed to <code>HEAD</code>) so the next run can diff against the last thing actually deployed to the worker, not merely the previous commit</li>\n<li>A standalone <code>workflow_dispatch</code>-only workflow (<code>build-and-deploy-integration-worker.yml</code>) exists as a manual escape hatch for redeploying a chosen image tag</li>\n</ul>\n<h3>Worker Change Detection</h3>\n<p>Rather than a static path glob, <code>detect-worker-changes.ts</code> walks the worker&#39;s real dependency graph. It runs <code>madge</code> from the worker entry point (<code>server/lib/ramblers/integration-worker-server.ts</code>) to resolve every <code>.ts</code> file the worker actually imports, then unions that set with a fixed list of infrastructure files. A change is only treated as worker-relevant if it falls inside that set <strong>and</strong> changes the emitted JavaScript — type-only edits (identical transpiled output) are ignored.</p>\n<p>The dependency graph plus the following explicit infrastructure files make up the detection scope:</p>\n<table>\n<thead>\n<tr>\n<th>Category</th>\n<th>Paths</th>\n</tr>\n</thead>\n<tbody><tr>\n<td>Worker dependency graph</td>\n<td>Every <code>.ts</code> file reachable from <code>server/lib/ramblers/integration-worker-server.ts</code> (resolved at build time via <code>madge</code>)</td>\n</tr>\n<tr>\n<td>Container + Fly config</td>\n<td><code>Dockerfile</code>, <code>fly.integration-worker.toml</code></td>\n</tr>\n<tr>\n<td>Server packages</td>\n<td><code>server/package.json</code>, <code>server/package-lock.json</code>, <code>server/playwright.config.ts</code></td>\n</tr>\n<tr>\n<td>Deploy + detection scripts</td>\n<td><code>server/deploy/deploy-integration-worker.ts</code>, <code>server/deploy/detect-worker-changes.ts</code></td>\n</tr>\n<tr>\n<td>Workflows</td>\n<td><code>.github/workflows/build-push-and-deploy-ngx-ramblers-docker-image.yml</code>, <code>.github/workflows/build-and-deploy-integration-worker.yml</code></td>\n</tr>\n</tbody></table>\n<p>Changes are compared against the <code>worker-deployed</code> ref when it exists, otherwise against <code>HEAD~1</code>.</p>\n<h3>Routing Uploads to the Worker</h3>\n<p>Environments route uploads to the worker by setting <code>INTEGRATION_WORKER_URL</code> in their per-environment secrets. When set, the dispatcher sends signed job requests to the worker instead of spawning Serenity locally. The worker exposes three walks-upload endpoints under <code>/api/integration-worker</code>:</p>\n<table>\n<thead>\n<tr>\n<th>Endpoint</th>\n<th>Direction</th>\n<th>Purpose</th>\n</tr>\n</thead>\n<tbody><tr>\n<td><code>POST /jobs</code></td>\n<td>Origin → Worker</td>\n<td>Submit a signed, AES-encrypted job. Worker runs it immediately or queues behind the active job, returning <code>{queued, queuePosition, activeJobId}</code></td>\n</tr>\n<tr>\n<td><code>POST /progress</code></td>\n<td>Worker → Origin</td>\n<td>Stream per-line stdout events, parsed test steps, and lifecycle markers back to the origin</td>\n</tr>\n<tr>\n<td><code>POST /result</code></td>\n<td>Worker → Origin</td>\n<td>Announce completion, exit status, and the S3 report key prefix</td>\n</tr>\n</tbody></table>\n<pre><code class=\"language-mermaid\">flowchart LR\n    subgraph origin[&quot;Origin (e.g. EKWG)&quot;]\n        DISPATCH@{ icon: &quot;ngx:express&quot;, label: &quot;Dispatcher&quot;, pos: &quot;b&quot;, h: 48 }\n    end\n    subgraph worker[&quot;Worker&quot;]\n        RECEIVE@{ icon: &quot;ngx:express&quot;, label: &quot;Job receiver&quot;, pos: &quot;b&quot;, h: 48 }\n        SERENITY[&quot;Serenity spawn&quot;]\n    end\n\n    DISPATCH --&gt;|&quot;POST /jobs — HMAC signed, AES-encrypted credentials&quot;| RECEIVE\n    RECEIVE --&gt; SERENITY\n    SERENITY --&gt;|&quot;POST /progress &amp; /result — signed callbacks&quot;| DISPATCH\n\n    style origin fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n    style worker fill:#EEE8F5,stroke:#AB9BC8,stroke-width:2px,rx:12,ry:12,color:#404143\n</code></pre>\n<p>Credentials are AES-encrypted in the job request and decrypted on the worker side. The worker never connects to MongoDB — <code>Login.performAs</code> short-circuits its Mongo lookup when <code>RAMBLERS_USERNAME</code> and <code>RAMBLERS_PASSWORD</code> are set in the environment (they always are on the worker, because the runner writes them from the decrypted payload before spawning Serenity).</p>\n<h3>Serenity Report Upload to S3</h3>\n<p>Every upload run — success or failure — uploads the complete Serenity HTML report to the origin&#39;s own S3 bucket so that operators can open the detailed test output from the Walk Export UI.</p>\n<pre><code class=\"language-mermaid\">flowchart LR\n    subgraph worker[&quot;Worker&quot;]\n        RUN[&quot;Serenity run&quot;] --&gt; GEN[&quot;Report generated at target/site/serenity&quot;]\n        GEN --&gt; UP@{ icon: &quot;ngx:aws&quot;, label: &quot;uploadSerenityReportToS3&quot;, pos: &quot;b&quot;, h: 48 }\n    end\n    subgraph origin[&quot;Origin&quot;]\n        RESULT@{ icon: &quot;ngx:express&quot;, label: &quot;POST /result&quot;, pos: &quot;b&quot;, h: 48 }\n        AUDIT@{ icon: &quot;ngx:mongodb&quot;, label: &quot;recordReportLocation&quot;, pos: &quot;b&quot;, h: 48 }\n        WS[&quot;WebSocket broadcast&quot;]\n        UI@{ icon: &quot;ngx:ramblers&quot;, label: &quot;View Report button&quot;, pos: &quot;b&quot;, h: 48 }\n    end\n    S3@{ icon: &quot;ngx:aws&quot;, label: &quot;S3 ramblers-upload-reports/&lt;csvFileName&gt;/&quot;, pos: &quot;b&quot;, h: 48 }\n\n    UP --&gt; S3\n    UP --&gt;|&quot;reportBucket + reportKeyPrefix&quot;| RESULT\n    RESULT --&gt; AUDIT\n    AUDIT --&gt; WS --&gt; UI\n    UI --&gt;|&quot;/api/aws/s3/.../index.html&quot;| S3\n\n    style origin fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n    style worker fill:#EEE8F5,stroke:#AB9BC8,stroke-width:2px,rx:12,ry:12,color:#404143\n</code></pre>\n<p>End-to-end flow:</p>\n<ol>\n<li>Origin&#39;s dispatcher computes <code>reportKeyPrefix = ramblers-upload-reports/&lt;csvFileName without .csv&gt;</code> and sends it, together with AES-encrypted S3 credentials (reusing the environment&#39;s own <code>AWS_ACCESS_KEY_ID</code> / <code>AWS_SECRET_ACCESS_KEY</code>) in the <code>/jobs</code> request.</li>\n<li>After the Serenity subprocess exits, <code>finishJob()</code> calls <code>uploadSerenityReportToS3()</code> which walks <code>target/site/serenity</code> and uploads every file via <code>uploadDirectoryToS3</code>, using the centralised <code>contentTypeForExtension</code> / <code>fileContentTypeMappings</code> helpers in <code>projects/ngx-ramblers/src/app/models/content-metadata.model.ts</code> (shared with the origin&#39;s AWS utilities).</li>\n<li>The worker then posts <code>/result</code> with <code>reportBucket</code> and <code>reportKeyPrefix</code> populated.</li>\n<li>The origin&#39;s <code>recordReportLocation</code> persists the prefix onto the audit session and broadcasts an audit row over the WebSocket so the Walk Export UI renders a <strong>View Report</strong> button next to <strong>Back To Walks Admin</strong>.</li>\n<li>The UI fetches the report through the existing <code>/api/aws/s3/.../index.html</code> proxy — no public bucket access, no extra credentials.</li>\n</ol>\n<h3>Lifecycle Audit Events</h3>\n<p>Beyond the per-step <code>SUCCESS</code> / <code>TEST_STEP</code> rows parsed from stdout, the worker emits additional lifecycle markers via the <code>IntegrationWorkerEventType.LIFECYCLE</code> type. The origin&#39;s <code>recordLifecycleEvent</code> writes each one as a <code>SUMMARY</code> audit row and pushes it over the WebSocket:</p>\n<table>\n<thead>\n<tr>\n<th>Marker</th>\n<th>When</th>\n</tr>\n</thead>\n<tbody><tr>\n<td><code>Scenario execution complete</code></td>\n<td>First time the Serenity report writer prints <code>[report]</code> to stderr</td>\n</tr>\n<tr>\n<td><code>Serenity report generation starting</code></td>\n<td>Immediately after, before the report writer runs</td>\n</tr>\n<tr>\n<td><code>Serenity report generation complete, uploading to S3</code></td>\n<td>After Serenity exits, before <code>uploadSerenityReportToS3</code> is invoked</td>\n</tr>\n<tr>\n<td><code>Report upload to S3 complete</code></td>\n<td>After the report directory has been uploaded to S3, before the final result callback</td>\n</tr>\n</tbody></table>\n<p>These make the &quot;silent&quot; stretch between the last scenario step and the final result callback visible in the audit log.</p>\n<h3>Local Developer Ergonomics</h3>\n<p><code>ngx-cli local dev</code> honours four worker-related overrides in <code>server/.env</code>:</p>\n<ul>\n<li><code>INTEGRATION_WORKER_URL</code></li>\n<li><code>INTEGRATION_WORKER_CALLBACK_BASE_URL</code></li>\n<li><code>INTEGRATION_WORKER_SHARED_SECRET</code></li>\n<li><code>INTEGRATION_WORKER_ENCRYPTION_KEY</code></li>\n</ul>\n<p>The <code>local.ts</code> command reads these values via <code>readServerEnvValue</code> and applies them on top of the secrets resolved from the database (see <code>SERVER_ENV_OVERRIDE_KEYS</code>). A developer can therefore flip between a locally-running worker container and the Fly-hosted worker purely by editing <code>.env</code>, without touching the database or redeploying anything.</p>\n<h2>Worker Secret Management</h2>\n<p>Worker configuration lives in the <code>uploadWorker</code> field of the environments config document — alongside <code>environments[]</code>, not inside it. This ensures &quot;deploy all environments&quot; never touches the worker.</p>\n<pre><code class=\"language-mermaid\">flowchart LR\n    UI@{ icon: &quot;ngx:ramblers&quot;, label: &quot;Admin UI&quot;, pos: &quot;b&quot;, h: 48 }\n    DB@{ icon: &quot;ngx:mongodb&quot;, label: &quot;MongoDB config&quot;, pos: &quot;b&quot;, h: 48 }\n    PUSH[&quot;Push to GitHub&quot;]\n    GH1@{ icon: &quot;logos:github-icon&quot;, label: &quot;CONFIGS_JSON&quot;, pos: &quot;b&quot;, h: 48 }\n    GH2@{ icon: &quot;logos:github-icon&quot;, label: &quot;INTEGRATION_WORKER_SECRETS_JSON&quot;, pos: &quot;b&quot;, h: 48 }\n\n    UI --&gt;|&quot;Save&quot;| DB\n    DB --&gt; PUSH\n    PUSH --&gt; GH1\n    PUSH --&gt; GH2\n</code></pre>\n<table>\n<thead>\n<tr>\n<th>Config location</th>\n<th>What it holds</th>\n<th>Managed via</th>\n</tr>\n</thead>\n<tbody><tr>\n<td><code>value.uploadWorker</code> in environments config</td>\n<td>App name, HMAC secret, AES key, memory, scale, org-scoped Fly <code>apiKey</code></td>\n<td>Global Settings tab in admin UI</td>\n</tr>\n<tr>\n<td><code>value.environments[].secrets</code> per routing env</td>\n<td><code>INTEGRATION_WORKER_URL</code>, <code>_SHARED_SECRET</code>, <code>_ENCRYPTION_KEY</code></td>\n<td>Per-environment settings in admin UI</td>\n</tr>\n<tr>\n<td>GitHub secret <code>INTEGRATION_WORKER_SECRETS_JSON</code></td>\n<td>JSON blob with HMAC + AES keys</td>\n<td>&quot;Push to GitHub&quot; button (synced alongside <code>CONFIGS_JSON</code>)</td>\n</tr>\n<tr>\n<td>GitHub variable <code>INTEGRATION_WORKER_APP_NAME</code></td>\n<td>Fly app name</td>\n<td>&quot;Push to GitHub&quot; button</td>\n</tr>\n</tbody></table>\n<h3>Key Rotation</h3>\n<p>To rotate the worker&#39;s HMAC or AES key:</p>\n<ol>\n<li>Edit the key in the <strong>Global Settings</strong> tab (Upload Worker Configuration section)</li>\n<li>Update the matching key in every routing environment&#39;s secrets (currently EKWG only)</li>\n<li>Click <strong>Save Configuration</strong></li>\n<li>Go to <strong>GitHub Secrets</strong> tab and click <strong>Push to GitHub</strong> — this updates both <code>CONFIGS_JSON</code> and <code>INTEGRATION_WORKER_SECRETS_JSON</code> atomically</li>\n<li>Redeploy the worker and the routing environment(s)</li>\n</ol>\n<h2>Cloudflare Configuration</h2>\n<p>Cloudflare configuration deserves special attention because it contains a sensitive API token that must never reach the frontend, yet the frontend needs certain non-sensitive fields to render the committee email routing UI.</p>\n<h3>Encryption at Deploy Time</h3>\n<p>During deployment, the Cloudflare config is encrypted using AES-256-GCM before being stored as a Fly.io secret:</p>\n<pre><code class=\"language-mermaid\">flowchart LR\n    DBCF@{ icon: &quot;ngx:mongodb&quot;, label: &quot;MongoDB config&quot;, pos: &quot;b&quot;, h: 48 }\n    ENVVAR@{ icon: &quot;logos:fly-icon&quot;, label: &quot;CLOUDFLARE_CONFIG&quot;, pos: &quot;b&quot;, h: 48 }\n\n    DBCF --&gt; ENCRYPT[&quot;AES-256-GCM encrypt&quot;] --&gt; ENVVAR\n</code></pre>\n<p>The encrypted payload structure is:</p>\n<table>\n<thead>\n<tr>\n<th>Bytes</th>\n<th>Content</th>\n</tr>\n</thead>\n<tbody><tr>\n<td>0-12</td>\n<td>Initialisation vector (IV)</td>\n</tr>\n<tr>\n<td>12-28</td>\n<td>Authentication tag</td>\n</tr>\n<tr>\n<td>28+</td>\n<td>Ciphertext</td>\n</tr>\n</tbody></table>\n<h3>Runtime: Backend to Frontend</h3>\n<p>At runtime, the backend decrypts <code>CLOUDFLARE_CONFIG</code> and uses the full config (including <code>apiToken</code>) for Cloudflare API calls. The frontend only receives non-sensitive fields:</p>\n<pre><code class=\"language-mermaid\">flowchart LR\n    subgraph backend[&quot;Backend&quot;]\n        DECRYPT[&quot;Decrypt&quot;] --&gt; FULL[&quot;Full config&quot;]\n        FULL --&gt; API@{ icon: &quot;ngx:cloudflare&quot;, label: &quot;Cloudflare API&quot;, pos: &quot;b&quot;, h: 48 }\n    end\n\n    subgraph frontend[&quot;Frontend&quot;]\n        SAFE[&quot;Safe config&quot;] --&gt; UI@{ icon: &quot;ngx:ramblers&quot;, label: &quot;Committee UI&quot;, pos: &quot;b&quot;, h: 48 }\n    end\n\n    FULL --&gt;|&quot;Non-sensitive only&quot;| SAFE\n    UI --&gt;|&quot;Via backend API&quot;| FULL\n</code></pre>\n<h3>Non-Sensitive Config Fields</h3>\n<p>The <code>/api/cloudflare/email-routing/config</code> endpoint returns only:</p>\n<table>\n<thead>\n<tr>\n<th>Field</th>\n<th>Type</th>\n<th>Purpose</th>\n</tr>\n</thead>\n<tbody><tr>\n<td><code>configured</code></td>\n<td><code>boolean</code></td>\n<td>Whether Cloudflare is set up for this environment</td>\n</tr>\n<tr>\n<td><code>accountId</code></td>\n<td><code>string</code></td>\n<td>Used to build Cloudflare dashboard links</td>\n</tr>\n<tr>\n<td><code>zoneId</code></td>\n<td><code>string</code></td>\n<td>Used to build Cloudflare dashboard links</td>\n</tr>\n<tr>\n<td><code>baseDomain</code></td>\n<td><code>string</code></td>\n<td>The domain for email routing (e.g. <code>nwkramblers.org.uk</code>)</td>\n</tr>\n<tr>\n<td><code>ownsZone</code></td>\n<td><code>boolean</code></td>\n<td>Whether this site owns its Cloudflare zone or is a subdomain of a shared zone (drives the apex-vs-subdomain inbox setup)</td>\n</tr>\n<tr>\n<td><code>zoneName</code></td>\n<td><code>string</code></td>\n<td>The resolved Cloudflare zone name for the domain</td>\n</tr>\n</tbody></table>\n<p>The <code>apiToken</code> is <strong>never</strong> sent to the frontend. All Cloudflare operations go through authenticated backend endpoints.</p>\n<h2>Fly API Token &amp; the <code>FLY_CONFIG</code> Bundle</h2>\n<p>The Fly API token follows the identical encrypted-bundle pattern as the Cloudflare config. Each environment carries one org-scoped Fly API token in <code>flyio.apiKey</code> (alongside <code>flyio.appName</code>) in the <code>ENVIRONMENTS</code> config document in MongoDB. That single token does everything Fly-related for the environment: it authenticates the deploy (<code>flyctl deploy</code> and <code>flyctl secrets import</code>), it restarts a stuck machine from the NGX admin UI, and it reads Fly&#39;s Prometheus metrics for the Memory Diagnostics charts.</p>\n<h3>Encryption at Deploy Time</h3>\n<p>During deployment, <code>buildSecretsFromDatabaseConfig()</code> encrypts <code>{apiToken, appName}</code> into a <code>FLY_CONFIG</code> Fly secret using AES-256-GCM keyed by <code>ENVIRONMENT_SETUP_API_KEY</code> — the same mechanism, same encryption key, and same payload layout used for <code>CLOUDFLARE_CONFIG</code>.</p>\n<pre><code class=\"language-mermaid\">flowchart LR\n    DBFLY@{ icon: &quot;ngx:mongodb&quot;, label: &quot;flyio.apiKey + appName&quot;, pos: &quot;b&quot;, h: 48 }\n    ENVVAR@{ icon: &quot;logos:fly-icon&quot;, label: &quot;FLY_CONFIG&quot;, pos: &quot;b&quot;, h: 48 }\n\n    DBFLY --&gt; ENCRYPT[&quot;AES-256-GCM encrypt&quot;] --&gt; ENVVAR\n</code></pre>\n<h3>Runtime Token Resolution</h3>\n<p>At runtime, <code>flyRuntimeConfig()</code> resolves the token in a fixed precedence order and stops at the first source that yields both a token and an app name:</p>\n<ol>\n<li><strong>Fly-injected environment variables</strong> — Fly sets <code>FLY_APP_NAME</code> and <code>FLY_MACHINE_ID</code> on every deployed machine; combined with a <code>FLY_API_TOKEN</code> env var this is the fast path</li>\n<li><strong>Decrypted <code>FLY_CONFIG</code> bundle</strong> — decrypted with <code>ENVIRONMENT_SETUP_API_KEY</code>, this is how deployed machines normally obtain the token</li>\n<li><strong>Environments config in the database</strong> — matched on the Mongo database name, used as a last-resort fallback</li>\n</ol>\n<pre><code class=\"language-mermaid\">flowchart LR\n    START@{ icon: &quot;logos:fly-icon&quot;, label: &quot;flyRuntimeConfig()&quot;, pos: &quot;b&quot;, h: 48 }\n    ENV@{ icon: &quot;logos:fly-icon&quot;, label: &quot;FLY_* env vars&quot;, pos: &quot;b&quot;, h: 48 }\n    BUNDLE@{ icon: &quot;logos:fly-icon&quot;, label: &quot;Decrypt FLY_CONFIG&quot;, pos: &quot;b&quot;, h: 48 }\n    DB@{ icon: &quot;ngx:mongodb&quot;, label: &quot;Environments config&quot;, pos: &quot;b&quot;, h: 48 }\n    TOKEN[&quot;Resolved token + appName&quot;]\n\n    START --&gt; ENV\n    ENV --&gt;|&quot;token + appName present&quot;| TOKEN\n    ENV --&gt;|&quot;missing&quot;| BUNDLE\n    BUNDLE --&gt;|&quot;decrypted&quot;| TOKEN\n    BUNDLE --&gt;|&quot;absent&quot;| DB\n    DB --&gt; TOKEN\n</code></pre>\n<h3>What the Token Powers</h3>\n<table>\n<thead>\n<tr>\n<th>Use</th>\n<th>Where</th>\n<th>Notes</th>\n</tr>\n</thead>\n<tbody><tr>\n<td>Deploy + <code>flyctl secrets import</code></td>\n<td>CI deploy runner</td>\n<td>The per-environment <code>apiKey</code> is loaded into <code>FLY_API_TOKEN</code> for that environment&#39;s <code>flyctl</code> commands</td>\n</tr>\n<tr>\n<td>Restart a stuck machine</td>\n<td>Admin UI (#304)</td>\n<td><code>restartCurrentMachine()</code> calls the Fly Machines API with the resolved token</td>\n</tr>\n<tr>\n<td>Read memory / metric history</td>\n<td>Memory Diagnostics charts (#304)</td>\n<td><code>fly-metrics</code> queries Fly&#39;s Prometheus API with the resolved token</td>\n</tr>\n</tbody></table>\n<p>An org-scoped token is required for the metrics path: an app-scoped token can manage and restart the app but returns 403 from Fly&#39;s Prometheus API.</p>\n<p>Originally five environments carried a separate app-scoped deploy token plus a distinct org-scoped <code>metricsToken</code>. On 9 Jul 2026 this was consolidated to a single org-scoped <code>apiKey</code> per environment and the <code>metricsToken</code> field was removed, so one token now covers deploy, restart and metrics.</p>\n<h2>Required Environment Variables</h2>\n<h3>Origin Environments</h3>\n<table>\n<thead>\n<tr>\n<th>Variable</th>\n<th>Required</th>\n<th>Purpose</th>\n</tr>\n</thead>\n<tbody><tr>\n<td><code>AUTH_SECRET</code></td>\n<td>Yes</td>\n<td>JWT authentication secret</td>\n</tr>\n<tr>\n<td><code>AWS_ACCESS_KEY_ID</code></td>\n<td>Yes</td>\n<td>S3 file storage (also reused to upload Serenity reports)</td>\n</tr>\n<tr>\n<td><code>AWS_SECRET_ACCESS_KEY</code></td>\n<td>Yes</td>\n<td>S3 file storage</td>\n</tr>\n<tr>\n<td><code>AWS_REGION</code></td>\n<td>Yes</td>\n<td>S3 region (e.g. <code>eu-west-2</code>)</td>\n</tr>\n<tr>\n<td><code>AWS_BUCKET</code></td>\n<td>Yes</td>\n<td>S3 bucket name</td>\n</tr>\n<tr>\n<td><code>MONGODB_URI</code></td>\n<td>Yes</td>\n<td>Database connection string</td>\n</tr>\n<tr>\n<td><code>CLOUDFLARE_CONFIG</code></td>\n<td>No</td>\n<td>Encrypted Cloudflare config (enables email routing)</td>\n</tr>\n<tr>\n<td><code>FLY_CONFIG</code></td>\n<td>No</td>\n<td>Encrypted per-environment Fly token + app name (enables machine restart + metrics)</td>\n</tr>\n<tr>\n<td><code>ENVIRONMENT_SETUP_API_KEY</code></td>\n<td>No</td>\n<td>Encryption key for both <code>CLOUDFLARE_CONFIG</code> and <code>FLY_CONFIG</code></td>\n</tr>\n<tr>\n<td><code>INTEGRATION_WORKER_URL</code></td>\n<td>No</td>\n<td>Worker URL (enables remote upload routing)</td>\n</tr>\n<tr>\n<td><code>INTEGRATION_WORKER_SHARED_SECRET</code></td>\n<td>If worker URL set</td>\n<td>HMAC signing key for worker requests</td>\n</tr>\n<tr>\n<td><code>INTEGRATION_WORKER_ENCRYPTION_KEY</code></td>\n<td>If worker URL set</td>\n<td>AES key for credential encryption</td>\n</tr>\n</tbody></table>\n<h3>Worker App</h3>\n<table>\n<thead>\n<tr>\n<th>Variable</th>\n<th>Required</th>\n<th>Purpose</th>\n</tr>\n</thead>\n<tbody><tr>\n<td><code>INTEGRATION_WORKER_SHARED_SECRET</code></td>\n<td>Yes</td>\n<td>HMAC signature validation</td>\n</tr>\n<tr>\n<td><code>INTEGRATION_WORKER_ENCRYPTION_KEY</code></td>\n<td>Yes</td>\n<td>Credential + report-credential decryption</td>\n</tr>\n<tr>\n<td><code>CHROME_BIN</code></td>\n<td>Built-in</td>\n<td>Chrome path (baked into Docker image via <code>ENV</code>)</td>\n</tr>\n<tr>\n<td><code>CHROMEDRIVER_PATH</code></td>\n<td>Built-in</td>\n<td>Chromedriver path (baked into Docker image via <code>ENV</code>)</td>\n</tr>\n</tbody></table>\n<p>The worker deliberately has no <code>MONGODB_URI</code>. <code>RAMBLERS_USERNAME</code> and <code>RAMBLERS_PASSWORD</code> are set by the runner from the decrypted job payload immediately before Serenity spawns, so <code>Login.performAs</code> takes its env-var fast path and never attempts a Mongo lookup.</p>\n<h3>Deploy-Only Variables</h3>\n<table>\n<thead>\n<tr>\n<th>Variable</th>\n<th>Required</th>\n<th>Purpose</th>\n</tr>\n</thead>\n<tbody><tr>\n<td><code>FLY_API_TOKEN</code></td>\n<td>Deploy only</td>\n<td>GitHub secret — the deploy runner&#39;s ambient Fly authentication. Distinct from the per-environment <code>flyio.apiKey</code>, which the deploy loads into <code>FLY_API_TOKEN</code> per environment and which is also baked into that environment&#39;s <code>FLY_CONFIG</code> secret</td>\n</tr>\n<tr>\n<td><code>ADMIN_MONGODB_URI</code></td>\n<td>Deploy only</td>\n<td>Staging database for secrets import</td>\n</tr>\n<tr>\n<td><code>INTEGRATION_WORKER_SECRETS_JSON</code></td>\n<td>Deploy only</td>\n<td>GitHub secret — worker HMAC + AES keys as JSON</td>\n</tr>\n<tr>\n<td><code>INTEGRATION_WORKER_APP_NAME</code></td>\n<td>Deploy only</td>\n<td>GitHub variable — worker Fly app name</td>\n</tr>\n</tbody></table>\n<h2>Issue History</h2>\n<p>The secrets and deployment infrastructure was built across several issues:</p>\n<pre><code class=\"language-mermaid\">flowchart BT\n    I146@{ icon: &quot;logos:github-icon&quot;, label: &quot;#146 Env Setup&quot;, pos: &quot;b&quot;, h: 48 }\n    I156@{ icon: &quot;ngx:cloudflare&quot;, label: &quot;#156 DNS/SSL&quot;, pos: &quot;b&quot;, h: 48 }\n    I125@{ icon: &quot;ngx:cloudflare&quot;, label: &quot;#125 Email Routing&quot;, pos: &quot;b&quot;, h: 48 }\n    I82[&quot;#82 Brevo Auth&quot;]\n    I130[&quot;#130 Replies Rejected&quot;]\n    I114@{ icon: &quot;logos:fly-icon&quot;, label: &quot;#114 Integration Worker&quot;, pos: &quot;b&quot;, h: 48 }\n    I304@{ icon: &quot;logos:fly-icon&quot;, label: &quot;#304 Machine Restart &amp; Metrics&quot;, pos: &quot;b&quot;, h: 48 }\n    TODAY@{ icon: &quot;logos:fly-icon&quot;, label: &quot;Today: Worker, Fly Tokens &amp; Secrets&quot;, pos: &quot;b&quot;, h: 48 }\n\n    I146 --&gt;|&quot;secrets&quot;| I125\n    I146 --&gt;|&quot;config&quot;| I156\n    I156 --&gt;|&quot;Cloudflare&quot;| I125\n    I156 --&gt;|&quot;DNS&quot;| I82\n    I130 --&gt;|&quot;motivated&quot;| I125\n    I125 --&gt; TODAY\n    I82 --&gt; TODAY\n    I114 --&gt;|&quot;worker + reports + lifecycle&quot;| TODAY\n    I304 --&gt;|&quot;Fly token + restart + metrics&quot;| TODAY\n</code></pre>\n<table>\n<thead>\n<tr>\n<th>Issue</th>\n<th>Title</th>\n<th>Key Contribution</th>\n</tr>\n</thead>\n<tbody><tr>\n<td><a href=\"https://github.com/nbarrett/ngx-ramblers/issues/146\">#146</a></td>\n<td>Automated environment creation</td>\n<td>Database-first secrets loading, GitHub secrets sync, global AWS</td>\n</tr>\n<tr>\n<td><a href=\"https://github.com/nbarrett/ngx-ramblers/issues/156\">#156</a></td>\n<td>DNS/SSL automation</td>\n<td>Cloudflare DNS and certificate management in environment setup</td>\n</tr>\n<tr>\n<td><a href=\"https://github.com/nbarrett/ngx-ramblers/issues/130\">#130</a></td>\n<td>Email replies rejected</td>\n<td>The original problem that drove Cloudflare email routing work</td>\n</tr>\n<tr>\n<td><a href=\"https://github.com/nbarrett/ngx-ramblers/issues/125\">#125</a></td>\n<td>Cloudflare email routing</td>\n<td>Committee email forwarding, worker scripts, email logs UI</td>\n</tr>\n<tr>\n<td><a href=\"https://github.com/nbarrett/ngx-ramblers/issues/82\">#82</a></td>\n<td>Brevo domain authentication</td>\n<td>DKIM/SPF DNS records via Cloudflare for email deliverability</td>\n</tr>\n<tr>\n<td><a href=\"https://github.com/nbarrett/ngx-ramblers/issues/114\">#114</a></td>\n<td>Distributed integration worker</td>\n<td>Shared worker (Serenity uploads, browser fetch, Flickr scraping, migration, resize, document conversion), queued job execution, dependency-graph deploy detection, S3 Serenity report upload, lifecycle audit events, env-var login short-circuit, <code>.env</code> overrides for local dev</td>\n</tr>\n<tr>\n<td><a href=\"https://github.com/nbarrett/ngx-ramblers/issues/304\">#304</a></td>\n<td>Machine restart &amp; memory diagnostics</td>\n<td>Per-environment org-scoped Fly token encrypted into <code>FLY_CONFIG</code>, admin restart of a stuck Fly machine, Fly Prometheus metric-history charts, consolidation of deploy + metrics tokens into one <code>apiKey</code></td>\n</tr>\n</tbody></table>\n<h2>Why No Code Changes Were Needed (Original Cloudflare Work)</h2>\n<p>The application code already handled Cloudflare gracefully — showing a warning when config was missing and enabling features when config was present. The missing piece was the deployment pipeline: managed environments had no <code>CLOUDFLARE_CONFIG</code> secret because the deploy script did not import secrets from the database. Adding <code>importSecretsFromDatabase()</code> to the deploy flow was the final link in the chain.</p>\n<h2>What Changed for the Integration Worker</h2>\n<p>The integration worker required changes across several layers:</p>\n<ul>\n<li><strong>Model</strong>: <code>UploadWorkerConfig</code> added to <code>EnvironmentsConfig</code> as a sibling to <code>environments[]</code>, keeping it out of the deploy-all matrix; <code>IntegrationWorkerEventType.LIFECYCLE</code> added for non-stdout audit markers</li>\n<li><strong>Worker runner</strong>: Serenity subprocess uploads <code>target/site/serenity</code> to <code>s3://&lt;envBucket&gt;/ramblers-upload-reports/&lt;csvFileName&gt;/</code> via <code>uploadSerenityReportToS3</code>, and emits lifecycle progress events around report generation and upload</li>\n<li><strong>Origin audit notifier</strong>: new <code>recordLifecycleEvent</code> and <code>recordReportLocation</code> helpers persist the extra rows and broadcast the report location over the WebSocket so the UI can surface a &quot;View Report&quot; link</li>\n<li><strong>Admin UI</strong>: New section in the Global Settings tab for worker app name, org-scoped Fly <code>apiKey</code>, HMAC secret, AES key, memory, and scale; Walk Export UI renders the report link against the selected session</li>\n<li><strong>GitHub sync</strong>: the &quot;Push to GitHub&quot; action writes <code>INTEGRATION_WORKER_SECRETS_JSON</code> alongside <code>CONFIGS_JSON</code> when worker config exists</li>\n<li><strong>CI workflow</strong>: a dependency-graph deploy step (<code>detect-worker-changes.ts</code>) piggybacked on the main build, reusing the same Docker image with a different Fly config (<code>fly.integration-worker.toml</code>) via <code>deploy-integration-worker.ts</code></li>\n<li><strong>Content-type mapping</strong>: <code>contentTypeForExtension</code> + <code>fileContentTypeMappings</code> centralised in <code>projects/ngx-ramblers/src/app/models/content-metadata.model.ts</code> and shared between the S3 report uploader and <code>aws-utils</code></li>\n<li><strong>Local dev</strong>: <code>ngx-cli local dev</code> reads four worker env vars from <code>server/.env</code> so a developer can flip between a local worker and the Fly worker without redeploying</li>\n</ul>\n"}