1import * as cloudfront from "@distilled.cloud/aws/cloudfront";
2import * as Effect from "effect/Effect";
3import { isResolved } from "../../Diff.ts";
4import { createPhysicalName } from "../../PhysicalName.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import type { Providers } from "../Providers.ts";
8
9export interface ResponseHeadersPolicyProps {
10 /**
11 * Name of the response headers policy. If omitted, a deterministic name is
12 * generated. Names must be unique per AWS account. Changing the name
13 * triggers a replacement.
14 */
15 name?: string;
16 /**
17 * Optional comment describing the policy.
18 */
19 comment?: string;
20 /**
21 * CORS configuration applied to viewer responses.
22 */
23 corsConfig?: cloudfront.ResponseHeadersPolicyCorsConfig;
24 /**
25 * Standard security headers (HSTS, X-Frame-Options, etc.) added to
26 * viewer responses.
27 */
28 securityHeadersConfig?: cloudfront.ResponseHeadersPolicySecurityHeadersConfig;
29 /**
30 * Server-Timing header configuration for measuring CloudFront performance.
31 */
32 serverTimingHeadersConfig?: cloudfront.ResponseHeadersPolicyServerTimingHeadersConfig;
33 /**
34 * Custom headers to add to viewer responses.
35 */
36 customHeadersConfig?: cloudfront.ResponseHeadersPolicyCustomHeadersConfig;
37 /**
38 * Headers to remove from viewer responses.
39 */
40 removeHeadersConfig?: cloudfront.ResponseHeadersPolicyRemoveHeadersConfig;
41}
42
43export interface ResponseHeadersPolicy extends Resource<
44 "AWS.CloudFront.ResponseHeadersPolicy",
45 ResponseHeadersPolicyProps,
46 {
47 /**
48 * CloudFront-assigned response headers policy identifier.
49 */
50 responseHeadersPolicyId: string;
51 /**
52 * Name of the response headers policy.
53 */
54 name: string;
55 /**
56 * Most recent entity tag for update/delete operations.
57 */
58 etag: string | undefined;
59 /**
60 * Current comment on the policy.
61 */
62 comment: string | undefined;
63 /**
64 * Current CORS configuration.
65 */
66 corsConfig: cloudfront.ResponseHeadersPolicyCorsConfig | undefined;
67 /**
68 * Current security headers configuration.
69 */
70 securityHeadersConfig:
71 | cloudfront.ResponseHeadersPolicySecurityHeadersConfig
72 | undefined;
73 /**
74 * Current Server-Timing configuration.
75 */
76 serverTimingHeadersConfig:
77 | cloudfront.ResponseHeadersPolicyServerTimingHeadersConfig
78 | undefined;
79 /**
80 * Current custom headers configuration.
81 */
82 customHeadersConfig:
83 | cloudfront.ResponseHeadersPolicyCustomHeadersConfig
84 | undefined;
85 /**
86 * Current remove-headers configuration.
87 */
88 removeHeadersConfig:
89 | cloudfront.ResponseHeadersPolicyRemoveHeadersConfig
90 | undefined;
91 },
92 never,
93 Providers
94> {}
95
96/**
97 * A CloudFront response headers policy.
98 *
99 * Response headers policies add or remove headers in viewer responses,
100 * including CORS, standard security headers (HSTS, X-Frame-Options,
101 * X-Content-Type-Options, Referrer-Policy, etc.), Server-Timing, custom
102 * headers and explicit header removal. They are referenced by ID on a
103 * Distribution's default behavior or per-path cache behaviors.
104 * ### Creating Response Headers Policies
105 * **Example:** Standard security + CORS
106 * ```typescript
107 * const responseHeadersPolicy = yield* ResponseHeadersPolicy("AppResponseHeaders", {
108 * comment: "Default app security + CORS",
109 * corsConfig: {
110 * AccessControlAllowOrigins: { Quantity: 1, Items: ["https://app.example.com"] },
111 * AccessControlAllowMethods: { Quantity: 2, Items: ["GET", "OPTIONS"] },
112 * AccessControlAllowHeaders: { Quantity: 1, Items: ["Authorization"] },
113 * AccessControlAllowCredentials: false,
114 * OriginOverride: true,
115 * },
116 * securityHeadersConfig: {
117 * StrictTransportSecurity: {
118 * AccessControlMaxAgeSec: 31536000,
119 * IncludeSubdomains: true,
120 * Preload: true,
121 * Override: true,
122 * },
123 * ContentTypeOptions: { Override: true },
124 * FrameOptions: { FrameOption: "DENY", Override: true },
125 * ReferrerPolicy: { ReferrerPolicy: "no-referrer", Override: true },
126 * },
127 * });
128 * ```
129 *
130 * @resource
131 */
132export const ResponseHeadersPolicy = Resource<ResponseHeadersPolicy>(
133 "AWS.CloudFront.ResponseHeadersPolicy",
134);
135
136export const ResponseHeadersPolicyProvider = () =>
137 Provider.effect(
138 ResponseHeadersPolicy,
139 Effect.gen(function* () {
140 const getById = Effect.fn(function* (id: string) {
141 const config = yield* cloudfront
142 .getResponseHeadersPolicyConfig({ Id: id })
143 .pipe(
144 Effect.catchTag("NoSuchResponseHeadersPolicy", () =>
145 Effect.succeed(undefined),
146 ),
147 );
148 if (!config?.ResponseHeadersPolicyConfig) return undefined;
149 return {
150 config: config.ResponseHeadersPolicyConfig,
151 etag: config.ETag,
152 };
153 });
154
155 const getByName = Effect.fn(function* (name: string) {
156 const listed = yield* cloudfront.listResponseHeadersPolicies({
157 Type: "custom",
158 });
159 const summary = listed.ResponseHeadersPolicyList?.Items?.find(
160 (item) =>
161 item.ResponseHeadersPolicy?.ResponseHeadersPolicyConfig?.Name ===
162 name,
163 );
164 if (!summary?.ResponseHeadersPolicy?.Id) return undefined;
165 return yield* getById(summary.ResponseHeadersPolicy.Id).pipe(
166 Effect.map((found) =>
167 found
168 ? { id: summary.ResponseHeadersPolicy.Id, ...found }
169 : undefined,
170 ),
171 );
172 });
173
174 const buildConfig = (
175 name: string,
176 props: ResponseHeadersPolicyProps,
177 ): cloudfront.ResponseHeadersPolicyConfig => ({
178 Name: name,
179 Comment: props.comment,
180 CorsConfig: props.corsConfig,
181 SecurityHeadersConfig: props.securityHeadersConfig,
182 ServerTimingHeadersConfig: props.serverTimingHeadersConfig,
183 CustomHeadersConfig: props.customHeadersConfig,
184 RemoveHeadersConfig: props.removeHeadersConfig,
185 });
186
187 const toAttrs = (
188 id: string,
189 config: cloudfront.ResponseHeadersPolicyConfig,
190 etag: string | undefined,
191 ) => ({
192 responseHeadersPolicyId: id,
193 name: config.Name,
194 etag,
195 comment: config.Comment,
196 corsConfig: config.CorsConfig,
197 securityHeadersConfig: config.SecurityHeadersConfig,
198 serverTimingHeadersConfig: config.ServerTimingHeadersConfig,
199 customHeadersConfig: config.CustomHeadersConfig,
200 removeHeadersConfig: config.RemoveHeadersConfig,
201 });
202
203 return {
204 stables: ["responseHeadersPolicyId"],
205 diff: Effect.fn(function* ({ id, news, olds }) {
206 if (!isResolved(news)) return undefined;
207 if (
208 (yield* createName(id, olds ?? {})) !==
209 (yield* createName(id, news))
210 ) {
211 return { action: "replace" } as const;
212 }
213 }),
214 read: Effect.fn(function* ({ id, olds, output }) {
215 if (output?.responseHeadersPolicyId) {
216 const found = yield* getById(output.responseHeadersPolicyId);
217 if (found) {
218 return toAttrs(
219 output.responseHeadersPolicyId,
220 found.config,
221 found.etag,
222 );
223 }
224 }
225 const name = yield* createName(id, olds ?? {});
226 const found = yield* getByName(name);
227 if (!found) return undefined;
228 return toAttrs(found.id, found.config, found.etag);
229 }),
230 list: () =>
231 Effect.gen(function* () {
232 // CloudFront is global; paginate via Marker/NextMarker. Only
233 // `Type: "custom"` policies are ours — AWS-managed ones are
234 // shared and not deletable, so they are skipped.
235 const items: ReturnType<typeof toAttrs>[] = [];
236 let marker: string | undefined = undefined;
237 do {
238 const listed: cloudfront.ListResponseHeadersPoliciesResult =
239 yield* cloudfront.listResponseHeadersPolicies({
240 Type: "custom",
241 Marker: marker,
242 });
243 for (const summary of listed.ResponseHeadersPolicyList?.Items ??
244 []) {
245 if (summary.Type !== "custom") continue;
246 const id = summary.ResponseHeadersPolicy?.Id;
247 const config =
248 summary.ResponseHeadersPolicy?.ResponseHeadersPolicyConfig;
249 if (!id || !config) continue;
250 const found = yield* getById(id);
251 items.push(toAttrs(id, found?.config ?? config, found?.etag));
252 }
253 marker = listed.ResponseHeadersPolicyList?.NextMarker;
254 } while (marker);
255 return items;
256 }),
257 reconcile: Effect.fn(function* ({ id, news, output, session }) {
258 const name = yield* createName(id, news);
259
260 // Observe — locate the policy by id (cached on `output`) or
261 // by name. Trust observed cloud state, not stale `olds`.
262 let observed = output?.responseHeadersPolicyId
263 ? yield* getById(output.responseHeadersPolicyId).pipe(
264 Effect.map((found) =>
265 found
266 ? { id: output.responseHeadersPolicyId, ...found }
267 : undefined,
268 ),
269 )
270 : undefined;
271 if (!observed) {
272 observed = yield* getByName(name);
273 }
274
275 // Ensure — create the policy if it's missing. Tolerate
276 // `ResponseHeadersPolicyAlreadyExists` (race with a peer
277 // reconciler).
278 if (!observed) {
279 const created = yield* cloudfront
280 .createResponseHeadersPolicy({
281 ResponseHeadersPolicyConfig: buildConfig(name, news),
282 })
283 .pipe(
284 Effect.catchTag("ResponseHeadersPolicyAlreadyExists", () =>
285 getByName(name).pipe(
286 Effect.flatMap((existing) =>
287 existing
288 ? Effect.succeed({
289 ResponseHeadersPolicy: {
290 Id: existing.id,
291 LastModifiedTime: new Date(),
292 ResponseHeadersPolicyConfig: existing.config,
293 },
294 ETag: existing.etag,
295 Location: undefined,
296 })
297 : Effect.fail(
298 new Error(
299 `Response headers policy '${name}' already exists but could not be recovered`,
300 ),
301 ),
302 ),
303 ),
304 ),
305 );
306 if (!created.ResponseHeadersPolicy?.Id) {
307 return yield* Effect.fail(
308 new Error("createResponseHeadersPolicy returned no identifier"),
309 );
310 }
311 yield* session.note(created.ResponseHeadersPolicy.Id);
312 return toAttrs(
313 created.ResponseHeadersPolicy.Id,
314 created.ResponseHeadersPolicy.ResponseHeadersPolicyConfig,
315 created.ETag,
316 );
317 }
318
319 // Sync — patch the observed config to the desired state. The
320 // freshly observed ETag handles optimistic concurrency.
321 const updated = yield* cloudfront.updateResponseHeadersPolicy({
322 Id: observed.id,
323 IfMatch: observed.etag,
324 ResponseHeadersPolicyConfig: buildConfig(
325 observed.config.Name,
326 news,
327 ),
328 });
329 if (!updated.ResponseHeadersPolicy?.Id) {
330 return yield* Effect.fail(
331 new Error("updateResponseHeadersPolicy returned no identifier"),
332 );
333 }
334 yield* session.note(observed.id);
335 return toAttrs(
336 updated.ResponseHeadersPolicy.Id,
337 updated.ResponseHeadersPolicy.ResponseHeadersPolicyConfig,
338 updated.ETag,
339 );
340 }),
341 delete: Effect.fn(function* ({ output }) {
342 const current = yield* getById(output.responseHeadersPolicyId);
343 if (!current) return;
344 yield* cloudfront
345 .deleteResponseHeadersPolicy({
346 Id: output.responseHeadersPolicyId,
347 IfMatch: current.etag,
348 })
349 .pipe(
350 Effect.catchTag("NoSuchResponseHeadersPolicy", () => Effect.void),
351 );
352 }),
353 };
354 }),
355 );
356
357const createName = (id: string, props: ResponseHeadersPolicyProps) =>
358 props.name
359 ? Effect.succeed(props.name)
360 : createPhysicalName({ id, maxLength: 128, lowercase: true });