feat(domainkit): serve the lifecycle over one HttpApi group and a fetch transport (#87)
## Summary
`domainkit/server` publishes the whole DNS lifecycle as one
`HttpApiGroup` a host adds to its own
`HttpApi`, with `Server.Identity` as the only service the host writes.
`domainkit/client` ships the
capability-gated fetch transport over those routes, and one test drives
plan/approve/apply through
both.
## Usage
```ts
import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi";
import { DomainKit, Reason } from "domainkit";
import { Server } from "domainkit/server";
// Verify a credential you issued and look the tenant up yourself; fail closed otherwise. Read it
// from a cookie: /callback/:provider is a browser navigation, so only what the browser sends by
// itself arrives with it.
const IdentityLive = Layer.succeed(Server.Identity)({
principal: (request) =>
Effect.flatMap(yourSessions.verify(request.cookies.session), (session) =>
session === null
? Effect.fail(
new DomainKit.Error({ reason: new Reason.Unauthenticated({ message: "No session" }) }),
)
: Effect.succeed({ ownerId: session.orgId, actorId: session.userId }),
),
});
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]),
);
```
```ts
import { Transport } from "domainkit/client";
const transport = Transport.fromFetch("/api/domainkit");
// Does a connection this customer already has reach the domain?
const discovery = yield* transport.connection.discover("app.example.com");
const started = yield* transport.connection.start({
domain: "app.example.com",
provider: "cloudflare",
method: Transport.Method.oauth({ returnTo: "/settings/domains" }),
});
```
Hosts that are not on Effect's HTTP stack take the Promise edge instead:
```ts
const { handler, dispose } = Server.toWebHandler(Layer.mergeAll(DomainKitLive, IdentityLive), {
prefix: "/api/domainkit",
});
```
## Routes
| Method | Path | Service | Transport |
|---|---|---|---|
| GET | `/domains/:domain` | `Connect.inspect` | `connection.inspect` |
| GET | `/domains/:domain/discovery` | `Connect.discover` |
`connection.discover` |
| POST | `/connections` | `Connect.start` | `connection.start` |
| GET | `/callback/:provider` | `Connect.complete`, then 302 | browser
only |
| POST | `/connections/:connectionId/attachments` | `Connect.attach` |
`connection.attach` |
| DELETE | `/attachments/:attachmentId` | `Connect.detach` |
`connection.detach` |
| DELETE | `/connections/:connectionId` | `Connect.disconnect` |
`connection.disconnect` |
| POST | `/domains/:domain/plans` | `Provision.plan` |
`provisioning.plan` |
| POST | `/plans/:planId/approvals` | `Provision.approve` or
`Cleanup.approve` | `*.approve` |
| POST | `/plans/:planId/rejections` | `Provision.reject` or
`Cleanup.reject` | `*.reject` |
| POST | `/approvals/:approvalId/apply` | `Provision.apply` or
`Cleanup.apply` | `*.apply` |
| GET | `/plans/:planId` | the stored attempt with `status` and
`rejection` | `provisioning.attempt` |
| GET | `/receipts/:receiptId` | the stored receipt | — |
| POST | `/domains/:domain/observations` | `Verify.observe` |
`verification.observe` |
| POST | `/receipts/:receiptId/cleanup-plans` | `Cleanup.plan` |
`cleanup.plan` |
## Flow
```text
browser
-> Transport.fromFetch encodes with the wire schema
-> the group's route decodes params, query, payload
-> Identity.principal(request) host-owned; one Principal per request
-> Connect / Provision / Cleanup / Verify with that Principal
-> success, or DomainKit.Error at the status its reason derives
-> Transport decodes the same schemas back
```
## Decisions
- **Prefix, not re-hosting.** The group declares no prefix of its own.
`Server.group.prefix(...)` or
`toWebHandler({ prefix })` moves every route, and the callback URL is
derived from the mounted
start path, so Samva's request re-hosting shim is unnecessary.
- **One wire error.** Every endpoint declares `DomainKit.Error` at each
status its reason table
produces (400/401/403/404/409/500/501/502/503). The body is the same
tagged value at every status, so
the client decodes one schema and a `Conflict` still arrives carrying
its conflicting operations.
- **Approve, reject, and apply serve cleanup too.** The server reads the
attempt and dispatches on
its kind rather than duplicating the routes; the transport exposes them
under both capability
groups.
- **The callback redirect is bound to the flow.** The destination comes
from the continuation the
customer's own start request created, or from `defaultReturnTo`, and
must be a path on this server
or a URL on the callback's origin. The provider's query string never
chooses it.
- **Zone selection stays opaque.** `SelectionRequired` carries `{ zone,
label }`; attaching a picked
zone resolves the candidates again server-side and matches by zone name,
so provider target
context never crosses the wire.
- **Forms render from the response.** `Snapshot.providers[].methods[]`
carries each method's kind,
label, docs URL, and token field descriptors (`name`, `required`,
`secret`), and the start payload
takes `Token { values }` keyed by those names, so a connect form never
hard-codes a provider.
- **Discovery keeps target context server-side.** `Connect.discover`
returns provider targets; the
wire carries `{ connectionId, zone, label }`, the same shape
`Started.SelectionRequired` uses.
- **Capability groups gate the UI.** `Transport.fromFetch(url, {
capabilities: ["connection"] })`
returns a transport with only that group, and
`Transport.capabilities(transport)` is what the
React lane renders from.
## Reuse and boundaries
- The transport goes through the package's existing
`internal/http.requestJson`, so a dropped
connection, a non-JSON body, and a 429 with `retry-after` are classified
exactly as a provider
reply is. `internal/http.headersFrom` is new: it resolves the host's
static or awaited headers.
- `tools/oxlint/effect-boundaries.js` gains `Transport.ts` to both
allowlists. `fromAsync` is a host
async adapter and `toAsync` is the Promise edge — the two things those
rules exist to enumerate.
## Naming
The server and client follow core's house names: services are
`X.Service` with shape `X.Interface`
(`Connect.Service`, `Provision.Service`, `Verify.Service`,
`Storage.Service`, `Principal.Service`,
`Providers.Service`), schema values are `X.Model` (`Plan.Model`,
`Receipt.Model`, `Approval.Model`,
`DnsRecord.Model`), and the error is `DomainKit.Error` with its reasons
in `Reason`. This lane's own
interface follows the same rule: `Transport.Transport` is now
**`Transport.Interface`**.
`Server.Identity` keeps its name because it is the host's seam, not a
lifecycle service. The
`DomainKitError` tag id is unchanged, so
`Effect.catchTag("DomainKitError", ...)` and the wire body's
`_tag` still match.
## Core touches
None. Every existing core module is untouched except `src/Testing.ts`
(adds `Testing.transport`) and
`src/internal/http.ts` (adds `headersFrom`), both additive.
## Tests
- `bun run --filter domainkit test` — 130 tests.
- `tests/server/httpapi.test.ts`: token connect → plan → approve → apply
→ observe → cleanup
plan/approve/apply → detach; reject as a terminal state with
approve-after-reject failing
`Stale`; the error status table and every reason having a declared
response; a 409 carrying the
conflicting operations; the OAuth callback following the flow's own
`returnTo`, refusing an
off-origin one, and ignoring one the provider appends; every route under
`/internal/dns`; and
`OpenApi.fromApi(Server.api)` listing all 15 operations. Discovery
answers `NotFound` before a
connection exists and `Resolved` after, with no provider target on the
wire; the token method's
field descriptors reach the snapshot; and the callback refuses `//host`,
`/\host`, `\/host`,
and an absolute foreign origin.
- `tests/client/transport.test.ts`: the same lifecycle through
`fromFetch` against
`Server.toWebHandler` in process; a `Conflict` arriving as the same
tagged class with
`httpStatus` 409; reject followed by a `Stale` approve; an unreadable
502 arriving as a
retryable `ProviderUnavailable`; capability gating; a connection-only
transport typechecking;
`fromAsync`/`toAsync` round trip; and `Testing.transport` recording
every call.
- `bun run --filter domainkit release:check` — typecheck, tests, build,
`typecheck:examples`
(`examples/effect/server.ts`), packed artifacts. The packed consumer
mounts the group under
`/api/domainkit` and connects through `Transport.fromFetch` in Node 24,
Bun, and workerd.
- Root `bun run lint && bun run lint:rules && bun run format:check`.
## Stack note
`@domainkit/react` typecheck and the docs app are red against the 0.9
core and stay red until their
own lanes land. Neither is edited here; `validate` and `Release preview`
are green.
## Not in this PR
- Nothing outstanding for this layer. S
Saatvik Arya committed
26d180d1b67e8b8a045a04aa11eba5f55ebc3476
Parent: 90b10f9
Committed by GitHub <noreply@github.com>
on 9/3/2026, 6:54:03 PM