# Fouria Dispatcher

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.io` → `tenant-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:

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

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 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.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)

| 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`

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

```typescript
export interface DispatchEnv {
  // ...
  YOUR_VAR: string;
}
```

### 3. If the var is a secret (not plaintext)

Use `wrangler secret put` instead of `wrangler.jsonc`:

```bash
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

```bash
# 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:

| 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](../../../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

```bash
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.
