# Configuration

The `quickback.config.ts` file configures your Quickback project. It defines which providers to use, which features to enable, and how the compiler generates code.

```typescript
import { defineConfig, defineRuntime, defineDatabase, defineAuth } from "@quickback/compiler";

export default defineConfig({
  name: "my-app",
  template: "hono",
  auth: {
    // Required for "+" role expansion in access rules (e.g. roles: ["member+"]).
    // Lowest → highest. Omit if you always list roles explicitly.
    roleHierarchy: ["member", "admin", "owner"],
  },
  providers: {
    runtime: defineRuntime("cloudflare"),
    database: defineDatabase("cloudflare-d1"),
    auth: defineAuth("better-auth"),
  },
});
```

## Discoverable options block

After your first `quickback compile`, the file gets a managed block bracketed by sentinel comments:

```typescript
export default defineConfig({
  name: "my-app",
  template: "hono",
  providers: cloudflare({}),

  // [quickback:options-start]
  // ── Project ──
  //
  // Primary custom domain (auto-inferred from cms/account/api domains if unset).
  // domain: "example.com",
  //
  // ── Embedded UIs (CMS / Account) ──
  //
  // Login/signup/profile/orgs SPA. Set `true` for default, or object to customize.
  // account: {
  //   fileUploads: false,           // show the avatar upload UI on the profile page
  //   auth: { passkey: false, emailOTP: false, ... },
  //   ...
  // },
  //
  // ... every other option Quickback supports
  // [quickback:options-end]
});
```

This block is **regenerated on every compile** — the compiler is the source of truth, so newly added options surface automatically when you upgrade Quickback (no CLI bump needed).

**To enable an option**, copy the line you care about *above* the `[quickback:options-start]` sentinel and uncomment it:

```typescript
export default defineConfig({
  name: "my-app",
  template: "hono",
  providers: cloudflare({}),

  account: {
    fileUploads: true,    // ← lifted out of the managed block
  },

  // [quickback:options-start]
  // ... account is now omitted from this block on next compile
  // [quickback:options-end]
});
```

Quickback notices that `account` is set above the marker and drops it from the managed block on the next compile to avoid duplicate definitions. Anything outside the marker block is yours and never touched.

If you ever want to opt out of the auto-managed block, run:

```bash
quickback compile --no-sync-config
```

## Required Fields

| Field | Type | Description |
|-------|------|-------------|
| `name` | `string` | Project name |
| `template` | `"hono"` | Application template. `"hono"` is the only accepted value. |
| `providers` | `object` | Runtime, database, and auth provider configuration |

## Optional Fields

