F FourIA GitHub ↗

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:

  1. Extracts the tenant slug from the hostname (tenant-a.fouria.iotenant-a)
  2. Resolves the tenant — checks KV cache (TTL: 60s), falls back to a Supabase query
  3. Enforces auth — verifies the Cloudflare Access JWT signature. In production, non-ROOT callers must present a JWT aud that resolves (via the Cloudflare API) to the requested hostname’s slug; Paso4 ROOT operators are exempt (platform wildcard AUD *.fouria.io or membership of the Paso4 Root Users Access group). Staging is ROOT-only (platform AUD or ROOT group membership).
  4. Builds billing metadata — queries clients, credit_pools, and client_users tables, encodes as base64 JSON in X-FourIA-Metadata
  5. 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 context

Data 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:

TableColumns used
clientsid, slug, billing_plan, status
credit_poolslifetime_consumed, lifetime_purchased
client_userscount by client_id (seats used)

Apex tag routing

Requests to fouria.io (host === PLATFORM_DOMAIN) are resolved differently:

  1. Verify the Cloudflare Access JWT signature (jose)
  2. Extract the aud claim from the JWT payload
  3. Call the Cloudflare API (cloudflare SDK) to list Access apps filtered by aud
  4. Find the app whose aud matches; read its domain ({slug}.fouria.io) to derive the slug
  5. 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 patternNamespaceExample
{slug}.fouria.iofouria-tenantsacme.fouria.io → tenant-acme
staging-{slug}.fouria.iofouria-tenants-stagingstaging-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):

AxisEncoded asExample
Platformstaging- hostname prefixstaging-acme.fouria.iotenant-acme
Client environment-dev hostname / worker-name suffixacme-dev.fouria.iotenant-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:

  • productionclients.status gates routing (active/production only); the instance must not be frozen/stopped.
  • dev — the instance is the source of truth. A dev instance is provisioned lazily while the client is still draft (a dev provision never drives the client status), so clients.status = draft must NOT block it. A dev request routes only when the instance exists and is running/upgrading, and is rejected when it is frozen/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)

VariableStagingProductionDescription
PLATFORM_DOMAINfouria.iofouria.ioDomain 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_DOMAINpaso4.cloudflareaccess.compaso4.cloudflareaccess.comCloudflare Access team domain for JWT auth
SUPABASE_URL(set per env)(set per env)Supabase project URL
CF_ACCOUNT_ID35afea16440634aa2350331d2a736eec35afea16440634aa2350331d2a736eecCloudflare account ID (used by Cloudflare SDK)

Wrangler secrets

SecretDescription
SUPABASE_SERVICE_ROLE_KEYSupabase service role key (bypasses RLS)
CF_ACCESS_PLATFORM_AUDCloudflare Access audience for Paso4 platform / staging auth (ROOT is also granted by Paso4 Root Users group membership)
CF_ACCESS_API_TOKENCloudflare API token (Access:Read)

Bound resources

BindingResourceDescription
TENANT_INDEXKV namespace 081f07b1e0e54fb2abd1253626b05880 (shared)Tenant cache (slug → TenantEntry)
DISPATCHERDispatch 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.vars explicitly.

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 deploy

Cloudflare API Token Permissions

This project requires a Cloudflare API token (CF_ACCESS_API_TOKEN) with these minimum permissions:

PermissionReason
Access:Apps & Policies:ReadResolve tenant slugs from Access JWT audience claims
Access:Groups:ReadResolve 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

ColumnTypePurpose
iduuidPrimary key (used to join credit_pools/client_users)
slugTEXTUnique tenant identifier (from hostname)
billing_planTEXTPlan name (free, basic, pro, enterprise)
statusTEXTClient status. Production routes only for active/production; a dev instance routes on its own instance status even when the client is draft

credit_pools

ColumnTypePurpose
client_iduuidFK to clients.id
lifetime_consumedint8Credits consumed by this tenant
lifetime_purchasedint8Credits purchased for this tenant

client_users

ColumnTypePurpose
client_iduuidFK 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.