1import * as Acme from "@distilled.cloud/acme";
2import * as acme from "@distilled.cloud/acme/acme";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Redacted from "effect/Redacted";
6import { isResolved } from "../Diff.ts";
7import * as Provider from "../Provider.ts";
8import { Resource } from "../Resource.ts";
9import type { CertificateAuthority } from "./CertificateAuthority.ts";
10import { accountLayer, type AccountCredentials } from "./Client.ts";
11import type { KeyAlgorithm } from "./Pki.ts";
12import type { Providers } from "./Providers.ts";
13
14export interface ExternalAccountBinding {
15 /** The key id the CA issued (ZeroSSL: `eab_kid`). */
16 keyId: string;
17 /** The base64url HMAC key the CA issued (ZeroSSL: `eab_hmac_key`). */
18 hmacKey: Redacted.Redacted<string> | string;
19}
20
21export interface AccountProps {
22 /**
23 * The certificate authority: a preset ({@link LetsEncrypt},
24 * {@link ZeroSSL}, …) or `{ directoryUrl }`. Changing it replaces the
25 * account.
26 */
27 ca: CertificateAuthority;
28 /**
29 * Contact URLs (`mailto:…`) the CA notifies about expiry and incidents.
30 * Updated in place.
31 */
32 contact?: string[];
33 /**
34 * You must agree to the CA's terms of service. Explicit on purpose —
35 * never defaulted.
36 */
37 termsOfServiceAgreed: true;
38 /**
39 * External Account Binding for CAs that require one (ZeroSSL, Google
40 * Trust Services). Changing the key id replaces the account.
41 */
42 eab?: ExternalAccountBinding;
43 /**
44 * Account key algorithm. Changing it replaces the account.
45 * @default "ES256"
46 */
47 keyAlgorithm?: KeyAlgorithm;
48}
49
50export interface Account extends Resource<
51 "ACME.Account",
52 AccountProps,
53 {
54 /** The CA's directory URL. */
55 directoryUrl: string;
56 /** PEM root trusted for the CA's HTTPS endpoint (private CAs only). */
57 trustedRoot: string | undefined;
58 /** The account URL the CA assigned — the `kid` on every later request. */
59 accountUrl: string;
60 /** Observed account status (`valid`, `deactivated`, `revoked`). */
61 status: string;
62 /** Observed contact URLs. */
63 contact: string[];
64 /** Account key algorithm. */
65 keyAlgorithm: KeyAlgorithm;
66 /** The account's private JWK (JSON). Sensitive; persisted in stack state. */
67 privateKey: Redacted.Redacted<string>;
68 },
69 never,
70 Providers
71> {}
72
73/**
74 * An ACME account with a certificate authority — the identity that every
75 * {@link Certificate} and every runtime {@link IssueCertificate} call
76 * signs with. One per CA per stack.
77 *
78 * The account key is generated on first reconcile and persisted as a
79 * redacted value alongside the CA's account URL. Protect the state store
80 * as secret material; redaction alone does not encrypt the storage backend.
81 *
82 * ### Let's Encrypt
83 * **Example:** An account with Let's Encrypt
84 * ```typescript
85 * export const LetsEncrypt = ACME.Account("LetsEncrypt", {
86 * ca: ACME.LetsEncrypt,
87 * contact: ["mailto:ops@example.com"],
88 * termsOfServiceAgreed: true,
89 * });
90 * ```
91 *
92 * ### External Account Binding
93 * ZeroSSL and Google Trust Services bind ACME accounts to an existing
94 * customer account with a key id + HMAC pair from their dashboard (ZeroSSL
95 * also mints them over its REST API — see `@distilled.cloud/zerossl`).
96 *
97 * **Example:** ZeroSSL
98 * ```typescript
99 * export const ZeroSsl = ACME.Account("ZeroSSL", {
100 * ca: ACME.ZeroSSL,
101 * eab: {
102 * keyId: Config.String("ZEROSSL_EAB_KID"),
103 * hmacKey: Config.Redacted("ZEROSSL_EAB_HMAC_KEY"),
104 * },
105 * termsOfServiceAgreed: true,
106 * });
107 * ```
108 *
109 * ### Private CAs
110 * A private CA (Pebble, step-ca) whose HTTPS certificate is not publicly
111 * trusted passes its root as `trustedRoot`.
112 *
113 * **Example:** Pebble
114 * ```typescript
115 * export const Pebble = ACME.Account("Pebble", {
116 * ca: { directoryUrl: "https://localhost:14000/dir", trustedRoot: pebbleRootPem },
117 * termsOfServiceAgreed: true,
118 * });
119 * ```
120 *
121 * @resource
122 * @product Account
123 * @category Certificates
124 */
125export const Account = Resource<Account>("ACME.Account");
126
127/** The CA answered `newAccount` without a `Location`, so the account has no URL. */
128export class AccountUrlMissing extends Data.TaggedError(
129 "ACME.AccountUrlMissing",
130)<{
131 readonly directoryUrl: string;
132}> {}
133
134/**
135 * Normalize to exactly one `Redacted` layer: a value that already came
136 * redacted (an SDK's sensitive field) wrapped again by the caller would
137 * otherwise sign with an object instead of the key.
138 */
139const unwrap = (value: Redacted.Redacted<string> | string) => {
140 let inner: unknown = value;
141 while (Redacted.isRedacted(inner)) inner = Redacted.value(inner);
142 return Redacted.make(String(inner));
143};
144
145const credentialsOf = (
146 props: Pick<AccountProps, "ca" | "eab">,
147 accountKey: Redacted.Redacted<string>,
148 accountUrl: string | undefined,
149): AccountCredentials => ({
150 ca: props.ca,
151 accountKey,
152 accountUrl,
153 externalAccountBinding:
154 props.eab === undefined
155 ? undefined
156 : { keyId: props.eab.keyId, hmacKey: unwrap(props.eab.hmacKey) },
157});
158
159/** `newAccount` with `onlyReturnExisting` — the account, or `undefined`. */
160const observeAccount = (credentials: AccountCredentials) =>
161 acme.newAccount({ onlyReturnExisting: true }).pipe(
162 Effect.catchTag("AcmeAccountDoesNotExist", () => Effect.succeed(undefined)),
163 Effect.provide(accountLayer(credentials)),
164 );
165
166const sameContacts = (
167 a: ReadonlyArray<string> | undefined,
168 b: ReadonlyArray<string> | undefined,
169) => {
170 const left = [...(a ?? [])].sort();
171 const right = [...(b ?? [])].sort();
172 return left.length === right.length && left.every((v, i) => v === right[i]);
173};
174
175export const AccountProvider = () =>
176 Provider.succeed(Account, {
177 stables: ["directoryUrl", "accountUrl", "keyAlgorithm", "privateKey"],
178
179 diff: Effect.fn(function* ({ news, olds, output }) {
180 if (news === undefined || !isResolved(news) || output === undefined) {
181 return undefined;
182 }
183 if (
184 news.ca.directoryUrl !== output.directoryUrl ||
185 (news.keyAlgorithm ?? "ES256") !== output.keyAlgorithm ||
186 news.eab?.keyId !== olds?.eab?.keyId
187 ) {
188 return { action: "replace" as const };
189 }
190 return undefined;
191 }),
192
193 read: Effect.fn(function* ({ olds, output }) {
194 if (output === undefined || olds === undefined) return undefined;
195 const credentials = credentialsOf(
196 olds,
197 output.privateKey,
198 output.accountUrl,
199 );
200 const existing = yield* observeAccount(credentials);
201 if (existing === undefined) return undefined;
202 return {
203 ...output,
204 status: existing.status,
205 contact: existing.contact ?? [],
206 };
207 }),
208
209 reconcile: Effect.fn(function* ({ news, output }) {
210 const keyAlgorithm = news.keyAlgorithm ?? "ES256";
211 // Observe: the key is the identity; keep it across reconciles.
212 const accountKey =
213 output?.privateKey ??
214 (yield* Acme.Jose.generateAccountKey(keyAlgorithm));
215 const credentials = credentialsOf(news, accountKey, output?.accountUrl);
216 let account = yield* observeAccount(credentials);
217
218 // Repeating newAccount with the same key returns the existing account.
219 if (account === undefined) {
220 account = yield* acme
221 .newAccount({
222 contact: news.contact,
223 termsOfServiceAgreed: true,
224 })
225 .pipe(Effect.provide(accountLayer(credentials)));
226 }
227 const accountUrl = account.location ?? output?.accountUrl;
228 if (accountUrl === undefined) {
229 return yield* new AccountUrlMissing({
230 directoryUrl: news.ca.directoryUrl,
231 });
232 }
233
234 // Sync: contacts are the only mutable aspect.
235 const withKid = accountLayer(credentialsOf(news, accountKey, accountUrl));
236 if (!sameContacts(news.contact, account.contact)) {
237 account = yield* acme
238 .updateAccount({ url: accountUrl, contact: news.contact ?? [] })
239 .pipe(Effect.provide(withKid));
240 }
241
242 return {
243 directoryUrl: news.ca.directoryUrl,
244 trustedRoot: news.ca.trustedRoot,
245 accountUrl,
246 status: account.status,
247 contact: account.contact ?? [],
248 keyAlgorithm,
249 privateKey: accountKey,
250 };
251 }),
252
253 delete: Effect.fn(function* ({ olds, output }) {
254 const credentials = credentialsOf(
255 olds,
256 output.privateKey,
257 output.accountUrl,
258 );
259 // Deactivation is permanent and idempotent: a deactivated account
260 // answers `unauthorized`, a purged one `accountDoesNotExist`.
261 yield* acme
262 .updateAccount({ url: output.accountUrl, status: "deactivated" })
263 .pipe(
264 Effect.catchTag(["AcmeAccountDoesNotExist", "AcmeUnauthorized"], () =>
265 Effect.succeed(undefined),
266 ),
267 Effect.provide(accountLayer(credentials)),
268 );
269 }),
270 });