1import * as cloudfront from "@distilled.cloud/aws/cloudfront";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Redacted from "effect/Redacted";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import type { Providers } from "../Providers.ts";
12import { extractValue } from "./common.ts";
13
14export interface PublicKeyProps {
15 /**
16 * Name of the public key. If omitted, a deterministic name is generated.
17 *
18 * Names must be unique per AWS account. Changing the name triggers
19 * a replacement.
20 */
21 name?: string;
22 /**
23 * PEM-encoded public key body. Changing the key material triggers a
24 * replacement (CloudFront does not allow rotating an existing public
25 * key in place).
26 */
27 encodedKey: Redacted.Redacted<string> | string;
28 /**
29 * Optional comment describing the key.
30 */
31 comment?: string;
32}
33
34export interface PublicKey extends Resource<
35 "AWS.CloudFront.PublicKey",
36 PublicKeyProps,
37 {
38 /**
39 * CloudFront-assigned public key identifier. Used by KeyGroups to
40 * reference the key.
41 */
42 publicKeyId: string;
43 /**
44 * Name of the public key.
45 */
46 name: string;
47 /**
48 * PEM-encoded public key body.
49 */
50 encodedKey: string;
51 /**
52 * Caller reference used at create time. Stable across updates.
53 */
54 callerReference: string;
55 /**
56 * Most recent entity tag for update/delete operations.
57 */
58 etag: string | undefined;
59 /**
60 * Current comment on the key.
61 */
62 comment: string | undefined;
63 },
64 never,
65 Providers
66> {}
67
68/**
69 * A CloudFront public key.
70 *
71 * Public keys are uploaded ahead of being grouped into a {@link KeyGroup} and
72 * used by Distributions for signed URL or signed cookie verification.
73 *
74 * The key body is immutable after creation — changing `encodedKey` triggers
75 * a replacement (CloudFront returns no API to rotate a key in place).
76 * ### Creating Public Keys
77 * **Example:** PEM-encoded RSA public key
78 * ```typescript
79 * const key = yield* PublicKey("SignedUrlKey", {
80 * encodedKey: Redacted.make(yield* fs.readFileString("./public_key.pem")),
81 * comment: "RSA-2048 signed URL key for /private",
82 * });
83 * ```
84 *
85 * @resource
86 */
87export const PublicKey = Resource<PublicKey>("AWS.CloudFront.PublicKey");
88
89export const PublicKeyProvider = () =>
90 Provider.effect(
91 PublicKey,
92 Effect.gen(function* () {
93 const getById = Effect.fn(function* (id: string) {
94 const config = yield* cloudfront
95 .getPublicKeyConfig({ Id: id })
96 .pipe(
97 Effect.catchTag("NoSuchPublicKey", () => Effect.succeed(undefined)),
98 );
99 if (!config?.PublicKeyConfig) return undefined;
100 return { config: config.PublicKeyConfig, etag: config.ETag };
101 });
102
103 const getByName = Effect.fn(function* (name: string) {
104 const summary = yield* cloudfront.listPublicKeys.pages({}).pipe(
105 Stream.map((page) => page.PublicKeyList?.Items ?? []),
106 Stream.flattenIterable,
107 Stream.filter((item) => item.Name === name),
108 Stream.runHead,
109 Effect.map(Option.getOrUndefined),
110 );
111 if (!summary?.Id) return undefined;
112 return yield* getById(summary.Id).pipe(
113 Effect.map((found) =>
114 found ? { id: summary.Id, ...found } : undefined,
115 ),
116 );
117 });
118
119 const buildConfig = (
120 name: string,
121 callerReference: string,
122 props: PublicKeyProps,
123 ): cloudfront.PublicKeyConfig => ({
124 Name: name,
125 CallerReference: callerReference,
126 EncodedKey: extractValue(props.encodedKey),
127 Comment: props.comment,
128 });
129
130 const toAttrs = (
131 id: string,
132 config: cloudfront.PublicKeyConfig,
133 etag: string | undefined,
134 ) => ({
135 publicKeyId: id,
136 name: config.Name,
137 encodedKey: config.EncodedKey,
138 callerReference: config.CallerReference,
139 etag,
140 comment: config.Comment,
141 });
142
143 return {
144 stables: ["publicKeyId", "callerReference"],
145 list: () =>
146 Effect.gen(function* () {
147 // CloudFront is global (no region). `listPublicKeys` summaries lack
148 // CallerReference/ETag, so fetch each key's full config via
149 // `getById` to produce the same Attributes shape `read` returns.
150 const ids = yield* cloudfront.listPublicKeys.pages({}).pipe(
151 Stream.runCollect,
152 Effect.map((chunk) =>
153 Array.from(chunk).flatMap((page) =>
154 (page.PublicKeyList?.Items ?? []).map((item) => item.Id),
155 ),
156 ),
157 );
158 const rows = yield* Effect.forEach(
159 ids,
160 (publicKeyId) =>
161 getById(publicKeyId).pipe(
162 Effect.map((found) =>
163 found
164 ? toAttrs(publicKeyId, found.config, found.etag)
165 : undefined,
166 ),
167 ),
168 { concurrency: 10 },
169 );
170 return rows.filter((row) => row !== undefined);
171 }),
172 diff: Effect.fn(function* ({ id, news, olds }) {
173 if (!isResolved(news)) return undefined;
174 if (
175 (yield* createName(id, olds ?? {})) !==
176 (yield* createName(id, news))
177 ) {
178 return { action: "replace" } as const;
179 }
180 // Compare only when the old key is known — an Output-valued
181 // `encodedKey` doesn't survive a `creating`-state round-trip (it
182 // deserializes as `undefined`).
183 if (
184 olds.encodedKey !== undefined &&
185 isResolved(olds.encodedKey) &&
186 extractValue(olds.encodedKey) !== extractValue(news.encodedKey)
187 ) {
188 return { action: "replace" } as const;
189 }
190 }),
191 read: Effect.fn(function* ({ id, olds, output }) {
192 if (output?.publicKeyId) {
193 const found = yield* getById(output.publicKeyId);
194 if (found)
195 return toAttrs(output.publicKeyId, found.config, found.etag);
196 }
197 const name = yield* createName(id, olds ?? {});
198 const found = yield* getByName(name);
199 if (!found) return undefined;
200 return toAttrs(found.id, found.config, found.etag);
201 }),
202 reconcile: Effect.fn(function* ({ id, news, output, session }) {
203 const name = yield* createName(id, news);
204
205 // Observe — locate the public key by id (cached on `output`)
206 // or by name. Trust observed cloud state, not stale `olds`.
207 let observed = output?.publicKeyId
208 ? yield* getById(output.publicKeyId).pipe(
209 Effect.map((found) =>
210 found ? { id: output.publicKeyId, ...found } : undefined,
211 ),
212 )
213 : undefined;
214 if (!observed) {
215 observed = yield* getByName(name);
216 }
217
218 // Ensure — create the public key if it's missing. Tolerate
219 // `PublicKeyAlreadyExists` (race with a peer reconciler).
220 if (!observed) {
221 const callerReference = name;
222 const created = yield* cloudfront
223 .createPublicKey({
224 PublicKeyConfig: buildConfig(name, callerReference, news),
225 })
226 .pipe(
227 Effect.catchTag("PublicKeyAlreadyExists", () =>
228 getByName(name).pipe(
229 Effect.flatMap((existing) =>
230 existing
231 ? Effect.succeed({
232 PublicKey: {
233 Id: existing.id,
234 CreatedTime: new Date(),
235 PublicKeyConfig: existing.config,
236 },
237 ETag: existing.etag,
238 Location: undefined,
239 })
240 : Effect.fail(
241 new Error(
242 `Public key '${name}' already exists but could not be recovered`,
243 ),
244 ),
245 ),
246 ),
247 ),
248 );
249 if (!created.PublicKey?.Id) {
250 return yield* Effect.fail(
251 new Error("createPublicKey returned no identifier"),
252 );
253 }
254 yield* session.note(created.PublicKey.Id);
255 return toAttrs(
256 created.PublicKey.Id,
257 created.PublicKey.PublicKeyConfig,
258 created.ETag,
259 );
260 }
261
262 // Sync — patch the comment via `updatePublicKey`. The key body
263 // is immutable (replacement is forced in `diff`), and the
264 // CallerReference is observed from the live config rather than
265 // re-derived. The freshly observed ETag handles optimistic
266 // concurrency.
267 const updated = yield* cloudfront.updatePublicKey({
268 Id: observed.id,
269 IfMatch: observed.etag,
270 PublicKeyConfig: buildConfig(
271 observed.config.Name,
272 observed.config.CallerReference,
273 news,
274 ),
275 });
276 if (!updated.PublicKey?.Id) {
277 return yield* Effect.fail(
278 new Error("updatePublicKey returned no identifier"),
279 );
280 }
281 yield* session.note(observed.id);
282 return toAttrs(
283 updated.PublicKey.Id,
284 updated.PublicKey.PublicKeyConfig,
285 updated.ETag,
286 );
287 }),
288 delete: Effect.fn(function* ({ output }) {
289 const current = yield* getById(output.publicKeyId);
290 if (!current) return;
291 yield* cloudfront
292 .deletePublicKey({
293 Id: output.publicKeyId,
294 IfMatch: current.etag,
295 })
296 .pipe(
297 Effect.catchTag("NoSuchPublicKey", () => Effect.void),
298 // Key groups and their public keys are independent resources in
299 // the engine graph, so their deletes may be scheduled together.
300 // CloudFront can continue reporting the key as associated for a
301 // short period after the group is deleted. Treat that typed
302 // dependency violation as eventual consistency, while keeping
303 // every other error immediately visible.
304 Effect.retry({
305 while: (error) => error._tag === "PublicKeyInUse",
306 schedule: Schedule.max([
307 Schedule.fixed("2 seconds"),
308 Schedule.recurs(15),
309 ]),
310 }),
311 );
312 }),
313 };
314 }),
315 );
316
317const createName = (id: string, props: PublicKeyProps) =>
318 props.name
319 ? Effect.succeed(props.name)
320 : createPhysicalName({ id, maxLength: 128, lowercase: true });