| 1 | import type * as Effect from "effect/Effect"; |
| 2 | import * as Binding from "../Binding.ts"; |
| 3 | import type { RuntimeContext } from "../RuntimeContext.ts"; |
| 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.DNS.acmeDnsSolver(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.DNS.acmeDnsSolver(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>( |
| 80 | "ACME.IssueCertificate", |
| 81 | ); |
| 82 | |
| 83 | /** Runtime issuance client bound to one account. */ |
| 84 | export interface IssueCertificateClient { |
| 85 | /** Order, solve DNS-01, finalize and download a certificate. */ |
| 86 | issue<R = never>( |
| 87 | request: IssueRequest<R>, |
| 88 | ): Effect.Effect<IssuedCertificate, IssueError, RuntimeContext | R>; |
| 89 | /** Revoke a certificate (`AcmeAlreadyRevoked` counts as success). */ |
| 90 | revoke( |
| 91 | request: RevokeRequest, |
| 92 | ): Effect.Effect<void, RevokeError, RuntimeContext>; |
| 93 | } |