Mount DomainKit in your app
Compose the lifecycle layer, write the Identity service, mount the route group in your API, and handle the provider callback the browser is sent to.
This guide mounts DomainKit in your API so your app can connect DNS providers and plan, approve, and
apply records for a customer’s domain. You write one service, Identity, and mount one route group.
Provider callbacks and the error-to-status table come with the group.
Prerequisites
domainkitandeffectinstalled, Node.js 24.10 or newer.- A
Storageimplementation. Persistence and custody covers the options. - A 32-byte custody key in
DOMAINKIT_CUSTODY_KEY, or a KMS. - A session credential your app already issues, readable from a cookie.
Compose the layer
DomainKit.layer({ providers }) provides every lifecycle service and requires Storage and
Custody beneath it.
/**
* `PgStorage.layer()` prepares at boot: it creates CapsuleDB's ledger, applies pending migrations,
* and only then provides `Storage`. Nothing can observe a database whose tables are missing.
*/
export const DomainKitLive = DomainKit.layer({
providers: [
Cloudflare.provider({
oauth: {
clientId: Config.String("CF_CLIENT_ID"),
clientSecret: Config.Redacted("CF_CLIENT_SECRET"),
},
}),
Vercel.provider(),
],
}).pipe(
// `provideMerge`, not `provide`: the route handlers read attempts and receipts straight from
// Storage, so the layer they are given has to still carry it.
Layer.provideMerge(Layer.mergeAll(PgStorage.layer(), Custody.layerConfig())),
Layer.provide(PgClient.layerConfig({ url: Config.Redacted("DATABASE_URL") })),
);Compose Storage with provideMerge, not provide: the route handlers read attempts and receipts
straight from it.
Write Identity
Server.Identity maps a request to a Principal. It is the only service you implement.
/**
* 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;
}),
});Two rules make this safe:
- Verify a credential you issued, then look the tenant up yourself. A request never names its
own
ownerId. Every stored row is scoped by whatever this returns, so a request you cannot attribute must fail closed. - Read it from a cookie.
/callback/:provideris 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 works until the first interactive connection and then fails at the last step.
Identity takes an optional authorize, which runs after principal with the name of the route
being served. Use it when reads and writes belong to different roles.
/**
* `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` }),
}),
),
});Leave it out when your own middleware already gates the mount: without it every authenticated principal reaches every route.
Mount the group
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.group covers the whole lifecycle in twenty-five endpoints. It declares no path of its own, so
one prefix moves every route and the callback URL follows it.
GET /zones is the read a picker needs: every zone the principal’s connections reach, how each
connection stands, and the provider catalog. Members add domains, so it belongs with the reads in
authorize; start stays a write.
/** The group declares no path of its own, so one prefix moves every route and the callback URL. */
export const PrefixedApi = HttpApi.make("app").add(Server.group.prefix("/internal/dns"));Hosts that are not on Effect’s HTTP stack take the same group as one fetch 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" },
);Mount handler on a catch-all route under the prefix, and call dispose when the process shuts
down. Hono, Next.js App Router, and TanStack Start all take a Web handler directly.
Handle the callback
An interactive connection ends at /callback/:provider. DomainKit spends the continuation, stores
the connection, and redirects the customer.
The destination is the returnTo the flow was started with, or defaultReturnTo. It is resolved
against the callback URL and must land on its origin, so neither the provider nor a crafted
returnTo can steer the customer off your application. returnTo is not a declared callback query
parameter, so the provider cannot set it at all.
By default the callback URL is the request’s own origin plus the prefix the group is mounted at. An
edge that rewrites Host leaves that pointing somewhere the browser never reaches, so name the
public base once and it wins everywhere:
/**
* The callback URL follows the mount and the incoming request's origin. An edge that rewrites
* `Host` leaves the request pointing at an origin the browser never sees, so name the public one
* once and it wins everywhere.
*/
export const behindProxy = Server.toWebHandler(Layer.mergeAll(DomainKitLive, IdentityLive), {
prefix: "/api/domainkit",
callbackBaseUrl: "https://app.acme.dev/api/domainkit",
});Register that same callback URL with the provider’s OAuth client or integration.
Document it
/** `Server.api` is the same group as a standalone API, so it documents itself. */
export const spec = OpenApi.fromApi(Server.api);