{"id":"6a56c0ad61dfc4b182d5f788","title":"2026 07 15 Integration Worker Architecture And Security","path":"how-to/technical-articles/2026-07-15-integration-worker-architecture-and-security","contentMarkdown":"# 15-Jul-2026 — Integration Worker: Distributed Architecture and Security\n\n_____\n\nThis article explains how the NGX Ramblers **integration worker** works: what it is, how the many websites talk to it, and — in rigorous, diagrammed detail — the security model that protects every message crossing the boundary between them. If you only read one part, read the **Security model** section further down: it is written and drawn so that anyone auditing this design can satisfy themselves it is a sound way to build a distributed, machine-to-machine architecture.\n\n## What the integration worker is — and why\n\nThe integration worker exists to keep the public websites small and fast. Each site runs on **512 MB** Fly machines, sized for ordinary visitor traffic — serving pages, the API and WebSockets. Some operations are far too memory-hungry to run there: they would push the V8 heap towards its cap, slow every other request, or trip an out-of-memory kill. So any such operation is **offloaded to the integration worker**, a separate Fly.io application (`ngx-ramblers-integration-worker`) provisioned with **1024 MB** — double the memory — and dedicated to heavy work.\n\nBrowser automation is the most visible example, but it is only one class. The worker handles two kinds of heavy job:\n\n**Jobs that need a real browser** (Chromium plus the Serenity-BDD Java tooling — expensive to load, and a large resident footprint):\n\n- **Walks Manager upload** — Ramblers has no bulk API, so walks are created, edited and published by driving its web forms with Playwright/Serenity.\n- **Legacy site migration** — scraping a legacy group website page by page to import its content.\n- **HTML fetch** — fetching and rendering a page's HTML for content import.\n- **Flickr album scraping** — reading a Flickr user's albums for gallery import.\n\n**Jobs that are simply memory- and CPU-heavy, with no browser at all:**\n\n- **Image resizing** — the `sharp` library decodes full-resolution images into memory buffers to produce the site's rendition sizes. A batch of large images would blow the 512 MB budget on its own.\n- **Document conversion** — turning uploaded committee documents (`.docx` via `mammoth`, PDFs via `pdf-parse` and styled extraction) into markdown. Whole documents are loaded and transformed in memory.\n\nThe organising principle is **memory, not browsers**: the worker is where any job runs when running it on the 512 MB site would be unsafe.\n\n```mermaid\nflowchart TB\n    subgraph site[\"A website — 512 MB\"]\n        VISITOR[\"Visitor traffic:<br/>pages, API, WebSockets\"]\n    end\n    subgraph worker[\"Integration worker — 1024 MB — one heavy job at a time\"]\n        subgraph browser[\"Needs a real browser\"]\n            WALKS[\"Walks Manager upload\"]\n            MIG[\"Legacy site migration\"]\n            HTML[\"HTML fetch\"]\n            FLICKR[\"Flickr album scrape\"]\n        end\n        subgraph heavy[\"Memory-heavy, no browser\"]\n            RESIZE[\"Image resizing (sharp)\"]\n            DOC[\"Document conversion<br/>(docx / pdf to markdown)\"]\n        end\n    end\n    site -->|\"offload anything that<br/>would exceed 512 MB\"| worker\n    style site fill:#EAF2FB,stroke:#8FB5DE,stroke-width:2px,rx:12,ry:12,color:#404143\n    style worker fill:#FDF3E7,stroke:#E4B876,stroke-width:2px,rx:12,ry:12,color:#404143\n    style browser fill:#E8F5EE,stroke:#9BC8AB,rx:10,ry:10,color:#404143\n    style heavy fill:#FBF0E4,stroke:#E4B876,rx:10,ry:10,color:#404143\n```\n\n## Many websites, one worker\n\nNGX Ramblers hosts many group websites — sixteen at the time of writing, each its own Fly application with its own database. They do **not** each run their own worker. Instead, **all of them share a single integration worker**. This is a many-to-one relationship: any site that needs heavy work submits a job to the one shared worker, which runs it and reports back to whichever site asked.\n\n```mermaid\nflowchart TB\n    S1[\"EKWG\"] --> WK\n    S2[\"Canterbury\"] --> WK\n    S3[\"Kent\"] --> WK\n    S4[\"Bolton\"] --> WK\n    S5[\"… 16 sites in total\"] --> WK\n    WK@{ icon: \"logos:fly-icon\", label: \"One shared integration worker\", pos: \"b\", h: 48 }\n    style WK fill:#FDF3E7,stroke:#E4B876,stroke-width:2px,rx:12,ry:12,color:#404143\n```\n\nSharing one worker keeps the estate cheap — one 1024 MB machine instead of sixteen — and it is possible precisely because all sixteen sites are operated by the same team. They form a **single administrative trust domain**, which is what makes a shared worker and shared worker secrets acceptable — the Security model section below explains why.\n\nIt works so comfortably because **heavy jobs are rare**. A walks upload happens when a volunteer chooses to publish; an image resize or document conversion only when content is added. Across the whole estate these are occasional, human-initiated actions, not a steady stream. The shared machine is therefore **idle the overwhelming majority of the time** — well into the high 99%s — so a single worker has ample headroom to serve all sixteen sites, and the chance of two jobs genuinely colliding is small. When it does happen, the queue simply runs them in turn.\n\n### One heavy job at a time\n\nBecause one worker serves every site, and heavy jobs are memory-hungry, the worker runs **exactly one heavy job at a time**. Walks uploads, image resizes and document conversions — from any site — all pass through a **single shared queue**. A new job either becomes the active job or waits its turn, so two memory-hungry operations never run concurrently and stack their footprints. This is what keeps a 1024 MB worker safely within budget, and why a submission may occasionally come back as \"queued\" rather than starting immediately.\n\nGiven how rarely heavy jobs occur, serialising them costs almost nothing: the queue is empty the vast majority of the time, so in practice a job starts at once, and the \"one at a time\" rule is a cheap safety guarantee rather than a throughput limit.\n\n```mermaid\nflowchart LR\n    J1[\"Walks upload<br/>(any site)\"] --> Q\n    J2[\"Image resize<br/>(any site)\"] --> Q\n    J3[\"Document conversion<br/>(any site)\"] --> Q\n    Q[\"Single heavy-job queue\"] --> ACTIVE[\"One active job\"]\n    Q --> WAIT[\"Others wait<br/>(returned as queued)\"]\n    style ACTIVE fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n    style WAIT fill:#F3F4F5,stroke:#B9BDC2,rx:10,ry:10,color:#404143\n```\n\n## The same application, two build targets\n\nThe worker is **not a separate application**. It runs the **same NGX Ramblers server code** as the websites, from the **same repository and the same Dockerfile**. That Dockerfile is a multi-stage build with two targets:\n\n- The **website target** builds on `node:24` — a lean runtime with no browser and no heavy toolchain.\n- The **worker target** builds on the `serenity-js/playwright` base, which adds a headless Chromium and the Java runtime for Serenity-BDD, then copies in the **identical** application code and installs the same dependencies (including `sharp` and the document-conversion libraries).\n\nSo the two images differ only in their base layer and therefore in what runtime tooling is present — not in the application they run. Both are the NGX server; the worker's image simply carries the extra machinery its heavy jobs need.\n\n```mermaid\nflowchart TB\n    SRC[\"One codebase, one Dockerfile<br/>(the NGX Ramblers application)\"]\n    SRC --> T1[\"Website target<br/>base node:24 — lean, 512 MB\"]\n    SRC --> T2[\"Worker target<br/>base serenity-js/playwright<br/>adds Chromium + JVM; same app code\"]\n    style T1 fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n    style T2 fill:#FDF3E7,stroke:#E4B876,stroke-width:2px,rx:12,ry:12,color:#404143\n```\n\nThey are built and deployed by **separate GitHub Actions workflows**, so the two images ship independently. One consequence matters operationally: because a single worker serves every site, **rebuilding and redeploying the worker updates the heavy-job behaviour for all sites at once**. When automation code changes, deploying the websites does not touch the worker; the worker only runs the new code once its own target is rebuilt and redeployed. Until then, every site's heavy jobs run the previous worker version.\n\n## How a job flows\n\nThe walks upload is the richest example. Here is the complete round trip, from the button press to the audit rows the user watches scroll past. Note that the browser automation runs **on the worker**, never in the visitor's browser — the client only submits the request and displays progress.\n\n```mermaid\nsequenceDiagram\n    participant C as Site (Angular SPA)\n    participant A as Site server\n    participant W as Shared integration worker\n    participant R as Ramblers Walks Manager\n\n    C->>A: WebSocket: upload request\n    A->>W: POST /jobs (signed body, encrypted credentials)\n    Note over W: Verify HMAC signature. Reject 401 if invalid.\n    W-->>A: 200 queued\n    A-->>C: WebSocket: \"Upload queued\"\n    Note over W: Decrypt credentials. Download images to /tmp.<br/>Wait for the shared queue, then run.\n    W->>W: spawn Serenity subprocess (headless Chromium)\n    W->>R: Log in, edit / upload / publish walks\n    loop each Serenity step\n        W->>A: POST /progress (signed)\n        A->>A: Match jobId to active session\n        A-->>C: WebSocket: audit row\n    end\n    W->>A: POST /result (signed)\n    A-->>C: WebSocket: final status\n```\n\nStep by step:\n\n1. **Site client → site server (WebSocket).** The SPA builds the request and sends it over a WebSocket. That is the client's entire outbound role: it downloads no images and launches no browser.\n2. **Site server → worker (signed HTTP).** The server builds a job and POSTs it to the worker's `/api/integration-worker/jobs`.\n3. **The worker runs it.** Source images are downloaded into a per-job temporary directory (`/tmp/ramblers/<jobId>/`), the job waits for the shared heavy-job queue, and then a Serenity subprocess drives Walks Manager in a headless browser.\n4. **Worker → site server (signed callbacks).** The worker holds no WebSocket to the user — it is a different machine, and may be serving a different site's job moments later. As each step completes it POSTs progress to the site that submitted the job, at the callback URL carried in the job, and the final outcome to `/result`.\n5. **Site server → client (WebSocket).** The server matches each callback to the WebSocket session that started the job and forwards it as an audit row. That is what populates the live \"Walk upload audit\" tab.\n\nSo the shape is: **client → (WebSocket) → site server → (signed HTTP) → worker runs the job → (signed HTTP callbacks) → site server → (WebSocket) → client.**\n\n## Security model\n\nThis is the section to scrutinise. The trust boundary is the network hop between a site and the shared worker — independently deployed services on the public internet. Every operational message that crosses it is protected on two axes: **authenticity and integrity** (is this message genuinely from a party that holds the secret, and has it been altered?) and, for credentials, **confidentiality** (can a third party read it?).\n\n```mermaid\nflowchart LR\n    subgraph website[\"Site server\"]\n        direction TB\n        W1[\"Job payload<br/>(not secret)\"]\n        W2[\"Credentials<br/>(secret)\"]\n    end\n    subgraph boundary[\"Public internet — over TLS\"]\n        SIG[\"Every message:<br/>HMAC-SHA256 signature\"]\n        ENC[\"Credentials only:<br/>AES-256-GCM ciphertext\"]\n    end\n    subgraph worker[\"Shared worker\"]\n        direction TB\n        WK1[\"Verify signature<br/>(constant time)\"]\n        WK2[\"Decrypt credentials\"]\n    end\n    W1 --> SIG\n    W2 --> ENC\n    W2 --> SIG\n    SIG --> WK1\n    ENC --> WK2\n    style boundary fill:#FDECEC,stroke:#E0A2A2,stroke-width:2px,rx:12,ry:12,color:#404143\n    style website fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n    style worker fill:#FDF3E7,stroke:#E4B876,stroke-width:2px,rx:12,ry:12,color:#404143\n```\n\n### Trust boundary and authenticated surface\n\nThe worker's job and callback endpoints are reachable over the internet, so the security of the whole system rests on the rule that **every operational request must carry a valid signature or it is rejected**.\n\n```mermaid\nflowchart TB\n    NET[\"Public internet\"]\n    subgraph worker[\"Integration worker endpoints\"]\n        JOBS[\"POST /jobs — signed\"]\n        PROG[\"POST /progress — signed\"]\n        RES[\"POST /result — signed\"]\n        HTML[\"POST /browser/html-fetch — signed\"]\n        HEALTH[\"GET /api/health — open, no input, no secrets\"]\n        ROOT[\"GET / — open, static descriptor\"]\n    end\n    NET --> JOBS\n    NET --> PROG\n    NET --> RES\n    NET --> HTML\n    NET --> HEALTH\n    NET --> ROOT\n    style JOBS fill:#EAF2FB,stroke:#8FB5DE,color:#404143\n    style PROG fill:#EAF2FB,stroke:#8FB5DE,color:#404143\n    style RES fill:#EAF2FB,stroke:#8FB5DE,color:#404143\n    style HTML fill:#EAF2FB,stroke:#8FB5DE,color:#404143\n    style HEALTH fill:#F3F4F5,stroke:#B9BDC2,color:#404143\n    style ROOT fill:#F3F4F5,stroke:#B9BDC2,color:#404143\n```\n\nConcretely, `POST /jobs` is verified against the shared secret and unsigned or mis-signed requests receive `401` and are never queued; the `POST /progress` and `POST /result` callbacks are verified against the callback secret; and the synchronous browser endpoints use the same signed request/response scheme. The **only** unauthenticated endpoints are `GET /api/health` and `GET /` — neither accepts input nor returns anything sensitive. There is no unauthenticated endpoint that performs work, accepts data, or discloses secrets.\n\n### Message authenticity and integrity — HMAC-SHA256, constant-time\n\nEvery signed request carries an `x-ramblers-upload-signature` header: an **HMAC-SHA256** of the exact JSON request body, keyed on the shared secret. The receiver recomputes the HMAC over the body it received and compares.\n\n```mermaid\nsequenceDiagram\n    participant S as Sender (holds secret)\n    participant R as Receiver (holds secret)\n    Note over S: signature = HMAC_SHA256(secret, body)\n    S->>R: POST body + header x-ramblers-upload-signature\n    Note over R: expected = HMAC_SHA256(secret, receivedBody)\n    alt length differs OR timingSafeEqual is false\n        R-->>S: 401 Unauthorized\n    else signatures equal (constant-time compare)\n        R-->>S: 200 — request accepted\n    end\n```\n\nTwo properties make this robust:\n\n- **Body binding.** The signature is over the whole body, so altering a single byte — a walk id, a deletion target, a callback status — invalidates it. An attacker cannot tamper with a message in flight without the secret.\n- **Constant-time comparison.** Verification uses Node's `crypto.timingSafeEqual`, preceded by a length check, so the comparison takes the same time whether the first byte or the last byte differs. This closes the timing side-channel that a naive string equality (`===`) would open, where an attacker could otherwise recover a valid signature byte by byte by measuring response times.\n\nOnly a party in possession of the secret can produce a signature that verifies. That is what authenticates a site to the worker, and the worker's callbacks back to the site.\n\n### Credential confidentiality — AES-256-GCM\n\nThe signature proves who sent a message and that it is intact, but it does not hide the contents. The job body itself is not secret — it is walk data — and travels over TLS. The **Ramblers login credentials** and the **AWS report-upload credentials**, however, must never be readable in transit, so they are **encrypted** before they are placed in the request, independently of the transport.\n\n```mermaid\nflowchart LR\n    CRED[\"Credentials<br/>(username, password)\"] --> JSON[\"JSON.stringify\"]\n    EK[\"encryptionKey\"] --> DK[\"key = SHA-256(encryptionKey)<br/>256-bit\"]\n    RB[\"crypto.randomBytes(12)\"] --> IV[\"fresh iv per message\"]\n    JSON --> GCM[\"AES-256-GCM encrypt\"]\n    DK --> GCM\n    IV --> GCM\n    GCM --> TAG[\"authTag<br/>(16 bytes)\"]\n    GCM --> CT[\"ciphertext\"]\n    IV --> ENV\n    TAG --> ENV\n    CT --> ENV[\"base64( iv ‖ authTag ‖ ciphertext )\"]\n    style ENV fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n    style GCM fill:#FDF3E7,stroke:#E4B876,stroke-width:2px,rx:12,ry:12,color:#404143\n```\n\n- **Cipher: AES-256-GCM.** A 256-bit key, authenticated encryption. GCM protects both confidentiality *and* integrity of the ciphertext: any tampering with the encrypted credentials causes decryption to fail rather than silently yielding altered values.\n- **Key derivation.** The 256-bit key is derived as `SHA-256(encryptionKey)`, so a secret of any length produces a correctly sized key.\n- **Fresh IV per message.** A new random 12-byte initialisation vector is generated for every encryption, so encrypting the same credentials twice produces different ciphertext and no patterns leak.\n- **On-the-wire form.** The output is `base64(iv ‖ authTag ‖ ciphertext)`, carrying everything the receiver needs to authenticate and decrypt, and nothing more.\n\nThe worker decrypts the credentials only in memory, only to hand them to the Serenity login step, and the per-job files are cleaned up afterwards.\n\n### Separation of secrets\n\nSigning and encryption use **two distinct secrets**, never the same value. Separating them means a compromise or rotation of one concern does not implicate the other, and neither secret is overloaded across two cryptographic purposes.\n\n```mermaid\nflowchart TB\n    SS[\"INTEGRATION_WORKER_SHARED_SECRET\"] --> HMAC[\"HMAC-SHA256 signing<br/>authenticity + integrity<br/>(every message)\"]\n    EK[\"INTEGRATION_WORKER_ENCRYPTION_KEY\"] --> AES[\"AES-256-GCM encryption<br/>confidentiality<br/>(credentials only)\"]\n    style HMAC fill:#EAF2FB,stroke:#8FB5DE,stroke-width:2px,rx:12,ry:12,color:#404143\n    style AES fill:#FDF3E7,stroke:#E4B876,stroke-width:2px,rx:12,ry:12,color:#404143\n```\n\n### Session-bound callbacks\n\nA signed callback is not blindly trusted just because it verifies. Each callback carries the `jobId`, and the site matches it to an **active session**. If no active session exists for that id — because the job never started, or already finished — the callback is rejected. This scopes callbacks to live work and means a replayed callback for a completed job has nowhere to land.\n\n```mermaid\nflowchart TB\n    CB[\"Callback: POST /progress\"] --> V{\"Signature valid?\"}\n    V -->|\"No\"| R401[\"401 Unauthorized\"]\n    V -->|\"Yes\"| SESS{\"jobId matches an<br/>active session?\"}\n    SESS -->|\"No\"| R404[\"404 — no session\"]\n    SESS -->|\"Yes\"| FWD[\"Forward as audit row<br/>over client WebSocket\"]\n    style FWD fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n    style R401 fill:#FDECEC,stroke:#E0A2A2,color:#404143\n    style R404 fill:#FDECEC,stroke:#E0A2A2,color:#404143\n```\n\n### Shared secrets within one trust domain\n\nBecause a single worker serves every site, the two secrets are **shared by the worker and all the sites that use it** — they have to be, since the worker verifies and decrypts with one key. They are not per-site. They live in the central worker configuration and are injected as **Fly secrets** at deploy time; they are not committed to the repository, not written to `.env` files in the image, and not exported into a shared store.\n\nThis is safe because **all the sites are within a single administrative trust domain** — one team operates every site and the worker. A shared secret is only a weakness when the parties sharing it do not already trust one another. Here they are all facets of the same operator, so a site holding the secret can forge nothing it could not already do directly.\n\n```mermaid\nflowchart LR\n    CFG@{ icon: \"ngx:mongodb\", label: \"Central worker config\", pos: \"b\", h: 48 }\n    DEPLOY@{ icon: \"logos:github-actions\", label: \"Deploy\", pos: \"b\", h: 48 }\n    subgraph domain[\"Single trust domain (one operator)\"]\n        SITES[\"All sites<br/>(shared secret + key)\"]\n        WK[\"Shared worker<br/>(same secret + key)\"]\n    end\n    CFG --> DEPLOY\n    DEPLOY -->|\"inject as Fly secrets\"| SITES\n    DEPLOY -->|\"inject as Fly secrets\"| WK\n    style domain fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n```\n\nThe one caveat, stated plainly: if the worker were ever opened to **mutually-distrusting** tenants, this shared-secret model would no longer be appropriate and per-tenant secrets would be required. Today, within one operator, it is a standard and sound arrangement.\n\n### Transport, and an honest threat model\n\nAll traffic is HTTPS (terminated by Fly, fronted by Cloudflare), so TLS already provides confidentiality and integrity on the wire. The application-level signing and encryption are deliberate **defence in depth** on top of TLS, not a substitute for it:\n\n- The HMAC signature guarantees end-to-end authenticity and integrity between a site and the worker **at the application layer**, independent of where TLS is terminated. Even a misconfigured proxy cannot forge or alter a message without the shared secret.\n- The AES-GCM credential encryption means the most sensitive fields are unreadable **even to anything that can see the decrypted TLS stream**, narrowing where cleartext credentials ever exist to the two process memories that legitimately need them.\n\nTo be precise about what is and is not claimed: the signature prevents **forgery and tampering**, not **replay**, in isolation — there is no per-message nonce or timestamp. Replay is mitigated by two other properties rather than by the signature: an attacker cannot capture a valid signed request in the first place without breaking TLS, and a replayed callback is rejected once its job's session is no longer active. If a future change ever moved either endpoint outside TLS, per-message anti-replay would need to be added; today, under TLS, the design does not depend on the recipient having seen a message before.\n\n## Summary\n\n- The integration worker is a **shared heavy-job service** on a bigger (1024 MB) machine, isolated from the lean 512 MB sites. It runs anything too memory-hungry for a site: browser automation (walks upload, migration, HTML fetch, Flickr scrape) and non-browser heavy work (image resizing, document conversion), **one job at a time** through a shared queue.\n- It is a **many-to-one** arrangement: sixteen sites share **one** worker, cheaply, because they are one operator's estate.\n- The worker is **the same application** as the sites — same code, same Dockerfile, a second build target whose base image adds the heavy toolchain — deployed independently, so rebuilding the worker updates heavy-job behaviour for every site at once.\n- The **client never runs the work** — it requests and it observes.\n- Every operational message across the boundary is **HMAC-SHA256 signed and constant-time verified**; **credentials are AES-256-GCM encrypted** with a separate key; the two secrets are **shared within one administrative trust domain** and never on disk; callbacks are **session-bound**; and the whole thing rides on **TLS** with the cryptography as defence in depth. This is a conservative, standard, auditable way to build a distributed machine-to-machine architecture.\n","contentHtml":"<h1>15-Jul-2026 — Integration Worker: Distributed Architecture and Security</h1>\n<hr>\n<p>This article explains how the NGX Ramblers <strong>integration worker</strong> works: what it is, how the many websites talk to it, and — in rigorous, diagrammed detail — the security model that protects every message crossing the boundary between them. If you only read one part, read the <strong>Security model</strong> section further down: it is written and drawn so that anyone auditing this design can satisfy themselves it is a sound way to build a distributed, machine-to-machine architecture.</p>\n<h2>What the integration worker is — and why</h2>\n<p>The integration worker exists to keep the public websites small and fast. Each site runs on <strong>512 MB</strong> Fly machines, sized for ordinary visitor traffic — serving pages, the API and WebSockets. Some operations are far too memory-hungry to run there: they would push the V8 heap towards its cap, slow every other request, or trip an out-of-memory kill. So any such operation is <strong>offloaded to the integration worker</strong>, a separate Fly.io application (<code>ngx-ramblers-integration-worker</code>) provisioned with <strong>1024 MB</strong> — double the memory — and dedicated to heavy work.</p>\n<p>Browser automation is the most visible example, but it is only one class. The worker handles two kinds of heavy job:</p>\n<p><strong>Jobs that need a real browser</strong> (Chromium plus the Serenity-BDD Java tooling — expensive to load, and a large resident footprint):</p>\n<ul>\n<li><strong>Walks Manager upload</strong> — Ramblers has no bulk API, so walks are created, edited and published by driving its web forms with Playwright/Serenity.</li>\n<li><strong>Legacy site migration</strong> — scraping a legacy group website page by page to import its content.</li>\n<li><strong>HTML fetch</strong> — fetching and rendering a page&#39;s HTML for content import.</li>\n<li><strong>Flickr album scraping</strong> — reading a Flickr user&#39;s albums for gallery import.</li>\n</ul>\n<p><strong>Jobs that are simply memory- and CPU-heavy, with no browser at all:</strong></p>\n<ul>\n<li><strong>Image resizing</strong> — the <code>sharp</code> library decodes full-resolution images into memory buffers to produce the site&#39;s rendition sizes. A batch of large images would blow the 512 MB budget on its own.</li>\n<li><strong>Document conversion</strong> — turning uploaded committee documents (<code>.docx</code> via <code>mammoth</code>, PDFs via <code>pdf-parse</code> and styled extraction) into markdown. Whole documents are loaded and transformed in memory.</li>\n</ul>\n<p>The organising principle is <strong>memory, not browsers</strong>: the worker is where any job runs when running it on the 512 MB site would be unsafe.</p>\n<pre><code class=\"language-mermaid\">flowchart TB\n    subgraph site[&quot;A website — 512 MB&quot;]\n        VISITOR[&quot;Visitor traffic:&lt;br/&gt;pages, API, WebSockets&quot;]\n    end\n    subgraph worker[&quot;Integration worker — 1024 MB — one heavy job at a time&quot;]\n        subgraph browser[&quot;Needs a real browser&quot;]\n            WALKS[&quot;Walks Manager upload&quot;]\n            MIG[&quot;Legacy site migration&quot;]\n            HTML[&quot;HTML fetch&quot;]\n            FLICKR[&quot;Flickr album scrape&quot;]\n        end\n        subgraph heavy[&quot;Memory-heavy, no browser&quot;]\n            RESIZE[&quot;Image resizing (sharp)&quot;]\n            DOC[&quot;Document conversion&lt;br/&gt;(docx / pdf to markdown)&quot;]\n        end\n    end\n    site --&gt;|&quot;offload anything that&lt;br/&gt;would exceed 512 MB&quot;| worker\n    style site fill:#EAF2FB,stroke:#8FB5DE,stroke-width:2px,rx:12,ry:12,color:#404143\n    style worker fill:#FDF3E7,stroke:#E4B876,stroke-width:2px,rx:12,ry:12,color:#404143\n    style browser fill:#E8F5EE,stroke:#9BC8AB,rx:10,ry:10,color:#404143\n    style heavy fill:#FBF0E4,stroke:#E4B876,rx:10,ry:10,color:#404143\n</code></pre>\n<h2>Many websites, one worker</h2>\n<p>NGX Ramblers hosts many group websites — sixteen at the time of writing, each its own Fly application with its own database. They do <strong>not</strong> each run their own worker. Instead, <strong>all of them share a single integration worker</strong>. This is a many-to-one relationship: any site that needs heavy work submits a job to the one shared worker, which runs it and reports back to whichever site asked.</p>\n<pre><code class=\"language-mermaid\">flowchart TB\n    S1[&quot;EKWG&quot;] --&gt; WK\n    S2[&quot;Canterbury&quot;] --&gt; WK\n    S3[&quot;Kent&quot;] --&gt; WK\n    S4[&quot;Bolton&quot;] --&gt; WK\n    S5[&quot;… 16 sites in total&quot;] --&gt; WK\n    WK@{ icon: &quot;logos:fly-icon&quot;, label: &quot;One shared integration worker&quot;, pos: &quot;b&quot;, h: 48 }\n    style WK fill:#FDF3E7,stroke:#E4B876,stroke-width:2px,rx:12,ry:12,color:#404143\n</code></pre>\n<p>Sharing one worker keeps the estate cheap — one 1024 MB machine instead of sixteen — and it is possible precisely because all sixteen sites are operated by the same team. They form a <strong>single administrative trust domain</strong>, which is what makes a shared worker and shared worker secrets acceptable — the Security model section below explains why.</p>\n<p>It works so comfortably because <strong>heavy jobs are rare</strong>. A walks upload happens when a volunteer chooses to publish; an image resize or document conversion only when content is added. Across the whole estate these are occasional, human-initiated actions, not a steady stream. The shared machine is therefore <strong>idle the overwhelming majority of the time</strong> — well into the high 99%s — so a single worker has ample headroom to serve all sixteen sites, and the chance of two jobs genuinely colliding is small. When it does happen, the queue simply runs them in turn.</p>\n<h3>One heavy job at a time</h3>\n<p>Because one worker serves every site, and heavy jobs are memory-hungry, the worker runs <strong>exactly one heavy job at a time</strong>. Walks uploads, image resizes and document conversions — from any site — all pass through a <strong>single shared queue</strong>. A new job either becomes the active job or waits its turn, so two memory-hungry operations never run concurrently and stack their footprints. This is what keeps a 1024 MB worker safely within budget, and why a submission may occasionally come back as &quot;queued&quot; rather than starting immediately.</p>\n<p>Given how rarely heavy jobs occur, serialising them costs almost nothing: the queue is empty the vast majority of the time, so in practice a job starts at once, and the &quot;one at a time&quot; rule is a cheap safety guarantee rather than a throughput limit.</p>\n<pre><code class=\"language-mermaid\">flowchart LR\n    J1[&quot;Walks upload&lt;br/&gt;(any site)&quot;] --&gt; Q\n    J2[&quot;Image resize&lt;br/&gt;(any site)&quot;] --&gt; Q\n    J3[&quot;Document conversion&lt;br/&gt;(any site)&quot;] --&gt; Q\n    Q[&quot;Single heavy-job queue&quot;] --&gt; ACTIVE[&quot;One active job&quot;]\n    Q --&gt; WAIT[&quot;Others wait&lt;br/&gt;(returned as queued)&quot;]\n    style ACTIVE fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n    style WAIT fill:#F3F4F5,stroke:#B9BDC2,rx:10,ry:10,color:#404143\n</code></pre>\n<h2>The same application, two build targets</h2>\n<p>The worker is <strong>not a separate application</strong>. It runs the <strong>same NGX Ramblers server code</strong> as the websites, from the <strong>same repository and the same Dockerfile</strong>. That Dockerfile is a multi-stage build with two targets:</p>\n<ul>\n<li>The <strong>website target</strong> builds on <code>node:24</code> — a lean runtime with no browser and no heavy toolchain.</li>\n<li>The <strong>worker target</strong> builds on the <code>serenity-js/playwright</code> base, which adds a headless Chromium and the Java runtime for Serenity-BDD, then copies in the <strong>identical</strong> application code and installs the same dependencies (including <code>sharp</code> and the document-conversion libraries).</li>\n</ul>\n<p>So the two images differ only in their base layer and therefore in what runtime tooling is present — not in the application they run. Both are the NGX server; the worker&#39;s image simply carries the extra machinery its heavy jobs need.</p>\n<pre><code class=\"language-mermaid\">flowchart TB\n    SRC[&quot;One codebase, one Dockerfile&lt;br/&gt;(the NGX Ramblers application)&quot;]\n    SRC --&gt; T1[&quot;Website target&lt;br/&gt;base node:24 — lean, 512 MB&quot;]\n    SRC --&gt; T2[&quot;Worker target&lt;br/&gt;base serenity-js/playwright&lt;br/&gt;adds Chromium + JVM; same app code&quot;]\n    style T1 fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n    style T2 fill:#FDF3E7,stroke:#E4B876,stroke-width:2px,rx:12,ry:12,color:#404143\n</code></pre>\n<p>They are built and deployed by <strong>separate GitHub Actions workflows</strong>, so the two images ship independently. One consequence matters operationally: because a single worker serves every site, <strong>rebuilding and redeploying the worker updates the heavy-job behaviour for all sites at once</strong>. When automation code changes, deploying the websites does not touch the worker; the worker only runs the new code once its own target is rebuilt and redeployed. Until then, every site&#39;s heavy jobs run the previous worker version.</p>\n<h2>How a job flows</h2>\n<p>The walks upload is the richest example. Here is the complete round trip, from the button press to the audit rows the user watches scroll past. Note that the browser automation runs <strong>on the worker</strong>, never in the visitor&#39;s browser — the client only submits the request and displays progress.</p>\n<pre><code class=\"language-mermaid\">sequenceDiagram\n    participant C as Site (Angular SPA)\n    participant A as Site server\n    participant W as Shared integration worker\n    participant R as Ramblers Walks Manager\n\n    C-&gt;&gt;A: WebSocket: upload request\n    A-&gt;&gt;W: POST /jobs (signed body, encrypted credentials)\n    Note over W: Verify HMAC signature. Reject 401 if invalid.\n    W--&gt;&gt;A: 200 queued\n    A--&gt;&gt;C: WebSocket: &quot;Upload queued&quot;\n    Note over W: Decrypt credentials. Download images to /tmp.&lt;br/&gt;Wait for the shared queue, then run.\n    W-&gt;&gt;W: spawn Serenity subprocess (headless Chromium)\n    W-&gt;&gt;R: Log in, edit / upload / publish walks\n    loop each Serenity step\n        W-&gt;&gt;A: POST /progress (signed)\n        A-&gt;&gt;A: Match jobId to active session\n        A--&gt;&gt;C: WebSocket: audit row\n    end\n    W-&gt;&gt;A: POST /result (signed)\n    A--&gt;&gt;C: WebSocket: final status\n</code></pre>\n<p>Step by step:</p>\n<ol>\n<li><strong>Site client → site server (WebSocket).</strong> The SPA builds the request and sends it over a WebSocket. That is the client&#39;s entire outbound role: it downloads no images and launches no browser.</li>\n<li><strong>Site server → worker (signed HTTP).</strong> The server builds a job and POSTs it to the worker&#39;s <code>/api/integration-worker/jobs</code>.</li>\n<li><strong>The worker runs it.</strong> Source images are downloaded into a per-job temporary directory (<code>/tmp/ramblers/&lt;jobId&gt;/</code>), the job waits for the shared heavy-job queue, and then a Serenity subprocess drives Walks Manager in a headless browser.</li>\n<li><strong>Worker → site server (signed callbacks).</strong> The worker holds no WebSocket to the user — it is a different machine, and may be serving a different site&#39;s job moments later. As each step completes it POSTs progress to the site that submitted the job, at the callback URL carried in the job, and the final outcome to <code>/result</code>.</li>\n<li><strong>Site server → client (WebSocket).</strong> The server matches each callback to the WebSocket session that started the job and forwards it as an audit row. That is what populates the live &quot;Walk upload audit&quot; tab.</li>\n</ol>\n<p>So the shape is: <strong>client → (WebSocket) → site server → (signed HTTP) → worker runs the job → (signed HTTP callbacks) → site server → (WebSocket) → client.</strong></p>\n<h2>Security model</h2>\n<p>This is the section to scrutinise. The trust boundary is the network hop between a site and the shared worker — independently deployed services on the public internet. Every operational message that crosses it is protected on two axes: <strong>authenticity and integrity</strong> (is this message genuinely from a party that holds the secret, and has it been altered?) and, for credentials, <strong>confidentiality</strong> (can a third party read it?).</p>\n<pre><code class=\"language-mermaid\">flowchart LR\n    subgraph website[&quot;Site server&quot;]\n        direction TB\n        W1[&quot;Job payload&lt;br/&gt;(not secret)&quot;]\n        W2[&quot;Credentials&lt;br/&gt;(secret)&quot;]\n    end\n    subgraph boundary[&quot;Public internet — over TLS&quot;]\n        SIG[&quot;Every message:&lt;br/&gt;HMAC-SHA256 signature&quot;]\n        ENC[&quot;Credentials only:&lt;br/&gt;AES-256-GCM ciphertext&quot;]\n    end\n    subgraph worker[&quot;Shared worker&quot;]\n        direction TB\n        WK1[&quot;Verify signature&lt;br/&gt;(constant time)&quot;]\n        WK2[&quot;Decrypt credentials&quot;]\n    end\n    W1 --&gt; SIG\n    W2 --&gt; ENC\n    W2 --&gt; SIG\n    SIG --&gt; WK1\n    ENC --&gt; WK2\n    style boundary fill:#FDECEC,stroke:#E0A2A2,stroke-width:2px,rx:12,ry:12,color:#404143\n    style website fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n    style worker fill:#FDF3E7,stroke:#E4B876,stroke-width:2px,rx:12,ry:12,color:#404143\n</code></pre>\n<h3>Trust boundary and authenticated surface</h3>\n<p>The worker&#39;s job and callback endpoints are reachable over the internet, so the security of the whole system rests on the rule that <strong>every operational request must carry a valid signature or it is rejected</strong>.</p>\n<pre><code class=\"language-mermaid\">flowchart TB\n    NET[&quot;Public internet&quot;]\n    subgraph worker[&quot;Integration worker endpoints&quot;]\n        JOBS[&quot;POST /jobs — signed&quot;]\n        PROG[&quot;POST /progress — signed&quot;]\n        RES[&quot;POST /result — signed&quot;]\n        HTML[&quot;POST /browser/html-fetch — signed&quot;]\n        HEALTH[&quot;GET /api/health — open, no input, no secrets&quot;]\n        ROOT[&quot;GET / — open, static descriptor&quot;]\n    end\n    NET --&gt; JOBS\n    NET --&gt; PROG\n    NET --&gt; RES\n    NET --&gt; HTML\n    NET --&gt; HEALTH\n    NET --&gt; ROOT\n    style JOBS fill:#EAF2FB,stroke:#8FB5DE,color:#404143\n    style PROG fill:#EAF2FB,stroke:#8FB5DE,color:#404143\n    style RES fill:#EAF2FB,stroke:#8FB5DE,color:#404143\n    style HTML fill:#EAF2FB,stroke:#8FB5DE,color:#404143\n    style HEALTH fill:#F3F4F5,stroke:#B9BDC2,color:#404143\n    style ROOT fill:#F3F4F5,stroke:#B9BDC2,color:#404143\n</code></pre>\n<p>Concretely, <code>POST /jobs</code> is verified against the shared secret and unsigned or mis-signed requests receive <code>401</code> and are never queued; the <code>POST /progress</code> and <code>POST /result</code> callbacks are verified against the callback secret; and the synchronous browser endpoints use the same signed request/response scheme. The <strong>only</strong> unauthenticated endpoints are <code>GET /api/health</code> and <code>GET /</code> — neither accepts input nor returns anything sensitive. There is no unauthenticated endpoint that performs work, accepts data, or discloses secrets.</p>\n<h3>Message authenticity and integrity — HMAC-SHA256, constant-time</h3>\n<p>Every signed request carries an <code>x-ramblers-upload-signature</code> header: an <strong>HMAC-SHA256</strong> of the exact JSON request body, keyed on the shared secret. The receiver recomputes the HMAC over the body it received and compares.</p>\n<pre><code class=\"language-mermaid\">sequenceDiagram\n    participant S as Sender (holds secret)\n    participant R as Receiver (holds secret)\n    Note over S: signature = HMAC_SHA256(secret, body)\n    S-&gt;&gt;R: POST body + header x-ramblers-upload-signature\n    Note over R: expected = HMAC_SHA256(secret, receivedBody)\n    alt length differs OR timingSafeEqual is false\n        R--&gt;&gt;S: 401 Unauthorized\n    else signatures equal (constant-time compare)\n        R--&gt;&gt;S: 200 — request accepted\n    end\n</code></pre>\n<p>Two properties make this robust:</p>\n<ul>\n<li><strong>Body binding.</strong> The signature is over the whole body, so altering a single byte — a walk id, a deletion target, a callback status — invalidates it. An attacker cannot tamper with a message in flight without the secret.</li>\n<li><strong>Constant-time comparison.</strong> Verification uses Node&#39;s <code>crypto.timingSafeEqual</code>, preceded by a length check, so the comparison takes the same time whether the first byte or the last byte differs. This closes the timing side-channel that a naive string equality (<code>===</code>) would open, where an attacker could otherwise recover a valid signature byte by byte by measuring response times.</li>\n</ul>\n<p>Only a party in possession of the secret can produce a signature that verifies. That is what authenticates a site to the worker, and the worker&#39;s callbacks back to the site.</p>\n<h3>Credential confidentiality — AES-256-GCM</h3>\n<p>The signature proves who sent a message and that it is intact, but it does not hide the contents. The job body itself is not secret — it is walk data — and travels over TLS. The <strong>Ramblers login credentials</strong> and the <strong>AWS report-upload credentials</strong>, however, must never be readable in transit, so they are <strong>encrypted</strong> before they are placed in the request, independently of the transport.</p>\n<pre><code class=\"language-mermaid\">flowchart LR\n    CRED[&quot;Credentials&lt;br/&gt;(username, password)&quot;] --&gt; JSON[&quot;JSON.stringify&quot;]\n    EK[&quot;encryptionKey&quot;] --&gt; DK[&quot;key = SHA-256(encryptionKey)&lt;br/&gt;256-bit&quot;]\n    RB[&quot;crypto.randomBytes(12)&quot;] --&gt; IV[&quot;fresh iv per message&quot;]\n    JSON --&gt; GCM[&quot;AES-256-GCM encrypt&quot;]\n    DK --&gt; GCM\n    IV --&gt; GCM\n    GCM --&gt; TAG[&quot;authTag&lt;br/&gt;(16 bytes)&quot;]\n    GCM --&gt; CT[&quot;ciphertext&quot;]\n    IV --&gt; ENV\n    TAG --&gt; ENV\n    CT --&gt; ENV[&quot;base64( iv ‖ authTag ‖ ciphertext )&quot;]\n    style ENV fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n    style GCM fill:#FDF3E7,stroke:#E4B876,stroke-width:2px,rx:12,ry:12,color:#404143\n</code></pre>\n<ul>\n<li><strong>Cipher: AES-256-GCM.</strong> A 256-bit key, authenticated encryption. GCM protects both confidentiality <em>and</em> integrity of the ciphertext: any tampering with the encrypted credentials causes decryption to fail rather than silently yielding altered values.</li>\n<li><strong>Key derivation.</strong> The 256-bit key is derived as <code>SHA-256(encryptionKey)</code>, so a secret of any length produces a correctly sized key.</li>\n<li><strong>Fresh IV per message.</strong> A new random 12-byte initialisation vector is generated for every encryption, so encrypting the same credentials twice produces different ciphertext and no patterns leak.</li>\n<li><strong>On-the-wire form.</strong> The output is <code>base64(iv ‖ authTag ‖ ciphertext)</code>, carrying everything the receiver needs to authenticate and decrypt, and nothing more.</li>\n</ul>\n<p>The worker decrypts the credentials only in memory, only to hand them to the Serenity login step, and the per-job files are cleaned up afterwards.</p>\n<h3>Separation of secrets</h3>\n<p>Signing and encryption use <strong>two distinct secrets</strong>, never the same value. Separating them means a compromise or rotation of one concern does not implicate the other, and neither secret is overloaded across two cryptographic purposes.</p>\n<pre><code class=\"language-mermaid\">flowchart TB\n    SS[&quot;INTEGRATION_WORKER_SHARED_SECRET&quot;] --&gt; HMAC[&quot;HMAC-SHA256 signing&lt;br/&gt;authenticity + integrity&lt;br/&gt;(every message)&quot;]\n    EK[&quot;INTEGRATION_WORKER_ENCRYPTION_KEY&quot;] --&gt; AES[&quot;AES-256-GCM encryption&lt;br/&gt;confidentiality&lt;br/&gt;(credentials only)&quot;]\n    style HMAC fill:#EAF2FB,stroke:#8FB5DE,stroke-width:2px,rx:12,ry:12,color:#404143\n    style AES fill:#FDF3E7,stroke:#E4B876,stroke-width:2px,rx:12,ry:12,color:#404143\n</code></pre>\n<h3>Session-bound callbacks</h3>\n<p>A signed callback is not blindly trusted just because it verifies. Each callback carries the <code>jobId</code>, and the site matches it to an <strong>active session</strong>. If no active session exists for that id — because the job never started, or already finished — the callback is rejected. This scopes callbacks to live work and means a replayed callback for a completed job has nowhere to land.</p>\n<pre><code class=\"language-mermaid\">flowchart TB\n    CB[&quot;Callback: POST /progress&quot;] --&gt; V{&quot;Signature valid?&quot;}\n    V --&gt;|&quot;No&quot;| R401[&quot;401 Unauthorized&quot;]\n    V --&gt;|&quot;Yes&quot;| SESS{&quot;jobId matches an&lt;br/&gt;active session?&quot;}\n    SESS --&gt;|&quot;No&quot;| R404[&quot;404 — no session&quot;]\n    SESS --&gt;|&quot;Yes&quot;| FWD[&quot;Forward as audit row&lt;br/&gt;over client WebSocket&quot;]\n    style FWD fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n    style R401 fill:#FDECEC,stroke:#E0A2A2,color:#404143\n    style R404 fill:#FDECEC,stroke:#E0A2A2,color:#404143\n</code></pre>\n<h3>Shared secrets within one trust domain</h3>\n<p>Because a single worker serves every site, the two secrets are <strong>shared by the worker and all the sites that use it</strong> — they have to be, since the worker verifies and decrypts with one key. They are not per-site. They live in the central worker configuration and are injected as <strong>Fly secrets</strong> at deploy time; they are not committed to the repository, not written to <code>.env</code> files in the image, and not exported into a shared store.</p>\n<p>This is safe because <strong>all the sites are within a single administrative trust domain</strong> — one team operates every site and the worker. A shared secret is only a weakness when the parties sharing it do not already trust one another. Here they are all facets of the same operator, so a site holding the secret can forge nothing it could not already do directly.</p>\n<pre><code class=\"language-mermaid\">flowchart LR\n    CFG@{ icon: &quot;ngx:mongodb&quot;, label: &quot;Central worker config&quot;, pos: &quot;b&quot;, h: 48 }\n    DEPLOY@{ icon: &quot;logos:github-actions&quot;, label: &quot;Deploy&quot;, pos: &quot;b&quot;, h: 48 }\n    subgraph domain[&quot;Single trust domain (one operator)&quot;]\n        SITES[&quot;All sites&lt;br/&gt;(shared secret + key)&quot;]\n        WK[&quot;Shared worker&lt;br/&gt;(same secret + key)&quot;]\n    end\n    CFG --&gt; DEPLOY\n    DEPLOY --&gt;|&quot;inject as Fly secrets&quot;| SITES\n    DEPLOY --&gt;|&quot;inject as Fly secrets&quot;| WK\n    style domain fill:#E8F5EE,stroke:#9BC8AB,stroke-width:2px,rx:12,ry:12,color:#404143\n</code></pre>\n<p>The one caveat, stated plainly: if the worker were ever opened to <strong>mutually-distrusting</strong> tenants, this shared-secret model would no longer be appropriate and per-tenant secrets would be required. Today, within one operator, it is a standard and sound arrangement.</p>\n<h3>Transport, and an honest threat model</h3>\n<p>All traffic is HTTPS (terminated by Fly, fronted by Cloudflare), so TLS already provides confidentiality and integrity on the wire. The application-level signing and encryption are deliberate <strong>defence in depth</strong> on top of TLS, not a substitute for it:</p>\n<ul>\n<li>The HMAC signature guarantees end-to-end authenticity and integrity between a site and the worker <strong>at the application layer</strong>, independent of where TLS is terminated. Even a misconfigured proxy cannot forge or alter a message without the shared secret.</li>\n<li>The AES-GCM credential encryption means the most sensitive fields are unreadable <strong>even to anything that can see the decrypted TLS stream</strong>, narrowing where cleartext credentials ever exist to the two process memories that legitimately need them.</li>\n</ul>\n<p>To be precise about what is and is not claimed: the signature prevents <strong>forgery and tampering</strong>, not <strong>replay</strong>, in isolation — there is no per-message nonce or timestamp. Replay is mitigated by two other properties rather than by the signature: an attacker cannot capture a valid signed request in the first place without breaking TLS, and a replayed callback is rejected once its job&#39;s session is no longer active. If a future change ever moved either endpoint outside TLS, per-message anti-replay would need to be added; today, under TLS, the design does not depend on the recipient having seen a message before.</p>\n<h2>Summary</h2>\n<ul>\n<li>The integration worker is a <strong>shared heavy-job service</strong> on a bigger (1024 MB) machine, isolated from the lean 512 MB sites. It runs anything too memory-hungry for a site: browser automation (walks upload, migration, HTML fetch, Flickr scrape) and non-browser heavy work (image resizing, document conversion), <strong>one job at a time</strong> through a shared queue.</li>\n<li>It is a <strong>many-to-one</strong> arrangement: sixteen sites share <strong>one</strong> worker, cheaply, because they are one operator&#39;s estate.</li>\n<li>The worker is <strong>the same application</strong> as the sites — same code, same Dockerfile, a second build target whose base image adds the heavy toolchain — deployed independently, so rebuilding the worker updates heavy-job behaviour for every site at once.</li>\n<li>The <strong>client never runs the work</strong> — it requests and it observes.</li>\n<li>Every operational message across the boundary is <strong>HMAC-SHA256 signed and constant-time verified</strong>; <strong>credentials are AES-256-GCM encrypted</strong> with a separate key; the two secrets are <strong>shared within one administrative trust domain</strong> and never on disk; callbacks are <strong>session-bound</strong>; and the whole thing rides on <strong>TLS</strong> with the cryptography as defence in depth. This is a conservative, standard, auditable way to build a distributed machine-to-machine architecture.</li>\n</ul>\n"}