1import * as AWS from "@/AWS";
2import * as Output from "@/Output";
3import * as Context from "effect/Context";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Result from "effect/Result";
8import * as Schedule from "effect/Schedule";
9import { HttpServerRequest } from "effect/http/HttpServerRequest";
10import * as HttpServerResponse from "effect/http/HttpServerResponse";
11
12// Bindings fixture: an S3 bucket + access point plus a Lambda that exercises
13// the nine ungated S3 Control runtime bindings against them — the three
14// access-point-scoped reads, the account-level access point listing, and the
15// full S3 Batch Operations job loop (create suspended → describe → bump
16// priority → cancel → list).
17export class S3ControlBindingsFunction extends AWS.Lambda.Function<AWS.Lambda.Function>()(
18 "S3ControlBindingsFunction",
19) {}
20
21export class BoundAccessPoint extends Context.Service<
22 BoundAccessPoint,
23 {
24 accessPoint: AWS.S3Control.AccessPoint;
25 bucket: AWS.S3.Bucket;
26 batchRole: AWS.IAM.Role;
27 }
28>()("BoundAccessPoint") {}
29
30export const BoundAccessPointLive = Layer.effect(
31 BoundAccessPoint,
32 Effect.gen(function* () {
33 const bucket = yield* AWS.S3.Bucket("S3ControlBindingsBucket", {});
34 const accessPoint = yield* AWS.S3Control.AccessPoint(
35 "S3ControlBindingsAccessPoint",
36 { bucket: bucket.bucketName },
37 );
38 // Execution role handed to S3 Batch Operations via CreateJob. The job is
39 // cancelled while suspended, so the role's policy only needs to satisfy
40 // create-time validation.
41 const batchRole = yield* AWS.IAM.Role("S3ControlBindingsBatchRole", {
42 assumeRolePolicyDocument: {
43 Version: "2012-10-17",
44 Statement: [
45 {
46 Effect: "Allow",
47 Principal: { Service: "batchoperations.s3.amazonaws.com" },
48 Action: ["sts:AssumeRole"],
49 },
50 ],
51 },
52 inlinePolicies: {
53 BatchAccess: {
54 Version: "2012-10-17",
55 Statement: [
56 {
57 Effect: "Allow",
58 Action: ["s3:ListBucket", "s3:GetObject", "s3:PutObjectTagging"],
59 Resource: [
60 bucket.bucketArn,
61 Output.interpolate`${bucket.bucketArn}/*`,
62 ],
63 },
64 ],
65 },
66 },
67 });
68 return { accessPoint, bucket, batchRole };
69 }),
70);
71
72export default S3ControlBindingsFunction.make(
73 {
74 main: import.meta.url,
75 functionUrl: true,
76 timeout: Duration.seconds(120),
77 },
78 Effect.gen(function* () {
79 const { accessPoint, bucket, batchRole } = yield* BoundAccessPoint;
80
81 const getAccessPoint = yield* AWS.S3Control.GetAccessPoint(accessPoint);
82 const getAccessPointPolicy =
83 yield* AWS.S3Control.GetAccessPointPolicy(accessPoint);
84 const getAccessPointPolicyStatus =
85 yield* AWS.S3Control.GetAccessPointPolicyStatus(accessPoint);
86 const listAccessPoints = yield* AWS.S3Control.ListAccessPoints();
87 const createJob = yield* AWS.S3Control.CreateJob();
88 const describeJob = yield* AWS.S3Control.DescribeJob();
89 const listJobs = yield* AWS.S3Control.ListJobs();
90 const updateJobStatus = yield* AWS.S3Control.UpdateJobStatus();
91 const updateJobPriority = yield* AWS.S3Control.UpdateJobPriority();
92
93 const bound = {
94 getAccessPoint,
95 getAccessPointPolicy,
96 getAccessPointPolicyStatus,
97 listAccessPoints,
98 createJob,
99 describeJob,
100 listJobs,
101 updateJobStatus,
102 updateJobPriority,
103 };
104
105 const bucketName = yield* bucket.bucketName;
106 const bucketArn = yield* bucket.bucketArn;
107 const batchRoleArn = yield* batchRole.roleArn;
108
109 return {
110 fetch: Effect.gen(function* () {
111 const request = yield* HttpServerRequest;
112 const url = new URL(request.originalUrl);
113 const pathname = url.pathname;
114
115 if (request.method === "GET" && pathname === "/bindings") {
116 return yield* HttpServerResponse.json({
117 bound: Object.keys(bound),
118 });
119 }
120
121 if (request.method === "GET" && pathname === "/access-point") {
122 const live = yield* getAccessPoint();
123 return yield* HttpServerResponse.json({
124 name: live.Name,
125 bucket: live.Bucket,
126 networkOrigin: live.NetworkOrigin,
127 });
128 }
129
130 if (request.method === "GET" && pathname === "/policy") {
131 const result = yield* getAccessPointPolicy().pipe(
132 Effect.map((r) => ({ hasPolicy: r.Policy !== undefined })),
133 // A fresh access point has no policy — the typed tag proves the
134 // binding round-trips.
135 Effect.catchTag("NoSuchAccessPointPolicy", () =>
136 Effect.succeed({ hasPolicy: false }),
137 ),
138 );
139 return yield* HttpServerResponse.json(result);
140 }
141
142 if (request.method === "GET" && pathname === "/policy-status") {
143 const result = yield* getAccessPointPolicyStatus().pipe(
144 Effect.map((r) => ({
145 isPublic: r.PolicyStatus?.IsPublic ?? false,
146 hasPolicy: true,
147 })),
148 Effect.catchTag("NoSuchAccessPointPolicy", () =>
149 Effect.succeed({ isPublic: false, hasPolicy: false }),
150 ),
151 );
152 return yield* HttpServerResponse.json(result);
153 }
154
155 if (request.method === "GET" && pathname === "/access-points") {
156 const page = yield* listAccessPoints({
157 Bucket: yield* bucketName,
158 });
159 return yield* HttpServerResponse.json({
160 names: (page.AccessPointList ?? []).map((ap) => ap.Name),
161 });
162 }
163
164 if (request.method === "GET" && pathname === "/job-lifecycle") {
165 // Wrapped in Effect.result so a failing step surfaces its typed
166 // tag in the JSON body instead of an opaque 500.
167 const outcome = yield* Effect.result(
168 Effect.gen(function* () {
169 // 1. CREATE — a suspended (ConfirmationRequired) tagging job over a
170 // generated manifest; nothing executes because it is cancelled
171 // below. Cancelled jobs are inert metadata that S3 expires
172 // automatically after 90 days.
173 const token = yield* Effect.sync(() => crypto.randomUUID());
174 const created = yield* createJob({
175 ClientRequestToken: token,
176 ConfirmationRequired: true,
177 Priority: 1,
178 RoleArn: yield* batchRoleArn,
179 Operation: {
180 S3PutObjectTagging: {
181 TagSet: [{ Key: "alchemy-sweep", Value: "true" }],
182 },
183 },
184 Report: { Enabled: false },
185 ManifestGenerator: {
186 S3JobManifestGenerator: {
187 SourceBucket: yield* bucketArn,
188 EnableManifestOutput: false,
189 },
190 },
191 });
192 const jobId = created.JobId!;
193
194 // 2. DESCRIBE — poll (bounded) until the job settles out of
195 // New/Preparing into a stable state.
196 const settledStates = [
197 "Suspended",
198 "Failed",
199 "Complete",
200 "Cancelled",
201 "Ready",
202 "Active",
203 ];
204 const settled = yield* describeJob({ JobId: jobId }).pipe(
205 Effect.map((r) => ({ status: r.Job?.Status as string })),
206 Effect.repeat({
207 schedule: Schedule.spaced("2 seconds"),
208 until: (r): boolean => settledStates.includes(r.status),
209 times: 25,
210 }),
211 );
212
213 // 3. PRIORITY — a settled ConfirmationRequired job refuses
214 // priority changes with the typed
215 // JobStatusTransitionForbidden tag; either outcome proves
216 // the binding (and the distilled patch) end-to-end.
217 const priorityOutcome = yield* updateJobPriority({
218 JobId: jobId,
219 Priority: 5,
220 }).pipe(
221 Effect.map(() => "updated" as const),
222 Effect.catchTag("JobStatusTransitionForbidden", () =>
223 Effect.succeed("JobStatusTransitionForbidden" as const),
224 ),
225 );
226
227 // 4. CANCEL — a suspended job cancels; a job that already
228 // settled terminally (Failed/Complete) refuses with the
229 // typed tag. Both prove the UpdateJobStatus binding.
230 const cancelOutcome = yield* updateJobStatus({
231 JobId: jobId,
232 RequestedJobStatus: "Cancelled",
233 StatusUpdateReason: "alchemy sweep test",
234 }).pipe(
235 Effect.map((r) => (r.Status ?? "Cancelled") as string),
236 Effect.catchTag("JobStatusTransitionForbidden", () =>
237 Effect.succeed("JobStatusTransitionForbidden" as const),
238 ),
239 );
240
241 // Observe the job's final state after the cancel attempt.
242 const final = yield* describeJob({ JobId: jobId });
243 const finalStatus = final.Job?.Status ?? "Cancelled";
244
245 // 5. LIST — the job is observable in the account listing.
246 // NOTE: a single status value only — distilled's SigV4 signer
247 // currently mis-signs repeated query params
248 // (jobStatuses=A&jobStatuses=B → SignatureDoesNotMatch).
249 const listed = yield* listJobs({
250 JobStatuses: [finalStatus],
251 });
252
253 return {
254 ok: true as const,
255 jobId,
256 settledStatus: settled.status,
257 priorityOutcome,
258 cancelOutcome,
259 finalStatus: finalStatus as string,
260 listedJobIds: (listed.Jobs ?? []).map((j) => j.JobId),
261 };
262 }),
263 );
264
265 if (Result.isFailure(outcome)) {
266 return yield* HttpServerResponse.json({
267 ok: false as const,
268 tag: outcome.failure._tag,
269 message: `${String(outcome.failure)} ${JSON.stringify(outcome.failure)}`,
270 });
271 }
272 return yield* HttpServerResponse.json(outcome.success);
273 }
274
275 return yield* HttpServerResponse.json(
276 { error: "Not found", method: request.method, pathname },
277 { status: 404 },
278 );
279 }).pipe(Effect.orDie),
280 };
281 }).pipe(
282 Effect.provide(
283 Layer.mergeAll(
284 AWS.S3Control.GetAccessPointHttp,
285 AWS.S3Control.GetAccessPointPolicyHttp,
286 AWS.S3Control.GetAccessPointPolicyStatusHttp,
287 AWS.S3Control.ListAccessPointsHttp,
288 AWS.S3Control.CreateJobHttp,
289 AWS.S3Control.DescribeJobHttp,
290 AWS.S3Control.ListJobsHttp,
291 AWS.S3Control.UpdateJobStatusHttp,
292 AWS.S3Control.UpdateJobPriorityHttp,
293 BoundAccessPointLive,
294 ),
295 ),
296 ),
297);