1import * as frauddetector from "@distilled.cloud/aws/frauddetector";
2import * as Effect from "effect/Effect";
3import * as Redacted from "effect/Redacted";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
11import type { Providers } from "../Providers.ts";
12import { readFraudDetectorTags, syncFraudDetectorTags } from "./internal.ts";
13
14export interface ListProps {
15 /**
16 * Name of the list. If omitted, a unique lowercase name is generated from
17 * the app, stage, and logical ID. Changing the name replaces the list.
18 */
19 name?: string;
20 /**
21 * Human-readable description. This is an in-place update.
22 */
23 description?: string;
24 /**
25 * The variable type of the list's elements (e.g. `IP_ADDRESS`,
26 * `EMAIL_ADDRESS`). Fraud Detector only allows setting the variable type
27 * once — changing an already-set variable type replaces the list.
28 */
29 variableType?: string;
30 /**
31 * The elements of the list. Reconcile converges the live list to exactly
32 * this set (a `REPLACE` update); elements appended out-of-band (e.g. via the
33 * `UpdateList` runtime binding) are removed on the next deploy unless they
34 * are added here.
35 *
36 * @default []
37 */
38 elements?: string[];
39 /**
40 * User-defined tags for the list.
41 */
42 tags?: Record<string, string>;
43}
44
45export interface List extends Resource<
46 "AWS.FraudDetector.List",
47 ListProps,
48 {
49 /** The name of the list. */
50 name: string;
51 /** The ARN of the list. */
52 arn: string;
53 },
54 never,
55 Providers
56> {}
57
58/**
59 * An Amazon Fraud Detector list — a set of input values for a variable (an
60 * allow-list or deny-list, e.g. known-fraud IP addresses) referenced from
61 * detector rule expressions.
62 *
63 * ### Creating a List
64 * **Example:** Deny-list of IP Addresses
65 * ```typescript
66 * const blockedIps = yield* FraudDetector.List("BlockedIps", {
67 * variableType: "IP_ADDRESS",
68 * description: "known-fraud source addresses",
69 * elements: ["203.0.113.7", "198.51.100.9"],
70 * });
71 * ```
72 *
73 * ### Using a List at Runtime
74 * **Example:** Append to the List from a Lambda
75 * ```typescript
76 * // init
77 * const updateList = yield* FraudDetector.UpdateList(blockedIps);
78 * const getListElements = yield* FraudDetector.GetListElements(blockedIps);
79 *
80 * // runtime
81 * yield* updateList({ elements: ["192.0.2.44"], updateMode: "APPEND" });
82 * const { elements } = yield* getListElements({});
83 * // on the Function effect:
84 * // .pipe(Effect.provide(Layer.mergeAll(
85 * // FraudDetector.UpdateListHttp,
86 * // FraudDetector.GetListElementsHttp,
87 * // )))
88 * ```
89 *
90 * @resource
91 */
92export const List = Resource<List>("AWS.FraudDetector.List");
93
94/** Coerce a distilled sensitive element (`string | Redacted<string>`) to a plain string. */
95const toElement = (e: string | Redacted.Redacted<string>): string =>
96 typeof e === "string" ? e : Redacted.value(e);
97
98export const ListProvider = () =>
99 Provider.effect(
100 List,
101 Effect.gen(function* () {
102 const createName = Effect.fn(function* (id: string, props: ListProps) {
103 return (
104 props.name ??
105 (yield* createPhysicalName({ id, maxLength: 64, lowercase: true }))
106 );
107 });
108
109 /** Look a list's metadata up by name; typed not-found → undefined. */
110 const get = Effect.fn(function* (name: string) {
111 const response = yield* frauddetector
112 .getListsMetadata({ name })
113 .pipe(
114 Effect.catchTag("ResourceNotFoundException", () =>
115 Effect.succeed(undefined),
116 ),
117 );
118 return response?.lists?.find((list) => list.name === name);
119 });
120
121 /** Read the full (paginated) element set of a list. */
122 const getElements = Effect.fn(function* (name: string) {
123 const pages = yield* frauddetector.getListElements
124 .pages({ name })
125 .pipe(Stream.runCollect);
126 return Array.from(pages).flatMap((page) =>
127 (page.elements ?? []).map(toElement),
128 );
129 });
130
131 const toAttrs = (list: frauddetector.AllowDenyList) => ({
132 name: list.name,
133 arn: list.arn!,
134 });
135
136 return {
137 stables: ["name", "arn"],
138
139 diff: Effect.fn(function* ({ id, olds = {}, news }) {
140 if (!isResolved(news)) return undefined;
141 const oldName = yield* createName(id, olds);
142 const newName = yield* createName(id, news);
143 if (
144 oldName !== newName ||
145 // The variable type can only be set once — changing an
146 // already-set variable type requires a replacement.
147 (olds.variableType !== undefined &&
148 news.variableType !== olds.variableType)
149 ) {
150 return { action: "replace" } as const;
151 }
152 }),
153
154 read: Effect.fn(function* ({ id, olds, output }) {
155 const name = output?.name ?? (yield* createName(id, olds ?? {}));
156 const list = yield* get(name);
157 if (list === undefined) return undefined;
158 const attrs = toAttrs(list);
159 const tags = yield* readFraudDetectorTags(list.arn!);
160 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
161 }),
162
163 reconcile: Effect.fn(function* ({ id, news = {}, session }) {
164 const name = yield* createName(id, news);
165 const internalTags = yield* createInternalTags(id);
166 const desiredTags = { ...internalTags, ...news.tags };
167 const desiredElements = news.elements ?? [];
168
169 // Observe — the live list is authoritative.
170 let list = yield* get(name);
171
172 if (list === undefined) {
173 // Ensure — greenfield create carries the full desired state.
174 yield* frauddetector.createList({
175 name,
176 description: news.description,
177 variableType: news.variableType,
178 elements: desiredElements,
179 tags: Object.entries(desiredTags).map(([key, value]) => ({
180 key,
181 value,
182 })),
183 });
184 list = yield* get(name);
185 } else {
186 // Sync — diff each mutable aspect against OBSERVED state and send
187 // only the delta.
188 const observedElements = yield* getElements(name);
189 const desiredSet = new Set(desiredElements);
190 const observedSet = new Set(observedElements);
191 const elementsChanged =
192 desiredSet.size !== observedSet.size ||
193 Array.from(desiredSet).some((e) => !observedSet.has(e));
194 const descriptionChanged =
195 (news.description ?? undefined) !==
196 (list.description ?? undefined);
197 // The variable type can only be introduced, never changed (a
198 // change is a replacement, handled by diff above).
199 const variableTypeIntroduced =
200 list.variableType === undefined &&
201 news.variableType !== undefined;
202
203 if (
204 elementsChanged ||
205 descriptionChanged ||
206 variableTypeIntroduced
207 ) {
208 yield* frauddetector.updateList({
209 name,
210 ...(elementsChanged
211 ? { elements: desiredElements, updateMode: "REPLACE" }
212 : {}),
213 ...(descriptionChanged
214 ? { description: news.description }
215 : {}),
216 ...(variableTypeIntroduced
217 ? { variableType: news.variableType }
218 : {}),
219 });
220 list = yield* get(name);
221 }
222 }
223
224 // Sync tags — diff against OBSERVED cloud tags.
225 yield* syncFraudDetectorTags(list!.arn!, desiredTags);
226
227 yield* session.note(name);
228 return toAttrs(list!);
229 }),
230
231 delete: Effect.fn(function* ({ output }) {
232 yield* frauddetector.deleteList({ name: output.name }).pipe(
233 // Deleting an already-removed list is a no-op for us; Fraud
234 // Detector surfaces a missing list as a validation error.
235 Effect.catchTag("ValidationException", () => Effect.void),
236 );
237 }),
238
239 list: () =>
240 frauddetector.getListsMetadata.pages({}).pipe(
241 Stream.runCollect,
242 Effect.map((chunk) =>
243 Array.from(chunk).flatMap((page) =>
244 (page.lists ?? []).map(toAttrs),
245 ),
246 ),
247 ),
248 };
249 }),
250 );