Fouria Dispatcher
Wildcard DNS dispatch worker that routes *.fouria.io requests to the correct tenant worker.
Wildcard dispatch Worker for FourIA’s multi-tenant OpenClaw gateway platform. Routes all *.fouria.io traffic to the correct tenant worker, enforces Cloudflare Access auth per-tenant via JWT audience matching, and applies plan-level CPU/subrequest limits.
Goal
The dispatch Worker is the entry point for all tenant traffic. On every request it:
- Extracts the tenant slug from the hostname (
tenant-a.fouria.io→tenant-a) - Resolves the tenant — checks KV cache (TTL: 60s), falls back to a Supabase query
- Enforces auth — verifies the Cloudflare Access JWT signature. In production, non-ROOT callers must present a JWT
audthat resolves (via the Cloudflare API) to the requested hostname’s slug; Paso4 ROOT operators are exempt (platform wildcard AUD*.fouria.ioor membership of thePaso4 Root UsersAccess group). Staging is ROOT-only (platform AUD or ROOT group membership). - Builds billing metadata — queries
clients,credit_pools, andclient_userstables, encodes as base64 JSON inX-FourIA-Metadata - Dispatches — forwards the request via
DISPATCHER.get(workerName)with plan-level limits and billing context
Architecture
Request → *.fouria.io
│
├─► extractSlug() — parse tenant from hostname
│ OR apex (fouria.io) — resolve slug from Access JWT aud via Cloudflare API
│
├─► resolveTenant()
│ ├─► KV cache (slug:<tenant>) — 60s TTL
│ └─► Supabase query (clients + credit_pools + client_users)
│
├─► requireAccessAuth() — Cloudflare Access JWT (jose)
├─► resolveSlugByAud() — Cloudflare SDK (production hostname + apex)
│
├─► buildBillingMetadata() → X-FourIA-Metadata base64 header
│
└─► DISPATCHER.get(workerName).fetch() — plan limits + billing contextData flow
The dispatch Worker resolves tenants by querying Supabase (shared Postgres backing lerma) using SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY. No HTTP dependency on the dashboard (lerma). The Supabase queries span three tables:
| Table | Columns used |
|---|---|
clients | id, slug, billing_plan, status |
credit_pools | lifetime_consumed, lifetime_purchased |
client_users | count by client_id (seats used) |
Apex tag routing
Requests to fouria.io (host === PLATFORM_DOMAIN) are resolved differently:
- Verify the Cloudflare Access JWT signature (
jose) - Extract the
audclaim from the JWT payload - Call the Cloudflare API (
cloudflareSDK) to list Access apps filtered byaud - Find the app whose
audmatches; read its domain ({slug}.fouria.io) to derive the slug - Dispatch to
tenant-{slug}in the production dispatch namespace
Staging tenants are NOT reachable via apex — they only use the hostname path (staging-{slug}.fouria.io).
Auth via Cloudflare SDK
JWT signature/JWKS verification uses jose (as before). Tenant-by-aud resolution uses the official cloudflare npm package (github.com/cloudflare/cloudflare-typescript). Per-tenant AUD is NOT persisted in the database anymore — the dispatch worker resolves it dynamically from the Cloudflare Access app configuration.
Staging Prefix
Staging tenants use the staging-{slug}.fouria.io pattern to avoid collisions with production tenants. The dispatch Worker handles both production ({slug}.fouria.io) and staging (staging-{slug}.fouria.io) subdomains on the same PLATFORM_DOMAIN. When extractSlug() encounters a staging- prefix, it strips the prefix and routes to the fouria-tenants-staging dispatch namespace with the un-prefixed slug.
| Subdomain pattern | Namespace | Example |
|---|---|---|
{slug}.fouria.io | fouria-tenants | acme.fouria.io → tenant-acme |
staging-{slug}.fouria.io | fouria-tenants-staging | staging-acme.fouria.io → tenant-acme |
Dashboard traffic (
dash.fouria.io,dev-dash.fouria.io) is served via bypass worker routes on the fouria.io zone — the Dispatch Worker does not intercept these hostnames.
Client environments and the routing gate
Routing has two independent axes (see Lerma.Instances.Naming):
| Axis | Encoded as | Example |
|---|---|---|
| Platform | staging- hostname prefix | staging-acme.fouria.io → tenant-acme |
| Client environment | -dev hostname / worker-name suffix | acme-dev.fouria.io → tenant-acme-dev |
resolveTenant() parses both axes: the -dev suffix selects the client’s dev
environment, the staging- prefix selects the staging platform, and the base
slug is looked up in clients with the instances.environment filter applied.
The operational gate before dispatch depends on the environment:
- production —
clients.statusgates routing (active/productiononly); the instance must not befrozen/stopped. - dev — the instance is the source of truth. A
devinstance is provisioned lazily while the client is stilldraft(a dev provision never drives the client status), soclients.status = draftmust NOT block it. Adevrequest routes only when the instance exists and isrunning/upgrading, and is rejected when it isfrozen/stopped(503),provisioning(503), or missing/error(404).
Explicit operator revocations (clients.status = suspended/archived) block
both environments; a production error only blocks production, never a healthy
dev instance.
Environment Variables
Wrangler vars (plaintext)
| Variable | Staging | Production | Description |
|---|---|---|---|
PLATFORM_DOMAIN | fouria.io | fouria.io | Domain suffix for tenant slug extraction |
| (staging prefix) | staging-{slug} | (not used in production) | Staging tenants use staging-{slug}.fouria.io via dispatch |
CF_ACCESS_TEAM_DOMAIN | paso4.cloudflareaccess.com | paso4.cloudflareaccess.com | Cloudflare Access team domain for JWT auth |
SUPABASE_URL | (set per env) | (set per env) | Supabase project URL |
CF_ACCOUNT_ID | 35afea16440634aa2350331d2a736eec | 35afea16440634aa2350331d2a736eec | Cloudflare account ID (used by Cloudflare SDK) |
Wrangler secrets
| Secret | Description |
|---|---|
SUPABASE_SERVICE_ROLE_KEY | Supabase service role key (bypasses RLS) |
CF_ACCESS_PLATFORM_AUD | Cloudflare Access audience for Paso4 platform / staging auth (ROOT is also granted by Paso4 Root Users group membership) |
CF_ACCESS_API_TOKEN | Cloudflare API token (Access:Read) |
Bound resources
| Binding | Resource | Description |
|---|---|---|
TENANT_INDEX | KV namespace 081f07b1e0e54fb2abd1253626b05880 (shared) | Tenant cache (slug → TenantEntry) |
DISPATCHER | Dispatch namespace fouria-tenants (prod) / fouria-tenants-staging (staging) | Tenant worker dispatch |
Provisioning Environment Variables
All env vars are defined in wrangler.jsonc under vars (top-level for local) and env.staging.vars / env.production.vars. To add or update a variable:
1. Edit wrangler.jsonc
// workers/fouria-dispatch/wrangler.jsonc
"vars": {
"YOUR_VAR": "value",
},
"env": {
"staging": {
"vars": {
"YOUR_VAR": "staging-value",
},
},
"production": {
"vars": {
"YOUR_VAR": "production-value",
},
},
}
Important: vars are not inherited between environments. If a var is needed in production, it must be defined in
env.production.varsexplicitly.
2. Add the TypeScript type
Update DispatchEnv interface in src/index.ts:
export interface DispatchEnv {
// ...
YOUR_VAR: string;
}3. If the var is a secret (not plaintext)
Use wrangler secret put instead of wrangler.jsonc:
npx wrangler secret put YOUR_SECRET --env staging
npx wrangler secret put YOUR_SECRET --env production
Secrets are available as env.YOUR_SECRET at runtime, same as vars. They do not need to be declared in wrangler.jsonc but do need the TypeScript type in DispatchEnv.
4. Deploy
# Staging
bun run deploy:staging
# Production
bun run deployCloudflare API Token Permissions
This project requires a Cloudflare API token (CF_ACCESS_API_TOKEN) with these minimum permissions:
| Permission | Reason |
|---|---|
Access:Apps & Policies:Read | Resolve tenant slugs from Access JWT audience claims |
Access:Groups:Read | Resolve Paso4 Root Users group membership for ROOT operator detection |
The token is deployed as a worker secret and used at runtime for every request that goes through apex routing. All credentials are documented in docs/schemas/secrets-manifest.schema.json.
Supabase
The shared Postgres database (backing lerma). Key tables and columns used by dispatch:
clients
| Column | Type | Purpose |
|---|---|---|
id | uuid | Primary key (used to join credit_pools/client_users) |
slug | TEXT | Unique tenant identifier (from hostname) |
billing_plan | TEXT | Plan name (free, basic, pro, enterprise) |
status | TEXT | Client status. Production routes only for active/production; a dev instance routes on its own instance status even when the client is draft |
credit_pools
| Column | Type | Purpose |
|---|---|---|
client_id | uuid | FK to clients.id |
lifetime_consumed | int8 | Credits consumed by this tenant |
lifetime_purchased | int8 | Credits purchased for this tenant |
client_users
| Column | Type | Purpose |
|---|---|---|
client_id | uuid | FK to clients.id (counted for seats) |
Local Development
bun install
bun run test # Vitest with @cloudflare/vitest-pool-workers
bun run typecheck
Tests use miniflare with ephemeral in-memory KV and mocked Supabase.