1/**
2 * DNS-01 solvers: how the ACME flow publishes `_acme-challenge` TXT
3 * records.
4 *
5 * A solver has two forms:
6 *
7 * - a **descriptor** — plain, serializable data in a resource's props
8 * (`{ type: "Cloudflare.DNS", zoneId }`) that survives state and the RPC
9 * boundary. Each DNS provider contributes a constructor
10 * (`Cloudflare.DNS.AcmeSolver(zone)`) and registers the matching
11 * implementation from its `providers()` layer, so the ACME provider
12 * never imports a DNS SDK itself.
13 * - a **runtime solver** — the {@link DnsSolver} interface a running
14 * Worker or Service hands to `IssueCertificate.issue`, built from a
15 * runtime DNS write client (`Cloudflare.DNS.acmeDnsSolver(dns)`).
16 */
17import * as Context from "effect/Context";
18import * as Effect from "effect/Effect";
19import * as Layer from "effect/Layer";
20import * as Option from "effect/Option";
21import type * as HttpClient from "effect/http/HttpClient";
22import type { PropagationOptions } from "./Dns.ts";
23import {
24 DnsSolverNotRegistered,
25 type DnsPropagationTimeout,
26 type DnsSolverError,
27} from "./Errors.ts";
28
29/** A challenge record to publish: the `_acme-challenge.<name>` FQDN and its TXT value. */
30export interface DnsChallengeRecord {
31 readonly fqdn: string;
32 readonly value: string;
33}
34
35/** Runtime DNS-01 solver. `R` is whatever context the underlying client needs. */
36export interface DnsSolver<R = never> {
37 /** Publish `value` as a TXT record at `fqdn`. Idempotent. */
38 readonly present: (
39 record: DnsChallengeRecord,
40 ) => Effect.Effect<void, DnsSolverError, R>;
41 /** Remove the record published by `present`. Idempotent; runs in a finalizer. */
42 readonly cleanup: (
43 record: DnsChallengeRecord,
44 ) => Effect.Effect<void, DnsSolverError, R>;
45 /**
46 * Wait until the record is visible. Defaults to polling public DNS over
47 * HTTPS; a solver for a private DNS (tests, split-horizon zones) overrides it.
48 */
49 readonly propagated?:
50 | ((
51 record: DnsChallengeRecord,
52 options: PropagationOptions,
53 ) => Effect.Effect<
54 void,
55 DnsSolverError | DnsPropagationTimeout,
56 R | HttpClient.HttpClient
57 >)
58 | undefined;
59}
60
61/**
62 * Serializable solver description stored in `ACME.Certificate` props.
63 * `type` selects the registered implementation; the rest is provider data.
64 */
65export interface DnsSolverDescriptor {
66 readonly type: string;
67 readonly [key: string]: unknown;
68}
69
70/** Builds a runtime solver from a descriptor at reconcile time. */
71export type DnsSolverFactory = (
72 descriptor: DnsSolverDescriptor,
73) => Effect.Effect<DnsSolver, DnsSolverError>;
74
75const solverService = (type: string) =>
76 Context.Service<DnsSolverFactory>(`alchemy/ACME/DnsSolver/${type}`);
77
78/** Resolve the registered implementation for a descriptor. */
79export const resolveDnsSolver = (
80 descriptor: DnsSolverDescriptor,
81): Effect.Effect<DnsSolver, DnsSolverNotRegistered | DnsSolverError> =>
82 Effect.gen(function* () {
83 const factory = yield* Effect.serviceOption(solverService(descriptor.type));
84 if (Option.isNone(factory)) {
85 return yield* new DnsSolverNotRegistered({ type: descriptor.type });
86 }
87 return yield* factory.value(descriptor);
88 });
89
90/** A solver whose methods run with `context` provided (drops `R`). */
91export const provideSolver = <R>(
92 solver: DnsSolver<R>,
93 context: Context.Context<R>,
94): DnsSolver => ({
95 present: (record) =>
96 solver.present(record).pipe(Effect.provideContext(context)),
97 cleanup: (record) =>
98 solver.cleanup(record).pipe(Effect.provideContext(context)),
99 propagated:
100 solver.propagated === undefined
101 ? undefined
102 : (record, options) =>
103 solver.propagated!(record, options).pipe(
104 Effect.provideContext(context),
105 ),
106});
107
108/**
109 * Register a solver implementation from a `providers()` layer, capturing
110 * whatever services `R` the solver needs from that layer's own context so
111 * the ACME provider can run it without knowing about the DNS provider.
112 */
113export const dnsSolverLayer = <R>(
114 type: string,
115 make: (
116 descriptor: DnsSolverDescriptor,
117 ) => Effect.Effect<DnsSolver<R>, DnsSolverError>,
118): Layer.Layer<DnsSolverFactory, never, R> =>
119 Layer.effect(
120 solverService(type),
121 Effect.gen(function* () {
122 const context = yield* Effect.context<R>();
123 return (descriptor: DnsSolverDescriptor) =>
124 make(descriptor).pipe(
125 Effect.map((solver) => provideSolver(solver, context)),
126 );
127 }),
128 );