1import * as IAM from "@/AWS/IAM";
2import * as Kendra from "@/AWS/Kendra";
3import * as Lambda from "@/AWS/Lambda";
4import * as S3 from "@/AWS/S3";
5import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
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
14export class KendraTestFunction extends Lambda.Function<Lambda.Function>()(
15 "KendraTestFunction",
16) {}
17
18/**
19 * Every route answers `{ …fields }` on success or `{ errorTag }` when the
20 * operation fails with a TYPED error — the test asserts on concrete fields
21 * (or a typed tag), which proves the binding wiring, the `IndexId`/`Id`
22 * injection, and the IAM grants. An untyped error crashes into a 500.
23 */
24const errorTagged = <A, E extends { _tag: string }, R>(
25 effect: Effect.Effect<A, E, R>,
26): Effect.Effect<A | { errorTag: string }, never, R> =>
27 effect.pipe(
28 Effect.map((a): A | { errorTag: string } => a),
29 Effect.catch((e) => Effect.succeed({ errorTag: e._tag })),
30 );
31
32/**
33 * Index-scoped binding fixture: deploys a real Developer-edition Kendra
34 * index (~20-30 minutes to provision, billed while it exists — gated behind
35 * AWS_TEST_SLOW) plus an S3 data source and a Lambda bound to all
36 * twenty-three Kendra bindings.
37 */
38export default KendraTestFunction.make(
39 {
40 main,
41 functionUrl: true,
42 timeout: Duration.seconds(30),
43 },
44 Effect.gen(function* () {
45 // Role Kendra assumes to publish CloudWatch metrics/logs.
46 const indexRole = yield* IAM.Role("KendraBindingsIndexRole", {
47 assumeRolePolicyDocument: {
48 Version: "2012-10-17",
49 Statement: [
50 {
51 Effect: "Allow",
52 Principal: { Service: ["kendra.amazonaws.com"] },
53 Action: ["sts:AssumeRole"],
54 },
55 ],
56 },
57 inlinePolicies: {
58 observability: {
59 Version: "2012-10-17",
60 Statement: [
61 {
62 Effect: "Allow",
63 Action: ["cloudwatch:PutMetricData"],
64 Resource: ["*"],
65 Condition: {
66 StringEquals: { "cloudwatch:namespace": "AWS/Kendra" },
67 },
68 },
69 {
70 Effect: "Allow",
71 Action: [
72 "logs:DescribeLogGroups",
73 "logs:CreateLogGroup",
74 "logs:DescribeLogStreams",
75 "logs:CreateLogStream",
76 "logs:PutLogEvents",
77 ],
78 Resource: ["*"],
79 },
80 ],
81 },
82 },
83 });
84
85 const bucket = yield* S3.Bucket("KendraBindingsDocs", {
86 forceDestroy: true,
87 });
88
89 // Role Kendra assumes to crawl the S3 bucket.
90 const dataSourceRole = yield* IAM.Role("KendraBindingsDataRole", {
91 assumeRolePolicyDocument: {
92 Version: "2012-10-17",
93 Statement: [
94 {
95 Effect: "Allow",
96 Principal: { Service: ["kendra.amazonaws.com"] },
97 Action: ["sts:AssumeRole"],
98 },
99 ],
100 },
101 inlinePolicies: {
102 s3: {
103 Version: "2012-10-17",
104 Statement: [
105 {
106 Effect: "Allow",
107 Action: ["s3:GetObject", "s3:ListBucket"],
108 Resource: ["*"],
109 },
110 {
111 Effect: "Allow",
112 Action: ["kendra:BatchPutDocument", "kendra:BatchDeleteDocument"],
113 Resource: ["*"],
114 },
115 ],
116 },
117 },
118 });
119
120 const index = yield* Kendra.Index("BindingsIndex", {
121 edition: "DEVELOPER_EDITION",
122 roleArn: indexRole.roleArn,
123 tags: { fixture: "kendra-bindings" },
124 });
125
126 const source = yield* Kendra.DataSource("BindingsSource", {
127 indexId: index.id,
128 type: "S3",
129 roleArn: dataSourceRole.roleArn,
130 configuration: {
131 S3Configuration: { BucketName: bucket.bucketName },
132 },
133 });
134
135 const query = yield* Kendra.Query(index);
136 const retrieve = yield* Kendra.Retrieve(index);
137 const suggest = yield* Kendra.GetQuerySuggestions(index);
138 const submitFeedback = yield* Kendra.SubmitFeedback(index);
139 const putDocuments = yield* Kendra.BatchPutDocument(index);
140 const deleteDocuments = yield* Kendra.BatchDeleteDocument(index);
141 const documentStatus = yield* Kendra.BatchGetDocumentStatus(index);
142 const getSnapshots = yield* Kendra.GetSnapshots(index);
143 const putPrincipalMapping = yield* Kendra.PutPrincipalMapping(index);
144 const deletePrincipalMapping = yield* Kendra.DeletePrincipalMapping(index);
145 const describePrincipalMapping =
146 yield* Kendra.DescribePrincipalMapping(index);
147 const listStaleGroups = yield* Kendra.ListGroupsOlderThanOrderingId(index);
148 const clearSuggestions = yield* Kendra.ClearQuerySuggestions(index);
149 const suggestionsConfig =
150 yield* Kendra.DescribeQuerySuggestionsConfig(index);
151 const updateSuggestions = yield* Kendra.UpdateQuerySuggestionsConfig(index);
152 const createAcl = yield* Kendra.CreateAccessControlConfiguration(index);
153 const describeAcl = yield* Kendra.DescribeAccessControlConfiguration(index);
154 const updateAcl = yield* Kendra.UpdateAccessControlConfiguration(index);
155 const deleteAcl = yield* Kendra.DeleteAccessControlConfiguration(index);
156 const listAcls = yield* Kendra.ListAccessControlConfigurations(index);
157 const startSync = yield* Kendra.StartDataSourceSyncJob(source);
158 const stopSync = yield* Kendra.StopDataSourceSyncJob(source);
159 const listSyncJobs = yield* Kendra.ListDataSourceSyncJobs(source);
160
161 const bound = {
162 query,
163 retrieve,
164 suggest,
165 submitFeedback,
166 putDocuments,
167 deleteDocuments,
168 documentStatus,
169 getSnapshots,
170 putPrincipalMapping,
171 deletePrincipalMapping,
172 describePrincipalMapping,
173 listStaleGroups,
174 clearSuggestions,
175 suggestionsConfig,
176 updateSuggestions,
177 createAcl,
178 describeAcl,
179 updateAcl,
180 deleteAcl,
181 listAcls,
182 startSync,
183 stopSync,
184 listSyncJobs,
185 };
186
187 return {
188 fetch: Effect.gen(function* () {
189 const request = yield* HttpServerRequest;
190 const url = new URL(request.originalUrl);
191 const pathname = url.pathname;
192
193 if (pathname === "/bindings") {
194 return yield* HttpServerResponse.json({ bound: Object.keys(bound) });
195 }
196
197 if (pathname === "/put-documents") {
198 const result = yield* errorTagged(
199 putDocuments({
200 Documents: [
201 {
202 Id: "welcome",
203 Title: "Welcome to Alchemy",
204 Blob: new TextEncoder().encode(
205 "Alchemy is an Infrastructure-as-Effects framework. " +
206 "The zanzibar passphrase is quicksilver.",
207 ),
208 ContentType: "PLAIN_TEXT",
209 },
210 ],
211 }),
212 );
213 return yield* HttpServerResponse.json(
214 "errorTag" in result
215 ? result
216 : { failed: (result.FailedDocuments ?? []).length },
217 );
218 }
219
220 if (pathname === "/document-status") {
221 const result = yield* errorTagged(
222 documentStatus({ DocumentInfoList: [{ DocumentId: "welcome" }] }),
223 );
224 return yield* HttpServerResponse.json(
225 "errorTag" in result
226 ? result
227 : {
228 status: result.DocumentStatusList?.[0]?.DocumentStatus,
229 },
230 );
231 }
232
233 if (pathname === "/query") {
234 const q = url.searchParams.get("q") ?? "zanzibar";
235 const result = yield* errorTagged(query({ QueryText: q }));
236 return yield* HttpServerResponse.json(
237 "errorTag" in result
238 ? result
239 : {
240 queryId: result.QueryId,
241 resultId: result.ResultItems?.[0]?.Id,
242 count: (result.ResultItems ?? []).length,
243 },
244 );
245 }
246
247 if (pathname === "/retrieve") {
248 const q = url.searchParams.get("q") ?? "zanzibar passphrase";
249 const result = yield* errorTagged(retrieve({ QueryText: q }));
250 return yield* HttpServerResponse.json(
251 "errorTag" in result
252 ? result
253 : { count: (result.ResultItems ?? []).length },
254 );
255 }
256
257 if (pathname === "/suggest") {
258 const q = url.searchParams.get("q") ?? "zanzi";
259 const result = yield* errorTagged(suggest({ QueryText: q }));
260 return yield* HttpServerResponse.json(
261 "errorTag" in result
262 ? result
263 : { count: (result.Suggestions ?? []).length },
264 );
265 }
266
267 if (pathname === "/feedback") {
268 const queryId = url.searchParams.get("queryId") ?? "";
269 const resultId = url.searchParams.get("resultId") ?? "";
270 const result = yield* errorTagged(
271 submitFeedback({
272 QueryId: queryId,
273 ClickFeedbackItems: [
274 { ResultId: resultId, ClickTime: new Date() },
275 ],
276 }),
277 );
278 return yield* HttpServerResponse.json(
279 "errorTag" in result ? result : { ok: true },
280 );
281 }
282
283 if (pathname === "/delete-documents") {
284 const result = yield* errorTagged(
285 deleteDocuments({ DocumentIdList: ["welcome"] }),
286 );
287 return yield* HttpServerResponse.json(
288 "errorTag" in result
289 ? result
290 : { failed: (result.FailedDocuments ?? []).length },
291 );
292 }
293
294 if (pathname === "/snapshots") {
295 const result = yield* errorTagged(
296 getSnapshots({
297 Interval: "ONE_WEEK_AGO",
298 MetricType: "QUERIES_BY_COUNT",
299 }),
300 );
301 return yield* HttpServerResponse.json(
302 "errorTag" in result
303 ? result
304 : { header: result.SnapshotsDataHeader ?? [] },
305 );
306 }
307
308 if (pathname === "/principal-mapping") {
309 const put = yield* errorTagged(
310 putPrincipalMapping({
311 GroupId: "engineering",
312 GroupMembers: {
313 MemberUsers: [{ UserId: "user@example.com" }],
314 },
315 }),
316 );
317 if ("errorTag" in put) {
318 return yield* HttpServerResponse.json(put);
319 }
320 const described = yield* errorTagged(
321 describePrincipalMapping({ GroupId: "engineering" }),
322 );
323 return yield* HttpServerResponse.json(
324 "errorTag" in described
325 ? described
326 : {
327 actions: (described.GroupOrderingIdSummaries ?? []).length,
328 },
329 );
330 }
331
332 if (pathname === "/delete-principal-mapping") {
333 const result = yield* errorTagged(
334 deletePrincipalMapping({ GroupId: "engineering" }),
335 );
336 return yield* HttpServerResponse.json(
337 "errorTag" in result ? result : { ok: true },
338 );
339 }
340
341 if (pathname === "/stale-groups") {
342 const result = yield* errorTagged(listStaleGroups({ OrderingId: 1 }));
343 return yield* HttpServerResponse.json(
344 "errorTag" in result
345 ? result
346 : { count: (result.GroupsSummaries ?? []).length },
347 );
348 }
349
350 if (pathname === "/suggestions-config") {
351 const result = yield* errorTagged(suggestionsConfig());
352 return yield* HttpServerResponse.json(
353 "errorTag" in result ? result : { mode: result.Mode },
354 );
355 }
356
357 if (pathname === "/update-suggestions") {
358 const result = yield* errorTagged(
359 updateSuggestions({
360 Mode: "LEARN_ONLY",
361 queryLogLookBackWindow: "14 days",
362 }),
363 );
364 return yield* HttpServerResponse.json(
365 "errorTag" in result ? result : { ok: true },
366 );
367 }
368
369 if (pathname === "/clear-suggestions") {
370 const result = yield* errorTagged(clearSuggestions());
371 return yield* HttpServerResponse.json(
372 "errorTag" in result ? result : { ok: true },
373 );
374 }
375
376 if (pathname === "/access-control") {
377 // Full create -> describe -> update -> delete round trip through
378 // the four ACL bindings.
379 const created = yield* errorTagged(
380 createAcl({
381 Name: "block-departed-users",
382 AccessControlList: [
383 { Name: "departed-user", Type: "USER", Access: "DENY" },
384 ],
385 }),
386 );
387 if ("errorTag" in created) {
388 return yield* HttpServerResponse.json(created);
389 }
390 const described = yield* errorTagged(describeAcl({ Id: created.Id }));
391 if ("errorTag" in described) {
392 return yield* HttpServerResponse.json(described);
393 }
394 const updated = yield* errorTagged(
395 updateAcl({
396 Id: created.Id,
397 AccessControlList: [
398 { Name: "departed-user", Type: "USER", Access: "DENY" },
399 { Name: "auditor", Type: "USER", Access: "ALLOW" },
400 ],
401 }),
402 );
403 if ("errorTag" in updated) {
404 return yield* HttpServerResponse.json(updated);
405 }
406 const deleted = yield* errorTagged(deleteAcl({ Id: created.Id }));
407 return yield* HttpServerResponse.json(
408 "errorTag" in deleted
409 ? deleted
410 : { id: created.Id, name: described.Name },
411 );
412 }
413
414 if (pathname === "/access-controls") {
415 const result = yield* errorTagged(listAcls());
416 return yield* HttpServerResponse.json(
417 "errorTag" in result
418 ? result
419 : { count: (result.AccessControlConfigurations ?? []).length },
420 );
421 }
422
423 if (pathname === "/sync") {
424 const result = yield* errorTagged(startSync());
425 return yield* HttpServerResponse.json(
426 "errorTag" in result ? result : { executionId: result.ExecutionId },
427 );
428 }
429
430 if (pathname === "/stop-sync") {
431 const result = yield* errorTagged(stopSync());
432 return yield* HttpServerResponse.json(
433 "errorTag" in result ? result : { ok: true },
434 );
435 }
436
437 if (pathname === "/sync-jobs") {
438 const result = yield* errorTagged(listSyncJobs());
439 return yield* HttpServerResponse.json(
440 "errorTag" in result
441 ? result
442 : { count: (result.History ?? []).length },
443 );
444 }
445
446 return yield* HttpServerResponse.json(
447 { error: "Not found", method: request.method, pathname },
448 { status: 404 },
449 );
450 }).pipe(Effect.orDie),
451 };
452 }).pipe(
453 Effect.provide(
454 Layer.mergeAll(
455 Kendra.QueryHttp,
456 Kendra.RetrieveHttp,
457 Kendra.GetQuerySuggestionsHttp,
458 Kendra.SubmitFeedbackHttp,
459 Kendra.BatchPutDocumentHttp,
460 Kendra.BatchDeleteDocumentHttp,
461 Kendra.BatchGetDocumentStatusHttp,
462 Kendra.GetSnapshotsHttp,
463 Kendra.PutPrincipalMappingHttp,
464 Kendra.DeletePrincipalMappingHttp,
465 Kendra.DescribePrincipalMappingHttp,
466 Kendra.ListGroupsOlderThanOrderingIdHttp,
467 Kendra.ClearQuerySuggestionsHttp,
468 Kendra.DescribeQuerySuggestionsConfigHttp,
469 Kendra.UpdateQuerySuggestionsConfigHttp,
470 Kendra.CreateAccessControlConfigurationHttp,
471 Kendra.DescribeAccessControlConfigurationHttp,
472 Kendra.UpdateAccessControlConfigurationHttp,
473 Kendra.DeleteAccessControlConfigurationHttp,
474 Kendra.ListAccessControlConfigurationsHttp,
475 Kendra.StartDataSourceSyncJobHttp,
476 Kendra.StopDataSourceSyncJobHttp,
477 Kendra.ListDataSourceSyncJobsHttp,
478 ),
479 ),
480 ),
481);