1import * as cloudfront from "@distilled.cloud/aws/cloudfront";
2import * as Effect from "effect/Effect";
3import { isResolved } from "../../Diff.ts";
4import type { Input } from "../../Input.ts";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import type { Providers } from "../Providers.ts";
9
10export interface KeyGroupProps {
11 /**
12 * Name of the key group. If omitted, a deterministic name is generated.
13 *
14 * Names must be unique per AWS account. Changing the name triggers
15 * a replacement.
16 */
17 name?: string;
18 /**
19 * Public key IDs that belong to the group. Order is preserved.
20 */
21 items: Input<string>[];
22 /**
23 * Optional comment describing the group.
24 */
25 comment?: string;
26}
27
28export interface KeyGroup extends Resource<
29 "AWS.CloudFront.KeyGroup",
30 KeyGroupProps,
31 {
32 /**
33 * CloudFront-assigned key group identifier. Used by Distributions in
34 * `TrustedKeyGroups` to authorize signed URLs/cookies.
35 */
36 keyGroupId: string;
37 /**
38 * Name of the key group.
39 */
40 name: string;
41 /**
42 * Public key IDs that belong to the group.
43 */
44 items: string[];
45 /**
46 * Most recent entity tag for update/delete operations.
47 */
48 etag: string | undefined;
49 /**
50 * Current comment on the group.
51 */
52 comment: string | undefined;
53 },
54 never,
55 Providers
56> {}
57
58/**
59 * A CloudFront key group.
60 *
61 * Key groups bundle one or more {@link PublicKey} resources for use as
62 * `TrustedKeyGroups` on a Distribution's cache behavior. CloudFront uses
63 * the keys in the group to verify the signatures on signed URLs and
64 * signed cookies for that behavior.
65 * ### Creating Key Groups
66 * **Example:** Group two public keys for signed URL verification
67 * ```typescript
68 * const primary = yield* PublicKey("PrimarySigningKey", {
69 * encodedKey: yield* fs.readFileString("./primary.pem"),
70 * });
71 * const secondary = yield* PublicKey("SecondarySigningKey", {
72 * encodedKey: yield* fs.readFileString("./secondary.pem"),
73 * });
74 *
75 * const keyGroup = yield* KeyGroup("SignedUrlKeys", {
76 * comment: "Trusted signers for /private",
77 * items: [primary.publicKeyId, secondary.publicKeyId],
78 * });
79 * ```
80 *
81 * @resource
82 */
83export const KeyGroup = Resource<KeyGroup>("AWS.CloudFront.KeyGroup");
84
85export const KeyGroupProvider = () =>
86 Provider.effect(
87 KeyGroup,
88 Effect.gen(function* () {
89 const getById = Effect.fn(function* (id: string) {
90 const config = yield* cloudfront
91 .getKeyGroupConfig({ Id: id })
92 .pipe(
93 Effect.catchTag("NoSuchResource", () => Effect.succeed(undefined)),
94 );
95 if (!config?.KeyGroupConfig) return undefined;
96 return { config: config.KeyGroupConfig, etag: config.ETag };
97 });
98
99 const getByName = Effect.fn(function* (name: string) {
100 const listed = yield* cloudfront.listKeyGroups({});
101 const summary = listed.KeyGroupList?.Items?.find(
102 (item) => item.KeyGroup?.KeyGroupConfig?.Name === name,
103 );
104 if (!summary?.KeyGroup?.Id) return undefined;
105 return yield* getById(summary.KeyGroup.Id).pipe(
106 Effect.map((found) =>
107 found ? { id: summary.KeyGroup.Id, ...found } : undefined,
108 ),
109 );
110 });
111
112 const buildConfig = (
113 name: string,
114 items: string[],
115 comment: string | undefined,
116 ): cloudfront.KeyGroupConfig => ({
117 Name: name,
118 Items: items,
119 Comment: comment,
120 });
121
122 const toAttrs = (
123 id: string,
124 config: cloudfront.KeyGroupConfig,
125 etag: string | undefined,
126 ) => ({
127 keyGroupId: id,
128 name: config.Name,
129 items: config.Items,
130 etag,
131 comment: config.Comment,
132 });
133
134 return {
135 stables: ["keyGroupId"],
136 list: () =>
137 Effect.gen(function* () {
138 const items: ReturnType<typeof toAttrs>[] = [];
139 let marker: string | undefined = undefined;
140 do {
141 const listed: cloudfront.ListKeyGroupsResult =
142 yield* cloudfront.listKeyGroups({ Marker: marker });
143 for (const summary of listed.KeyGroupList?.Items ?? []) {
144 const id = summary.KeyGroup?.Id;
145 const config = summary.KeyGroup?.KeyGroupConfig;
146 if (!id || !config) continue;
147 // The list summary omits the ETag; re-read each group to
148 // produce the exact Attributes shape `read` returns.
149 const found = yield* getById(id);
150 items.push(toAttrs(id, found?.config ?? config, found?.etag));
151 }
152 marker = listed.KeyGroupList?.NextMarker;
153 } while (marker);
154 return items;
155 }),
156 diff: Effect.fn(function* ({ id, news, olds }) {
157 if (!isResolved(news)) return undefined;
158 if (
159 (yield* createName(id, olds ?? {})) !==
160 (yield* createName(id, news))
161 ) {
162 return { action: "replace" } as const;
163 }
164 }),
165 read: Effect.fn(function* ({ id, olds, output }) {
166 if (output?.keyGroupId) {
167 const found = yield* getById(output.keyGroupId);
168 if (found)
169 return toAttrs(output.keyGroupId, found.config, found.etag);
170 }
171 const name = yield* createName(id, olds ?? {});
172 const found = yield* getByName(name);
173 if (!found) return undefined;
174 return toAttrs(found.id, found.config, found.etag);
175 }),
176 reconcile: Effect.fn(function* ({ id, news, output, session }) {
177 const name = yield* createName(id, news);
178 const items = news.items as string[];
179
180 // Observe — locate the key group by id (cached on `output`) or
181 // by name. Trust observed cloud state, not stale `olds`.
182 let observed = output?.keyGroupId
183 ? yield* getById(output.keyGroupId).pipe(
184 Effect.map((found) =>
185 found ? { id: output.keyGroupId, ...found } : undefined,
186 ),
187 )
188 : undefined;
189 if (!observed) {
190 observed = yield* getByName(name);
191 }
192
193 // Ensure — create the key group if it's missing. Tolerate
194 // `KeyGroupAlreadyExists` (race with a peer reconciler).
195 if (!observed) {
196 const created = yield* cloudfront
197 .createKeyGroup({
198 KeyGroupConfig: buildConfig(name, items, news.comment),
199 })
200 .pipe(
201 Effect.catchTag("KeyGroupAlreadyExists", () =>
202 getByName(name).pipe(
203 Effect.flatMap((existing) =>
204 existing
205 ? Effect.succeed({
206 KeyGroup: {
207 Id: existing.id,
208 LastModifiedTime: new Date(),
209 KeyGroupConfig: existing.config,
210 },
211 ETag: existing.etag,
212 Location: undefined,
213 })
214 : Effect.fail(
215 new Error(
216 `Key group '${name}' already exists but could not be recovered`,
217 ),
218 ),
219 ),
220 ),
221 ),
222 );
223 if (!created.KeyGroup?.Id) {
224 return yield* Effect.fail(
225 new Error("createKeyGroup returned no identifier"),
226 );
227 }
228 yield* session.note(created.KeyGroup.Id);
229 return toAttrs(
230 created.KeyGroup.Id,
231 created.KeyGroup.KeyGroupConfig,
232 created.ETag,
233 );
234 }
235
236 // Sync — patch the observed config to the desired state. The
237 // freshly observed ETag handles optimistic concurrency.
238 const updated = yield* cloudfront.updateKeyGroup({
239 Id: observed.id,
240 IfMatch: observed.etag,
241 KeyGroupConfig: buildConfig(
242 observed.config.Name,
243 items,
244 news.comment,
245 ),
246 });
247 if (!updated.KeyGroup?.Id) {
248 return yield* Effect.fail(
249 new Error("updateKeyGroup returned no identifier"),
250 );
251 }
252 yield* session.note(observed.id);
253 return toAttrs(
254 updated.KeyGroup.Id,
255 updated.KeyGroup.KeyGroupConfig,
256 updated.ETag,
257 );
258 }),
259 delete: Effect.fn(function* ({ output }) {
260 const current = yield* getById(output.keyGroupId);
261 if (!current) return;
262 yield* cloudfront
263 .deleteKeyGroup({
264 Id: output.keyGroupId,
265 IfMatch: current.etag,
266 })
267 .pipe(Effect.catchTag("NoSuchResource", () => Effect.void));
268 }),
269 };
270 }),
271 );
272
273const createName = (id: string, props: { name?: string }) =>
274 props.name
275 ? Effect.succeed(props.name)
276 : createPhysicalName({ id, maxLength: 128, lowercase: true });