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

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.

Last updated on

Was this page helpful?