1import * as paymentcryptography from "@distilled.cloud/aws/payment-cryptography";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4
5/**
6 * Out-of-band reaper for leaked Payment Cryptography keys.
7 *
8 * Payment Cryptography keys cannot be hard-deleted: `DeleteKey` only
9 * *schedules* deletion after a mandatory waiting window (minimum 3 days),
10 * during which the key stays visible in `ListKeys` as `DELETE_PENDING`.
11 * The scratch stacks these suites deploy through keep state in memory, so a
12 * crashed run (vitest timeout kill, OOM) leaves its keys ACTIVE with no
13 * state pointing at them — no later run can reclaim them through the engine.
14 *
15 * This sweep lists every key, skips those already pending/complete deletion,
16 * and idempotently schedules deletion (minimum window) for any key branded
17 * with the internal alchemy tags at stage `test` — optionally narrowed to
18 * specific stack names so a gated suite only ever reaps its own keys.
19 *
20 * `DELETE_PENDING` is the best achievable end state; the service purges the
21 * key itself when the window elapses. Errors are defects (`orDie`) so this
22 * is a valid `Effect.ensuring` finalizer.
23 */
24export const reapLeakedKeys = (stackNames?: ReadonlyArray<string>) =>
25 Effect.gen(function* () {
26 const keys = yield* paymentcryptography.listKeys.items({}).pipe(
27 Stream.runCollect,
28 Effect.map((chunk) => Array.from(chunk)),
29 );
30 for (const key of keys) {
31 if (
32 key.KeyState === "DELETE_PENDING" ||
33 key.KeyState === "DELETE_COMPLETE"
34 ) {
35 continue; // already handled — the service purges it after the window
36 }
37 const tags: Record<string, string> =
38 yield* paymentcryptography.listTagsForResource
39 .items({ ResourceArn: key.KeyArn })
40 .pipe(
41 Stream.runCollect,
42 Effect.map((chunk) =>
43 Object.fromEntries(
44 Array.from(chunk).map((t) => [t.Key, t.Value ?? ""]),
45 ),
46 ),
47 Effect.catchTag("ResourceNotFoundException", () =>
48 Effect.succeed({}),
49 ),
50 );
51 const stack = tags["alchemy::stack"];
52 const owned =
53 tags["alchemy::stage"] === "test" &&
54 stack !== undefined &&
55 (stackNames === undefined || stackNames.includes(stack));
56 if (!owned) continue;
57 yield* paymentcryptography
58 .deleteKey({ KeyIdentifier: key.KeyArn, DeleteKeyInDays: 3 })
59 .pipe(
60 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
61 // Raced into DELETE_PENDING between observe and delete — done.
62 Effect.catchTag("ConflictException", () => Effect.void),
63 );
64 }
65 }).pipe(Effect.orDie);