| 1 | import * as cloudfront from "@distilled.cloud/aws/cloudfront"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import { isResolved } from "../../Diff.ts"; |
| 4 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 5 | import * as Provider from "../../Provider.ts"; |
| 6 | import { Resource } from "../../Resource.ts"; |
| 7 | import type { Providers } from "../Providers.ts"; |
| 8 | |
| 9 | export 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 | |
| 43 | export 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 | */ |
| 132 | export const ResponseHeadersPolicy = Resource<ResponseHeadersPolicy>( |
| 133 | "AWS.CloudFront.ResponseHeadersPolicy", |
| 134 | ); |
| 135 | |
| 136 | export 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 | |
| 357 | const createName = (id: string, props: ResponseHeadersPolicyProps) => |
| 358 | props.name |
| 359 | ? Effect.succeed(props.name) |
| 360 | : createPhysicalName({ id, maxLength: 128, lowercase: true }); |