1import * as ApiGateway from "@/AWS/ApiGateway";
2import * as Lambda from "@/AWS/Lambda";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Redacted from "effect/Redacted";
6import { HttpServerRequest } from "effect/http/HttpServerRequest";
7import * as HttpServerResponse from "effect/http/HttpServerResponse";
8import path from "pathe";
9
10const main = path.resolve(import.meta.dirname, "bindings-handler.ts");
11
12export class ApiGatewayBindingsFunction extends Lambda.Function<Lambda.Function>()(
13 "ApiGatewayBindingsFunction",
14) {}
15
16export default ApiGatewayBindingsFunction.make(
17 {
18 main,
19 functionUrl: true,
20 },
21 Effect.gen(function* () {
22 // Usage plan the key-management routes enroll keys into.
23 const plan = yield* ApiGateway.UsagePlan("AgBindingsPlan", {
24 throttle: { rateLimit: 10, burstLimit: 5 },
25 });
26
27 // Minimal REST API + stage so the flush-cache bindings have a target.
28 const api = yield* ApiGateway.RestApi("AgBindingsApi", {
29 endpointConfiguration: { types: ["REGIONAL"] },
30 });
31 yield* ApiGateway.Method("AgBindingsMock", {
32 restApi: api,
33 httpMethod: "GET",
34 authorizationType: "NONE",
35 integration: { type: "MOCK" },
36 });
37 const deployment = yield* ApiGateway.Deployment("AgBindingsDep", {
38 restApi: api,
39 });
40 const stage = yield* ApiGateway.Stage("AgBindingsStage", {
41 restApi: api,
42 stageName: "bindings",
43 deploymentId: deployment.deploymentId,
44 });
45
46 const createApiKey = yield* ApiGateway.CreateApiKey();
47 const getApiKey = yield* ApiGateway.GetApiKey();
48 const getApiKeys = yield* ApiGateway.GetApiKeys();
49 const updateApiKey = yield* ApiGateway.UpdateApiKey();
50 const deleteApiKey = yield* ApiGateway.DeleteApiKey();
51 const createUsagePlanKey = yield* ApiGateway.CreateUsagePlanKey(plan);
52 const getUsagePlanKey = yield* ApiGateway.GetUsagePlanKey(plan);
53 const getUsagePlanKeys = yield* ApiGateway.GetUsagePlanKeys(plan);
54 const deleteUsagePlanKey = yield* ApiGateway.DeleteUsagePlanKey(plan);
55 const getUsage = yield* ApiGateway.GetUsage(plan);
56 const updateUsage = yield* ApiGateway.UpdateUsage(plan);
57 const flushStageCache = yield* ApiGateway.FlushStageCache(stage);
58 const flushStageAuthorizersCache =
59 yield* ApiGateway.FlushStageAuthorizersCache(stage);
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 if (request.method === "POST" && pathname === "/keys") {
68 const body = (yield* request.json) as unknown as { name: string };
69 const created = yield* createApiKey({
70 name: body.name,
71 enabled: true,
72 });
73 yield* createUsagePlanKey({ keyId: created.id! });
74 return yield* HttpServerResponse.json({
75 id: created.id,
76 // Responses decode the key material to Redacted<string> — prove
77 // it round-tripped redacted without ever logging the plaintext.
78 valueRedacted: Redacted.isRedacted(created.value),
79 });
80 }
81
82 if (request.method === "GET" && pathname === "/keys") {
83 const nameQuery = url.searchParams.get("nameQuery") ?? undefined;
84 const page = yield* getApiKeys({ nameQuery, limit: 100 });
85 return yield* HttpServerResponse.json({
86 ids: (page.items ?? []).map((k) => k.id),
87 });
88 }
89
90 if (request.method === "GET" && pathname === "/key") {
91 const id = url.searchParams.get("id")!;
92 const key = yield* getApiKey({ apiKey: id });
93 return yield* HttpServerResponse.json({
94 id: key.id,
95 name: key.name,
96 enabled: key.enabled,
97 });
98 }
99
100 if (request.method === "POST" && pathname === "/disable") {
101 const body = (yield* request.json) as unknown as { id: string };
102 const key = yield* updateApiKey({
103 apiKey: body.id,
104 patchOperations: [
105 { op: "replace", path: "/enabled", value: "false" },
106 ],
107 });
108 return yield* HttpServerResponse.json({ enabled: key.enabled });
109 }
110
111 if (request.method === "GET" && pathname === "/plan-key") {
112 const id = url.searchParams.get("id")!;
113 const enrolled = yield* getUsagePlanKey({ keyId: id }).pipe(
114 Effect.map(() => true),
115 Effect.catchTag("NotFoundException", () => Effect.succeed(false)),
116 );
117 return yield* HttpServerResponse.json({ enrolled });
118 }
119
120 if (request.method === "GET" && pathname === "/plan-keys") {
121 const page = yield* getUsagePlanKeys({ limit: 100 });
122 return yield* HttpServerResponse.json({
123 ids: (page.items ?? []).map((k) => k.id),
124 });
125 }
126
127 if (request.method === "GET" && pathname === "/usage") {
128 const keyId = url.searchParams.get("keyId") ?? undefined;
129 const startDate = url.searchParams.get("startDate")!;
130 const endDate = url.searchParams.get("endDate")!;
131 const usage = yield* getUsage({ keyId, startDate, endDate });
132 return yield* HttpServerResponse.json({
133 usagePlanId: usage.usagePlanId,
134 items: usage.items ?? {},
135 });
136 }
137
138 if (request.method === "POST" && pathname === "/extend") {
139 const body = (yield* request.json) as unknown as { keyId: string };
140 // Quota extensions only apply to plans with a quota; on this
141 // plan the API answers BadRequest — the IAM grant is still
142 // exercised (a missing grant surfaces as UnauthorizedException /
143 // AccessDenied instead, failing the route).
144 const extended = yield* updateUsage({
145 keyId: body.keyId,
146 patchOperations: [
147 { op: "replace", path: "/remaining", value: "100" },
148 ],
149 }).pipe(
150 Effect.map(() => true),
151 Effect.catchTag(["BadRequestException", "NotFoundException"], () =>
152 Effect.succeed(false),
153 ),
154 );
155 return yield* HttpServerResponse.json({ extended });
156 }
157
158 if (request.method === "DELETE" && pathname === "/key") {
159 const id = url.searchParams.get("id")!;
160 yield* deleteUsagePlanKey({ keyId: id }).pipe(
161 Effect.catchTag("NotFoundException", () => Effect.void),
162 );
163 yield* deleteApiKey({ apiKey: id }).pipe(
164 Effect.catchTag("NotFoundException", () => Effect.void),
165 );
166 return yield* HttpServerResponse.json({ deleted: true });
167 }
168
169 if (request.method === "POST" && pathname === "/flush") {
170 // The stage has no cache cluster, so API Gateway answers
171 // BadRequest/NotFound — the IAM grant is still exercised (a
172 // missing grant surfaces as an auth error instead).
173 const cache = yield* flushStageCache().pipe(
174 Effect.map(() => true),
175 Effect.catchTag(["BadRequestException", "NotFoundException"], () =>
176 Effect.succeed(false),
177 ),
178 );
179 const authorizers = yield* flushStageAuthorizersCache().pipe(
180 Effect.map(() => true),
181 Effect.catchTag(["BadRequestException", "NotFoundException"], () =>
182 Effect.succeed(false),
183 ),
184 );
185 return yield* HttpServerResponse.json({ cache, authorizers });
186 }
187
188 return yield* HttpServerResponse.json(
189 { error: "Not found", method: request.method, pathname },
190 { status: 404 },
191 );
192 }).pipe(Effect.orDie),
193 };
194 }).pipe(
195 Effect.provide(
196 Layer.mergeAll(
197 ApiGateway.CreateApiKeyHttp,
198 ApiGateway.GetApiKeyHttp,
199 ApiGateway.GetApiKeysHttp,
200 ApiGateway.UpdateApiKeyHttp,
201 ApiGateway.DeleteApiKeyHttp,
202 ApiGateway.CreateUsagePlanKeyHttp,
203 ApiGateway.GetUsagePlanKeyHttp,
204 ApiGateway.GetUsagePlanKeysHttp,
205 ApiGateway.DeleteUsagePlanKeyHttp,
206 ApiGateway.GetUsageHttp,
207 ApiGateway.UpdateUsageHttp,
208 ApiGateway.FlushStageCacheHttp,
209 ApiGateway.FlushStageAuthorizersCacheHttp,
210 ),
211 ),
212 ),
213);