# Connecting

The CMS always talks to a real Quickback API. There is no mock client, no seed-data
mode, and no role switcher — the signed-in user's role comes from their Better Auth
organization membership. What varies is only *which* API it points at.

## Resolving the API URL

Every per-project value resolves in this order:

1. `window.__QUICKBACK_RUNTIME` — the runtime blob a Quickback-generated worker injects into the HTML shell
2. The baked `VITE_*` env from the Vite build
3. A built-in default

For the API base URL specifically, the default chain ends at
`window.location.origin`, so a CMS served from the same origin as the API needs no
configuration at all.

Set `VITE_QUICKBACK_API_URL` when the API lives somewhere else:

```bash title=".env.production"
VITE_QUICKBACK_API_URL=https://api.example.com
```

> **Runtime config takes precedence**
>
> When a Quickback-generated worker serves the CMS (unified `/cms`, custom domain, or dev hostnames), it injects the project's values into the HTML shell as `window.__QUICKBACK_RUNTIME` — `apiUrl`, `accountUrl`, `cmsAccess`, `cmsSysadmin`, `pinnedOrganizationId`, `enableRealtime`. Every per-project field resolves as **runtime blob → baked `VITE_*` env → default**, so a single generic CMS build (no `.env`) served by the worker behaves identically to a per-project baked build. Baked builds keep working unchanged.


Once connected, the CMS:

- Reads the **Better Auth session** from cookies
- Fetches the user's **organization membership** and derives their role (`owner`, `admin`, or `member`) from it
- Makes API calls to the Quickback backend for all CRUD operations
- Applies server-side security (firewall, guards, masking) in addition to client-side UI filtering

## API Client Interface

The CMS communicates with the backend through a single interface, implemented by
the HTTP client:

```typescript
type Record_ = Record<string, unknown>;

interface IApiClient {
  list(table: string, params?: ListParams): Promise<ListResult>;
  get(table: string, id: string): Promise<Record_ | null>;
  create(table: string, data: Record_): Promise<Record_>;
  update(table: string, id: string, data: Partial<Record_>): Promise<Record_>;
  delete(table: string, id: string): Promise<void>;
  executeAction(
    table: string,
    actionName: string,
    recordId: string | null,
    input: Record_,
    action?: { standalone?: boolean; path?: string; method?: string }
  ): Promise<Record_>;
  listView(
    table: string,
    viewName: string,
    params?: ListParams
  ): Promise<ListResult>;
}
```

### List Parameters

```typescript
interface ColumnFilter {
  op: "eq" | "ne" | "like" | "in" | "gt" | "gte" | "lt" | "lte";
  value: string;
}

interface ListParams {
  page?: number;
  pageSize?: number;
  sort?: string;
  order?: "asc" | "desc";
  search?: string;
  /** Per-column filters keyed by column name. */
  filters?: Record<string, ColumnFilter>;
}
```

Each `ColumnFilter` maps 1:1 to the API's flat query-param form — `{ op: 'like', value: 'engineer' }` becomes `?title.like=engineer`.

### List Result

```typescript
interface ListResult {
  data: Record_[];
  total: number;
  page: number;
  pageSize: number;
}
```

> **Server-Side Security**
>
> All four Quickback security layers (firewall, CRUD access, guards, masking) are enforced server-side. The CMS hides unauthorized UI elements as a convenience, but the API rejects unauthorized requests regardless.


## How the Client is Created

The client is constructed once from the resolved `apiUrl` and provided via React
context (`ApiClientContext`), available throughout the component tree. It reads
the Better Auth session cookie and calls the standard REST endpoints
(`/api/v1/{table}`, or `/api/v2/…` when `contract.routes` is `"v2"`).

## Embedded Mode (Recommended)

Set `cms: true` in your config to embed the CMS directly in your compiled Worker:

```typescript title="quickback/quickback.config.ts"
export default defineConfig({
  name: "my-app",
  cms: true,
  // ...providers
});
```

When you run `quickback compile`, the CLI:
1. Builds the CMS SPA from source and places assets in `src/apps/cms/`
2. Adds `[assets]` config to `wrangler.toml` with `run_worker_first = true`
3. Generates Worker routing to serve the CMS SPA

Run `wrangler dev` and the CMS is served at `/cms/`. API routes (`/api/*`, `/auth/*`, etc.) pass through to your Hono app. Same origin — no CORS, auth cookies work naturally. On a custom domain, the CMS is served at root (`/`).

### Custom CMS Domain

Optionally serve the CMS on a separate subdomain:

```typescript
cms: { domain: "cms.example.com" }
```

This adds a custom domain route to `wrangler.toml`. Both `api.example.com` and `cms.example.com` point to the same Worker. The compiler auto-configures hostname-based routing and cross-subdomain cookies.

CMS access defaults to `access: "sysadmin"` — only `user.role === "sysadmin"` (the cross-tenant data-plane tier) can use `/cms/*`. Set `access: "member"` to also admit anyone holding an organization membership. `user.role === "appmanager"` opens the CMS in neither mode: it is the control-plane tier and belongs at `/account/admin`. The gate is enforced at multiple layers:

- **Account UI** hides the "Go to CMS" button for callers outside those roles.
- **CMS SPA shell** — the Worker returns `403` (or redirects unauthenticated requests to login) before serving `index.html` to a non-operator. A non-operator who types the CMS URL directly cannot load the app.
- **`/api/v1/schema`** returns `403 Platform operator access required` to callers outside those roles, so schema metadata can't be scraped by hitting the API directly.
- **`custom_view` routes** are gated on `userRole: ["appmanager"]` rather than org membership, so saved views can't be listed or modified without platform-control-plane rights.
- **In-SPA screen** — a signed-in caller who isn't admitted is served the shell and shown a screen naming what they'd need. There is no wrong-role redirect.

Your resource API endpoints (the ones generated for your features) are unaffected — they continue to use the per-resource `read`, `create`, `update`, `delete`, and `upsert` access rules you defined.

To let organization members in as well as sysadmins:

```typescript
cms: { access: "member" }
```

See [Access gate](/ui/admin#access-gate) for the full model — including why `appmanager` opens the CMS in neither mode.

See [Multi-Domain Architecture](/configure/domains) for details on multi-domain routing.

## Default Org Mode vs Pinned Organization Mode

The CMS supports two organization flows depending on your project's config.

### Default Org Mode

By default, the CMS gates access behind organization membership:

1. User logs in via Better Auth session
2. CMS fetches the user's organization memberships
3. If the user belongs to multiple organizations, an **org selector** is displayed
4. Once an org is selected, the user's role within that org determines their CMS permissions

This is the default behavior for Quickback projects with no pinned org.

### Pinned Organization Mode

When your project sets `features.pinnedOrganizationId`, the CMS keeps org-backed auth but fixes the active org at runtime. In this mode:

- No org selector is shown
- The org gate is bypassed because the active org is known up front
- The user's `roles` still come from membership in the pinned org
- Org-scoped data remains scoped by `organizationId`

Pinned organization mode is useful for internal tools, blogs, and one-customer deployments that still want org-backed roles and schema conventions without runtime org switching.

## Next Steps

- **[Table Views](/ui/admin/table-views)** — Browse and Data Table view modes
- **[Security](/ui/admin/security)** — How roles, guards, and masking work in the CMS
