1import * as Glacier from "@/AWS/Glacier";
2import * as Lambda from "@/AWS/Lambda";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import { HttpServerRequest } from "effect/http/HttpServerRequest";
6import * as HttpServerResponse from "effect/http/HttpServerResponse";
7import path from "pathe";
8
9const main = path.resolve(import.meta.dirname, "bindings-handler.ts");
10
11// A syntactically-valid-looking but nonexistent 138-char job/upload id.
12const BOGUS_ID =
13 "alchemy-nonexistent-glacier-id-000000000000000000000000000000000000";
14
15export class GlacierBindingsFunction extends Lambda.Function<Lambda.Function>()(
16 "GlacierBindingsFunction",
17) {}
18
19export default GlacierBindingsFunction.make(
20 {
21 main,
22 functionUrl: true,
23 },
24 Effect.gen(function* () {
25 const vault = yield* Glacier.Vault("BindingsVault");
26
27 const describeVault = yield* Glacier.DescribeVault(vault);
28 const uploadArchive = yield* Glacier.UploadArchive(vault);
29 const deleteArchive = yield* Glacier.DeleteArchive(vault);
30 const initiateJob = yield* Glacier.InitiateJob(vault);
31 const describeJob = yield* Glacier.DescribeJob(vault);
32 const listJobs = yield* Glacier.ListJobs(vault);
33 const getJobOutput = yield* Glacier.GetJobOutput(vault);
34 const initiateMultipartUpload =
35 yield* Glacier.InitiateMultipartUpload(vault);
36 const uploadMultipartPart = yield* Glacier.UploadMultipartPart(vault);
37 const completeMultipartUpload =
38 yield* Glacier.CompleteMultipartUpload(vault);
39 const abortMultipartUpload = yield* Glacier.AbortMultipartUpload(vault);
40 const listMultipartUploads = yield* Glacier.ListMultipartUploads(vault);
41 const listParts = yield* Glacier.ListParts(vault);
42
43 const bound = {
44 describeVault,
45 uploadArchive,
46 deleteArchive,
47 initiateJob,
48 describeJob,
49 listJobs,
50 getJobOutput,
51 initiateMultipartUpload,
52 uploadMultipartPart,
53 completeMultipartUpload,
54 abortMultipartUpload,
55 listMultipartUploads,
56 listParts,
57 };
58
59 return {
60 fetch: Effect.gen(function* () {
61 const request = yield* HttpServerRequest;
62 const url = new URL(request.originalUrl);
63 const pathname = url.pathname;
64
65 if (request.method === "GET" && pathname === "/bindings") {
66 return yield* HttpServerResponse.json({
67 bound: Object.keys(bound),
68 });
69 }
70
71 if (request.method === "GET" && pathname === "/vault") {
72 // vaultName injection scopes the call to the bound vault.
73 const response = yield* describeVault();
74 return yield* HttpServerResponse.json({
75 vaultName: response.VaultName,
76 numberOfArchives: response.NumberOfArchives ?? 0,
77 });
78 }
79
80 if (request.method === "GET" && pathname === "/jobs") {
81 const response = yield* listJobs();
82 return yield* HttpServerResponse.json({
83 count: (response.JobList ?? []).length,
84 });
85 }
86
87 if (request.method === "GET" && pathname === "/uploads") {
88 const response = yield* listMultipartUploads();
89 return yield* HttpServerResponse.json({
90 count: (response.UploadsList ?? []).length,
91 });
92 }
93
94 // Each typed-not-found route proves the grant + injection
95 // end-to-end: an IAM gap would surface AccessDeniedException (a
96 // 500), while the typed ResourceNotFoundException proves the request
97 // reached the vault-scoped API.
98 if (request.method === "GET" && pathname === "/jobs/typed-not-found") {
99 const typed = yield* describeJob({ jobId: BOGUS_ID }).pipe(
100 Effect.map(() => false),
101 Effect.catchTag("ResourceNotFoundException", () =>
102 Effect.succeed(true),
103 ),
104 );
105 return yield* HttpServerResponse.json({ typed });
106 }
107
108 if (
109 request.method === "GET" &&
110 pathname === "/job-output/typed-not-found"
111 ) {
112 const typed = yield* getJobOutput({ jobId: BOGUS_ID }).pipe(
113 Effect.map(() => false),
114 Effect.catchTag("ResourceNotFoundException", () =>
115 Effect.succeed(true),
116 ),
117 );
118 return yield* HttpServerResponse.json({ typed });
119 }
120
121 if (request.method === "GET" && pathname === "/parts/typed-not-found") {
122 const typed = yield* listParts({ uploadId: BOGUS_ID }).pipe(
123 Effect.map(() => false),
124 Effect.catchTag("ResourceNotFoundException", () =>
125 Effect.succeed(true),
126 ),
127 );
128 return yield* HttpServerResponse.json({ typed });
129 }
130
131 if (
132 request.method === "POST" &&
133 pathname === "/multipart/typed-not-found"
134 ) {
135 // All three mutation ops on a nonexistent upload id round-trip to
136 // the typed ResourceNotFoundException without writing anything —
137 // the vault stays empty so stack.destroy can delete it.
138 const upload = yield* uploadMultipartPart({
139 uploadId: BOGUS_ID,
140 range: "bytes 0-1048575/*",
141 }).pipe(
142 Effect.map(() => false),
143 Effect.catchTag("ResourceNotFoundException", () =>
144 Effect.succeed(true),
145 ),
146 );
147 const complete = yield* completeMultipartUpload({
148 uploadId: BOGUS_ID,
149 }).pipe(
150 Effect.map(() => false),
151 Effect.catchTag("ResourceNotFoundException", () =>
152 Effect.succeed(true),
153 ),
154 );
155 const abort = yield* abortMultipartUpload({
156 uploadId: BOGUS_ID,
157 }).pipe(
158 Effect.map(() => false),
159 Effect.catchTag("ResourceNotFoundException", () =>
160 Effect.succeed(true),
161 ),
162 );
163 return yield* HttpServerResponse.json({ upload, complete, abort });
164 }
165
166 if (
167 request.method === "POST" &&
168 pathname === "/archive/typed-not-found"
169 ) {
170 // DeleteArchive is documented idempotent for already-deleted
171 // archives, but a syntactically-bogus id surfaces the typed
172 // not-found / bad-parameter union — either tag proves the grant.
173 const typed = yield* deleteArchive({ archiveId: BOGUS_ID }).pipe(
174 Effect.map(() => false),
175 Effect.catchTag(
176 ["ResourceNotFoundException", "InvalidParameterValueException"],
177 () => Effect.succeed(true),
178 ),
179 );
180 return yield* HttpServerResponse.json({ typed });
181 }
182
183 if (request.method === "POST" && pathname === "/archive/probe") {
184 // UploadArchive with a deliberately-wrong checksum: the service
185 // rejects the write with a typed 400 before storing anything, so
186 // the vault deletes cleanly at stack.destroy. A successful write
187 // here would block DeleteVault for ~24h (until the next
188 // inventory).
189 const typed = yield* uploadArchive({
190 checksum: "0".repeat(64),
191 body: "alchemy-glacier-binding-probe",
192 }).pipe(
193 Effect.map(() => false),
194 Effect.catchTag(
195 [
196 "InvalidParameterValueException",
197 "MissingParameterValueException",
198 ],
199 () => Effect.succeed(true),
200 ),
201 );
202 return yield* HttpServerResponse.json({ typed });
203 }
204
205 if (request.method === "POST" && pathname === "/multipart/probe") {
206 // Initiate requires a power-of-two MiB part size; a size of 1 is
207 // rejected with the typed 400 before any upload is created.
208 const typed = yield* initiateMultipartUpload({
209 archiveDescription: "alchemy-glacier-binding-probe",
210 partSize: "1",
211 }).pipe(
212 Effect.map(() => false),
213 Effect.catchTag(
214 [
215 "InvalidParameterValueException",
216 "MissingParameterValueException",
217 ],
218 () => Effect.succeed(true),
219 ),
220 );
221 return yield* HttpServerResponse.json({ typed });
222 }
223
224 if (request.method === "POST" && pathname === "/jobs/inventory") {
225 // A brand-new vault has no inventory yet — Glacier rejects the
226 // inventory-retrieval with a typed 400 until the first nightly
227 // inventory runs. Either branch proves InitiateJob's grant.
228 const result = yield* initiateJob({
229 jobParameters: { Type: "inventory-retrieval" },
230 }).pipe(
231 Effect.map((r) => ({ started: true, jobId: r.jobId })),
232 Effect.catchTag(
233 ["InvalidParameterValueException", "ResourceNotFoundException"],
234 (e) => Effect.succeed({ started: false, error: e._tag }),
235 ),
236 );
237 return yield* HttpServerResponse.json(result);
238 }
239
240 return yield* HttpServerResponse.json(
241 { error: "Not found", method: request.method, pathname },
242 { status: 404 },
243 );
244 }).pipe(Effect.orDie),
245 };
246 }).pipe(
247 Effect.provide(
248 Layer.mergeAll(
249 Glacier.DescribeVaultHttp,
250 Glacier.UploadArchiveHttp,
251 Glacier.DeleteArchiveHttp,
252 Glacier.InitiateJobHttp,
253 Glacier.DescribeJobHttp,
254 Glacier.ListJobsHttp,
255 Glacier.GetJobOutputHttp,
256 Glacier.InitiateMultipartUploadHttp,
257 Glacier.UploadMultipartPartHttp,
258 Glacier.CompleteMultipartUploadHttp,
259 Glacier.AbortMultipartUploadHttp,
260 Glacier.ListMultipartUploadsHttp,
261 Glacier.ListPartsHttp,
262 ),
263 ),
264 ),
265);