Every optional top-level key the config accepts is listed below. You never have to memorize them — after your first compile, the [discoverable options block](#discoverable-options-block) lists each one (minus the ones you've set) right inside your `quickback.config.ts`. This table is the human reference; follow the links for detailed pages.

| Field | Type | Description |
|-------|------|-------------|
| `domain` | `string` | Primary custom domain (auto-inferred from `cms`/`account`/`api` domains if unset) — see [Domains](/configure/domains) |
| `contract` | `{ routes?: "v1" \| "v2" }` | `routes` selects the URL prefix and nothing else (default `"v1"`). See [API Contract](/platform/api-contract#one-axis-routes). |
| `api` | `object` | Dedicated hostname for the selected feature API contract (`/api/v1/*` or `/api/v2/*`); additive alias — see [Domains](/configure/domains) |
| `auth` | `object` | App-level auth settings (`roleHierarchy`, `jwt`, delegated `principals`, `loginUrl`, `domain`) — see [Access](/define/access#role-hierarchy) and [Delegated principals](#delegated-principals) |
| `authz` | `object` | Relationship-based authorization (`relationships`, `roles`, `rules`, `tenants`, `permissions`, `arrows`) — see [Access](/define/authz-roles#centrally-defined-access-rules) and [Permissions](/define/permissions). `rules` = named gates (who + `record:` + firewall lanes under one name); `tenants` = named tenant anchors (`{ column, equals }`, referenced as `{ tenant: '<name>' }` in firewalls and `tenant: '<name>'` in auth-view includes). Root `authz` is the GLOBAL vocabulary level — names scoped to one concept belong in that concept's [feature area](/define/areas) (`_area.ts`), and root bodies may reference root-declared names only |
| `requires` | `string[]` | Capability floor (fail-closed guard). Declare `['feature-areas']` when the project uses `features/**/_area.ts` — an area-unaware CLI/compiler then refuses the compile instead of silently dropping nested features. See [Feature areas](/define/areas#the-capability-floor-requires-feature-areas) |
| `trustedOrigins` | `string[]` | CORS trusted origins for cross-domain auth |
| `features` | `object` | Feature flags such as `organizations` / `pinnedOrganizationId` — see [Pinned Organization Mode](/configure/single-tenant) |
| `cms` | `boolean \| object` | Embed CMS in compiled Worker (`domain`, `access`, `sysadmin`) — see [CMS](/ui/admin) |
| `account` | `boolean \| object` | Embed Account UI in compiled Worker (`domain`, `name`, `auth.*`, branding) — see [Account UI](/ui/account) |
| `apps` | `object` | Mount hand-authored SPAs on their own hostname or path prefix — see [Domains](/configure/domains) |
| `agents` | `boolean \| object` | AI/agent discovery surface (`robots.txt`, `llms.txt`, MCP, OAuth discovery) — see [Agents](/configure/agents) |
| `security` | `object \| false` | HTTP response security headers (CSP, HSTS, COOP/COEP, Referrer/Permissions-Policy) — see [Security Headers](/configure/security) |
| `openapi` | `object` | OpenAPI spec generation (`generate`, `publish`, `docs: 'scalar' \| 'swagger'`, `types`) — see [OpenAPI](/api/openapi) |
| `types` | `boolean \| object` | Quickback's first-party TypeScript contract (`output`, `brandIds`) — see [TypeScript](/api/typescript) |
| `schemaRegistry` | `object` | Schema registry generation for the CMS (`{ generate: false }` to disable) — see [Schema Registry](/ui/admin/schema-registry) |
| `etag` | `object` | ETag caching for GET responses (`{ enabled: false }` to disable) — see [Caching & ETags](/api/caching) |
| `email` | `object` | Email delivery config (`provider`, `from`, `fromName`, `region`) — see [Email Configuration](#email-configuration) |
| `push` | `object` | Web Push (VAPID keys, TTL/urgency defaults, queue fan-out) — see [Push](/platform/push) |
| `bindings` | `object` | Extra Cloudflare bindings (durable objects, queues, R2, services, `vars`, AI) — see [Bindings](/configure/bindings) |
| `environments` | `object` | Named deployment targets (`dev`/`staging`/`production`) → one `[env.<name>]` Wrangler stanza each, on Cloudflare D1 or Neon Hyperdrive — see [D1](/platform/database/d1#named-cloudflare-deployment-environments) and [Neon](/platform/database/neon#named-cloudflare-deployment-environments) |
| `compiler` | `object` | Compiler internals — `features.auditFields: false`, `migrations.renames` (headless CI), Neon `migrations.foreignKeys`, `securityContracts`, `injectId`, `routes` |
| `build` | `object` | Build options (`outputDir`, `packageManager`, `dependencies`) — see [Custom Dependencies](#custom-dependencies) |
| `dev` | `object` | Local development settings (`port`, `api`, `cms`, `account`, `hostnames`) — see [Variables](/configure/variables) |
| `preset` | `string` | Inherit defaults from a named preset |

## Public contract routes

`contract.routes` selects the URL prefix and nothing else. It defaults to
`"v1"`; set `"v2"` to move the URLs:

```typescript
export default defineConfig({
  name: "my-app",
  template: "hono",
  contract: { routes: "v2" },
  providers: cloudflare({}),
});
```

`routes` is atomic across every surface: feature routes move to `/api/v2/*`,
Better Auth to `/auth/v2/*`, realtime to `/broadcast/v2`, and generated
OpenAPI, MCP tool calls, OAuth discovery, `llms.txt`, schema/embeddings routes,
and dedicated-domain shortcuts follow. When CMS or Account is enabled Quickback
bakes those paths into the SPA build and injects them at runtime. Quickback
does not expose a mixed v1/v2 URL surface from one compiled Worker.

`contract.version` is retired — there is one standard API shape. Pinning it is
a compile error; delete that line and keep `routes`. See
[API Contract](/platform/api-contract#contractversion-is-retired) for what
changes if you were pinned to `version: "v1"`.

Action input-schema fidelity is a compile gate since v0.63.0. The CLI must
successfully harvest `z.toJSONSchema(action.input)` for every authored action;
partial or missing harvests fail with the exact action file keys, bundle or
evaluation phase, and underlying local error. Dependencies are resolved from
the generated runtime's `build.outputDir/node_modules` before unrelated
ancestor installs, so an isolated `quickback/` definition tree can use a
sibling `src/` runtime without a workspace-root symlink. The compiler enforces
the same invariant at its shared input boundary, including
`POST /compile?complete=false`, so validation-only or raw API calls cannot
bypass it.

## Delegated principals

`auth.principals` defines non-account authority such as an event pass or a
short-lived event session. It requires both of:

- the Cloudflare runtime
- Neon with `connectionMode: "hyperdrive"`

Each principal type owns an exact Authorization scheme. `Bearer` is reserved
for Quickback account authentication, including case variants, and two
principal types cannot share a scheme.

```typescript
export default defineConfig({
  name: "attend-v2",
  template: "hono",
  auth: {
    principals: {
      event_pass: {
        scheme: "EventPass",
        authenticate: {
          module: "./event-pass-proof",
          export: "authenticateEventPass",
        },
        lookup: {
          table: {
            module: "../features/event-access/event-access",
            export: "eventAccess",
            sqlName: "event_access",
          },
          digest: { field: "digest", column: "digest" },
          actorId: { field: "personId", column: "person_id" },
          claims: {
            eventId: { field: "eventId", column: "event_id" },
            personId: { field: "personId", column: "person_id" },
          },
          status: { field: "status", column: "status", allow: ["active"] },
        },
      },
      event_delegate: {
        scheme: "EventSession",
        authenticate: {
          module: "./event-session-proof",
          export: "authenticateEventSession",
        },
        lookup: {
          table: {
            module: "../features/event-sessions/event-sessions",
            export: "eventSessions",
            sqlName: "event_sessions",
          },
          digest: { field: "digest", column: "digest" },
          actorId: { field: "personId", column: "person_id" },
          sessionId: { field: "id", column: "id" },
          familyId: { field: "familyId", column: "family_id" },
          claims: {
            eventId: { field: "eventId", column: "event_id" },
            personId: { field: "personId", column: "person_id" },
          },
          status: { field: "status", column: "status", allow: ["active"] },
        },
      },
    },
  },
  providers: {
    runtime: defineRuntime("cloudflare"),
    database: defineDatabase("neon", {
      connectionMode: "hyperdrive",
      hyperdrive: { id: "<32-character-hyperdrive-id>" },
    }),
    auth: defineAuth("better-auth"),
  },
});
```

The `authenticate.module` path is relative to generated
`src/lib/principal-auth.ts`. Put project proof hooks under `quickback/lib`; the
compiler stages them into `src/lib` even if no action imports them. A hook has
one job: prove the presented credential and return its stable digest.

```typescript
// quickback/lib/event-session-proof.ts
export async function authenticateEventSession({ credential, env, request }: {
  credential: string;
  env: Env;
  request: Request;
}): Promise<{ digest: string } | null> {
  const valid = await verifySignedEventSession(credential, env, request);
  return valid ? { digest: await sha256(credential) } : null;
}
```

The hook receives no `db`, and it must not implement row authorization or
business behavior. Quickback performs exactly one narrow, unique digest lookup
under a lookup-only RLS principal. A digest must be 16–512 non-whitespace
characters; resolved identity strings must be 1–512 characters and serialized
claims are limited to 4096 characters. A lookup must resolve exactly one active
row or authentication fails.

On success, actions receive `ctx.principal`:

```typescript
{
  type: "event_delegate",
  actor: { type: "event_delegate", id: "person_123" },
  session: { id: "session_123", familyId: "family_123" },
  claims: { eventId: "event_123", personId: "person_123" },
}
```

Quickback never synthesizes `userId`, `activeOrgId`, `activeTeamId`, or
membership roles from a delegated credential. Use `principals` in an access
arm to admit it. Values inside one arm are ANDed; sibling `or` arms are ORed:

```typescript
access: {
  or: [
    { roles: ["AUTHENTICATED"] },
    { principals: ["event_delegate"], record: {
      eventId: { equals: "$ctx.principal.claims.eventId" },
    } },
  ],
}
```

An Authorization header cannot be combined with `x-api-key` or a Better Auth
session cookie. Quickback recognizes only Better Auth's exact session cookie
names for this conflict check, so analytics and preference cookies remain
harmless.

## Custom Dependencies

Add third-party npm packages to the generated `package.json` using `build.dependencies`. This is useful when your action handlers import external libraries.

```typescript
export default defineConfig({
  name: "my-app",
  template: "hono",
  build: {
    dependencies: {
      "fast-xml-parser": "^4.5.0",
      "lodash-es": "^4.17.21",
    },
  },
  providers: {
    runtime: defineRuntime("cloudflare"),
    database: defineDatabase("cloudflare-d1"),
    auth: defineAuth("better-auth"),
  },
});
```

These are merged into the generated `package.json` alongside Quickback's own dependencies. Run `npm install` after compiling to install them.

## Headless Migration Rename Hints

When `drizzle-kit generate` detects a potential rename, it normally prompts for confirmation. Cloud/CI compiles are non-interactive, so you must provide explicit rename hints.

Add hints in `compiler.migrations.renames`:

```typescript
export default defineConfig({
  // ...
  compiler: {
    migrations: {
      renames: {
        // Keys are NEW names, values are OLD names
        tables: {
          events_v2: "events",
        },
        columns: {
          events: {
            summary_text: "summary",
          },
        },
      },
    },
  },
});
```

Rules:
- `tables`: `new_table_name -> old_table_name`
- `columns.<table>`: `new_column_name -> old_column_name`
- Keys must match the names in your current schema (the new names)
- Validation fails fast for malformed rename config, unsupported keys, or conflicting legacy/new rename paths.

## Neon Foreign Key Migrations

Use `compiler.migrations.foreignKeys` when a Neon relationship cannot be
expressed in one generated Drizzle schema file—for example, a feature table
that references a Better Auth table, or a composite integrity relationship
between independently authored feature modules.

```typescript
export default defineConfig({
  // ...
  providers: {
    runtime: defineRuntime("cloudflare"),
    database: defineDatabase("neon", { connectionMode: "hyperdrive" }),
    auth: defineAuth("better-auth"),
  },
  compiler: {
    migrations: {
      foreignKeys: [
        {
          name: "person_account_links_user_fk",
          source: {
            schema: "app",
            table: "person_account_links",
            columns: ["user_id"],
          },
          target: {
            schema: "auth",
            table: "users",
            columns: ["id"],
          },
          onDelete: "restrict",
          onUpdate: "restrict",
        },
        {
          name: "event_access_grants_consumed_family_fk",
          source: {
            schema: "app",
            table: "event_access_grants",
            columns: [
              "consumed_by_family_id",
              "id",
              "organization_id",
              "event_id",
              "event_person_id",
            ],
          },
          target: {
            schema: "app",
            table: "event_session_families",
            columns: [
              "id",
              "source_grant_id",
              "organization_id",
              "event_id",
              "event_person_id",
            ],
          },
          onDelete: "restrict",
          onUpdate: "restrict",
        },
      ],
    },
  },
});
```

All identifiers must be lowercase Postgres identifiers of at most 63
characters. Source and target tuples must be nonempty and have equal arity;
column order is significant. Supported actions are `"no action"`,
`"restrict"`, `"cascade"`, `"set null"`, and `"set default"`.

Constraint names and source tuples must be unique in the config. On replay,
Quickback verifies that an existing same-named constraint has the exact target,
ordered columns, and referential actions. It fails instead of silently
accepting an ambiguous definition. Existing rows are validated during
migration, so clean up orphans before deploying.

This API is for database integrity, not business behavior. Keep authorization,
state transitions, event-pass consumption, and other business rules in the API
action layer.

## Disable Audit Field Injection

Quickback manages four **audit columns** (`createdAt`, `modifiedAt`, `createdBy`, `modifiedBy`) on every feature table, plus two **soft-delete columns** (`deletedAt`, `deletedBy`) on tables whose resource uses soft delete (the default delete mode). The audit columns drive record-provenance queries and the CMS audit panel; the soft-delete pair drives soft-delete filtering.

You declare them in the table's `columns` object with `...q.audit()` and `...q.softDelete()`. The compiler validates every managed column against its canonical shape and fails the compile when one is missing, diverges, or is present on a hard-delete table — run `quickback migrate visible-columns` to adopt the declarations on an existing project. Set `compiler.features.visibleAuditColumns: false` to downgrade those checks to warnings and keep the pre-v0.57.0 silent-injection behavior.

To opt out of the managed columns entirely, project-wide:

```typescript
export default defineConfig({
  name: "my-app",
  compiler: {
    features: {
      auditFields: false,  // skip audit-field injection globally
    },
  },
  providers: { /* ... */ },
});
```

When disabled, you're responsible for supplying these columns yourself if you want soft delete, audit trails, or modifiedAt-based optimistic concurrency. See the [Schema → Audit Fields](/define/schema#audit-fields) section for the full list and what each field drives.

## Security Contract Reports and Signing

After generation, Quickback verifies machine-checkable security contracts for Hono routes and RLS SQL.

It also emits a report artifact and signature artifact by default:

- `reports/security-contracts.report.json`
- `reports/security-contracts.report.sig.json`

You can configure output paths and signing behavior:

```typescript
export default defineConfig({
  // ...
  compiler: {
    securityContracts: {
      failMode: "error", // or "warn"
      report: {
        path: "reports/security-contracts.report.json",
        signature: {
          enabled: true,
          required: true,
          keyEnv: "QUICKBACK_SECURITY_REPORT_SIGNING_KEY",
          keyId: "prod-k1",
          path: "reports/security-contracts.report.sig.json",
        },
      },
    },
  },
});
```

If `signature.required: true` and no key is available, compilation fails with a clear error message (or warning in `failMode: "warn"`).

## Hostname architecture (v0.33+)

A modern Quickback project runs on **one Cloudflare Worker** that serves multiple URL surfaces. By default, everything lives under a single unified backend hostname (auto-inferred as `quickback.{baseDomain}` from your other configured subdomains, or overridable via `config.domain`). For larger deployments, you can opt-in to **dedicated hostnames** for specific surfaces — they're additive aliases, never replacements.

### The unified backend contract

When any subdomain is configured (`account.domain`, `cms.domain`, `auth.domain`, `api.domain`, `apps.<n>.domain`), the compiler always emits a unified backend host where **every compiler-emitted backend route** is reachable:

- `/api/v1/*` and `/auth/v1/*` by default, or `/api/v2/*` and `/auth/v2/*`
  when `contract.routes` is `"v2"` (the URL axis — the standard shape does
  not move these)
- `/storage/*` — R2 file storage
- `/health`, `/openapi.json` — monitoring/discovery
- `/account/*`, `/cms/*` — bundled SPAs (when enabled)
- `/broadcast/v1/*` (realtime; `/broadcast/v2/*` under `contract.routes: 'v2'`), `/mcp` (MCP), etc.

The unified hostname is `quickback.{baseDomain}` by default (where baseDomain is extracted from your first configured subdomain). Override with `config.domain` if you prefer a different name.

This contract is forward-pointing: it gives a stable URL pattern for future management tooling — `https://quickback.{baseDomain}/...` will always reach a Quickback backend regardless of how the operator splits their surfaces.

### Dedicated hostnames (optional, additive)

For branding or operational separation, you can opt specific surfaces onto their own hostnames:

```ts
{
  // Bundled SPAs on their own hosts (already-shipped pattern):
  cms:     { domain: 'cms.example.com' },
  account: { domain: 'account.example.com' },

  // v0.33+ — backend surfaces on their own hosts:
  auth:    { domain: 'auth.example.com' },
  api:     { domain: 'api.example.com'  },

  // User SPAs (existing pattern):
  apps: {
    www:   { domain: 'app.example.com' },
  },
}
```

On a dedicated hostname, **the hostname IS the namespace** — the path drops the prefix that matches the hostname:

| URL on dedicated host | Internally rewritten to | Notes |
|---|---|---|
| `https://api.example.com/{version}/users` | `/api/{version}/users` | `{version}` is the selected `v1` or `v2`; the rewriter prepends `/api` |
| `https://auth.example.com/{version}/sign-in/email` | `/auth/{version}/sign-in/email` | Same selected version with `/auth` |
| `https://api.example.com/api/{version}/users` | (unchanged) | Long form also accepted |
| `https://api.example.com/auth/{version}/sign-in` | **404** | Auth doesn't live on the API hostname |
| `https://api.example.com/health` | (unchanged) | Explicit exception — monitoring agents expect `/health` everywhere |

Dual-reach is preserved on the unified host:

| URL on unified host | Handler | Notes |
|---|---|---|
| `https://quickback.example.com/api/{version}/users` | feature route | Works for the selected version whether `api.domain` is set or not |
| `https://quickback.example.com/auth/{version}/sign-in/email` | Better Auth | Works for the selected version whether `auth.domain` is set or not |
| `https://quickback.example.com/{version}/users` | **404** | Shortcut only applies on dedicated hostnames |

Each dedicated hostname also:

- Gets a `{ pattern, custom_domain: true }` route in `wrangler.toml` automatically
- Joins `trustedOrigins` for CORS
- Joins the cross-subdomain cookie inference (auto-enables `crossSubDomainCookies` when ≥2 same-parent subdomains are configured, so `auth.example.com` and `app.example.com` can share session cookies)
- Joins CSP `connect-src` so SPAs can fetch from it

### Why two URL shapes (Shape 1 vs Shape 2)

If you only want Better Auth to **advertise** itself on a different URL (e.g. so verification emails link to `auth.example.com` instead of the unified host), set the `BETTER_AUTH_URL` env var on the Worker — no code change required. BA generates outgoing URLs against that value. This is sufficient when the goal is branding email/OAuth callback URLs without changing where requests actually land.

`auth.domain` (Shape 1, above) is the heavier option: it binds an actual hostname to the Worker, configures CORS/cookies/CSP for it, and adds the path-shortcut rewriter. Use it when you want the dedicated hostname as a real ingress, not just a label.

## CMS Configuration

Embed the [Admin UI](/ui/admin) in your compiled Worker. It reads your schema registry and renders a complete admin interface — zero UI code per table.

```typescript
// Simple — embed CMS at root
cms: true,

// With custom domain
cms: { domain: "cms.example.com" },

// With access control (only admins can access CMS)
cms: { domain: "cms.example.com", access: "sysadmin" },

// Cross-tenant DB-admin tier — sysadmins see every tenant's data
cms: { domain: "cms.example.com", sysadmin: true },
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `domain` | `string` | — | Custom domain for CMS. Adds a `custom_domain` route to `wrangler.toml`. |
| `access` | `"user" \| "admin"` | `"admin"` | Controls who can access the CMS. When `"admin"`, platform control-plane users (`user.role === "appmanager"`) can enter; when `sysadmin` mode is also on, `user.role === "sysadmin"` is admitted too. The gate is enforced at the Worker level (SPA shell, `/api/v1/schema`, and `custom_view` CRUD all return 403 to callers outside those roles) and in the SPA. Also hides the CMS link in Account UI. See [CMS Connecting](/ui/admin/connecting) for the full enforcement model. |
| `sysadmin` | `boolean` | `false` | Cross-tenant DB-admin tier. When `true`, opts into the `SYSADMIN` pseudo-role (`user.role === "sysadmin"`), the firewall escape hatch (sysadmins bypass tenant scopes, soft-delete still enforced), the cross-tenant `/auth/v1/admin/list-organizations` endpoint (sibling of Better Auth's `/auth/v1/admin/list-users`, gated to sysadmin only), and the SPA's "All organizations" sentinel. The CMS HTML gate admits `userRole === "sysadmin"` alongside `"appmanager"`. Off by default. See [`SYSADMIN` pseudo-role](/define/access#sysadmin--cross-tenant-db-admin-tier). |

### Sysadmin tier (`cms.sysadmin: true`)

Quickback's default model assumes every CMS user belongs to at least one organization (`member` row in the auth DB). For platforms that need a Supabase-style cross-tenant DB-admin tool — debugging a tenant's data, running cross-tenant queries, supporting a customer — that's a poor fit: a sysadmin without `member` rows would dead-end, and auto-filling their active org from "first membership" silently scopes them to a tenant they have no relationship with.

`cms: { sysadmin: true }` introduces a separate role tier:

- **Role**: `user.role === "sysadmin"` (set on the Better Auth user table). Distinct from `"appmanager"` — appmanagers keep platform control-plane access (Better Auth admin surfaces, organization and user management, support tooling — but NOT the CMS), while sysadmins get true cross-tenant data access. `ADMIN` is now a rejected legacy pseudo-role; use `userRole: ["appmanager"]` for control-plane gates and `roles: ["SYSADMIN"]` for cross-tenant access.
- **Firewall**: every `buildFirewallConditions()` emits an `if (ctx.userRole === "sysadmin") return undefined` (or just the soft-delete predicate when present) at the top, so sysadmins see every tenant's rows. See [Firewall escape hatch](/define/firewall#sysadmin-escape-hatch).
- **Org listing**: a `GET /auth/v1/admin/list-organizations` endpoint selects directly from the auth-schema `organization` table — every row, every tenant. Sits under Better Auth's admin URL namespace (sibling of `/auth/v1/admin/list-users`) so the URL shape matches BA convention; the handler is Quickback-emitted and gated to `user.role === "sysadmin"` only. For the membership-bound caller's-orgs listing, use BA's own `/auth/v1/organization/list`.
- **CMS SPA**: when the user is a sysadmin, the header switcher loads from the cross-tenant endpoint, defaults to "All organizations" (which omits `?organizationId=` and reaches the firewall escape), and pins selected tenants via `?organizationId=` on every API call. Org admins keep the existing membership-based switcher.
- **CMS gate**: `cms.access: "sysadmin"` (the default) admits the `sysadmin` tier only; `cms.access: "member"` also admits any caller with an organization membership. `appmanager` opens the CMS in neither mode — see [Access gate](/ui/admin#access-gate).

Off by default. Projects that don't enable it get zero behavior change; setting `cms.sysadmin: true` and granting a user `user.role = "sysadmin"` is the only way to unlock the cross-tenant view.

## Account UI Configuration

Embed the [Account UI](/ui/account) in your compiled Worker. Provides login, signup, profile management, organization management, and admin — all built from your config at compile time.

For post-signup logic — auto-provisioning a workspace, gating signup, setting an active organization — drop a file in `quickback/hooks/`. See [Auth Hooks](/ui/account/hooks).

```typescript
// Simple — embed Account UI with defaults
account: true,

// Full configuration
account: {
  domain: "auth.example.com",
  adminDomain: "admin.example.com",
  name: "My App",
  companyName: "My Company",
  auth: {
    password: true,
    emailOTP: false,
    passkey: true,
    organizations: true,
    admin: true,
  },
},
```

| Option | Type | Description |
|--------|------|-------------|
| `domain` | `string` | Custom domain for Account UI (e.g., `auth.example.com`) |
| `adminDomain` | `string` | Custom domain for admin-only access (e.g., `admin.example.com`). Serves the same Account SPA — the client-side router handles admin routing. |
| `appUrl` | `string` | Origin of the tenant's main application. Baked into the Account SPA as `VITE_QUICKBACK_APP_URL`. Combined with `tenantPattern` for the profile **Go to…** picker. Independent from `domain`. A deploy-target `APP_URL` can override this at serve time. |
| `tenantPattern` | `string` | URL pattern for a tenant, with `{slug}` substituted. Default `/{slug}`. Relative patterns are resolved against `appUrl`; a full `https://{slug}.example.com` pattern is used as-is. Baked as `VITE_TENANT_URL_PATTERN`. |
| `name` | `string` | App display name shown on login, profile, and email templates |
| `companyName` | `string` | Company name for branding |
| `tagline` | `string` | App tagline shown on login page |
| `description` | `string` | App description |

### Auth Feature Flags

Control which authentication features are exposed. Disabled features are excluded from the Account UI bundle at compile time (routes removed before the Vite build, keeping bundle sizes minimal) **and** enforced on the server side via the Better Auth config — so "signup off" actually closes `POST /auth/v1/sign-up/email`, not just the SPA's signup screen.

`account.auth.*` is a shorthand for `providers.auth.config` — the compiler maps each flag onto the Better Auth server config before any code is generated. You can write either side directly; setting both incompatibly (e.g. `account.auth.signup: false` and `emailAndPassword.disableSignUp: false`) fails the compile with a precise error rather than picking a silent winner.

| Flag | Type | Default | Maps to (server) | Description |
|------|------|---------|------------------|-------------|
| `auth.password` | `boolean` | `true` | `emailAndPassword.enabled` | Email/password authentication — both server endpoint and UI |
| `auth.emailOTP` | `boolean` | `false` | adds `emailOtp` plugin | Email one-time password login — adds plugin server-side, surfaces UI |
| `auth.passkey` | `boolean` | `false` | adds `passkey` plugin | WebAuthn/FIDO2 passkey authentication — adds plugin server-side, surfaces UI |
| `auth.signup` | `boolean` | `true` | `emailAndPassword.disableSignUp` (inverted) | Self-service account registration — false hides the UI **and** rejects the server endpoint with 403 |
| `auth.organizations` | `boolean` | `true` | UI surface only | Organization management UI (`/dashboard` org list, invitations, roles). Sign-in lands on `/profile`. Server-side organization support stays on regardless; set `features.pinnedOrganizationId` when you want one fixed org with no switching UI |
| `auth.admin` | `boolean` | `false` | UI surface only | Admin panel for user management in the Account SPA. The Better Auth admin plugin is compiler-managed separately from this UI toggle |
| `auth.emailVerification` | `boolean` | `false` | `emailAndPassword.requireEmailVerification` | Require email verification before sessions are issued. False = sessions usable immediately after signup |

## Trusted Origins

List of origins allowed to make cross-origin requests to your API. Required when your CMS, Account UI, or frontend app run on different domains.

```typescript
trustedOrigins: [
  "https://auth.example.com",
  "https://admin.example.com",
  "https://cms.example.com",
  "https://app.example.com",
],
```

## Local Development

Configure local development URLs. These are automatically added to the trusted origins list for CORS and Better Auth, so authentication works out of the box during local development.

```typescript
dev: {
  port: 8787,                                // wrangler dev port (default: 8787)
  api: "http://localhost:8787",              // API backend URL
  cms: "http://localhost:8787/cms",          // CMS URL (path on same worker)
  account: "http://localhost:8787/account",  // Account URL (path on same worker)
},
```

If you run a SPA standalone on its own Vite dev server, set its URL here so CORS allows it:

```typescript
dev: {
  port: 8787,
  api: "http://localhost:8787",
  account: "http://localhost:5173",  // standalone Account dev server
},
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `port` | `number` | `8787` | Local dev server port (sets `[dev].port` in wrangler.toml) |
| `api` | `string` | `http://localhost:{port}` | API backend URL |
| `cms` | `string` | — | CMS dev URL (added to trusted origins if set) |
| `account` | `string` | — | Account dev URL (added to trusted origins if set) |

## Email Configuration

Configure email delivery for authentication emails (verification, password reset, invitations). Inbound mail (Email Routing or SES → a Worker handler) is a separate surface — see [Email (Inbound)](/platform/email) (`defineEmail`). This section is outbound only.

> **Cloudflare Email Sending is now in public beta** and is Quickback's default transport for Cloudflare deploys. The Workers `send_email` binding is zero-config — no API keys, no third-party accounts. AWS SES remains supported as an explicit opt-in (`email.provider: 'aws-ses'`) for non-Cloudflare runtimes or existing SES setups.

```typescript
// Cloudflare Email Service (default) — uses send_email binding, no API keys needed
email: {
  from: "noreply@example.com",
  fromName: "My App",
},

// AWS SES — requires AWS credentials set as Wrangler secrets
email: {
  provider: "aws-ses",
  region: "us-east-2",
  from: "noreply@example.com",
  fromName: "My App",
},
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `provider` | `"cloudflare" \| "aws-ses"` | `"cloudflare"` | Email transport |
| `from` | `string` | `"noreply@example.com"` | Sender email address |
| `fromName` | `string` | `"{name} \| Account Services"` | Sender display name |
| `region` | `string` | `"us-east-2"` | AWS SES region (aws-ses only) |

### Auto-wiring

For the Cloudflare provider (the default), the compiler automatically:
- Emits `[[send_email]] EMAIL` into `wrangler.toml`
- Generates `src/lib/cloudflare-email.ts`
- Wires `sendResetPassword`, `sendVerificationEmail`, `sendInvitationEmail`, etc. into Better Auth
- Types the binding with Cloudflare's native `SendEmail` / `EmailSendResult`
  contract (`{ email, name }`, `replyTo`, and `contentId` on Workers). The REST
  helper remains a separate `{ address, name }` / `reply_to` / `content_id`
  contract.
- Anchors each authentication delivery to `executionCtx.waitUntil(...)`. The
  background promise records a structured generic failure and stays rejected;
  outside a Worker request, the callback returns the observed promise for the
  caller to await. Every Cloudflare Email callback is wired through that shared
  scheduler exactly once, so no auth action can bypass failure observation or
  double-schedule the send. Provider error bodies are never written to
  generated logs.

…whenever any auth surface needs to send mail — `emailAndPassword.enabled`, the `emailOtp` / `magicLink` / `organization` plugins — or you set the top-level `email: {…}` config. You don't need to add `bindings.sendEmail` by hand for the standard cases.

> **Cloudflare Email Routing setup is required.** The binding alone does not deliver mail. Enable Email Routing on the sending zone and add at least one verified destination address ([Cloudflare docs](https://developers.cloudflare.com/email-routing/get-started/)). Otherwise sends fail at runtime even with the binding present and the `/auth/v1/ses/status` check passing locally.

## Multi-Domain Example

A complete config with CMS, Account UI, and Admin on separate subdomains:

```typescript
export default {
  name: "my-app",
  template: "hono",
  cms: { domain: "cms.example.com", access: "sysadmin" },
  account: {
    domain: "auth.example.com",
    adminDomain: "admin.example.com",
    name: "My App",
    companyName: "Example Inc.",
    auth: {
      password: true,
      emailOTP: false,
      passkey: true,
      organizations: false,
      admin: true,
    },
  },
  email: {
    from: "noreply@example.com",
    fromName: "My App",
  },
  trustedOrigins: [
    "https://auth.example.com",
    "https://admin.example.com",
    "https://cms.example.com",
    "https://example.com",
  ],
  providers: {
    runtime: { name: "cloudflare", config: {
      routes: [{ pattern: "api.example.com", custom_domain: true }],
    }},
    database: { name: "cloudflare-d1", config: { binding: "DB" } },
    auth: { name: "better-auth", config: { plugins: ["admin"] } },
  },
};
```

This produces a single Cloudflare Worker serving five domains. See [Multi-Domain Architecture](/configure/domains) for details on hostname routing, cross-subdomain cookies, and the unified fallback domain.

---

See the sub-pages for detailed reference on each section:
- [Providers](/configure/providers) — Runtime, database, and auth options
- [Variables](/configure/variables) — Environment variables and flags
- [Output](/tooling/output) — Generated file structure
- [Pinned Organization Mode](/configure/single-tenant) — Fixed-org deployments without org switching UI
- [Multi-Domain Architecture](/configure/domains) — Custom domains, hostname routing, and cross-subdomain auth
