@alchemy.run/acme 0.0.0 → 2.0.0-beta.85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +48 -4
  3. package/lib/Account.d.ts +121 -0
  4. package/lib/Account.d.ts.map +1 -0
  5. package/lib/Account.js +163 -0
  6. package/lib/Account.js.map +1 -0
  7. package/lib/Certificate.d.ts +138 -0
  8. package/lib/Certificate.d.ts.map +1 -0
  9. package/lib/Certificate.js +157 -0
  10. package/lib/Certificate.js.map +1 -0
  11. package/lib/CertificateAuthority.d.ts +25 -0
  12. package/lib/CertificateAuthority.d.ts.map +1 -0
  13. package/lib/CertificateAuthority.js +22 -0
  14. package/lib/CertificateAuthority.js.map +1 -0
  15. package/lib/Client.d.ts +89 -0
  16. package/lib/Client.d.ts.map +1 -0
  17. package/lib/Client.js +237 -0
  18. package/lib/Client.js.map +1 -0
  19. package/lib/Der.d.ts +43 -0
  20. package/lib/Der.d.ts.map +1 -0
  21. package/lib/Der.js +138 -0
  22. package/lib/Der.js.map +1 -0
  23. package/lib/Dns.d.ts +59 -0
  24. package/lib/Dns.d.ts.map +1 -0
  25. package/lib/Dns.js +104 -0
  26. package/lib/Dns.js.map +1 -0
  27. package/lib/DnsSolver.d.ts +60 -0
  28. package/lib/DnsSolver.d.ts.map +1 -0
  29. package/lib/DnsSolver.js +48 -0
  30. package/lib/DnsSolver.js.map +1 -0
  31. package/lib/Errors.d.ts +93 -0
  32. package/lib/Errors.d.ts.map +1 -0
  33. package/lib/Errors.js +51 -0
  34. package/lib/Errors.js.map +1 -0
  35. package/lib/IssueCertificate.d.ts +76 -0
  36. package/lib/IssueCertificate.d.ts.map +1 -0
  37. package/lib/IssueCertificate.js +3 -0
  38. package/lib/IssueCertificate.js.map +1 -0
  39. package/lib/IssueCertificateHttp.d.ts +24 -0
  40. package/lib/IssueCertificateHttp.d.ts.map +1 -0
  41. package/lib/IssueCertificateHttp.js +56 -0
  42. package/lib/IssueCertificateHttp.js.map +1 -0
  43. package/lib/Pki.d.ts +56 -0
  44. package/lib/Pki.d.ts.map +1 -0
  45. package/lib/Pki.js +182 -0
  46. package/lib/Pki.js.map +1 -0
  47. package/lib/Providers.d.ts +30 -0
  48. package/lib/Providers.d.ts.map +1 -0
  49. package/lib/Providers.js +31 -0
  50. package/lib/Providers.js.map +1 -0
  51. package/lib/index.d.ts +12 -0
  52. package/lib/index.d.ts.map +1 -0
  53. package/lib/index.js +12 -0
  54. package/lib/index.js.map +1 -0
  55. package/package.json +47 -3
  56. package/src/Account.ts +254 -0
  57. package/src/Certificate.ts +272 -0
  58. package/src/CertificateAuthority.ts +41 -0
  59. package/src/Client.ts +405 -0
  60. package/src/Der.ts +157 -0
  61. package/src/Dns.ts +227 -0
  62. package/src/DnsSolver.ts +109 -0
  63. package/src/Errors.ts +90 -0
  64. package/src/IssueCertificate.ts +89 -0
  65. package/src/IssueCertificateHttp.ts +67 -0
  66. package/src/Pki.ts +275 -0
  67. package/src/Providers.ts +39 -0
  68. package/src/index.ts +11 -0
