Skip to content

Container Dispatch

Status: Authoritative — describes how the Worker dispatches merge queue reconciliation to the Go Container via an authenticated internal path.

Cross-references:

cron / webhook trigger
index.mjs (Worker)
├── fetch encrypted DEK + token from Supabase
├── call LeaseDO /internal/reconcile (POST, auth header)
LeaseDO (Durable Object)
├── validate X-Container-Auth header (CONTAINER_AUTH_SECRET)
├── acquire lease (atomic CAS with TTL)
├── decrypt token via encrypt.mjs (envelope decryption)
├── dispatch to ShuntContainer /dispatch (POST)
│ │
│ ▼
│ ShuntContainer (DO)
│ ├── get container via this.env.shunt_container
│ ├── getTcpPort(8787) → Fetcher
│ ├── POST http://localhost:8787/internal/reconcile
│ │ │
│ │ ▼
│ │ Go Container (WASI)
│ │ ├── validate X-Container-Auth (CONTAINER_AUTH_SECRET)
│ │ ├── validate & parse ReconcileRequest
│ │ ├── run engine.Reconcile()
│ │ └── return {"status":"ok"} or {"status":"error","errors":[]}
│ │
│ └── parse response, return {ok, status, errors}
├── release lease (finally)
└── return safe summary fields (status, errors — no credentials)

Service-to-service auth uses a shared secret from Cloudflare Secrets.

Component How auth is provided
Worker (index.mjs) env.CONTAINER_AUTH_SECRETX-Container-Auth header
LeaseDO this.env.CONTAINER_AUTH_SECRET → compares to request header
Go Container os.Getenv("CONTAINER_AUTH_SECRET") → compares to header

The secret is the same value injected as a Cloudflare Secret at deploy time. It is never logged, persisted, or returned in any response.

A short-lived per-invocation capability would require key distribution (each DO must share a signing key with the container). The shared secret is simpler and equally secure in the Cloudflare Workers runtime: the DO and container are isolated from external access, and the secret never appears in logs or API responses.

Tokens are decrypted in-memory via encrypt.mjs for exactly one tick:

Master key (Cloudflare Secret, 32 bytes, base64)
↓ AES-256-GCM
Per-tenant DEK (base64 in Supabase)
↓ AES-256-GCM
Forge token (plaintext, in memory only)

The decryptEnvelope function:

  1. Base64-decodes master key, encrypted DEK, DEK nonce, encrypted token, token nonce
  2. Validates master key length (32 bytes for AES-256, 24 for AES-192)
  3. Decrypts DEK with master key
  4. Decrypts token with DEK
  5. Returns plaintext token

The plaintext token exists only in the calling function’s scope. It is:

  • Never logged
  • Never persisted
  • Never returned in API responses
  • Only used for the single container dispatch request body

On engine or network failure, the token is garbage-collected with the function scope.

Exactly-once semantics are provided by the existing LeaseDO lease mechanism:

  1. Acquire — atomic CAS: succeeds if no lease is held or lease is expired
  2. Dispatch — execute the container call
  3. Release — clear the lease (runs in finally block)

If acquire fails (lease held by another tick), the response is 409 Conflict with the holder’s identity. The cron job will retry on the next tick.

Request contract (Worker → Go Container)

Section titled “Request contract (Worker → Go Container)”

The Go Container POST /internal/reconcile endpoint expects:

{
"tenant_id": "string", // required
"repo_slug": "owner/name", // required
"base_branch": "string", // required
"queue_name": "string", // required (always "merge-queue")
"instance_url": "https://…", // required, https-only
"token": "string" // required, ephemeral decrypted token
}

Response:

{
"status": "ok",
"errors": []
}

Or on error:

{
"status": "error",
"errors": ["reconciliation failed"]
}
  • Body size capped at 1 KiB
  • DisallowUnknownFields rejects unknown JSON keys
  • Engine errors are genericised — no raw messages in response
  • Token never appears in logs, errors, or responses

The following fields were removed from the dispatch request body because they are not consumed by the Go Container’s ReconcileRequest struct or engine config:

  • owner — derived from repo_slug in the Go container (splitRepoSlug)
  • statusCtx — hardcoded to "merge-queue" in the Go container’s engine config
  • mergeStyle — not used by the Reconcile path (the Go container runs a stateless reconcile, not a merge decision)
  • maxBatch — the Go container engine uses its own defaults
  • repoId — not a field on ReconcileRequest; tenant+repo are identified by tenant_id + repo_slug

These fields remain in the legacy POST /reconcile path but are not sent to the Go Container via POST /internal/reconcile.

Failure mode Action
Container unreachable (network error) Return {ok:false, errors:["container unreachable"]}
Container returns non-200 Parse response, return safe {ok, status, errors}
Container returns non-JSON Return {ok:false, errors:["container unreachable"]}
Token decryption fails Return {error: "decryption failed"} (500)
Auth header missing or wrong Return 401 {error: "unauthorized"}
Lease already held Return 409 {error: "locked", held_by: "..."}
Engine error in Go container Go container returns {"status":"error","errors":["reconciliation failed"]}

In all failure cases, the lease is released (via finally) and no partial state is claimed. The cron job will retry on the next tick.

File Change
shunt-container.mjs Added dispatch() method — invokes container via getTcpPort(8787)
encrypt.mjs Envelope decryption helper (decryptToken) — from PR #25
encrypt.test.mjs Tests for encrypt.mjs — from PR #25
lease-do.mjs Added POST /internal/reconcile endpoint — auth, lease, decrypt, dispatch
index.mjs Cron updated — calls /internal/reconcile with auth header
cmd/gondolier/container/main.go Added X-Container-Auth header validation
shunt-container.test.mjs Updated tests for new dispatch method
dispatch.test.mjs Created — tests for dispatch auth, lease contention, fail-closed, token leakage
docs/architecture/container-dispatch.md Created — this document
  1. Add Cloudflare Secrets (one-time):
Terminal window
wrangler secret put CONTAINER_AUTH_SECRET # 32+ random bytes, base64

(Envelope encryption master key CREDENTIAL_MASTER_KEY was configured in PR #25.)

  1. Go container — The container reads CONTAINER_AUTH_SECRET from its environment at runtime (injected by Cloudflare Containers from the same Cloudflare Secrets namespace).

  2. Deploy:

Terminal window
wrangler deploy

This change is fully backward-compatible:

  • The existing POST /reconcile endpoint is unchanged
  • The new POST /internal/reconcile is an additional endpoint
  • The cron job switches to /internal/reconcile but falls back gracefully on 4xx/5xx
  • The Go container auth validation is opt-in (skipped when CONTAINER_AUTH_SECRET is empty)
  • Reverting to the previous commit restores the old cron behavior