Write a provider
Turn a DNS provider's HTTP API into one declarative definition, declare its auth methods, and prove it with the conformance runner.
This guide turns a DNS provider’s HTTP API into a DomainKit provider you can register beside
Cloudflare and Vercel. A provider is one value. Auth methods are optional cases, so a provider that
only issues personal tokens declares token and nothing else. Adding OAuth is one more case on the
same definition.
The whole thing
// A provider is one value. Token-only providers skip OAuth; that is the whole difference.
import { Effect, Redacted, Schema } from "effect";
import { DnsRecord, DomainKit, Provider, Reason } from "domainkit";
const Context = Schema.Struct({ apiKey: Schema.String });
export const porkbun = Provider.make({
id: "porkbun",
name: "Porkbun",
context: Context,
contextVersion: "porkbun.v1",
auth: {
token: Provider.tokenAuth({
label: "API key",
docsUrl: "https://porkbun.com/account/api",
requiredCapabilities: ["dns:read", "dns:write"],
fields: Schema.Struct({ token: Schema.RedactedFromValue(Schema.String) }),
// `label` names the account the credential reaches, so a UI can name the connection later.
authenticate: ({ token }) =>
Effect.succeed({
secret: token,
context: { apiKey: "pk1" },
label: "Porkbun (pk1)",
expiresAt: null,
}),
}),
},
session: (credential) => ({
capabilities: () => Effect.succeed(["dns:read", "dns:write"]),
listTargets: () =>
Effect.succeed([{ zone: "example.com", label: "example.com", context: credential.context }]),
resolveTarget: (domain) =>
Effect.succeed(
Provider.Resolution.Resolved({
target: {
zone: domain.split(".").slice(-2).join("."),
label: domain,
context: credential.context,
},
}),
),
dns: () => ({
list: (zone) => porkbunApi(credential, "GET", `/dns/retrieve/${zone}`),
create: (zone, record) => porkbunApi(credential, "POST", `/dns/create/${zone}`, record),
get: (zone, id) => porkbunApi(credential, "GET", `/dns/retrieve/${zone}/${id}`),
delete: (zone, id) =>
porkbunApi(credential, "POST", `/dns/delete/${zone}/${id}`).pipe(Effect.asVoid),
}),
}),
});
// The same shape with OAuth added is Cloudflare; DomainKit handles redirect, callback, refresh, and revoke.
// Stand-in for the HTTP client this example does not ship; a real adapter decodes Porkbun's JSON here.
const rows = new Map<string, DnsRecord.Model>();
function porkbunApi<A>(
credential: Provider.Credential,
method: string,
path: string,
body?: DnsRecord.Model,
): Effect.Effect<A, DomainKit.Error> {
if (Redacted.value(credential.secret).length === 0) {
return Effect.fail(
new DomainKit.Error({ reason: new Reason.Unauthenticated({ message: "missing API key" }) }),
);
}
return Effect.sync(() => {
const [, action, , id] = path.split("/");
if (action === "create" && body !== undefined) {
const providerRecordId = `pb-${rows.size + 1}`;
rows.set(providerRecordId, body);
return { providerRecordId } as A;
}
if (action === "retrieve" && id !== undefined) return (rows.get(id) ?? null) as A;
if (action === "retrieve") {
return [...rows].map(([providerRecordId, record]) => ({ record, providerRecordId })) as A;
}
if (id !== undefined) rows.delete(id);
return undefined as A;
});
}Declare the token fields once
Provider.tokenAuth takes the values the method needs as a fields schema. Secrets are
Schema.RedactedFromValue(Schema.String) and optional values use Schema.optionalKey.
That one declaration does three jobs: the connect form renders from it without knowing the
provider, Connect.start decodes the submitted values before authenticate runs, and
Snapshot.providers[].methods[] carries the field list to the browser.
authenticate receives the decoded record and returns the credential: the opaque secret, the
account context your context schema describes, and expiresAt (null when it does not expire).
The session
session(credential) returns four operations bound to that credential.
| Operation | Contract |
|---|---|
capabilities() |
What the credential actually holds; DomainKit records it on the authorization |
listTargets() |
Every zone the credential reaches, with the context that addresses it |
resolveTarget(domain) |
Resolved, SelectionRequired, or NotFound |
dns(target) |
list, create, get, delete inside one zone |
Provider.resolveAmong(domain, targets) implements the standard rule so an adapter usually does not
write it: the most specific zone wins, several matches at the same depth need a selection, none is
NotFound.
Report the zone’s nameservers on a Target when the provider exposes them. Connect.discover uses
them to break a tie between two connections that both hold the zone.
Declare the suffixes of the nameservers the provider operates as nameservers on the definition
(ns.cloudflare.com, vercel-dns.com). Connect.discover names your provider as the host of a
domain whose authoritative nameservers all end in one of them, so a connect screen can offer it
first.
create must return the provider’s id for the new record. Cleanup deletes by that id, so an adapter
that cannot return one produces receipts nothing can clean up.
Keep opaque records opaque
A record type DomainKit does not model becomes DnsRecord.Opaque with the provider’s own payload.
Planning still sees it, refuses to overwrite it, and reports a conflict, so an adapter never has to
guess at a shape it cannot represent.
Classify failures
Decode the response at the HTTP boundary and fail with the reason that tells a host what to do next.
Never let a raw response body or a credential reach a DomainKit.Error; a provider status or error
code belongs in code.
| Provider condition | Reason |
|---|---|
| Credential rejected | Unauthenticated |
| Credential lacks the permission | Forbidden |
| Zone or record absent | NotFound |
| A conflicting record already exists | ProviderConflict |
| The operation is not supported there | Unsupported |
| Rate limited or unavailable | ProviderUnavailable |
| Anything else the provider refused | ProviderRejected |
Version the stored context
context is persisted as an envelope tagged with contextVersion. Bump the version whenever the
schema changes, and add migrateContext to upgrade an older envelope. Without one, an older
envelope fails Unsupported instead of decoding into the wrong shape.
Register it
Pass the definition to DomainKit.layer({ providers: [porkbun] }), where porkbun is the value the
example above exports.
Providers rejects two definitions with the same id, and Provider.make rejects an id that is not
lowercase kebab-case, a definition with no auth method, and an empty contextVersion.
Prove it
/**
* A provider author runs this against a real account before shipping: create and read back, exact
* no-op, conflict, stale plan, and partial apply, all through the same services hosts use. Every
* record it creates carries the prefix and is removed again.
*/
export const check = (definition: Provider.Definition, token: string, zone: string) =>
Testing.conformance.provider(
definition,
{ secret: Redacted.make(token), context: { apiKey: token } },
zone,
{ prefix: "acme-conformance" },
);
export const run = (definition: Provider.Definition, token: string) =>
Effect.runPromise(check(definition, token, "example.com"));The runner exercises create and read back, exact no-op, conflict, stale plan, and partial apply
through the same Provision and Cleanup services a host uses. It writes to a real zone, so point
it at one you own; every record it creates carries the prefix and is removed again.
Adapting a Promise client
Provider.fromAsync wraps a Promise-shaped definition, for an adapter built on an existing SDK or
client. The lifecycle sees the same Provider.Definition either way.
The provider contract reference states the same rules without the walkthrough.