1import * as AWS from "@/AWS";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Redacted from "effect/Redacted";
6import { HttpServerRequest } from "effect/http/HttpServerRequest";
7import * as HttpServerResponse from "effect/http/HttpServerResponse";
8import {
9 ServerlessResources,
10 ServerlessResourcesLive,
11} from "./serverless-resources.ts";
12
13const plain = (
14 value: string | Redacted.Redacted<string> | undefined,
15): string | undefined =>
16 value === undefined
17 ? undefined
18 : typeof value === "string"
19 ? value
20 : Redacted.value(value);
21
22const PASSWORD = "Alchemy-Smoke-Passw0rd!";
23
24/**
25 * API Lambda: fronted by an API Gateway v2 HTTP API (see the test's stack
26 * program for the JWT authorizer + protected routes). One route per
27 * behavior of the serverless story:
28 *
29 * - `GET /signup-free` → unauthenticated `$default` route, returns ok
30 * - `POST /auth` → Cognito signup + JWT mint via UserPoolAdmin/Auth
31 * - `POST /todo` → JWT-protected DynamoDB write
32 * - `GET /todo` → JWT-protected DynamoDB read
33 * - `GET /upload-url` → presigned S3 PUT URL
34 * - `POST /enqueue` → SQS SendMessage to the jobs queue
35 * - `POST /compute` → Step Functions EXPRESS StartSyncExecution
36 * - `GET /config` → resource identifiers (readiness + cross-checks)
37 */
38export class SmokeApiFunction extends AWS.Lambda.Function<AWS.Lambda.Function>()(
39 "SmokeApiFunction",
40) {}
41
42export const SmokeApiFunctionLive = SmokeApiFunction.make(
43 {
44 main: import.meta.url,
45 // /auth chains several Cognito calls and /compute round-trips a whole
46 // workflow — AWS's 3s default intermittently times out on cold starts.
47 timeout: Duration.seconds(30),
48 },
49 Effect.gen(function* () {
50 const { pool, client, table, bucket, jobsQueue, resultsQueue, machine } =
51 yield* ServerlessResources;
52
53 const admin = yield* AWS.Cognito.UserPoolAdmin(pool);
54 const auth = yield* AWS.Cognito.UserPoolAuth(client);
55 const putItem = yield* AWS.DynamoDB.PutItem(table);
56 const getItem = yield* AWS.DynamoDB.GetItem(table);
57 const presignPutObject = yield* AWS.S3.PresignPutObject(bucket);
58 const sendMessage = yield* AWS.SQS.SendMessage(jobsQueue);
59 const startSyncExecution =
60 yield* AWS.StepFunctions.StartSyncExecution(machine);
61
62 // Output → deferred effect; resolved per-request inside the handler.
63 const UserPoolId = yield* pool.userPoolId;
64 const ClientId = yield* client.clientId;
65 const TableName = yield* table.tableName;
66 const BucketName = yield* bucket.bucketName;
67 const JobsQueueUrl = yield* jobsQueue.queueUrl;
68 const ResultsQueueUrl = yield* resultsQueue.queueUrl;
69 const MachineArn = yield* machine.stateMachineArn;
70
71 return {
72 fetch: Effect.gen(function* () {
73 const request = yield* HttpServerRequest;
74 const url = new URL(request.originalUrl);
75 const pathname = url.pathname;
76
77 // The first event after a cold start can observe not-yet-hydrated
78 // resource Outputs. Answer 503 so callers retry.
79 const userPoolId = yield* UserPoolId;
80 if (!userPoolId) {
81 return HttpServerResponse.text("Outputs not hydrated yet", {
82 status: 503,
83 });
84 }
85
86 if (request.method === "GET" && pathname === "/config") {
87 return yield* HttpServerResponse.json({
88 ok: true,
89 userPoolId,
90 clientId: yield* ClientId,
91 tableName: yield* TableName,
92 bucketName: yield* BucketName,
93 jobsQueueUrl: yield* JobsQueueUrl,
94 resultsQueueUrl: yield* ResultsQueueUrl,
95 machineArn: yield* MachineArn,
96 });
97 }
98
99 // Unauthenticated route served by the $default catch-all.
100 if (request.method === "GET" && pathname === "/signup-free") {
101 return yield* HttpServerResponse.json({ ok: true });
102 }
103
104 // Signup story: create + confirm a user through the UserPoolAdmin
105 // binding, then mint JWTs through the UserPoolAuth binding.
106 if (request.method === "POST" && pathname === "/auth") {
107 const username = url.searchParams.get("username") ?? "smoke-user";
108 yield* admin
109 .adminCreateUser({
110 Username: username,
111 MessageAction: "SUPPRESS",
112 UserAttributes: [
113 { Name: "email", Value: `${username}@example.com` },
114 { Name: "email_verified", Value: "true" },
115 ],
116 })
117 .pipe(
118 // rerun tolerance: an earlier run may have left the user
119 Effect.catchTag("UsernameExistsException", () => Effect.void),
120 );
121 yield* admin.adminSetUserPassword({
122 Username: username,
123 Password: PASSWORD,
124 Permanent: true,
125 });
126 const signIn = yield* auth.initiateAuth({
127 AuthFlow: "USER_PASSWORD_AUTH",
128 AuthParameters: { USERNAME: username, PASSWORD },
129 });
130 return yield* HttpServerResponse.json({
131 idToken: plain(signIn.AuthenticationResult?.IdToken),
132 accessToken: plain(signIn.AuthenticationResult?.AccessToken),
133 tokenType: signIn.AuthenticationResult?.TokenType,
134 });
135 }
136
137 // JWT-protected (enforced by the API Gateway JWT authorizer; the
138 // request only reaches this code with a validated Cognito token).
139 if (request.method === "POST" && pathname === "/todo") {
140 const body = (yield* request.json) as unknown as {
141 id: string;
142 text: string;
143 };
144 yield* putItem({
145 Item: {
146 pk: { S: "todo" },
147 sk: { S: body.id },
148 text: { S: body.text },
149 },
150 });
151 return yield* HttpServerResponse.json({ ok: true, id: body.id });
152 }
153
154 if (request.method === "GET" && pathname === "/todo") {
155 const id = url.searchParams.get("id");
156 if (!id) {
157 return HttpServerResponse.text("Missing id", { status: 400 });
158 }
159 const result = yield* getItem({
160 Key: { pk: { S: "todo" }, sk: { S: id } },
161 ConsistentRead: true,
162 });
163 return yield* HttpServerResponse.json({
164 item: result.Item
165 ? { id: result.Item.sk?.S, text: result.Item.text?.S }
166 : null,
167 });
168 }
169
170 if (request.method === "GET" && pathname === "/upload-url") {
171 const key = url.searchParams.get("key");
172 if (!key) {
173 return HttpServerResponse.text("Missing key", { status: 400 });
174 }
175 const contentType = url.searchParams.get("contentType");
176 const presignedUrl = yield* presignPutObject({
177 key,
178 expiresIn: 300,
179 contentType: contentType ?? undefined,
180 });
181 return yield* HttpServerResponse.json({ url: presignedUrl });
182 }
183
184 if (request.method === "POST" && pathname === "/enqueue") {
185 const body = (yield* request.json) as unknown as {
186 message: string;
187 };
188 const result = yield* sendMessage({ MessageBody: body.message });
189 return yield* HttpServerResponse.json({
190 messageId: result.MessageId,
191 });
192 }
193
194 if (request.method === "POST" && pathname === "/compute") {
195 const body = (yield* request.json) as unknown;
196 const result = yield* startSyncExecution({
197 input: JSON.stringify(body),
198 });
199 return yield* HttpServerResponse.json({
200 status: result.status,
201 output: plain(result.output),
202 error: plain(result.error),
203 });
204 }
205
206 return yield* HttpServerResponse.json(
207 { error: "Not found", method: request.method, pathname },
208 { status: 404 },
209 );
210 }).pipe(Effect.orDie),
211 };
212 }).pipe(
213 Effect.provide(
214 Layer.mergeAll(
215 AWS.Cognito.UserPoolAdminHttp,
216 AWS.Cognito.UserPoolAuthHttp,
217 AWS.DynamoDB.PutItemHttp,
218 AWS.DynamoDB.GetItemHttp,
219 AWS.S3.PresignPutObjectHttp,
220 AWS.SQS.SendMessageHttp,
221 AWS.StepFunctions.StartSyncExecutionHttp,
222 ServerlessResourcesLive,
223 ),
224 ),
225 ),
226).pipe(Layer.provideMerge(ServerlessResourcesLive));
227
228export default SmokeApiFunctionLive;