> ## Documentation Index
> Fetch the complete documentation index at: https://docs.userkit.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Architecture

> Where each piece runs, and which of them ever sees a credential.

Three pieces.

* **The API**, at `https://api.userkit.dev` — owns the data and every rule.
* **The panel**, at `app.userkit.dev` — where your team administers the
  organization.
* **Your product** — whatever you are building. Talks to the API with an
  organization API key from your server, and with a publishable key from your
  pages.

## The panel never hands a token to the browser

```
browser ──httpOnly cookie──▶ panel server ──Bearer──▶ API ──▶ Postgres
```

Panel screens never carry a credential: the session token lives in an `httpOnly`
cookie and the panel's own server attaches the `Authorization` header. No XSS can
read that token, and the endpoints that *answer* with a credential — login,
two-factor, sign-up — write the value straight into the cookie instead of handing
it to script.

Worth copying in your own front end: what the browser never holds, an attack in
the browser cannot steal.

## Your product does talk to the API directly

The customer plane is the opposite posture on purpose.

```
your server  ──Bearer uk_sk_…──▶  /v1/contacts, /v1/me
your pages   ──publishable key──▶  /v1/boot, /v1/contact-auth/*
your users   ──Bearer uk_ct_…──▶  /v1/contact/me
```

`/v1/boot`, `/v1/contact-auth/*` and `/v1/contact/*` answer **any** origin,
because they are called from your domain by design. CORS is not the gate there:
the publishable key's [allowed-origins list](/en/guides/api-keys#publishable-keys)
is, and no cookie rides those requests.

Every other path stays on the narrow allowlist.

## Authorization is one query

A user belongs to an organization through a **membership**. The membership carries
a **role**. A role is a set of **permissions**.

The permission *catalogue* lives in Go — the set of things the system can do is a
fact of the codebase. *Who holds what* lives in the database, so an organization
can define its own roles without a deploy.

Resolving a session resolves all of it in one round-trip: identity, active
organization, role, permissions. The membership JOIN **is** the authorization,
which has two consequences worth internalizing:

* revoking a membership invalidates that session immediately, with no
  cache to wait on;
* an organization code someone pastes into the URL matches no row unless they
  are a member of it.

<Note>
  The organization travels in the URL (`/{org_code}/…`) and in the
  `X-Organization-Id` header. It **identifies**; it does not authorize. Switching
  organizations is navigation, not a mutation.
</Note>

In the panel, `can(session, PERMISSIONS.x)` hides what a role cannot do. That is
courtesy. `requirePermission` in the API is the enforcement.

## Environments cut through everything

Every organization owns exactly two: **live** and **test**, seeded at creation and
not creatable or deletable. Customer-plane tables carry `environment_id`
alongside `organization_id`.

A machine credential resolves its environment from the stored key row on every
request. There is no environment parameter on that surface, ever — which is what
makes "I pointed staging at production" a thing that cannot happen.

The panel is the exception, and deliberately so: staff are authorized for the
whole organization, so panel reads take `?environment=live|test` as an explicit
**view** parameter.

## Capabilities come back on the session

`GET /v1/session` carries `two_factor_available` and `uploads_available`. They
describe the **server**, not your organization: a client reads them to hide what
the platform cannot do right now, instead of discovering it through a `501` in
the middle of a flow.

`GET /health` reports the state of the dependencies. Only the database moves the
status code.

## One internal event bus

Domain mutations publish to an outbox **in the same transaction as the rows they
describe**, so an event never describes a write that rolled back. A commit nudges
the queue to drain it; a periodic sweep is what guarantees it drains when every
nudge is lost.

See [Domain events](/en/guides/events).
