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

Provider contract

What a provider definition must implement, which record types travel portably, and how provider failures are classified.

A provider is one Provider.Definition value. Auth methods are optional cases, so a provider that only issues personal tokens declares token and nothing else. The definition drives the connection routes, refresh, the UI method catalog, and rebuilding a session from stored context.

The definition

Field Purpose
id Lowercase kebab-case, stable, stored on every row
name The display name a UI shows
auth Any of token, oauth, integration; at least one
context The schema for the account and zone context stored on authorizations and attachments
contextVersion The tag written into every stored context envelope, bumped with the schema
migrateContext Upgrades an envelope written under an older version; absent means Unsupported
session A credential to a Session

The session

Operation Contract
capabilities() What the credential actually holds; promoted onto the authorization
listTargets() Every zone the credential reaches, with the identity needed to address it
resolveTarget(domain) Resolved, SelectionRequired, or NotFound; never chooses across accounts
dns(target) list, create, get, delete inside one zone

create must return the provider’s id for the new record. Cleanup deletes by that id, so a provider that cannot return one cannot be cleaned up by receipt.

Provider.resolveAmong(domain, targets) implements the standard rule: the most specific zone wins, several matches at the same depth need a selection, none is NotFound.

Token methods

Provider.tokenAuth declares the values a method needs once, as a fields schema. Secrets are Schema.RedactedFromValue(Schema.String) and optional values use Schema.optionalKey, so the connect form renders from the definition and Connect.start decodes the values before the provider sees them.

// 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;
  });
}

Interactive registration metadata

Provider.OAuthAuth.registration optionally declares clientAuth (none, client_secret_basic, or client_secret_post) and pkce: "S256". The OAuth method’s existing scopes supplies its scope requirements. Provider.IntegrationAuth.registration optionally declares clientAuth: "client_secret_post", the authentication for its code exchange. These fields describe the adapter’s actual requests and carry no credentials. Provider.fromAsync preserves the same optional metadata from AsyncOAuthAuth and AsyncIntegrationAuth.

Existing custom methods can omit metadata and still connect. Offline Server.registrationSettings fails Unsupported for an interactive method that omits it, so an incomplete description cannot look complete. Token methods need no application registration.

Naming the account

Every method answers with an IssuedCredential: the sealed secret, the account context, an expiresAt, and an optional label. The label is what the provider calls the account the credential reaches, stored at connect time so a UI names a connection without a provider call. A credential that spans several accounts names none, and answers null.

Portable record types

Both first-party adapters support A, AAAA, CAA, CNAME, MX, NS, SRV, and TXT. A provider record DomainKit cannot model is kept as DnsRecord.Opaque during reads, so planning sees it, refuses to overwrite it, and reports a conflict instead.

Classifying a failure

Decode the provider’s 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.

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

Conformance

/**
 * 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 hosts use, then removes every record it wrote.

Last updated on

Was this page helpful?