1import { Region as AwsRegion } from "@distilled.cloud/aws/Region";
2import type * as WAFV2 from "@distilled.cloud/aws/wafv2";
3import * as wafv2 from "@distilled.cloud/aws/wafv2";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import { diffTags } from "../../Tags.ts";
7
8/**
9 * Scope of a WAFv2 resource.
10 *
11 * - `REGIONAL` — protects regional resources (ALB, API Gateway, AppSync,
12 * Cognito user pool, App Runner, Verified Access) and follows the ambient
13 * AWS region.
14 * - `CLOUDFRONT` — protects CloudFront distributions and must live in
15 * `us-east-1`; the providers pin the region automatically.
16 */
17export type WafScope = "REGIONAL" | "CLOUDFRONT";
18
19const CLOUDFRONT_REGION = "us-east-1" as const;
20
21/**
22 * WAFv2 `CLOUDFRONT`-scoped resources exist exclusively in `us-east-1`
23 * (like ACM certificates for CloudFront). Pin the distilled `Region`
24 * service for CLOUDFRONT scope; REGIONAL scope follows the ambient region.
25 *
26 * `AwsRegion`'s service value is an `Effect<RegionName>` (see
27 * `@distilled.cloud/aws/Region`), so it must be provided as an effect, not a
28 * bare string.
29 *
30 * @internal
31 */
32export const withWafScope = <A, E, R>(
33 scope: WafScope,
34 effect: Effect.Effect<A, E, R>,
35): Effect.Effect<A, E, R> =>
36 scope === "CLOUDFRONT"
37 ? effect.pipe(
38 Effect.provideService(AwsRegion, Effect.succeed(CLOUDFRONT_REGION)),
39 )
40 : effect;
41
42/**
43 * WAFv2 mutations (update/delete) use `LockToken` optimistic concurrency.
44 * The caller re-reads the entity (obtaining a fresh `LockToken`) inside
45 * `self`, so retrying the whole effect on `WAFOptimisticLockException`
46 * converges after concurrent writers.
47 *
48 * Explicitly typed: inlining `Effect.retry` with options in provider
49 * lifecycle code widens the provider layer to `unknown` in declaration emit.
50 *
51 * @internal
52 */
53export const retryOptimisticLock = <A, E extends { _tag: string }, R>(
54 self: Effect.Effect<A, E, R>,
55): Effect.Effect<A, E, R> =>
56 Effect.retry(self, {
57 while: (e) => e._tag === "WAFOptimisticLockException",
58 schedule: Schedule.max([Schedule.fixed("1 second"), Schedule.recurs(8)]),
59 });
60
61/**
62 * WAF changes take "a few seconds to a number of minutes" to propagate.
63 * A freshly created web ACL (or rule group) referenced by another call —
64 * or a freshly created protected resource (e.g. a Cognito user pool) that
65 * WAF cannot "retrieve" yet — surfaces `WAFUnavailableEntityException`
66 * until propagation completes. Retry it on a bounded schedule (~90s
67 * total); fresh Cognito user pools routinely need more than 40s.
68 *
69 * @internal
70 */
71export const retryUnavailableEntity = <A, E extends { _tag: string }, R>(
72 self: Effect.Effect<A, E, R>,
73): Effect.Effect<A, E, R> =>
74 Effect.retry(self, {
75 while: (e) => e._tag === "WAFUnavailableEntityException",
76 schedule: Schedule.max([Schedule.fixed("3 seconds"), Schedule.recurs(30)]),
77 });
78
79/**
80 * Like {@link retryUnavailableEntity} but with a ~150s budget. Associating
81 * a web ACL with a freshly created protected resource (Cognito user pool,
82 * ALB, …) surfaces `WAFUnavailableEntityException` until the resource
83 * propagates to WAF — observed to routinely exceed 90s for new Cognito
84 * user pools (Terraform retries this for 5 minutes).
85 *
86 * @internal
87 */
88export const retryUnavailableEntityLong = <A, E extends { _tag: string }, R>(
89 self: Effect.Effect<A, E, R>,
90): Effect.Effect<A, E, R> =>
91 Effect.retry(self, {
92 while: (e) => e._tag === "WAFUnavailableEntityException",
93 schedule: Schedule.max([Schedule.fixed("3 seconds"), Schedule.recurs(50)]),
94 });
95
96/**
97 * Deleting a web ACL shortly after its association was removed can surface
98 * `WAFAssociatedItemException` until disassociation propagates. Retry it
99 * (and propagation-flavored unavailability) on a bounded schedule (~30s).
100 *
101 * @internal
102 */
103export const retryAssociatedItem = <A, E extends { _tag: string }, R>(
104 self: Effect.Effect<A, E, R>,
105): Effect.Effect<A, E, R> =>
106 Effect.retry(self, {
107 while: (e) =>
108 e._tag === "WAFAssociatedItemException" ||
109 e._tag === "WAFUnavailableEntityException",
110 schedule: Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(15)]),
111 });
112
113/**
114 * Read the observed tags of a WAFv2 entity as a plain record.
115 *
116 * @internal
117 */
118export const fetchWafTags = Effect.fn(function* (
119 scope: WafScope,
120 resourceArn: string,
121) {
122 const tags: Record<string, string> = {};
123 let marker: string | undefined;
124 // WAF tag sets are small; bound pagination defensively.
125 for (let page = 0; page < 10; page++) {
126 const response = yield* withWafScope(
127 scope,
128 wafv2.listTagsForResource({
129 ResourceARN: resourceArn,
130 NextMarker: marker,
131 Limit: 100,
132 }),
133 );
134 const list = response.TagInfoForResource?.TagList ?? [];
135 for (const tag of list) {
136 tags[tag.Key] = tag.Value;
137 }
138 // WAF returns an empty-string NextMarker on the terminal page.
139 if (!response.NextMarker || list.length === 0) {
140 break;
141 }
142 marker = response.NextMarker;
143 }
144 return tags;
145});
146
147/**
148 * Sync a WAFv2 entity's tags to the desired set by diffing against the
149 * OBSERVED cloud tags (never `olds`/`output`).
150 *
151 * @internal
152 */
153export const syncWafTags = Effect.fn(function* (
154 scope: WafScope,
155 resourceArn: string,
156 desiredTags: Record<string, string>,
157) {
158 const observed = yield* fetchWafTags(scope, resourceArn);
159 const { removed, upsert } = diffTags(observed, desiredTags);
160 if (upsert.length > 0) {
161 yield* withWafScope(
162 scope,
163 wafv2.tagResource({ ResourceARN: resourceArn, Tags: upsert }),
164 );
165 }
166 if (removed.length > 0) {
167 yield* withWafScope(
168 scope,
169 wafv2.untagResource({ ResourceARN: resourceArn, TagKeys: removed }),
170 );
171 }
172});
173
174/**
175 * Restore `ByteMatchStatement.SearchString` blobs inside a rule tree.
176 *
177 * Resource props survive the engine's plan/state serialization as plain
178 * JSON, so a `Uint8Array` SearchString arrives at the provider as an
179 * index-keyed object (`{ "0": 47, ... }`) or a number array. Distilled
180 * requires a real `Uint8Array` to base64-encode the blob on the wire, so
181 * walk the (recursive) statement tree and coerce every SearchString back.
182 *
183 * @internal
184 */
185export const normalizeWafRules = (
186 rules: WAFV2.Rule[] | undefined,
187): WAFV2.Rule[] =>
188 (rules ?? []).map((rule) => normalizeRuleValue(rule) as WAFV2.Rule);
189
190const normalizeRuleValue = (value: unknown): unknown => {
191 if (value instanceof Uint8Array) {
192 return value;
193 }
194 if (Array.isArray(value)) {
195 return value.map(normalizeRuleValue);
196 }
197 if (value !== null && typeof value === "object") {
198 return Object.fromEntries(
199 Object.entries(value).map(([key, nested]) => [
200 key,
201 key === "SearchString"
202 ? toSearchString(nested)
203 : normalizeRuleValue(nested),
204 ]),
205 );
206 }
207 return value;
208};
209
210const toSearchString = (value: unknown): Uint8Array => {
211 if (value instanceof Uint8Array) {
212 return value;
213 }
214 if (typeof value === "string") {
215 return new TextEncoder().encode(value);
216 }
217 if (Array.isArray(value)) {
218 return Uint8Array.from(value as number[]);
219 }
220 if (value !== null && typeof value === "object") {
221 // Index-keyed object — integer keys iterate in ascending order.
222 return Uint8Array.from(Object.values(value as Record<string, number>));
223 }
224 return new Uint8Array();
225};