---
title: domainkit/server
description: The mountable route group and its twenty-five endpoints, the Identity service a host implements, the wire schemas, the options, and the Web handler.
sidebar:
  label: domainkit/server
seo:
  title: DomainKit server reference
---

```ts
import { Server } from "domainkit/server";
```

{/* reference-inventory:start */}

| Namespace | Carries                                                                                                                                                          |
| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Server`  | `group`, `api`, `layer`, `toWebHandler`, `registrationSettings`, `RegistrationSettings`, `CallbackOutcome`, `Identity`, and every wire schema the client decodes |

{/* reference-inventory:end */}

## Endpoints

`Server.group` is one `HttpApiGroup` with twenty-five endpoints. It declares no prefix of its own, so
`Server.group.prefix("/internal/dns")` or a router mount moves every route at once and the OAuth
callback URL follows the mount.

| Method   | Path                                       | Endpoint       | Answers                  |
| -------- | ------------------------------------------ | -------------- | ------------------------ |
| `GET`    | `/domains/:domain`                         | `inspect`      | `Snapshot`               |
| `GET`    | `/domains/:domain/discovery`               | `discover`     | `Discovery`              |
| `GET`    | `/zones`                                   | `zones`        | `Zones`                  |
| `POST`   | `/connections`                             | `start`        | `Started`                |
| `GET`    | `/callback/:provider`                      | `callback`     | `302` to the return path |
| `POST`   | `/connections/:connectionId/reconnections` | `reconnect`    | `Started`                |
| `POST`   | `/connections/:connectionId/attachments`   | `attach`       | `Started`                |
| `DELETE` | `/attachments/:attachmentId`               | `detach`       | Empty                    |
| `DELETE` | `/connections/:connectionId`               | `disconnect`   | Empty                    |
| `POST`   | `/domains/:domain/plans`                   | `createPlan`   | `Plan.Model`             |
| `POST`   | `/plans/:planId/approvals`                 | `approve`      | `Approval.Model`         |
| `POST`   | `/plans/:planId/rejections`                | `reject`       | `Attempt`                |
| `POST`   | `/approvals/:approvalId/apply`             | `apply`        | `Receipt.Model`          |
| `GET`    | `/plans/:planId`                           | `plan`         | `Attempt`                |
| `GET`    | `/receipts/:receiptId`                     | `receipt`      | `Receipt.Model`          |
| `POST`   | `/domains/:domain/observations`            | `observe`      | `Readiness`              |
| `GET`    | `/domains/:domain/readiness`               | `readiness`    | `Readiness \| null`      |
| `POST`   | `/receipts/:receiptId/cleanup-plans`       | `cleanupPlan`  | `Plan.Model`             |
| `POST`   | `/batches`                                 | `createBatch`  | `Batch`                  |
| `GET`    | `/batches?unfinished=true`                 | `batches`      | `BatchSummary[]`         |
| `GET`    | `/batches/:batchId`                        | `batch`        | `Batch`                  |
| `POST`   | `/batches/:batchId/plans`                  | `planBatch`    | `Batch`                  |
| `POST`   | `/batches/:batchId/approvals`              | `approveBatch` | `Batch`                  |
| `POST`   | `/batches/:batchId/rejections`             | `rejectBatch`  | `Batch`                  |
| `POST`   | `/batches/:batchId/apply`                  | `applyBatch`   | `Batch`                  |

Approve, reject, and apply serve both provisioning and cleanup: the stored attempt knows its kind.

Every batch route answers with the whole `Batch`, so a UI renders the aggregate from one response.
`createBatch` takes the batch's identity in an `Idempotency-Key` header: a retried create answers
with the batch the first call made rather than planning the same domains twice. `planBatch` re-plans
the items that have none, with their requirements supplied again. `batches` is the owner's
unfinished index and reads no plans; a finished batch is read by its own id. See
[batches](/docs/core/batches) for the lifecycle those routes serve.

`start` takes an optional `domain`. Without one it connects the account alone and answers
`Connected` with the connection it made and no snapshot, which is how a customer grants an account
before they have named a domain in it. `zones` lists every zone those accounts reach, ordered by
zone, and marks a connection whose credential the provider turned down as `reconnect` beside the
zones that did come back. `?provider=` narrows it to one provider's connections. `reconnect` proves the connection's own
provider again and replaces the credential behind it, keeping the connection and every domain
attached to it; starting instead would mint a second account and leave the domains on the one the
provider turned down.

## Identity

`Server.Identity` is the only service a host implements. Everything else comes from
`DomainKit.layer` plus `Storage`.

| Member      | Required | Runs                                                                   |
| ----------- | -------- | ---------------------------------------------------------------------- |
| `principal` | yes      | On every request, before the handler, to derive `{ ownerId, actorId }` |
| `authorize` | no       | After `principal`, with the `EndpointName` of the route being served   |

<Snippet file="examples/server/mount.ts" region="identity" title="The Identity layer" />

Without `authorize`, every authenticated principal reaches every route, which is right for a host
whose own middleware already gates the mount. Implement it to split reads from writes, and fail
with the `Forbidden` reason for the 403 a UI expects.

<Snippet file="examples/server/mount.ts" region="authorize" title="Per-route authorization" />

`Server.EndpointName` is derived from the group, so it is exactly the endpoint identifiers in the
table above and a new route is a type error in an exhaustive host check rather than a silent gap.

### The callback resolves identity the other way round

`/callback/:provider` is the one route that knows something about the request before it knows who
made it. It is a top-level navigation the provider sends the browser on, carrying a `state` the
provider echoed back and whatever credential the browser attaches by itself, so ambient host state
is the wrong authority on whose connection it finishes.

So the handler reads the flow's own record first — owner, actor, provider, and expiry, never the
payload, and a read rather than a spend — and passes it to `principal` as a second argument:

```ts
interface IdentityContext {
  readonly endpoint: EndpointName;
  readonly continuation: Storage.ContinuationHeader;
}
```

`context` is optional and present on the callback alone, so a host that takes one argument keeps
compiling. Take it when one session holds several tenants and the callback has to land on the one
the flow belongs to.

<Snippet
  file="examples/server/mount.ts"
  region="callback-identity"
  title="Identity on the callback"
/>

The doctrine that a request never names its own `ownerId` is intact. `context.continuation` is
DomainKit's durable record of the flow, written when the connection started, and the `state` in the
query string only points at it.

DomainKit then checks the principal you return against that record itself, rather than trusting you
to: the owner and the actor must both match the session that started the flow, and the recorded
provider must match the route. A callback that matches no flow, one that has expired, one presented
at another provider's route, and one presented by a session that did not start it are all the same
`InvalidInput` refusal with the same text, and the host authenticates before any of them, so
neither the status nor the message tells a caller holding a leaked `state` whether it names a live
flow. The flow is left unspent, so the person who started it can still finish it.

## Mounting

<Snippet file="examples/server/mount.ts" region="mount" title="Mounting the group" />

`Server.layer(api, options)` builds the handlers. `Server.api` is the same group as a standalone
`HttpApi`, so `OpenApi.fromApi(Server.api)` documents it.

| Option            | Effect                                                                                                                                                                                               |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `callbackBaseUrl` | The origin and mount path the provider redirects back to. Set it behind an edge that rewrites `Host`; otherwise the callback follows the request's own origin and the prefix the group is mounted at |
| `defaultReturnTo` | Where an interactive flow lands when the continuation carries no `returnTo`                                                                                                                          |
| `callback`        | Optional sanitized outcome policy: return an Effect of a same-origin destination, or `undefined` to keep the default response                                                                        |
| `prefix`          | `toWebHandler` only: mount every route under this path                                                                                                                                               |

## Callback destinations

For a live, authenticated flow, the callback answers 302 to the validated saved `returnTo`, or
`defaultReturnTo` when the flow has none. `Connected` clears all recovery parameters. Cancellation and
completion failure add the browser-safe recovery hint below:

| Outcome                             | `domainkit` | `domainkit-provider` |
| ----------------------------------- | ----------- | -------------------- |
| `Cancelled`                         | `cancelled` | Provider id          |
| `Failed` with `recovery: "restart"` | `restart`   | Provider id          |
| `Failed` with `recovery: "inspect"` | `inspect`   | Provider id          |

Existing query parameters and the fragment are preserved; stale copies of all recovery parameters
are replaced. [`CallbackRecovery`](/docs/reference/transport#callback-recovery) owns this URL
contract. [`Connect.useRecovery()`](/docs/reference/react#callback-recovery) reads and dismisses it
in React. The parameters are display hints anyone can edit, so they authorize no operation and
prove neither connection nor DNS readiness.

A reconnect also carries `domainkit-connection=<connectionId>`, taken from the verified live
continuation. New connections omit it. A retry uses this target to replace the existing credential
without creating another account connection. The host still authorizes every reconnect request.

`Options.callback` receives `Server.CallbackOutcome`, a schema and type for these server-side values:

| Tag         | Fields                                                      | Meaning                                                                                                                             |
| ----------- | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `Connected` | `provider`, `connectionId`, `returnTo`                      | The account connection is durable; a zone may still need selection                                                                  |
| `Cancelled` | `provider`, `returnTo`, optional `connectionId`             | A live flow returned the allowlisted `access_denied` consent denial                                                                 |
| `Failed`    | `provider`, `returnTo`, `recovery`, optional `connectionId` | `restart` for missing code, other callback errors, or a refused grant; `inspect` for other failures or possible partial persistence |

<Snippet
  file="examples/server/mount.ts"
  region="callback-policy"
  title="Choose a callback destination"
/>

The policy returns `Effect<string | undefined, DomainKit.Error>` with no environment requirement.
Close over host services acquired when building the server layer. `undefined` keeps the default:
302 to the saved destination, with recovery parameters for cancellation or completion failure.
A string selects a 302 destination, normalized and checked against the public callback origin,
including behind a proxy. Cross-origin, malformed, and non-HTTP(S) destinations fail `InvalidInput`.
The saved `returnTo` or `defaultReturnTo` is validated before exchange and before calling policy.

The policy receives no request, state, grant code, raw parameters, provider error text, secret,
provider account context, or storage row. Owner, actor, provider, and live continuation verification
precede classification inside the completion lock. Unknown, expired, spent, and mismatched flows
remain the same refusal and invoke no policy, including expiry or consumption during authentication
or before the locked read. Host authentication and authorization failures do not invoke policy.
There is no recovery under a different session.

Classified cancellation and failure annotate the current span and emit the
`DomainKit callback recovery` log before the destination policy runs. Cancellation uses Info;
failure uses Warning. The fields are `domainkit.callback.outcome`, `domainkit.callback.provider`,
and `domainkit.callback.reason` (the reason tag), plus `domainkit.callback.recovery` for a failure.
They contain no error message, callback URL, state, grant code, or credential. Success and refused
callbacks emit no recovery event. The host configures its logger and tracer.

`Connected` describes account persistence, not DNS readiness, plan approval, or apply. Inspect the
connection and select its zone before planning DNS. Cancellation and completion failure do not spend
the flow; its expiry and retry behavior remain intact. A recovery redirect does not replay a grant.
Partial persistence can leave a sealed credential or connection even when completion fails. A policy
failure or rejected destination after `Connected` cannot undo the connection; inspect durable state
before asking the customer to start again.

Effect hosts that mount their own callback can use `Connect.completeOutcome`; see the
[core reference](/docs/reference/core#completion-outcomes). The mounted server owns sanitization and
destination validation.

## Registration settings

`Server.registrationSettings({ providers, callbackBaseUrl })` returns an Effect of a readonly array
of `Server.RegistrationSettings`. Pass the same configured definitions used by `DomainKit.layer`
and the same public callback base used by the server. The explicit base includes the mount path;
it must be an HTTP(S) URL without credentials, query, or fragment. It does not infer request origins.

<Snippet
  file="examples/server/mount.ts"
  region="registration-settings"
  title="Derive public setup requirements"
/>

| Tag           | Fields                                                    | Meaning                                                                    |
| ------------- | --------------------------------------------------------- | -------------------------------------------------------------------------- |
| `OAuth`       | `provider`, `callbackUrl`, `clientAuth`, `pkce`, `scopes` | Effective client authentication, `S256` PKCE, and configured scopes        |
| `Integration` | `provider`, `callbackUrl`, `clientAuth`                   | Integration code exchange authentication (`client_secret_post` for Vercel) |

Token-only definitions contribute no settings. An interactive custom provider without registration
metadata fails `Unsupported`; see the [provider contract](/docs/reference/providers). The union is
also a runtime schema. This programmatic operation requires no credentials, Principal, storage,
custody, or network, and adds no HTTP endpoint. It emits no client ids, secrets, tokens, state,
account context, or emulator addresses. It describes requirements rather than verifying console
registration, credential resolution, or granted permissions.

## Web handler

<Snippet file="examples/server/mount.ts" region="web-handler" title="The Promise edge" />

## Wire schemas

The client and `@domainkit/react` decode these, so the three cannot drift.

| Schema             | Shape                                                                                                                          |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
| `Snapshot`         | Domain, attachment, connection, provider, status, reusable connections, provider catalog                                       |
| `Attachment`       | The attachment's `id` and the zone `label` the provider gave when it was created                                               |
| `Zones`            | Every zone the principal's connections reach, how each connection stands with the account label the provider gave, the catalog |
| `Started`          | `Connected`, `Redirect`, or `SelectionRequired`                                                                                |
| `Discovery`        | `DiscoveryResolved`, `DiscoverySelectionRequired`, or `DiscoveryNotFound`                                                      |
| `Method`           | `Token`, `OAuth`, or `Integration`                                                                                             |
| `MethodDescriptor` | A method's `kind`, `label`, `docsUrl`, and token `Field` list                                                                  |
| `Attempt`          | `plan`, `status`, `approval`, `receipt`, `rejection`                                                                           |
| `ObservePayload`   | The `requirements` to observe, or nothing to use the attachment's receipt                                                      |
| `Readiness`        | `Verify.Readiness` with timestamps as ISO strings; each requirement carries its `key`                                          |

## Failures

A failure crosses the wire as the `DomainKit.Error` value with the status its reason derives, so a
`Conflict` is a 409 carrying the conflicting operations. See
[failure reasons](/docs/core/errors) for the whole table.