package/src/Dns.ts ADDED
@@ -0,0 +1,227 @@
1
+ /**
2
+ * TXT propagation checks.
3
+ *
4
+ * Public recursive resolvers are a poor oracle: they cache negative
5
+ * answers for the zone's SOA minimum (30 minutes is common) and old
6
+ * record sets past their TTL, so a challenge value published a moment ago
7
+ * can stay invisible for far longer than the CA takes to validate. Where
8
+ * `node:dns` is available (deploy time, Fly Machines) the check therefore
9
+ * asks the zone's **authoritative** nameservers directly — the same
10
+ * servers the CA asks. Networks can intercept port 53, so DNS over HTTPS
11
+ * also checks for the exact challenge value. A stale recursive cache must
12
+ * not veto a positive answer; the CA performs its own validation.
13
+ */
14
+ import * as Duration from "effect/Duration";
15
+ import * as Effect from "effect/Effect";
16
+ import * as HttpClient from "effect/http/HttpClient";
17
+ import * as HttpClientRequest from "effect/http/HttpClientRequest";
18
+ import * as Schedule from "effect/Schedule";
19
+ import { DnsPropagationTimeout } from "./Errors.ts";
20
+
21
+ export interface PropagationOptions {
22
+ /**
23
+ * Wait before the first lookup to avoid caching an unpublished record.
24
+ * Outside the polling budget. Cloudflare defaults to 60 seconds.
25
+ * @default "0 seconds"
26
+ */
27
+ readonly initialDelay?: Duration.Input | undefined;
28
+ /**
29
+ * Extra wait after the record is seen (or, for solvers that skip the
30
+ * check, the whole wait): anycast edges lag the first one that answers.
31
+ * @default "5 seconds"
32
+ */
33
+ readonly delay?: Duration.Input | undefined;
34
+ /**
35
+ * DNS-over-HTTPS JSON endpoints to use instead of automatic discovery.
36
+ * When supplied, every resolver must return the record. Automatic lookup
37
+ * accepts all discovered nameservers or any default DoH resolver.
38
+ * @default Cloudflare (1.1.1.1) and Google public DNS
39
+ */
40
+ readonly resolvers?: ReadonlyArray<string> | undefined;
41
+ /** Polling budget. @default "45 seconds" */
42
+ readonly timeout?: Duration.Input | undefined;
43
+ /** Polling interval. @default "3 seconds" */
44
+ readonly interval?: Duration.Input | undefined;
45
+ }
46
+
47
+ export const DEFAULT_PROPAGATION_DELAY: Duration.Input = "5 seconds";
48
+
49
+ /** Sleep `options.delay` (default 5 seconds). */
50
+ export const propagationDelay = (options: PropagationOptions = {}): Effect.Effect<void> =>
51
+ Effect.sleep(options.delay ?? DEFAULT_PROPAGATION_DELAY);
52
+
53
+ // =============================================================================
54
+ // Authoritative lookup (node:dns where available)
55
+ // =============================================================================
56
+
57
+ interface NodeDns {
58
+ readonly resolveNs: (name: string) => Promise<string[]>;
59
+ readonly resolve4: (name: string) => Promise<string[]>;
60
+ readonly Resolver: new () => {
61
+ setServers(servers: string[]): void;
62
+ resolveTxt(name: string): Promise<string[][]>;
63
+ };
64
+ }
65
+
66
+ const nodeDns: Effect.Effect<NodeDns | undefined> = Effect.tryPromise(
67
+ () => import("node:dns/promises") as unknown as Promise<NodeDns>,
68
+ ).pipe(
69
+ Effect.map((mod) =>
70
+ typeof mod?.Resolver === "function" && typeof mod.resolveNs === "function" ? mod : undefined,
71
+ ),
72
+ Effect.orElseSucceed(() => undefined),
73
+ );
74
+
75
+ /** The zone apex's nameserver IPv4s for `fqdn`, or `[]` when unknown. */
76
+ const authoritativeServers = (dns: NodeDns, fqdn: string): Effect.Effect<ReadonlyArray<string>> =>
77
+ Effect.gen(function* () {
78
+ const labels = fqdn.replace(/\.$/, "").split(".");
79
+ for (let i = 0; i < labels.length - 1; i++) {
80
+ const candidate = labels.slice(i).join(".");
81
+ const names = yield* Effect.tryPromise(() => dns.resolveNs(candidate)).pipe(
82
+ Effect.orElseSucceed(() => [] as string[]),
83
+ );
84
+ if (names.length === 0) continue;
85
+ const addresses = yield* Effect.forEach(
86
+ names,
87
+ (name) =>
88
+ Effect.tryPromise(() => dns.resolve4(name)).pipe(
89
+ Effect.orElseSucceed(() => [] as string[]),
90
+ ),
91
+ { concurrency: "unbounded" },
92
+ );
93
+ return addresses.flat();
94
+ }
95
+ return [];
96
+ });
97
+
98
+ /** TXT values requested from one nameserver (port 53 may be intercepted). */
99
+ const resolveTxtAt = (
100
+ dns: NodeDns,
101
+ server: string,
102
+ fqdn: string,
103
+ ): Effect.Effect<ReadonlyArray<string>> =>
104
+ Effect.gen(function* () {
105
+ const resolver = yield* Effect.sync(() => new dns.Resolver());
106
+ yield* Effect.sync(() => resolver.setServers([server]));
107
+ const records = yield* Effect.tryPromise(() => resolver.resolveTxt(fqdn));
108
+ return records.map((chunks) => chunks.join(""));
109
+ }).pipe(
110
+ Effect.timeout("3 seconds"),
111
+ Effect.orElseSucceed(() => [] as ReadonlyArray<string>),
112
+ );
113
+
114
+ export const DEFAULT_RESOLVERS: ReadonlyArray<string> = [
115
+ "https://cloudflare-dns.com/dns-query",
116
+ "https://dns.google/resolve",
117
+ ];
118
+
119
+ interface DohAnswer {
120
+ readonly type?: number;
121
+ readonly data?: string;
122
+ }
123
+
124
+ const unquote = (data: string): string =>
125
+ (data.match(/"((?:[^"\\]|\\.)*)"/g) ?? [data])
126
+ .map((part) => part.replace(/^"|"$/g, "").replace(/\\"/g, '"'))
127
+ .join("");
128
+
129
+ /**
130
+ * TXT values for `fqdn` from one DoH resolver. Any transport or parse
131
+ * failure reads as "no records yet".
132
+ */
133
+ export const resolveTxt = (
134
+ resolver: string,
135
+ fqdn: string,
136
+ ): Effect.Effect<ReadonlyArray<string>, never, HttpClient.HttpClient> =>
137
+ Effect.gen(function* () {
138
+ const client = yield* HttpClient.HttpClient;
139
+ const request = HttpClientRequest.get(resolver).pipe(
140
+ HttpClientRequest.setUrlParams({ name: fqdn, type: "TXT" }),
141
+ HttpClientRequest.setHeader("Accept", "application/dns-json"),
142
+ );
143
+ const response = yield* client.execute(request);
144
+ const body = (yield* response.json) as { Answer?: DohAnswer[] };
145
+ return (body.Answer ?? [])
146
+ .filter((answer) => answer.type === 16 && typeof answer.data === "string")
147
+ .map((answer) => unquote(answer.data!));
148
+ }).pipe(
149
+ Effect.timeout("3 seconds"),
150
+ Effect.orElseSucceed(() => [] as ReadonlyArray<string>),
151
+ );
152
+
153
+ /**
154
+ * Wait `initialDelay`, then poll for the exact challenge value and wait
155
+ * `delay`. Explicit resolvers must all agree; automatic lookup accepts all
156
+ * discovered nameservers or a positive default DoH answer. Discovery and
157
+ * polling share `timeout`; the delays before and after are outside it.
158
+ */
159
+ export const waitForTxt = (
160
+ fqdn: string,
161
+ value: string,
162
+ options: PropagationOptions = {},
163
+ ): Effect.Effect<void, DnsPropagationTimeout, HttpClient.HttpClient> =>
164
+ Effect.gen(function* () {
165
+ const interval = options.interval ?? "3 seconds";
166
+ const timeout = options.timeout ?? "45 seconds";
167
+ // Bounded by `timeout` below, so poll for the caller's whole budget.
168
+ const attempts = Math.max(
169
+ 1,
170
+ Math.ceil(Duration.toMillis(timeout) / Duration.toMillis(interval)),
171
+ );
172
+ yield* Effect.sleep(options.initialDelay ?? "0 seconds");
173
+ const propagated = yield* Effect.gen(function* () {
174
+ // Explicit resolvers must work even when node:dns is available.
175
+ const dns = options.resolvers === undefined ? yield* nodeDns : undefined;
176
+ const servers =
177
+ dns === undefined
178
+ ? []
179
+ : yield* authoritativeServers(dns, fqdn).pipe(
180
+ Effect.timeout("5 seconds"),
181
+ Effect.orElseSucceed(() => [] as ReadonlyArray<string>),
182
+ );
183
+ const check = Effect.all(
184
+ {
185
+ direct: Effect.all(
186
+ dns === undefined
187
+ ? []
188
+ : servers.map((server) =>
189
+ resolveTxtAt(dns, server, fqdn).pipe(
190
+ Effect.map((values) => values.includes(value)),
191
+ ),
192
+ ),
193
+ { concurrency: "unbounded" },
194
+ ),
195
+ doh: Effect.forEach(
196
+ options.resolvers ?? DEFAULT_RESOLVERS,
197
+ (resolver) =>
198
+ resolveTxt(resolver, fqdn).pipe(Effect.map((values) => values.includes(value))),
199
+ { concurrency: "unbounded" },
200
+ ),
201
+ },
202
+ { concurrency: "unbounded" },
203
+ ).pipe(
204
+ Effect.map(({ direct, doh }) =>
205
+ options.resolvers !== undefined
206
+ ? doh.length > 0 && doh.every(Boolean)
207
+ : (direct.length > 0 && direct.every(Boolean)) || doh.some(Boolean),
208
+ ),
209
+ );
210
+ return yield* check.pipe(
211
+ Effect.repeat({
212
+ schedule: Schedule.spaced(interval),
213
+ until: (seen) => seen,
214
+ times: attempts,
215
+ }),
216
+ );
217
+ }).pipe(
218
+ Effect.timeoutOrElse({
219
+ duration: timeout,
220
+ orElse: () => Effect.fail(new DnsPropagationTimeout({ fqdn, value })),
221
+ }),
222
+ );
223
+ if (!propagated) {
224
+ return yield* new DnsPropagationTimeout({ fqdn, value });
225
+ }
226
+ yield* propagationDelay(options);
227
+ });
@@ -0,0 +1,109 @@
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 solver has a constructor
10
+ * (`Cloudflare.ACME.ZoneSolver(zone)`) and a layer registering the
11
+ * matching implementation, so the ACME provider never imports a DNS SDK
12
+ * 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.ACME.zoneSolver(dns)`).
16
+ */
17
+ import * as Context from "effect/Context";
18
+ import * as Effect from "effect/Effect";
19
+ import type * as HttpClient from "effect/http/HttpClient";
20
+ import * as Layer from "effect/Layer";
21
+ import * as Option from "effect/Option";
22
+ import type { PropagationOptions } from "./Dns.ts";
23
+ import {
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. */
30
+ export 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. */
36
+ export interface DnsSolver<R = never> {
37
+ /** Publish `value` as a TXT record at `fqdn`. Idempotent. */
38
+ readonly present: (record: DnsChallengeRecord) => Effect.Effect<void, DnsSolverError, R>;
39
+ /** Remove the record published by `present`. Idempotent; runs in a finalizer. */
40
+ readonly cleanup: (record: DnsChallengeRecord) => Effect.Effect<void, DnsSolverError, R>;
41
+ /**
42
+ * Wait until the record is visible. Defaults to polling public DNS over
43
+ * HTTPS; a solver for a private DNS (tests, split-horizon zones) overrides it.
44
+ */
45
+ readonly propagated?:
46
+ | ((
47
+ record: DnsChallengeRecord,
48
+ options: PropagationOptions,
49
+ ) => Effect.Effect<void, DnsSolverError | DnsPropagationTimeout, R | HttpClient.HttpClient>)
50
+ | undefined;
51
+ }
52
+
53
+ /**
54
+ * Serializable solver description stored in `ACME.Certificate` props.
55
+ * `type` selects the registered implementation; the rest is provider data.
56
+ */
57
+ export interface DnsSolverDescriptor {
58
+ readonly type: string;
59
+ readonly [key: string]: unknown;
60
+ }
61
+
62
+ /** Builds a runtime solver from a descriptor at reconcile time. */
63
+ export type DnsSolverFactory = (
64
+ descriptor: DnsSolverDescriptor,
65
+ ) => Effect.Effect<DnsSolver, DnsSolverError>;
66
+
67
+ const solverService = (type: string) =>
68
+ Context.Service<DnsSolverFactory>(`alchemy/ACME/DnsSolver/${type}`);
69
+
70
+ /** Resolve the registered implementation for a descriptor. */
71
+ export const resolveDnsSolver = (
72
+ descriptor: DnsSolverDescriptor,
73
+ ): Effect.Effect<DnsSolver, DnsSolverNotRegistered | DnsSolverError> =>
74
+ Effect.gen(function* () {
75
+ const factory = yield* Effect.serviceOption(solverService(descriptor.type));
76
+ if (Option.isNone(factory)) {
77
+ return yield* new DnsSolverNotRegistered({ type: descriptor.type });
78
+ }
79
+ return yield* factory.value(descriptor);
80
+ });
81
+
82
+ /** A solver whose methods run with `context` provided (drops `R`). */
83
+ export const provideSolver = <R>(solver: DnsSolver<R>, context: Context.Context<R>): DnsSolver => ({
84
+ present: (record) => solver.present(record).pipe(Effect.provideContext(context)),
85
+ cleanup: (record) => solver.cleanup(record).pipe(Effect.provideContext(context)),
86
+ propagated:
87
+ solver.propagated === undefined
88
+ ? undefined
89
+ : (record, options) =>
90
+ solver.propagated!(record, options).pipe(Effect.provideContext(context)),
91
+ });
92
+
93
+ /**
94
+ * Register a solver implementation from a `providers()` layer, capturing
95
+ * whatever services `R` the solver needs from that layer's own context so
96
+ * the ACME provider can run it without knowing about the DNS provider.
97
+ */
98
+ export const dnsSolverLayer = <R>(
99
+ type: string,
100
+ make: (descriptor: DnsSolverDescriptor) => Effect.Effect<DnsSolver<R>, DnsSolverError>,
101
+ ): Layer.Layer<DnsSolverFactory, never, R> =>
102
+ Layer.effect(
103
+ solverService(type),
104
+ Effect.gen(function* () {
105
+ const context = yield* Effect.context<R>();
106
+ return (descriptor: DnsSolverDescriptor) =>
107
+ make(descriptor).pipe(Effect.map((solver) => provideSolver(solver, context)));
108
+ }),
109
+ );
package/src/Errors.ts ADDED
@@ -0,0 +1,90 @@
1
+ import * as Data from "effect/Data";
2
+
3
+ /** A problem the CA attached to an order or one of its identifiers. */
4
+ export interface IdentifierProblem {
5
+ /** The identifier the problem concerns, when the CA said. */
6
+ readonly identifier?: string | undefined;
7
+ /** The `urn:ietf:params:acme:error:*` type. */
8
+ readonly type?: string | undefined;
9
+ /** Human-readable detail from the CA. */
10
+ readonly detail?: string | undefined;
11
+ }
12
+
13
+ /**
14
+ * The CA moved the order to `invalid`. Carries the problem documents the
15
+ * order and its authorizations reported, one per failed identifier where
16
+ * the CA said which.
17
+ */
18
+ export class OrderInvalid extends Data.TaggedError("ACME.OrderInvalid")<{
19
+ readonly orderUrl: string;
20
+ readonly problems: ReadonlyArray<IdentifierProblem>;
21
+ }> {
22
+ override get message() {
23
+ const details = this.problems
24
+ .map((p) => [p.identifier, p.type, p.detail].filter(Boolean).join(": "))
25
+ .join("; ");
26
+ return `ACME order ${this.orderUrl} is invalid${details ? `: ${details}` : ""}`;
27
+ }
28
+ }
29
+
30
+ /** The CA rejected the DNS-01 challenge for one identifier. */
31
+ export class ChallengeFailed extends Data.TaggedError("ACME.ChallengeFailed")<{
32
+ readonly identifier: string;
33
+ readonly type?: string | undefined;
34
+ readonly detail?: string | undefined;
35
+ }> {
36
+ override get message() {
37
+ return `DNS-01 challenge for ${this.identifier} failed${this.detail ? `: ${this.detail}` : ""}`;
38
+ }
39
+ }
40
+
41
+ /** The CA offered no `dns-01` challenge for an identifier. */
42
+ export class ChallengeUnsupported extends Data.TaggedError("ACME.ChallengeUnsupported")<{
43
+ readonly identifier: string;
44
+ readonly offered: ReadonlyArray<string>;
45
+ }> {
46
+ override get message() {
47
+ return `The CA offered no dns-01 challenge for ${this.identifier} (offered: ${this.offered.join(", ") || "none"})`;
48
+ }
49
+ }
50
+
51
+ /** The order never reached a terminal state within the polling budget. */
52
+ export class OrderTimeout extends Data.TaggedError("ACME.OrderTimeout")<{
53
+ readonly orderUrl: string;
54
+ readonly status: string;
55
+ }> {
56
+ override get message() {
57
+ return `ACME order ${this.orderUrl} is still "${this.status}" after the polling budget`;
58
+ }
59
+ }
60
+
61
+ /** The `_acme-challenge` TXT record never became visible to public resolvers. */
62
+ export class DnsPropagationTimeout extends Data.TaggedError("ACME.DnsPropagationTimeout")<{
63
+ readonly fqdn: string;
64
+ readonly value: string;
65
+ }> {
66
+ override get message() {
67
+ return `TXT ${this.fqdn} did not propagate within the wait budget`;
68
+ }
69
+ }
70
+
71
+ /** A DNS solver could not publish or remove a challenge record. */
72
+ export class DnsSolverError extends Data.TaggedError("ACME.DnsSolverError")<{
73
+ readonly message: string;
74
+ readonly cause?: unknown;
75
+ }> {}
76
+
77
+ /** A solver descriptor names a `type` no registered DNS provider handles. */
78
+ export class DnsSolverNotRegistered extends Data.TaggedError("ACME.DnsSolverNotRegistered")<{
79
+ readonly type: string;
80
+ }> {
81
+ override get message() {
82
+ return `No DNS-01 solver is registered for "${this.type}". Include ACME.providers() in the stack.`;
83
+ }
84
+ }
85
+
86
+ /** Key generation, CSR encoding or certificate parsing failed. */
87
+ export class PkiError extends Data.TaggedError("ACME.PkiError")<{
88
+ readonly message: string;
89
+ readonly cause?: unknown;
90
+ }> {}
@@ -0,0 +1,89 @@
1
+ import * as Binding from "alchemy/Binding";
2
+ import type { RuntimeContext } from "alchemy/RuntimeContext";
3
+ import type * as Effect from "effect/Effect";
4
+ import type { Account } from "./Account.ts";
5
+ import type {
6
+ IssueError,
7
+ IssueRequest,
8
+ IssuedCertificate,
9
+ RevokeError,
10
+ RevokeRequest,
11
+ } from "./Client.ts";
12
+
13
+ /**
14
+ * Issue (and revoke) certificates at runtime as an {@link Account} — for
15
+ * services that terminate TLS themselves and need certificates on demand,
16
+ * like a relay minting `*.<tenant>.example.com` when a tenant first
17
+ * connects.
18
+ *
19
+ * Init transports the account's directory URL, account URL and redacted
20
+ * private key through resource Outputs. Protect the runtime and state store
21
+ * as secret material. The caller chooses where to store the issued PEMs.
22
+ *
23
+ * ### Issue on demand
24
+ * Publish the DNS-01 record through any runtime DNS write client wrapped
25
+ * as a solver (`Cloudflare.ACME.zoneSolver(dns)`).
26
+ *
27
+ * **Example:** Mint a wildcard from a Fly Service and upload it to its App
28
+ * ```typescript
29
+ * export default class Relay extends Fly.Service<Relay>()(
30
+ * "Relay",
31
+ * { app: RelayApp, main: import.meta.url },
32
+ * Effect.gen(function* () {
33
+ * const acme = yield* ACME.IssueCertificate(ZeroSsl);
34
+ * const dns = yield* Cloudflare.DNS.WriteDns(Zone);
35
+ * const certs = yield* Fly.WriteCertificates(RelayApp);
36
+ * return {
37
+ * fetch: Effect.gen(function* () {
38
+ * const issued = yield* acme.issue({
39
+ * identifiers: ["*.tenant.example.com"],
40
+ * solver: Cloudflare.ACME.zoneSolver(dns),
41
+ * });
42
+ * yield* certs.upload({
43
+ * hostname: "*.tenant.example.com",
44
+ * fullchain: issued.chain,
45
+ * privateKey: issued.privateKey,
46
+ * });
47
+ * return HttpServerResponse.text("ok");
48
+ * }),
49
+ * };
50
+ * }).pipe(
51
+ * Effect.provide(ACME.IssueCertificateHttp),
52
+ * Effect.provide(Cloudflare.DNS.WriteDnsHttp),
53
+ * Effect.provide(Fly.WriteCertificatesHttp),
54
+ * ),
55
+ * ) {}
56
+ * ```
57
+ *
58
+ * ### Rate limits
59
+ * The SDK retry policy applies to individual requests. Apply
60
+ * `Acme.Retry.none` from `@distilled.cloud/acme` to surface failures
61
+ * immediately; `AcmeRateLimited.retryAfter` carries the CA's retry hint.
62
+ *
63
+ * ### Which CA from a Cloudflare Worker
64
+ * CA reachability depends on the host's egress. Let's Encrypt has returned
65
+ * TLS failures from deployed Cloudflare Workers in live testing; the
66
+ * deployed-Worker example uses ZeroSSL instead. Verify the selected CA
67
+ * from the runtime where issuance will run.
68
+ *
69
+ * @binding
70
+ * @product Certificate
71
+ * @category Certificates
72
+ */
73
+ export interface IssueCertificate extends Binding.Service<
74
+ IssueCertificate,
75
+ "ACME.IssueCertificate",
76
+ (account: Account) => Effect.Effect<IssueCertificateClient>
77
+ > {}
78
+
79
+ export const IssueCertificate = Binding.Service<IssueCertificate>("ACME.IssueCertificate");
80
+
81
+ /** Runtime issuance client bound to one account. */
82
+ export interface IssueCertificateClient {
83
+ /** Order, solve DNS-01, finalize and download a certificate. */
84
+ issue<R = never>(
85
+ request: IssueRequest<R>,
86
+ ): Effect.Effect<IssuedCertificate, IssueError, RuntimeContext | R>;
87
+ /** Revoke a certificate (`AcmeAlreadyRevoked` counts as success). */
88
+ revoke(request: RevokeRequest): Effect.Effect<void, RevokeError, RuntimeContext>;
89
+ }
@@ -0,0 +1,67 @@
1
+ import * as Output from "alchemy/Output";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Layer from "effect/Layer";
4
+ import type { Account } from "./Account.ts";
5
+ import {
6
+ accountLayer,
7
+ issueCertificate,
8
+ revokeCertificate,
9
+ type AccountCredentials,
10
+ } from "./Client.ts";
11
+ import { IssueCertificate, type IssueCertificateClient } from "./IssueCertificate.ts";
12
+
13
+ /**
14
+ * Implementation of {@link IssueCertificate}: the account's directory
15
+ * URL, account URL and private key are bound into the host at deploy time
16
+ * and read back at runtime; every call signs with them over HTTPS.
17
+ *
18
+ * Provide it on the Worker / Service / Action Effect.
19
+ *
20
+ * ### Provide the layer
21
+ * **Example:** On a Fly Service
22
+ * ```typescript
23
+ * Effect.gen(function* () {
24
+ * const acme = yield* ACME.IssueCertificate(LetsEncrypt);
25
+ * // ...
26
+ * }).pipe(Effect.provide(ACME.IssueCertificateHttp))
27
+ * ```
28
+ *
29
+ * @layer
30
+ * @product Certificate
31
+ * @provides ACME.IssueCertificate
32
+ */
33
+ export const IssueCertificateHttp = Layer.succeed(
34
+ IssueCertificate,
35
+ Effect.fn(function* (account: Account) {
36
+ const directoryUrl = yield* account.directoryUrl;
37
+ const accountUrl = yield* account.accountUrl;
38
+ const trustedRoot = yield* account.trustedRoot.pipe(Output.map((root) => root ?? ""));
39
+ const privateKey = yield* account.privateKey;
40
+ const credentials = Effect.all({
41
+ directoryUrl,
42
+ trustedRoot,
43
+ accountUrl,
44
+ privateKey,
45
+ }).pipe(
46
+ Effect.map((bound): AccountCredentials => ({
47
+ ca: {
48
+ directoryUrl: bound.directoryUrl,
49
+ trustedRoot: bound.trustedRoot || undefined,
50
+ },
51
+ accountKey: bound.privateKey,
52
+ accountUrl: bound.accountUrl,
53
+ })),
54
+ );
55
+ const client: IssueCertificateClient = {
56
+ issue: Effect.fn("ACME.IssueCertificate.issue")(function* (request) {
57
+ const creds = yield* credentials;
58
+ return yield* issueCertificate(request).pipe(Effect.provide(accountLayer(creds)));
59
+ }),
60
+ revoke: Effect.fn("ACME.IssueCertificate.revoke")(function* (request) {
61
+ const creds = yield* credentials;
62
+ return yield* revokeCertificate(request).pipe(Effect.provide(accountLayer(creds)));
63
+ }),
64
+ };
65
+ return client;
66
+ }),
67
+ );