1import * as secretsmanager from "@distilled.cloud/aws/secrets-manager";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Redacted from "effect/Redacted";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { createInternalTags, diffTags } from "../../Tags.ts";
12import type { PolicyDocument } from "../IAM/Policy.ts";
13import {
14 normalizePolicyDocument,
15 stringifyPolicyDocument,
16} from "../IAM/Policy.ts";
17import type { Providers } from "../Providers.ts";
18
19export interface GenerateSecretStringProps
20 extends secretsmanager.GetRandomPasswordRequest {
21 /**
22 * JSON template merged with the generated password.
23 * @default "{}"
24 */
25 secretStringTemplate?: string;
26 /**
27 * Key written into the generated secret payload.
28 * @default "password"
29 */
30 generateStringKey?: string;
31}
32
33export interface SecretProps {
34 /**
35 * Secret name. If omitted, Alchemy generates a deterministic physical name.
36 */
37 name?: string;
38 /**
39 * Optional description for the secret.
40 */
41 description?: string;
42 /**
43 * Optional KMS key used to encrypt the secret.
44 */
45 kmsKeyId?: string;
46 /**
47 * Plain string secret value.
48 */
49 secretString?: Redacted.Redacted<string>;
50 /**
51 * Binary secret value.
52 */
53 secretBinary?: Redacted.Redacted<Uint8Array<ArrayBufferLike>>;
54 /**
55 * Generate a password and store it inside a JSON secret string.
56 */
57 generateSecretString?: GenerateSecretStringProps;
58 /**
59 * Resource-based permission policy attached to the secret
60 * (`PutResourcePolicy`). Accepts a typed {@link PolicyDocument} or a raw
61 * JSON string as an escape hatch (e.g. for adoption of an existing
62 * policy). Omitting the prop removes any policy previously attached by
63 * Alchemy.
64 */
65 resourcePolicy?: PolicyDocument | string;
66 /**
67 * User-defined tags for the secret.
68 */
69 tags?: Record<string, string>;
70}
71
72export interface Secret extends Resource<
73 "AWS.SecretsManager.Secret",
74 SecretProps,
75 {
76 /**
77 * ARN of the secret.
78 */
79 secretArn: string;
80 /**
81 * Name of the secret.
82 */
83 secretName: string;
84 /**
85 * Version ID of the `AWSCURRENT` secret value, if a value has been set.
86 */
87 versionId: string | undefined;
88 /**
89 * Description of the secret.
90 */
91 description: string | undefined;
92 /**
93 * KMS key ID (or ARN) used to encrypt the secret value, if a
94 * customer-managed key was configured.
95 */
96 kmsKeyId: string | undefined;
97 /**
98 * Tags on the secret.
99 */
100 tags: Record<string, string>;
101 },
102 never,
103 Providers
104> {}
105
106/**
107 * An AWS Secrets Manager secret.
108 *
109 * `Secret` owns the lifecycle of the secret metadata and current value. It can
110 * store a caller-provided value or generate a password-backed JSON payload for
111 * downstream resources such as Aurora clusters and RDS proxies.
112 * ### Creating Secrets
113 * **Example:** Static Secret String
114 * ```typescript
115 * const secret = yield* Secret("DbSecret", {
116 * secretString: Redacted.make(JSON.stringify({
117 * username: "app",
118 * password: "super-secret",
119 * })),
120 * });
121 * ```
122 *
123 * **Example:** Generated Password Secret
124 * ```typescript
125 * const secret = yield* Secret("DbSecret", {
126 * generateSecretString: {
127 * secretStringTemplate: JSON.stringify({ username: "app" }),
128 * generateStringKey: "password",
129 * PasswordLength: 32,
130 * },
131 * });
132 * ```
133 *
134 * ### Resource Policies
135 * **Example:** Typed Resource Policy
136 * ```typescript
137 * const secret = yield* Secret("SharedSecret", {
138 * secretString: Redacted.make("shared-value"),
139 * resourcePolicy: {
140 * Version: "2012-10-17",
141 * Statement: [
142 * {
143 * Effect: "Allow",
144 * Principal: { AWS: `arn:aws:iam::${accountId}:root` },
145 * Action: ["secretsmanager:GetSecretValue"],
146 * Resource: "*",
147 * },
148 * ],
149 * },
150 * });
151 * ```
152 *
153 * @resource
154 */
155export const Secret = Resource<Secret>("AWS.SecretsManager.Secret");
156
157const toTagRecord = (
158 tags: Array<{ Key?: string; Value?: string }> | undefined,
159): Record<string, string> =>
160 Object.fromEntries(
161 (tags ?? [])
162 .filter(
163 (tag): tag is { Key: string; Value: string } =>
164 typeof tag.Key === "string" && typeof tag.Value === "string",
165 )
166 .map((tag) => [tag.Key, tag.Value]),
167 );
168
169/**
170 * Bounded retry through the async `ForceDeleteWithoutRecovery` window
171 * (`InvalidRequestException` "already scheduled for deletion"; force
172 * deletions complete within seconds).
173 *
174 * Expressed as an explicitly-typed helper: inlining `Effect.retry` here
175 * leaves `Retry.Return`'s conditional type unresolved in the provider's
176 * inferred layer type, which TypeScript's declaration emit widens to an
177 * `unknown` R — poisoning the whole `AWS.providers()` union for every
178 * downstream consumer.
179 */
180const retryThroughDeletionWindow = <A, E extends { _tag: string }, R>(
181 self: Effect.Effect<A, E, R>,
182): Effect.Effect<A, E, R> =>
183 Effect.retry(self, {
184 while: (e) =>
185 e._tag === "InvalidRequestException" &&
186 isDeletionInProgress((e as { message?: string }).message),
187 schedule: Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(10)]),
188 });
189
190const isDeletionInProgress = (message: string | undefined): boolean => {
191 const normalized = message?.toLowerCase();
192 return (
193 normalized?.includes("scheduled for deletion") === true ||
194 normalized?.includes("marked for deletion") === true
195 );
196};
197
198class SecretNotVisible extends Data.TaggedError("SecretNotVisible")<{
199 readonly secretId: string;
200}> {}
201
202class SecretStillExists extends Data.TaggedError("SecretStillExists")<{
203 readonly secretId: string;
204}> {}
205
206export const SecretProvider = () =>
207 Provider.effect(
208 Secret,
209 Effect.gen(function* () {
210 const toSecretName = (id: string, props: SecretProps) =>
211 props.name
212 ? Effect.succeed(props.name)
213 : createPhysicalName({ id, maxLength: 512 });
214
215 const createValue = Effect.fn(function* (props: SecretProps) {
216 if (props.secretBinary !== undefined) {
217 return { SecretBinary: props.secretBinary } as const;
218 }
219
220 if (props.secretString !== undefined) {
221 return { SecretString: props.secretString } as const;
222 }
223
224 if (props.generateSecretString) {
225 const {
226 secretStringTemplate = "{}",
227 generateStringKey = "password",
228 ...request
229 } = props.generateSecretString;
230 const password = yield* secretsmanager.getRandomPassword(request);
231 const generated = password.RandomPassword
232 ? typeof password.RandomPassword === "string"
233 ? password.RandomPassword
234 : Redacted.value(password.RandomPassword)
235 : "";
236 const template = JSON.parse(secretStringTemplate) as Record<
237 string,
238 unknown
239 >;
240 return {
241 SecretString: JSON.stringify({
242 ...template,
243 [generateStringKey]: generated,
244 }),
245 } as const;
246 }
247
248 return {} as const;
249 });
250
251 const readSecret = Effect.fn(function* (secretId: string) {
252 const described = yield* secretsmanager
253 .describeSecret({
254 SecretId: secretId,
255 })
256 .pipe(
257 Effect.catchTag("ResourceNotFoundException", () =>
258 Effect.succeed(undefined),
259 ),
260 );
261 // A secret with `DeletedDate` set is scheduled for deletion (our
262 // delete uses `ForceDeleteWithoutRecovery`, which still completes
263 // asynchronously). It cannot be updated, so treat it as missing —
264 // reconcile recreates it once the pending deletion finishes.
265 return described?.DeletedDate ? undefined : described;
266 });
267
268 // `CreateSecret` may return before `DescribeSecret` can observe the new
269 // secret. Keep this wait provider-local so every caller (including nuke
270 // recovery and adoption) gets the same bounded consistency handling.
271 const readSecretAfterCreate = (secretId: string) =>
272 Effect.retry(
273 readSecret(secretId).pipe(
274 Effect.flatMap((secret) =>
275 secret?.ARN && secret.Name
276 ? Effect.succeed(secret)
277 : Effect.fail(new SecretNotVisible({ secretId })),
278 ),
279 ),
280 {
281 while: (error) => error._tag === "SecretNotVisible",
282 schedule: Schedule.max([
283 Schedule.fixed("2 seconds"),
284 Schedule.recurs(10),
285 ]),
286 },
287 );
288
289 // Force deletion is asynchronous. `DeletedDate` means the operation was
290 // accepted, not that the resource is absent, so deletion completion must
291 // be checked with the raw API rather than `readSecret`.
292 const waitForSecretAbsence = (secretId: string) =>
293 Effect.retry(
294 secretsmanager
295 .describeSecret({ SecretId: secretId })
296 .pipe(
297 Effect.flatMap(() =>
298 Effect.fail(new SecretStillExists({ secretId })),
299 ),
300 ),
301 {
302 while: (error) => error._tag === "SecretStillExists",
303 schedule: Schedule.max([
304 Schedule.fixed("3 seconds"),
305 Schedule.recurs(10),
306 ]),
307 },
308 ).pipe(Effect.catchTag("ResourceNotFoundException", () => Effect.void));
309
310 return {
311 stables: ["secretArn", "secretName"],
312 diff: Effect.fn(function* ({ id, olds, news }) {
313 if (!isResolved(news)) return undefined;
314 if (
315 (yield* toSecretName(id, olds ?? {})) !==
316 (yield* toSecretName(id, news ?? {}))
317 ) {
318 return { action: "replace" } as const;
319 }
320 }),
321 read: Effect.fn(function* ({ id, olds, output }) {
322 const secretName =
323 output?.secretName ?? (yield* toSecretName(id, olds ?? {}));
324 const described = yield* readSecret(output?.secretArn ?? secretName);
325 if (!described?.ARN || !described.Name) {
326 return undefined;
327 }
328
329 return {
330 secretArn: described.ARN,
331 secretName: described.Name,
332 versionId: output?.versionId,
333 description: described.Description,
334 kmsKeyId: described.KmsKeyId,
335 tags: toTagRecord(described.Tags),
336 };
337 }),
338 reconcile: Effect.fn(function* ({ id, news, output, session }) {
339 const secretName =
340 output?.secretName ?? (yield* toSecretName(id, news));
341 const internalTags = yield* createInternalTags(id);
342 const desiredTags = { ...internalTags, ...news.tags };
343 const hasNewValue =
344 news.secretString !== undefined ||
345 news.secretBinary !== undefined ||
346 news.generateSecretString !== undefined;
347
348 // Observe — describe the secret using whichever identifier we
349 // have (ARN preferred, name as fallback).
350 let observed = yield* readSecret(output?.secretArn ?? secretName);
351
352 // Ensure — create if missing. Tolerate `ResourceExistsException`
353 // by re-describing; the sync step below converges metadata and
354 // value. Recreating a physical name right after our own
355 // `ForceDeleteWithoutRecovery` can race the asynchronous deletion
356 // and fail with `InvalidRequestException` ("already scheduled for
357 // deletion") — force deletions complete within seconds, so retry
358 // through that window (bounded).
359 if (!observed?.ARN) {
360 // Data-FIRST `Effect.retry(self, options)`: the data-last form
361 // infers `Retry.Options<E>`'s `E` from BOTH `while` and the
362 // schedule's input slot (`unknown` for `Schedule.fixed`), and the
363 // unioned candidates collapse this provider's layer to an
364 // `unknown` R that then poisons all of `AWS.providers()`.
365 yield* retryThroughDeletionWindow(
366 secretsmanager
367 .createSecret({
368 Name: secretName,
369 Description: news.description,
370 KmsKeyId: news.kmsKeyId,
371 Tags: Object.entries(desiredTags).map(([Key, Value]) => ({
372 Key,
373 Value,
374 })),
375 ...(yield* createValue(news)),
376 })
377 .pipe(
378 Effect.catchTag("ResourceExistsException", () => Effect.void),
379 ),
380 );
381 observed = yield* readSecretAfterCreate(secretName);
382 }
383
384 if (!observed?.ARN || !observed.Name) {
385 return yield* Effect.fail(
386 new Error(`Failed to describe Secret '${secretName}'`),
387 );
388 }
389
390 const secretArn = observed.ARN;
391
392 // Sync metadata + value. `updateSecret` accepts description,
393 // KMS key, and the secret value in one call. We always send
394 // metadata (idempotent) and only send a new value if the user
395 // provided one — `updateSecret` requires SecretString or
396 // SecretBinary to actually rotate, but is fine to call without
397 // them to update description/kmsKeyId only.
398 const valuePayload = yield* createValue(news);
399 const updated = yield* secretsmanager.updateSecret({
400 SecretId: secretArn,
401 Description: news.description,
402 KmsKeyId: news.kmsKeyId,
403 ...valuePayload,
404 });
405
406 // Sync tags — diff observed cloud tags against desired.
407 const observedTags = toTagRecord(observed.Tags);
408 const { removed, upsert } = diffTags(observedTags, desiredTags);
409
410 if (upsert.length > 0) {
411 yield* secretsmanager.tagResource({
412 SecretId: secretArn,
413 Tags: upsert,
414 });
415 }
416
417 if (removed.length > 0) {
418 yield* secretsmanager.untagResource({
419 SecretId: secretArn,
420 TagKeys: removed,
421 });
422 }
423
424 // Sync the resource-based policy — diff the observed policy
425 // against the desired one (both canonicalized via
426 // `normalizePolicyDocument`) so a re-deploy of an equivalent
427 // document is a no-op API-wise.
428 const desiredPolicy =
429 news.resourcePolicy === undefined
430 ? undefined
431 : typeof news.resourcePolicy === "string"
432 ? news.resourcePolicy
433 : stringifyPolicyDocument(news.resourcePolicy);
434 const observedPolicy = yield* secretsmanager
435 .getResourcePolicy({ SecretId: secretArn })
436 .pipe(
437 Effect.map((response) => response.ResourcePolicy),
438 Effect.catchTag("ResourceNotFoundException", () =>
439 Effect.succeed(undefined),
440 ),
441 );
442
443 if (desiredPolicy !== undefined) {
444 if (
445 observedPolicy === undefined ||
446 normalizePolicyDocument(observedPolicy) !==
447 normalizePolicyDocument(desiredPolicy)
448 ) {
449 yield* secretsmanager.putResourcePolicy({
450 SecretId: secretArn,
451 ResourcePolicy: desiredPolicy,
452 });
453 }
454 } else if (observedPolicy !== undefined) {
455 yield* secretsmanager
456 .deleteResourcePolicy({ SecretId: secretArn })
457 .pipe(
458 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
459 );
460 }
461
462 yield* session.note(secretArn);
463 return {
464 secretArn,
465 secretName: observed.Name,
466 versionId: hasNewValue
467 ? (updated.VersionId ?? output?.versionId)
468 : output?.versionId,
469 description: news.description,
470 kmsKeyId: news.kmsKeyId,
471 tags: desiredTags,
472 };
473 }),
474 delete: Effect.fn(function* ({ output }) {
475 yield* secretsmanager
476 .deleteSecret({
477 SecretId: output.secretArn,
478 ForceDeleteWithoutRecovery: true,
479 })
480 .pipe(
481 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
482 Effect.catchTag("InvalidRequestException", (error) =>
483 isDeletionInProgress(error.message)
484 ? Effect.void
485 : Effect.fail(error),
486 ),
487 );
488 yield* waitForSecretAbsence(output.secretArn);
489 }),
490 // `listSecrets` returns full secret metadata (ARN, name, description,
491 // KMS key, and tags) inline, so we hydrate the exact `read` Attributes
492 // shape directly — without fetching plaintext values via
493 // `getSecretValue`. `versionId` is per-value state not surfaced by the
494 // list API, so it is `undefined` (matching `read` when there is no
495 // prior output).
496 list: () =>
497 secretsmanager.listSecrets.pages({}).pipe(
498 Stream.runCollect,
499 Effect.map((chunk) =>
500 Array.from(chunk).flatMap((page) =>
501 (page.SecretList ?? [])
502 .filter(
503 (
504 entry,
505 ): entry is secretsmanager.SecretListEntry & {
506 ARN: string;
507 Name: string;
508 } => entry.ARN != null && entry.Name != null,
509 )
510 .map((entry) => ({
511 secretArn: entry.ARN,
512 secretName: entry.Name,
513 versionId: undefined,
514 description: entry.Description,
515 kmsKeyId: entry.KmsKeyId,
516 tags: toTagRecord(entry.Tags),
517 })),
518 ),
519 ),
520 ),
521 };
522 }),
523 );