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.