1import * as FraudDetector from "@/AWS/FraudDetector";
2import * as Lambda from "@/AWS/Lambda";
3import * as Output from "@/Output";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Redacted from "effect/Redacted";
8import { HttpServerRequest } from "effect/http/HttpServerRequest";
9import * as HttpServerResponse from "effect/http/HttpServerResponse";
10import path from "pathe";
11
12const main = path.resolve(import.meta.dirname, "handler.ts");
13
14/** The email value the sample rule flags as high-risk. */
15export const FRAUD_EMAIL = "fraud@example.com";
16/** The outcome the sample rule returns on a match. */
17export const REVIEW_OUTCOME = "alchemy_test_review";
18/** The deny-list element the List resource seeds. */
19export const SEED_BLOCKED_IP = "203.0.113.7";
20
21export class FraudDetectorTestFunction extends Lambda.Function<Lambda.Function>()(
22 "FraudDetectorTestFunction",
23) {}
24
25export default FraudDetectorTestFunction.make(
26 {
27 main,
28 functionUrl: true,
29 // getEventPrediction fans out over the detector's active version; give it
30 // headroom over the 3s default.
31 timeout: Duration.seconds(30),
32 },
33 Effect.gen(function* () {
34 // Build the whole rule-based detector: entity type + variables + outcome +
35 // event type + detector + an ACTIVE detector version with one rule. The
36 // Output wiring (eventVariables/entityTypes/eventTypeName/detectorId/rule
37 // outcomes) forces the deploy order EntityType/Variable/Outcome →
38 // EventType → Detector → DetectorVersion.
39 const customer = yield* FraudDetector.EntityType("Customer", {
40 description: "the buyer placing an order",
41 });
42 const email = yield* FraudDetector.Variable("Email", {
43 dataType: "STRING",
44 dataSource: "EVENT",
45 defaultValue: "unknown",
46 variableType: "EMAIL_ADDRESS",
47 });
48 const ip = yield* FraudDetector.Variable("Ip", {
49 dataType: "STRING",
50 dataSource: "EVENT",
51 defaultValue: "0.0.0.0",
52 variableType: "IP_ADDRESS",
53 });
54 const review = yield* FraudDetector.Outcome("Review", {
55 name: REVIEW_OUTCOME,
56 description: "send the event to manual review",
57 });
58 const fraudLabel = yield* FraudDetector.Label("Fraud", {
59 description: "confirmed fraudulent event",
60 });
61 const eventType = yield* FraudDetector.EventType("Purchase", {
62 eventVariables: [email.name, ip.name],
63 entityTypes: [customer.name],
64 labels: [fraudLabel.name],
65 // Stored-event ingestion powers the SendEvent/GetEvent/UpdateEventLabel/
66 // DeleteEvent data-plane round-trip.
67 eventIngestion: "ENABLED",
68 });
69 const detector = yield* FraudDetector.Detector("Checkout", {
70 eventTypeName: eventType.name,
71 description: "rule-based checkout fraud detector",
72 });
73 const blockedIps = yield* FraudDetector.List("BlockedIps", {
74 variableType: "IP_ADDRESS",
75 description: "known-fraud source addresses",
76 elements: [SEED_BLOCKED_IP],
77 });
78
79 // Runtime needs the resolved names to build the prediction request. The
80 // yields return Accessors that resolve to strings inside the handler.
81 const emailVar = yield* email.name;
82 const ipVar = yield* ip.name;
83 const entityTypeName = yield* customer.name;
84 const eventTypeName = yield* eventType.name;
85 const fraudLabelName = yield* fraudLabel.name;
86
87 yield* FraudDetector.DetectorVersion("V1", {
88 detectorId: detector.detectorId,
89 status: "ACTIVE",
90 ruleExecutionMode: "FIRST_MATCHED",
91 rules: [
92 {
93 ruleId: "high_risk",
94 expression: Output.map(
95 email.name,
96 (name) => `$${name} == "${FRAUD_EMAIL}"`,
97 ),
98 outcomes: [review.name],
99 description: "flag a known-fraud email for review",
100 },
101 ],
102 });
103
104 const getEventPrediction =
105 yield* FraudDetector.GetEventPrediction(detector);
106 const sendEvent = yield* FraudDetector.SendEvent(eventType);
107 const getEvent = yield* FraudDetector.GetEvent(eventType);
108 const updateEventLabel = yield* FraudDetector.UpdateEventLabel(eventType);
109 const deleteEvent = yield* FraudDetector.DeleteEvent(eventType);
110 const getListElements = yield* FraudDetector.GetListElements(blockedIps);
111 const updateList = yield* FraudDetector.UpdateList(blockedIps);
112 const listEventPredictions =
113 yield* FraudDetector.ListEventPredictions(detector);
114 const getEventPredictionMetadata =
115 yield* FraudDetector.GetEventPredictionMetadata(detector);
116 const deleteEventsByEventType =
117 yield* FraudDetector.DeleteEventsByEventType(eventType);
118 const getDeleteEventsByEventTypeStatus =
119 yield* FraudDetector.GetDeleteEventsByEventTypeStatus(eventType);
120
121 return {
122 fetch: Effect.gen(function* () {
123 const request = yield* HttpServerRequest;
124 const url = new URL(request.originalUrl);
125 const pathname = url.pathname;
126
127 if (request.method === "GET" && pathname === "/health") {
128 return yield* HttpServerResponse.json({ ok: true });
129 }
130
131 if (request.method === "POST" && pathname === "/predict") {
132 const body = (yield* request.json) as {
133 email: string;
134 ip?: string;
135 entityId?: string;
136 eventId?: string;
137 };
138 const resolvedEventTypeName = yield* eventTypeName;
139 const resolvedEntityTypeName = yield* entityTypeName;
140 const emailVarName = yield* emailVar;
141 const ipVarName = yield* ipVar;
142 const result = yield* getEventPrediction({
143 eventId: body.eventId ?? `evt-${Date.now()}`,
144 eventTypeName: resolvedEventTypeName,
145 eventTimestamp: new Date().toISOString(),
146 entities: [
147 {
148 entityType: resolvedEntityTypeName,
149 entityId: body.entityId ?? "cust-1",
150 },
151 ],
152 eventVariables: {
153 [emailVarName]: body.email,
154 [ipVarName]: body.ip ?? "1.2.3.4",
155 },
156 });
157 const outcomes = (result.ruleResults ?? []).flatMap(
158 (r) => r.outcomes ?? [],
159 );
160 return yield* HttpServerResponse.json({
161 outcomes,
162 ruleResults: result.ruleResults ?? [],
163 });
164 }
165
166 if (request.method === "POST" && pathname === "/event") {
167 const body = (yield* request.json) as {
168 eventId: string;
169 email: string;
170 ip?: string;
171 entityId?: string;
172 };
173 const emailVarName = yield* emailVar;
174 const ipVarName = yield* ipVar;
175 yield* sendEvent({
176 eventId: body.eventId,
177 eventTimestamp: new Date().toISOString(),
178 entities: [
179 {
180 entityType: yield* entityTypeName,
181 entityId: body.entityId ?? "cust-1",
182 },
183 ],
184 eventVariables: {
185 [emailVarName]: body.email,
186 [ipVarName]: body.ip ?? "1.2.3.4",
187 },
188 });
189 return yield* HttpServerResponse.json({ ok: true });
190 }
191
192 if (request.method === "GET" && pathname === "/event") {
193 const eventId = url.searchParams.get("id")!;
194 // A deleted / not-yet-consistent event surfaces as a typed
195 // ResourceNotFoundException — report it as absent, not a 500.
196 const { event } = yield* getEvent({ eventId }).pipe(
197 Effect.catchTag("ResourceNotFoundException", () =>
198 Effect.succeed({ event: undefined }),
199 ),
200 );
201 return yield* HttpServerResponse.json({
202 found: event !== undefined,
203 currentLabel: event?.currentLabel,
204 });
205 }
206
207 if (request.method === "POST" && pathname === "/event/label") {
208 const body = (yield* request.json) as { eventId: string };
209 yield* updateEventLabel({
210 eventId: body.eventId,
211 assignedLabel: yield* fraudLabelName,
212 labelTimestamp: new Date().toISOString(),
213 });
214 return yield* HttpServerResponse.json({ ok: true });
215 }
216
217 if (request.method === "DELETE" && pathname === "/event") {
218 const eventId = url.searchParams.get("id")!;
219 yield* deleteEvent({ eventId, deleteAuditHistory: true });
220 return yield* HttpServerResponse.json({ ok: true });
221 }
222
223 if (request.method === "GET" && pathname === "/list") {
224 const { elements } = yield* getListElements({});
225 return yield* HttpServerResponse.json({
226 elements: (elements ?? []).map((e) =>
227 typeof e === "string" ? e : Redacted.value(e),
228 ),
229 });
230 }
231
232 if (request.method === "POST" && pathname === "/list/append") {
233 const body = (yield* request.json) as { element: string };
234 yield* updateList({
235 elements: [body.element],
236 updateMode: "APPEND",
237 });
238 return yield* HttpServerResponse.json({ ok: true });
239 }
240
241 if (request.method === "GET" && pathname === "/predictions") {
242 const eventId = url.searchParams.get("eventId")!;
243 const { eventPredictionSummaries } = yield* listEventPredictions({
244 eventId: { value: eventId },
245 });
246 return yield* HttpServerResponse.json({
247 summaries: eventPredictionSummaries ?? [],
248 });
249 }
250
251 if (request.method === "GET" && pathname === "/prediction-metadata") {
252 const eventId = url.searchParams.get("eventId")!;
253 // Find the prediction summary first, then audit its full metadata.
254 const { eventPredictionSummaries } = yield* listEventPredictions({
255 eventId: { value: eventId },
256 });
257 const summary = eventPredictionSummaries?.[0];
258 if (summary === undefined) {
259 return yield* HttpServerResponse.json({ found: false });
260 }
261 const metadata = yield* getEventPredictionMetadata({
262 eventId,
263 eventTypeName: summary.eventTypeName!,
264 detectorVersionId: summary.detectorVersionId!,
265 predictionTimestamp: summary.predictionTimestamp!,
266 });
267 return yield* HttpServerResponse.json({
268 found: true,
269 outcomes: metadata.outcomes ?? [],
270 ruleCount: (metadata.rules ?? []).length,
271 });
272 }
273
274 if (request.method === "POST" && pathname === "/events/purge") {
275 const { eventsDeletionStatus } = yield* deleteEventsByEventType({});
276 return yield* HttpServerResponse.json({
277 status: eventsDeletionStatus,
278 });
279 }
280
281 if (request.method === "GET" && pathname === "/events/purge-status") {
282 const { eventsDeletionStatus } =
283 yield* getDeleteEventsByEventTypeStatus({});
284 return yield* HttpServerResponse.json({
285 status: eventsDeletionStatus,
286 });
287 }
288
289 return yield* HttpServerResponse.json(
290 { error: "Not found", method: request.method, pathname },
291 { status: 404 },
292 );
293 }).pipe(Effect.orDie),
294 };
295 }).pipe(
296 Effect.provide(
297 Layer.mergeAll(
298 FraudDetector.GetEventPredictionHttp,
299 FraudDetector.SendEventHttp,
300 FraudDetector.GetEventHttp,
301 FraudDetector.UpdateEventLabelHttp,
302 FraudDetector.DeleteEventHttp,
303 FraudDetector.GetListElementsHttp,
304 FraudDetector.UpdateListHttp,
305 FraudDetector.ListEventPredictionsHttp,
306 FraudDetector.GetEventPredictionMetadataHttp,
307 FraudDetector.DeleteEventsByEventTypeHttp,
308 FraudDetector.GetDeleteEventsByEventTypeStatusHttp,
309 ),
310 ),
311 ),
312);