1import * as Lambda from "@/AWS/Lambda";
2import * as SecretsManager from "@/AWS/SecretsManager";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Redacted from "effect/Redacted";
6import * as Result from "effect/Result";
7import * as Schedule from "effect/Schedule";
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// Secrets Manager marks values as sensitive, so the distilled client can hand
15// them back either raw or wrapped in `Redacted` — unwrap for JSON transport.
16const unwrapString = (
17 value: string | Redacted.Redacted<string> | undefined,
18): string | undefined =>
19 value === undefined
20 ? undefined
21 : typeof value === "string"
22 ? value
23 : Redacted.value(value);
24
25const unwrapBinary = (
26 value: Uint8Array | Redacted.Redacted<Uint8Array> | undefined,
27): Uint8Array | undefined =>
28 value === undefined
29 ? undefined
30 : value instanceof Uint8Array
31 ? value
32 : Redacted.value(value);
33
34export class SecretsManagerTestFunction extends Lambda.Function<Lambda.Function>()(
35 "SecretsManagerTestFunction",
36) {}
37
38export default SecretsManagerTestFunction.make(
39 {
40 main,
41 functionUrl: true,
42 },
43 Effect.gen(function* () {
44 const stringSecret = yield* SecretsManager.Secret("StringSecret", {
45 description: "alchemy binding fixture (string value)",
46 secretString: Redacted.make("alchemy-sm-fixture-value"),
47 });
48 // Created without an initial value: the binary round-trip is driven
49 // entirely through the PutSecretValue/GetSecretValue bindings.
50 const binarySecret = yield* SecretsManager.Secret("BinarySecret", {
51 description: "alchemy binding fixture (binary value)",
52 });
53 // Rotated by this function itself via the RotationEventSource below.
54 const rotationSecret = yield* SecretsManager.Secret("RotationSecret", {
55 description: "alchemy binding fixture (rotated value)",
56 secretString: Redacted.make("alchemy-sm-rotation-initial"),
57 });
58
59 const getStringSecret = yield* SecretsManager.GetSecretValue(stringSecret);
60 const putStringSecret = yield* SecretsManager.PutSecretValue(stringSecret);
61 const getBinarySecret = yield* SecretsManager.GetSecretValue(binarySecret);
62 const putBinarySecret = yield* SecretsManager.PutSecretValue(binarySecret);
63 const describeStringSecret =
64 yield* SecretsManager.DescribeSecret(stringSecret);
65 const getRandomPassword = yield* SecretsManager.GetRandomPassword();
66 const listSecrets = yield* SecretsManager.ListSecrets();
67 const listStringVersions =
68 yield* SecretsManager.ListSecretVersionIds(stringSecret);
69 const batchGetSecrets = yield* SecretsManager.BatchGetSecretValue([
70 stringSecret,
71 rotationSecret,
72 ]);
73
74 // Rotation-protocol bindings for the rotation secret.
75 const getRotationValue =
76 yield* SecretsManager.GetSecretValue(rotationSecret);
77 const putRotationValue =
78 yield* SecretsManager.PutSecretValue(rotationSecret);
79 const describeRotationSecret =
80 yield* SecretsManager.DescribeSecret(rotationSecret);
81 const updateRotationStage =
82 yield* SecretsManager.UpdateSecretVersionStage(rotationSecret);
83 const listRotationVersions =
84 yield* SecretsManager.ListSecretVersionIds(rotationSecret);
85 const rotateRotationSecret =
86 yield* SecretsManager.RotateSecret(rotationSecret);
87
88 // Register this function as the rotation function for the rotation
89 // secret. The handler implements the 4-step protocol against the secret
90 // itself (self-contained: no downstream system to `setSecret`/`testSecret`).
91 yield* SecretsManager.onSecretRotation(
92 rotationSecret,
93 { rotationRules: { scheduleExpression: "rate(4 hours)" } },
94 (event) =>
95 Effect.gen(function* () {
96 if (event.Step === "createSecret") {
97 // Idempotent: skip if the pending version already exists.
98 const versions = yield* listRotationVersions({
99 IncludeDeprecated: true,
100 });
101 const exists = (versions.Versions ?? []).some(
102 (version) => version.VersionId === event.ClientRequestToken,
103 );
104 if (!exists) {
105 const generated = yield* getRandomPassword({
106 PasswordLength: 24,
107 ExcludePunctuation: true,
108 });
109 const password = unwrapString(generated.RandomPassword) ?? "";
110 yield* putRotationValue({
111 ClientRequestToken: event.ClientRequestToken,
112 SecretString: `alchemy-sm-rotated-${password}`,
113 VersionStages: ["AWSPENDING"],
114 });
115 }
116 } else if (event.Step === "finishSecret") {
117 const described = yield* describeRotationSecret();
118 const stages = described.VersionIdsToStages ?? {};
119 const currentVersion = Object.entries(stages).find(([, labels]) =>
120 (labels ?? []).includes("AWSCURRENT"),
121 )?.[0];
122 if (currentVersion !== event.ClientRequestToken) {
123 yield* updateRotationStage({
124 VersionStage: "AWSCURRENT",
125 MoveToVersionId: event.ClientRequestToken,
126 RemoveFromVersionId: currentVersion,
127 });
128 }
129 }
130 // setSecret / testSecret: nothing to apply or verify — the secret
131 // is self-contained.
132 }).pipe(
133 // Secrets Manager runs a validation invocation right after the
134 // rotation schedule is configured at deploy time — the freshly
135 // attached role policies may not have propagated yet. A failed
136 // validation wedges the rotation "in progress", so retry through
137 // the IAM-propagation window (bounded).
138 Effect.retry({
139 while: (e): boolean => e._tag === "AccessDeniedException",
140 schedule: Schedule.max([
141 Schedule.fixed("2 seconds"),
142 Schedule.recurs(8),
143 ]),
144 }),
145 Effect.asVoid,
146 Effect.orDie,
147 ),
148 );
149
150 return {
151 fetch: Effect.gen(function* () {
152 const request = yield* HttpServerRequest;
153 const url = new URL(request.originalUrl);
154 const pathname = url.pathname;
155
156 if (request.method === "GET" && pathname === "/string-value") {
157 const result = yield* getStringSecret();
158 return yield* HttpServerResponse.json({
159 name: result.Name,
160 arn: result.ARN,
161 versionId: result.VersionId,
162 secretString: unwrapString(result.SecretString),
163 });
164 }
165
166 if (request.method === "POST" && pathname === "/put-string") {
167 const body = (yield* request.json) as unknown as { value: string };
168 const result = yield* putStringSecret({ SecretString: body.value });
169 return yield* HttpServerResponse.json({
170 versionId: result.VersionId,
171 });
172 }
173
174 if (request.method === "POST" && pathname === "/put-binary") {
175 const body = (yield* request.json) as unknown as { base64: string };
176 const bytes = yield* Effect.sync(
177 () => new Uint8Array(Buffer.from(body.base64, "base64")),
178 );
179 const result = yield* putBinarySecret({ SecretBinary: bytes });
180 return yield* HttpServerResponse.json({
181 versionId: result.VersionId,
182 });
183 }
184
185 if (request.method === "GET" && pathname === "/binary-value") {
186 const result = yield* getBinarySecret();
187 const bytes = unwrapBinary(result.SecretBinary);
188 const base64 = bytes
189 ? yield* Effect.sync(() => Buffer.from(bytes).toString("base64"))
190 : undefined;
191 return yield* HttpServerResponse.json({
192 versionId: result.VersionId,
193 secretString: unwrapString(result.SecretString),
194 base64,
195 });
196 }
197
198 if (request.method === "GET" && pathname === "/describe") {
199 const result = yield* describeStringSecret();
200 return yield* HttpServerResponse.json({
201 name: result.Name,
202 arn: result.ARN,
203 description: result.Description,
204 });
205 }
206
207 if (request.method === "GET" && pathname === "/random-password") {
208 const length = Number(url.searchParams.get("length") ?? "24");
209 const result = yield* getRandomPassword({
210 PasswordLength: length,
211 ExcludePunctuation: true,
212 });
213 return yield* HttpServerResponse.json({
214 password: unwrapString(result.RandomPassword),
215 });
216 }
217
218 if (request.method === "GET" && pathname === "/list") {
219 const name = url.searchParams.get("name");
220 if (!name) {
221 return HttpServerResponse.text("Missing name", { status: 400 });
222 }
223 const result = yield* listSecrets({
224 Filters: [{ Key: "name", Values: [name] }],
225 });
226 return yield* HttpServerResponse.json({
227 names: (result.SecretList ?? []).map((entry) => entry.Name),
228 });
229 }
230
231 if (request.method === "GET" && pathname === "/versions") {
232 const result = yield* listStringVersions({
233 IncludeDeprecated: true,
234 });
235 return yield* HttpServerResponse.json({
236 versions: (result.Versions ?? []).map((version) => ({
237 versionId: version.VersionId,
238 stages: version.VersionStages ?? [],
239 })),
240 });
241 }
242
243 if (request.method === "GET" && pathname === "/batch") {
244 const result = yield* batchGetSecrets();
245 return yield* HttpServerResponse.json({
246 values: (result.SecretValues ?? [])
247 .map((entry) => ({
248 name: entry.Name,
249 secretString: unwrapString(entry.SecretString),
250 }))
251 .sort((a, b) => (a.name ?? "").localeCompare(b.name ?? "")),
252 errors: (result.Errors ?? []).map((error) => error.ErrorCode),
253 });
254 }
255
256 if (request.method === "POST" && pathname === "/rotate") {
257 // Error-transparent: surface the typed failure to the test as a
258 // 409 body instead of an opaque 500 from `orDie`.
259 const result = yield* Effect.result(rotateRotationSecret());
260 if (Result.isFailure(result)) {
261 return yield* HttpServerResponse.json(
262 {
263 error: result.failure._tag,
264 message:
265 (result.failure as { Message?: string }).Message ??
266 (result.failure as { message?: string }).message,
267 },
268 { status: 409 },
269 );
270 }
271 return yield* HttpServerResponse.json({
272 versionId: result.success.VersionId,
273 });
274 }
275
276 if (request.method === "GET" && pathname === "/rotation-value") {
277 const result = yield* Effect.result(getRotationValue());
278 if (Result.isFailure(result)) {
279 return yield* HttpServerResponse.json(
280 {
281 error: result.failure._tag,
282 message:
283 (result.failure as { Message?: string }).Message ??
284 (result.failure as { message?: string }).message,
285 },
286 { status: 409 },
287 );
288 }
289 return yield* HttpServerResponse.json({
290 versionId: result.success.VersionId,
291 secretString: unwrapString(result.success.SecretString),
292 });
293 }
294
295 if (request.method === "GET" && pathname === "/rotation-status") {
296 const result = yield* describeRotationSecret();
297 return yield* HttpServerResponse.json({
298 rotationEnabled: result.RotationEnabled === true,
299 lastRotated: result.LastRotatedDate,
300 });
301 }
302
303 return yield* HttpServerResponse.json(
304 {
305 error: "Not found",
306 method: request.method,
307 pathname,
308 },
309 { status: 404 },
310 );
311 }).pipe(Effect.orDie),
312 };
313 }).pipe(
314 Effect.provide(
315 Layer.mergeAll(
316 SecretsManager.GetSecretValueHttp,
317 SecretsManager.PutSecretValueHttp,
318 SecretsManager.DescribeSecretHttp,
319 SecretsManager.GetRandomPasswordHttp,
320 SecretsManager.ListSecretsHttp,
321 SecretsManager.ListSecretVersionIdsHttp,
322 SecretsManager.BatchGetSecretValueHttp,
323 SecretsManager.UpdateSecretVersionStageHttp,
324 SecretsManager.RotateSecretHttp,
325 Lambda.SecretRotationEventSource,
326 ),
327 ),
328 ),
329);