1import * as Lambda from "@/AWS/Lambda";
2import * as Route53 from "@/AWS/Route53";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Schedule from "effect/Schedule";
7import { HttpServerRequest } from "effect/http/HttpServerRequest";
8import * as HttpServerResponse from "effect/http/HttpServerResponse";
9import path from "pathe";
10
11const main = path.resolve(import.meta.dirname, "bindings-handler.ts");
12
13// Deterministic probe record written and deleted by /record/roundtrip.
14const PROBE_LABEL = "probe";
15const PROBE_VALUE = '"alchemy-route53-bindings"';
16
17export class Route53BindingsFunction extends Lambda.Function<Lambda.Function>()(
18 "Route53BindingsFunction",
19) {}
20
21export default Route53BindingsFunction.make(
22 {
23 main,
24 functionUrl: true,
25 // /record/roundtrip polls GetChange until INSYNC (typically ~20-60s).
26 timeout: Duration.minutes(3),
27 },
28 Effect.gen(function* () {
29 // Public zone under the reserved-domain-safe `.alchemy` TLD — never
30 // delegated, but Route 53's authoritative servers (and TestDNSAnswer)
31 // answer for it regardless.
32 const zone = yield* Route53.HostedZone("BindingsZone", {
33 name: "alchemy-route53-bindings.alchemy.",
34 comment: "alchemy Route53 bindings fixture",
35 forceDestroy: true,
36 });
37
38 const check = yield* Route53.HealthCheck("BindingsCheck", {
39 type: "HTTP",
40 fullyQualifiedDomainName: "example.com",
41 resourcePath: "/",
42 port: 80,
43 requestInterval: "30 seconds",
44 failureThreshold: 3,
45 });
46
47 const changeRecordSets = yield* Route53.ChangeResourceRecordSets(zone);
48 const listRecordSets = yield* Route53.ListResourceRecordSets(zone);
49 const getHostedZone = yield* Route53.GetHostedZone(zone);
50 const testDnsAnswer = yield* Route53.TestDNSAnswer(zone);
51 const getChange = yield* Route53.GetChange();
52 const getHealthCheckStatus = yield* Route53.GetHealthCheckStatus(check);
53 const getHealthCheckLastFailureReason =
54 yield* Route53.GetHealthCheckLastFailureReason(check);
55 const listHostedZones = yield* Route53.ListHostedZones();
56 const listHostedZonesByName = yield* Route53.ListHostedZonesByName();
57 const listHostedZonesByVpc = yield* Route53.ListHostedZonesByVPC();
58
59 const bound = {
60 changeRecordSets,
61 listRecordSets,
62 getHostedZone,
63 testDnsAnswer,
64 getChange,
65 getHealthCheckStatus,
66 getHealthCheckLastFailureReason,
67 listHostedZones,
68 listHostedZonesByName,
69 listHostedZonesByVpc,
70 };
71
72 // `ChangeInfo.Id` comes back as "/change/C…" but GetChange only accepts
73 // the bare id. Bounded: 2s x 40 ≈ 80s worst case.
74 const waitForInsync = (changeId: string) =>
75 getChange({ Id: changeId.replace(/^\/change\//, "") }).pipe(
76 Effect.map((r) => r.ChangeInfo.Status),
77 Effect.repeat({
78 schedule: Schedule.max([
79 Schedule.fixed("2 seconds"),
80 Schedule.recurs(40),
81 ]),
82 until: (status): boolean => status === "INSYNC",
83 }),
84 );
85
86 return {
87 fetch: Effect.gen(function* () {
88 const request = yield* HttpServerRequest;
89 const url = new URL(request.originalUrl);
90 const pathname = url.pathname;
91
92 if (request.method === "GET" && pathname === "/bindings") {
93 return yield* HttpServerResponse.json({
94 bound: Object.keys(bound),
95 });
96 }
97
98 if (request.method === "GET" && pathname === "/zone") {
99 // `Id` injection scopes the read to the bound zone.
100 const detail = yield* getHostedZone();
101 return yield* HttpServerResponse.json({
102 name: detail.HostedZone.Name,
103 nameServerCount: (detail.DelegationSet?.NameServers ?? []).length,
104 });
105 }
106
107 if (request.method === "GET" && pathname === "/zones") {
108 const detail = yield* getHostedZone();
109 const response = yield* listHostedZones({ MaxItems: 100 });
110 return yield* HttpServerResponse.json({
111 count: (response.HostedZones ?? []).length,
112 found: (response.HostedZones ?? []).some(
113 (z) => z.Id === detail.HostedZone.Id,
114 ),
115 });
116 }
117
118 if (request.method === "GET" && pathname === "/zones/by-name") {
119 const detail = yield* getHostedZone();
120 const response = yield* listHostedZonesByName({
121 DNSName: detail.HostedZone.Name,
122 MaxItems: 1,
123 });
124 return yield* HttpServerResponse.json({
125 firstName: response.HostedZones?.[0]?.Name,
126 });
127 }
128
129 if (request.method === "GET" && pathname === "/zones/by-vpc") {
130 // A well-formed VPC id with no private-zone associations — the
131 // call round-trips (IAM + query encoding) and returns an empty
132 // summary list.
133 const region = yield* Effect.sync(
134 () => process.env.AWS_REGION ?? "us-east-1",
135 );
136 const response = yield* listHostedZonesByVpc({
137 VPCId: "vpc-0123456789abcdef0",
138 VPCRegion: region,
139 }).pipe(
140 Effect.map((r) => ({
141 count: (r.HostedZoneSummaries ?? []).length,
142 rejected: false,
143 notOwned: false,
144 })),
145 // Route 53 validates VPC ownership before listing: a VPC id the
146 // account doesn't own comes back as the typed AccessDeniedException
147 // ("The VPC ... is not owned by you"), NOT an empty list. That
148 // rejection still proves the grant + query encoding end-to-end —
149 // but only when the message is the ownership check. A genuine IAM
150 // denial ("not authorized to perform") sets notOwned=false and the
151 // test fails loudly instead of being masked.
152 Effect.catchTag(["InvalidInput", "AccessDeniedException"], (e) =>
153 Effect.succeed({
154 count: 0,
155 rejected: true,
156 notOwned:
157 e._tag === "AccessDeniedException"
158 ? (e.message ?? "").includes("not owned by you")
159 : true,
160 // Surfaced so a failing assertion shows the real rejection.
161 detail: `${e._tag}: ${e.message ?? ""}`.slice(0, 300),
162 }),
163 ),
164 );
165 return yield* HttpServerResponse.json(response);
166 }
167
168 if (request.method === "GET" && pathname === "/dns-test") {
169 // The zone apex NS record answers even without delegation.
170 const detail = yield* getHostedZone();
171 const answer = yield* testDnsAnswer({
172 RecordName: detail.HostedZone.Name,
173 RecordType: "NS",
174 });
175 return yield* HttpServerResponse.json({
176 responseCode: answer.ResponseCode,
177 recordCount: (answer.RecordData ?? []).length,
178 });
179 }
180
181 if (request.method === "POST" && pathname === "/record/roundtrip") {
182 // UPSERT → GetChange-poll INSYNC → List (finds it) → DELETE.
183 // Exercises the full dynamic-DNS loop end-to-end and leaves no
184 // orphan record (UPSERT makes re-runs after a crash idempotent).
185 const detail = yield* getHostedZone();
186 const zoneName = detail.HostedZone.Name;
187 const recordName = `${PROBE_LABEL}.${zoneName}`;
188 const recordSet = {
189 Name: recordName,
190 Type: "TXT" as const,
191 TTL: 60,
192 ResourceRecords: [{ Value: PROBE_VALUE }],
193 };
194
195 const upserted = yield* changeRecordSets({
196 ChangeBatch: {
197 Comment: "alchemy Route53 bindings roundtrip",
198 Changes: [{ Action: "UPSERT", ResourceRecordSet: recordSet }],
199 },
200 });
201 const changeStatus = yield* waitForInsync(upserted.ChangeInfo.Id);
202
203 const listed = yield* listRecordSets({
204 StartRecordName: recordName,
205 StartRecordType: "TXT",
206 MaxItems: 1,
207 });
208 const found = (listed.ResourceRecordSets ?? []).some(
209 (set) => set.Name === recordName && set.Type === "TXT",
210 );
211
212 // A concurrent/previous cleanup racing the DELETE surfaces the
213 // typed InvalidChangeBatch ("not found") — the record is gone
214 // either way.
215 yield* changeRecordSets({
216 ChangeBatch: {
217 Changes: [{ Action: "DELETE", ResourceRecordSet: recordSet }],
218 },
219 }).pipe(Effect.catchTag("InvalidChangeBatch", () => Effect.void));
220
221 return yield* HttpServerResponse.json({
222 changeStatus,
223 found,
224 deleted: true,
225 });
226 }
227
228 if (request.method === "GET" && pathname === "/health/status") {
229 const response = yield* getHealthCheckStatus();
230 return yield* HttpServerResponse.json({
231 observations: response.HealthCheckObservations.length,
232 });
233 }
234
235 if (request.method === "GET" && pathname === "/health/failure-reason") {
236 const response = yield* getHealthCheckLastFailureReason();
237 return yield* HttpServerResponse.json({
238 observations: response.HealthCheckObservations.length,
239 });
240 }
241
242 return yield* HttpServerResponse.json(
243 { error: "Not found", method: request.method, pathname },
244 { status: 404 },
245 );
246 }).pipe(Effect.orDie),
247 };
248 }).pipe(
249 Effect.provide(
250 Layer.mergeAll(
251 Route53.ChangeResourceRecordSetsHttp,
252 Route53.ListResourceRecordSetsHttp,
253 Route53.GetHostedZoneHttp,
254 Route53.TestDNSAnswerHttp,
255 Route53.GetChangeHttp,
256 Route53.GetHealthCheckStatusHttp,
257 Route53.GetHealthCheckLastFailureReasonHttp,
258 Route53.ListHostedZonesHttp,
259 Route53.ListHostedZonesByNameHttp,
260 Route53.ListHostedZonesByVPCHttp,
261 ),
262 ),
263 ),
264);