1import * as Lambda from "@/AWS/Lambda";
2import * as RDS from "@/AWS/RDS";
3import * as Drizzle from "@/Drizzle/Postgres.ts";
4import { sql } from "drizzle-orm";
5import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";
7import * as Redacted from "effect/Redacted";
8import { HttpServerRequest } from "effect/http/HttpServerRequest";
9import * as HttpServerResponse from "effect/http/HttpServerResponse";
10import path from "pathe";
11import { RDSDataInfra } from "./infra.ts";
12
13const main = path.resolve(import.meta.dirname, "drizzle-iam-handler.ts");
14
15export class RDSDrizzleIamFunction extends Lambda.Function<Lambda.Function>()(
16 "RDSDrizzleIamFunction",
17) {}
18
19/**
20 * Lambda fixture for the `RDS.Connect` IAM-auth path (db-drivers §3
21 * "RDS/Aurora"):
22 *
23 * - `auth: "iam"` — the deploy half binds `rds-db:connect` on the cluster's
24 * `dbuser:{resourceId}/app_iam`; the runtime half presigns a 15-minute
25 * token as the password (no Secrets Manager call).
26 * - `subnetIds`/`securityGroupIds` attach THIS function to the fixture VPC
27 * through the Function binding contract's `vpc` channel (DECISION #5) —
28 * the isolated VPC is fine because IAM-auth needs zero AWS API calls at
29 * runtime (the presign is local; credentials come from the Lambda env).
30 * - `Drizzle.Postgres(url)` proves the `ConnectionInfo.url` composition:
31 * the pool dials the cluster endpoint over a real socket per execution.
32 *
33 * Shares the Aurora infra with the RDSData fixture (`infra.ts`); the
34 * `app_iam` user is bootstrapped by that fixture's `/setup-iam-user` route
35 * (Data API), since this VPC-attached function cannot reach the Data API.
36 */
37export default RDSDrizzleIamFunction.make(
38 {
39 main,
40 functionUrl: true,
41 // First query per execution builds the pool + TLS handshake while the
42 // serverless cluster may be scaling from idle.
43 timeout: Duration.seconds(60),
44 },
45 Effect.gen(function* () {
46 const { cluster, subnetA, subnetB, lambdaSecurityGroup } =
47 yield* RDSDataInfra;
48
49 const connect = yield* RDS.Connect(cluster, {
50 auth: "iam",
51 username: "app_iam",
52 database: "app",
53 subnetIds: [subnetA.subnetId, subnetB.subnetId],
54 securityGroupIds: [lambdaSecurityGroup.groupId],
55 });
56
57 // `url` embeds the freshly minted token; the pool is per-execution
58 // (ExecutionMemo), so every invoke re-runs `connect` and re-mints.
59 const db = yield* Drizzle.Postgres(Effect.map(connect, (info) => info.url));
60
61 return {
62 fetch: Effect.gen(function* () {
63 const request = yield* HttpServerRequest;
64 const url = new URL(request.originalUrl);
65 const pathname = url.pathname;
66
67 // Token-minting proof without opening a socket: the password is a
68 // presigned `rds-db` URL (never echoed — only its shape), and
69 // `refreshPassword` re-mints for serverful lazy-password hooks.
70 if (request.method === "GET" && pathname === "/connect-info") {
71 const info = yield* connect;
72 const refreshed = info.refreshPassword
73 ? Redacted.value(yield* info.refreshPassword)
74 : undefined;
75 return yield* HttpServerResponse.json({
76 host: info.host,
77 port: info.port,
78 database: info.database,
79 username: info.username,
80 hasToken:
81 typeof info.password === "string" &&
82 info.password.includes("X-Amz-Signature="),
83 ssl: info.ssl,
84 canRefresh:
85 refreshed !== undefined && refreshed.includes("X-Amz-Signature="),
86 });
87 }
88
89 // Socket round-trip: IAM token auth + TLS + in-VPC connectivity.
90 if (request.method === "GET" && pathname === "/drizzle-health") {
91 const rows = (yield* db.execute(sql`select 1 as one`)) as unknown as {
92 one: number;
93 }[];
94 return yield* HttpServerResponse.json({ rows });
95 }
96
97 // Cross-path consistency: rows written via the Data API fixture are
98 // visible over the wire-protocol connection.
99 if (request.method === "GET" && pathname === "/drizzle-todos") {
100 const rows = (yield* db.execute(
101 sql`select count(*)::int as count from todos`,
102 )) as unknown as { count: number }[];
103 return yield* HttpServerResponse.json({ rows });
104 }
105
106 return yield* HttpServerResponse.json(
107 { error: "Not found", method: request.method, pathname },
108 { status: 404 },
109 );
110 }).pipe(Effect.orDie),
111 };
112 }).pipe(Effect.provide(RDS.ConnectHttp)),
113);