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.