1import * as frauddetector from "@distilled.cloud/aws/frauddetector";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
10import type { Providers } from "../Providers.ts";
11import { readFraudDetectorTags, syncFraudDetectorTags } from "./internal.ts";
12
13export interface DetectorProps {
14 /**
15 * The detector identifier. If omitted, a unique lowercase id is generated
16 * from the app, stage, and logical ID. Changing it replaces the detector.
17 */
18 detectorId?: string;
19 /**
20 * Human-readable description. This is an in-place update.
21 */
22 description?: string;
23 /**
24 * Name of the event type this detector evaluates. Immutable — changing it
25 * replaces the detector.
26 */
27 eventTypeName: string;
28 /**
29 * User-defined tags for the detector.
30 */
31 tags?: Record<string, string>;
32}
33
34export interface Detector extends Resource<
35 "AWS.FraudDetector.Detector",
36 DetectorProps,
37 {
38 /** The detector identifier. */
39 detectorId: string;
40 /** The ARN of the detector. */
41 arn: string;
42 /** The event type the detector evaluates. */
43 eventTypeName: string;
44 },
45 never,
46 Providers
47> {}
48
49/**
50 * An Amazon Fraud Detector detector — the container that binds an event type
51 * to versioned rule sets and models used to evaluate fraud. Creating the
52 * detector is cheap; the rules, models, and detector versions that produce
53 * predictions are provisioned separately.
54 *
55 * ### Creating a Detector
56 * **Example:** Basic Detector
57 * ```typescript
58 * const detector = yield* FraudDetector.Detector("checkout", {
59 * eventTypeName: purchase.name,
60 * });
61 * ```
62 *
63 * **Example:** Detector with an Active Version
64 * ```typescript
65 * const detector = yield* FraudDetector.Detector("checkout", {
66 * eventTypeName: purchase.name,
67 * });
68 *
69 * const version = yield* FraudDetector.DetectorVersion("v1", {
70 * detectorId: detector.detectorId,
71 * status: "ACTIVE",
72 * rules: [
73 * {
74 * ruleId: "high_risk",
75 * expression: '$email == "fraud@example.com"',
76 * outcomes: [review.name],
77 * },
78 * ],
79 * });
80 * ```
81 *
82 * ### Runtime Predictions
83 * Bind `GetEventPrediction` in the init phase (providing the
84 * `GetEventPredictionHttp` layer on the Function effect) and score events at
85 * runtime against the detector's `ACTIVE` version.
86 *
87 * **Example:** Score an event from a Lambda
88 * ```typescript
89 * // init
90 * const getEventPrediction = yield* FraudDetector.GetEventPrediction(detector);
91 *
92 * // runtime
93 * const { ruleResults } = yield* getEventPrediction({
94 * eventId: "order-123",
95 * eventTypeName: "purchase",
96 * eventTimestamp: new Date().toISOString(),
97 * entities: [{ entityType: "customer", entityId: "cust-1" }],
98 * eventVariables: { email: "buyer@example.com", ip: "1.2.3.4" },
99 * });
100 * ```
101 *
102 * @resource
103 */
104export const Detector = Resource<Detector>("AWS.FraudDetector.Detector");
105
106export const DetectorProvider = () =>
107 Provider.effect(
108 Detector,
109 Effect.gen(function* () {
110 const createId = Effect.fn(function* (
111 id: string,
112 props: Partial<DetectorProps>,
113 ) {
114 return (
115 props.detectorId ??
116 (yield* createPhysicalName({ id, maxLength: 64, lowercase: true }))
117 );
118 });
119
120 const get = Effect.fn(function* (detectorId: string) {
121 const response = yield* frauddetector
122 .getDetectors({ detectorId })
123 .pipe(
124 Effect.catchTag("ResourceNotFoundException", () =>
125 Effect.succeed(undefined),
126 ),
127 );
128 return response?.detectors?.[0];
129 });
130
131 const toAttrs = (detector: frauddetector.Detector) => ({
132 detectorId: detector.detectorId!,
133 arn: detector.arn!,
134 eventTypeName: detector.eventTypeName!,
135 });
136
137 return {
138 stables: ["detectorId", "arn"],
139
140 diff: Effect.fn(function* ({ id, olds = {}, news }) {
141 if (!isResolved(news)) return undefined;
142 const oldId = yield* createId(id, olds);
143 const newId = yield* createId(id, news);
144 if (
145 oldId !== newId ||
146 (olds.eventTypeName ?? undefined) !==
147 (news.eventTypeName ?? undefined)
148 ) {
149 return { action: "replace" } as const;
150 }
151 }),
152
153 read: Effect.fn(function* ({ id, olds, output }) {
154 const detectorId =
155 output?.detectorId ?? (yield* createId(id, olds ?? {}));
156 const detector = yield* get(detectorId);
157 if (detector === undefined) return undefined;
158 const attrs = toAttrs(detector);
159 const tags = yield* readFraudDetectorTags(detector.arn!);
160 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
161 }),
162
163 reconcile: Effect.fn(function* ({ id, news, session }) {
164 const detectorId = yield* createId(id, news);
165 const internalTags = yield* createInternalTags(id);
166 const desiredTags = { ...internalTags, ...news.tags };
167
168 // putDetector is an idempotent upsert — call it to converge whether
169 // creating or updating the description.
170 yield* frauddetector.putDetector({
171 detectorId,
172 description: news.description,
173 eventTypeName: news.eventTypeName,
174 });
175
176 const detector = yield* get(detectorId);
177 yield* syncFraudDetectorTags(detector!.arn!, desiredTags);
178
179 yield* session.note(detectorId);
180 return toAttrs(detector!);
181 }),
182
183 delete: Effect.fn(function* ({ output }) {
184 yield* frauddetector
185 .deleteDetector({ detectorId: output.detectorId })
186 .pipe(Effect.catchTag("ValidationException", () => Effect.void));
187 }),
188
189 list: () =>
190 frauddetector.getDetectors.pages({}).pipe(
191 Stream.runCollect,
192 Effect.map((chunk) =>
193 Array.from(chunk).flatMap((page) =>
194 (page.detectors ?? []).map(toAttrs),
195 ),
196 ),
197 ),
198 };
199 }),
200 );