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

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

  • domainkit and effect installed, Node.js 24.10 or newer.
  • A Storage implementation. 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/: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 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);

Next

Last updated on

Was this page helpful?