Skip to content
DomainKit
Esc
↑↓navigate↵open⌘Jpreview
On this page

domainkit/server

The mountable route group and its twenty-five endpoints, the Identity service a host implements, the wire schemas, the options, and the Web handler.

import { Server } from "domainkit/server";
Namespace Carries
Server group, api, layer, toWebHandler, registrationSettings, RegistrationSettings, CallbackOutcome, Identity, and every wire schema the client decodes

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 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
/**
 * The one service you write. Verify a credential you issued and look the tenant up yourself: a
 * request never names its own `ownerId`, and one you cannot attribute fails closed. The provider
 * callback is the one route where something else names it, and that something is DomainKit's own
 * record of the flow rather than the request; `CallbackAwareIdentity` below takes it.
 *
 * Read it from a cookie. `/callback/:provider` is a top-level navigation the provider sends the
 * browser on, so only what the browser attaches by itself arrives with it; a header-only scheme
 * fails every interactive connection at the last step.
 */
export const IdentityLive = Layer.succeed(Server.Identity)({
  principal: (request) =>
    Effect.gen(function* () {
      const token = request.cookies.session;
      const session = token === undefined ? null : yield* sessions.verify(token);
      return session === null
        ? yield* Effect.fail(
            new DomainKit.Error({
              reason: new Reason.Unauthenticated({ message: "The request carries no session" }),
            }),
          )
        : session;
    }),
});

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.

/**
 * `authorize` runs after `principal` on every request and decides which routes that principal may
 * reach. Without it every authenticated principal reaches every route, which is right for a host
 * whose own middleware already gates the mount. Fail with `Forbidden` for the 403 a UI expects.
 */
const writeRoutes = new Set<Server.EndpointName>([
  "start",
  "attach",
  "detach",
  "disconnect",
  "approve",
  "reject",
  "apply",
]);

export const AdminWritesIdentity = Layer.succeed(Server.Identity)({
  principal: (request) => Effect.orDie(sessions.principal(request.cookies.session ?? "")),
  authorize: (principal, endpoint) =>
    !writeRoutes.has(endpoint) || principal.actorId.startsWith("admin_")
      ? Effect.void
      : Effect.fail(
          new DomainKit.Error({
            reason: new Reason.Forbidden({ message: `${endpoint} needs an administrator` }),
          }),
        ),
});

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:

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.

/**
 * `context` arrives on `/callback/:provider` and nowhere else. It carries the flow DomainKit
 * recorded when the connection started, so the doctrine above still holds: the request does not
 * name its own owner. DomainKit's durable record does, and the `state` the provider echoed back
 * only points at it.
 *
 * Ignore the argument and nothing breaks. Reach for it when one session holds several tenants and
 * the callback has to land on the one the flow belongs to, which no cookie can tell you. Either
 * way DomainKit checks the principal you return against the recorded owner and actor and refuses a
 * mismatch, so a wrong answer is a refusal rather than a cross-tenant write.
 */
export const CallbackAwareIdentity = Layer.succeed(Server.Identity)({
  principal: (request, context) =>
    Effect.gen(function* () {
      const session = yield* Effect.orDie(sessions.principal(request.cookies.session ?? ""));
      const ownerId = context?.continuation.ownerId ?? session.ownerId;
      return { ownerId, actorId: session.actorId };
    }),
});

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

export const Api = HttpApi.make("app").add(Server.group);

export const ApiLive = HttpApiBuilder.layer(Api).pipe(
  Layer.provide(Server.layer(Api, { defaultReturnTo: "/settings/domains" })),
  Layer.provide([DomainKitLive, IdentityLive]),
);

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

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 A live flow returned the allowlisted access_denied consent denial
Failed provider, returnTo, recovery restart for missing code, other callback errors, or a refused grant; inspect for other failures or possible partial persistence
/** Only live, authenticated flows reach this policy; destinations are checked against the public origin. */
export const callbackOptions: Server.Options = {
  callback: (outcome) => {
    if (outcome._tag === "Connected") return Effect.succeed(undefined);
    const destination = new URL(outcome.returnTo);
    destination.searchParams.set(
      "connection",
      outcome._tag === "Cancelled" ? "cancelled" : outcome.recovery,
    );
    return Effect.succeed(destination.toString());
  },
};
export const recoveryServer = Server.toWebHandler(Layer.mergeAll(DomainKitLive, IdentityLive), {
  prefix: "/api/domainkit",
  callbackBaseUrl,
  defaultReturnTo: "/settings/domains",
  ...callbackOptions,
});

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 on success, or the existing typed error on cancellation/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.

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

/** Reuse this public base in the mounted server options; derivation never reads credentials. */
export const callbackBaseUrl = "https://app.acme.dev/api/domainkit";
export const providers = [
  Cloudflare.provider({ oauth: { clientId: "registered-client", clientAuth: "none" } }),
];
export const registrationSettings = Server.registrationSettings({ providers, callbackBaseUrl });
export const registeredServer = Server.toWebHandler(
  Layer.mergeAll(DomainKit.layerMemory({ providers }), IdentityLive),
  {
    prefix: "/api/domainkit",
    callbackBaseUrl,
    defaultReturnTo: "/settings/domains",
  },
);
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. 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

/**
 * Not on Effect's HTTP stack? The same group behind one `fetch` handler. Mount it wherever your
 * router puts a catch-all route and call `dispose` when the process shuts down.
 */
export const { handler, dispose } = Server.toWebHandler(
  Layer.mergeAll(DomainKitLive, IdentityLive),
  { prefix: "/api/domainkit", defaultReturnTo: "/settings/domains" },
);

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 for the whole table.

Last updated on

Was this page helpful?