1import type * as elbv2 from "@distilled.cloud/aws/elastic-load-balancing-v2";
2import type * as Duration from "effect/Duration";
3import type * as Redacted from "effect/Redacted";
4import { toSeconds } from "../../Util/Duration.ts";
5import type { TargetGroupArn } from "./TargetGroup.ts";
6
7/**
8 * A single forward target with an optional traffic weight. Used by the
9 * weighted-forward action to split traffic across multiple target groups.
10 */
11export interface ForwardTarget {
12 /**
13 * The target group to forward to. Accepts a `TargetGroup` resource reference
14 * — the engine resolves it to the ARN automatically (the `Input` machinery
15 * applies deeply to nested props).
16 */
17 targetGroupArn: TargetGroupArn;
18 /**
19 * The weight applied to this target group when splitting traffic. The
20 * proportion a target group receives is its weight divided by the sum of all
21 * weights in the action.
22 * @default 1
23 */
24 weight?: number;
25}
26
27/**
28 * Forward action: routes the request to one or more target groups, optionally
29 * with session stickiness so a client stays pinned to a single target group.
30 */
31export interface ForwardAction {
32 /** Discriminator identifying this as a forward action. */
33 type: "forward";
34 /** One or more target groups to forward to (weighted). */
35 targetGroups: ForwardTarget[];
36 /** Session stickiness across the target groups in this action. */
37 stickiness?: {
38 /** Whether target-group stickiness is enabled. */
39 enabled: boolean;
40 /**
41 * The time a client remains pinned to a target group — e.g. `"1 hour"`
42 * or `Duration.hours(1)`. Sent to AWS as whole seconds.
43 * @default "1 hour"
44 */
45 duration?: Duration.Input;
46 };
47}
48
49/**
50 * Redirect action: returns an HTTP redirect (301/302) to a computed URL. Any
51 * component (protocol, host, path, query, port) may use the reserved keywords
52 * `#{protocol}`, `#{host}`, `#{path}`, `#{query}`, `#{port}` to copy from the
53 * original request.
54 */
55export interface RedirectAction {
56 /** Discriminator identifying this as a redirect action. */
57 type: "redirect";
58 /** The redirect status code. */
59 statusCode: "HTTP_301" | "HTTP_302";
60 /** The protocol (`HTTP`, `HTTPS`, or `#{protocol}`). */
61 protocol?: string;
62 /** The port. */
63 port?: string;
64 /** The hostname. */
65 host?: string;
66 /** The absolute path, starting with `/`. */
67 path?: string;
68 /** The query parameters, not including the leading `?`. */
69 query?: string;
70}
71
72/**
73 * Fixed-response action: returns a static HTTP response directly from the load
74 * balancer without forwarding to any target.
75 */
76export interface FixedResponseAction {
77 /** Discriminator identifying this as a fixed-response action. */
78 type: "fixedResponse";
79 /** The HTTP response status code (2XX/4XX/5XX). */
80 statusCode: string;
81 /** The content type of the response body. */
82 contentType?: string;
83 /** The response body. */
84 messageBody?: string;
85}
86
87/**
88 * Authenticate-OIDC action: authenticates the request through an OpenID Connect
89 * (OIDC) identity provider before forwarding to the next action.
90 */
91export interface AuthenticateOidcAction {
92 /** Discriminator identifying this as an authenticate-OIDC action. */
93 type: "authenticateOidc";
94 /** The OIDC issuer identifier of the identity provider (IdP). */
95 issuer: string;
96 /** The authorization endpoint of the IdP. */
97 authorizationEndpoint: string;
98 /** The token endpoint of the IdP. */
99 tokenEndpoint: string;
100 /** The user info endpoint of the IdP. */
101 userInfoEndpoint: string;
102 /** The OAuth 2.0 client identifier registered with the IdP. */
103 clientId: string;
104 /** Required on first use; omit with `useExistingClientSecret: true` to keep the existing one. */
105 clientSecret?: Redacted.Redacted<string>;
106 /**
107 * The set of user claims requested from the IdP.
108 * @default "openid"
109 */
110 scope?: string;
111 /**
112 * The name of the cookie used to maintain session information.
113 * @default "AWSELBAuthSessionCookie"
114 */
115 sessionCookieName?: string;
116 /** The maximum duration of the authentication session — e.g. `"7 days"`. Sent to AWS as whole seconds. */
117 sessionTimeout?: Duration.Input;
118 /**
119 * Behavior when the user is not authenticated (`deny`, `allow`, or `authenticate`).
120 * @default "authenticate"
121 */
122 onUnauthenticatedRequest?: "deny" | "allow" | "authenticate";
123 /** Set to `true` on updates to keep the client secret already stored on the action. */
124 useExistingClientSecret?: boolean;
125}
126
127/**
128 * Authenticate-Cognito action: authenticates the request through an Amazon
129 * Cognito user pool before forwarding to the next action.
130 */
131export interface AuthenticateCognitoAction {
132 /** Discriminator identifying this as an authenticate-Cognito action. */
133 type: "authenticateCognito";
134 /** The ARN of the Cognito user pool. */
135 userPoolArn: string;
136 /** The ID of the Cognito user pool client. */
137 userPoolClientId: string;
138 /** The domain prefix or fully-qualified domain of the Cognito user pool. */
139 userPoolDomain: string;
140 /**
141 * The set of user claims requested from Cognito.
142 * @default "openid"
143 */
144 scope?: string;
145 /**
146 * The name of the cookie used to maintain session information.
147 * @default "AWSELBAuthSessionCookie"
148 */
149 sessionCookieName?: string;
150 /** The maximum duration of the authentication session — e.g. `"7 days"`. Sent to AWS as whole seconds. */
151 sessionTimeout?: Duration.Input;
152 /**
153 * Behavior when the user is not authenticated (`deny`, `allow`, or `authenticate`).
154 * @default "authenticate"
155 */
156 onUnauthenticatedRequest?: "deny" | "allow" | "authenticate";
157}
158
159/**
160 * A listener/rule action. The terminal action of a listener default-action or a
161 * rule must be `forward`, `redirect`, or `fixedResponse`; authentication
162 * actions are non-terminal and are ordered before the terminal action.
163 */
164export type ListenerAction =
165 | ForwardAction
166 | RedirectAction
167 | FixedResponseAction
168 | AuthenticateOidcAction
169 | AuthenticateCognitoAction;
170
171// At reconcile time the engine has already resolved Input/resource refs to
172// plain ARN strings.
173const resolveTargetGroupArn = (ref: TargetGroupArn): string => ref;
174
175/**
176 * Serialize a list of {@link ListenerAction} into the wire `Action[]` shape AWS
177 * expects, assigning `Order` so authentication actions run before the terminal
178 * action. Shared by both Listener default-actions and ListenerRule actions.
179 */
180export const serializeActions = (actions: ListenerAction[]): elbv2.Action[] =>
181 actions.map((action, index): elbv2.Action => {
182 const Order = index + 1;
183 switch (action.type) {
184 case "forward":
185 return {
186 Type: "forward",
187 Order,
188 ForwardConfig: {
189 TargetGroups: action.targetGroups.map((t) => ({
190 TargetGroupArn: resolveTargetGroupArn(t.targetGroupArn),
191 Weight: t.weight,
192 })),
193 TargetGroupStickinessConfig: action.stickiness
194 ? {
195 Enabled: action.stickiness.enabled,
196 DurationSeconds: toSeconds(action.stickiness.duration),
197 }
198 : undefined,
199 },
200 // For a single, unweighted target group, AWS also accepts the legacy
201 // top-level TargetGroupArn; sending only ForwardConfig is canonical.
202 TargetGroupArn:
203 action.targetGroups.length === 1 &&
204 action.targetGroups[0].weight === undefined
205 ? resolveTargetGroupArn(action.targetGroups[0].targetGroupArn)
206 : undefined,
207 };
208 case "redirect":
209 return {
210 Type: "redirect",
211 Order,
212 RedirectConfig: {
213 StatusCode: action.statusCode,
214 Protocol: action.protocol,
215 Port: action.port,
216 Host: action.host,
217 Path: action.path,
218 Query: action.query,
219 },
220 };
221 case "fixedResponse":
222 return {
223 Type: "fixed-response",
224 Order,
225 FixedResponseConfig: {
226 StatusCode: action.statusCode,
227 ContentType: action.contentType,
228 MessageBody: action.messageBody,
229 },
230 };
231 case "authenticateOidc":
232 return {
233 Type: "authenticate-oidc",
234 Order,
235 AuthenticateOidcConfig: {
236 Issuer: action.issuer,
237 AuthorizationEndpoint: action.authorizationEndpoint,
238 TokenEndpoint: action.tokenEndpoint,
239 UserInfoEndpoint: action.userInfoEndpoint,
240 ClientId: action.clientId,
241 // Redacted end-to-end: distilled's AuthenticateOidcActionConfig
242 // marks ClientSecret sensitive, so the Redacted passes straight
243 // through and is only unwrapped by the wire serializer.
244 ClientSecret: action.clientSecret,
245 Scope: action.scope,
246 SessionCookieName: action.sessionCookieName,
247 SessionTimeout: toSeconds(action.sessionTimeout),
248 OnUnauthenticatedRequest: action.onUnauthenticatedRequest,
249 UseExistingClientSecret: action.useExistingClientSecret,
250 },
251 };
252 case "authenticateCognito":
253 return {
254 Type: "authenticate-cognito",
255 Order,
256 AuthenticateCognitoConfig: {
257 UserPoolArn: action.userPoolArn,
258 UserPoolClientId: action.userPoolClientId,
259 UserPoolDomain: action.userPoolDomain,
260 Scope: action.scope,
261 SessionCookieName: action.sessionCookieName,
262 SessionTimeout: toSeconds(action.sessionTimeout),
263 OnUnauthenticatedRequest: action.onUnauthenticatedRequest,
264 },
265 };
266 }
267 });
268
269/**
270 * A condition under which a {@link ListenerRule} matches a request. Exactly one
271 * of the config fields should be set per condition; multiple conditions on a
272 * rule are AND-ed together, while values within a single condition are OR-ed.
273 */
274export interface ListenerRuleCondition {
275 /** Match on the `Host` header. Supports `*` and `?` wildcards. */
276 hostHeader?: { values?: string[]; regexValues?: string[] };
277 /** Match on the request path. Supports `*` and `?` wildcards. */
278 pathPattern?: { values?: string[]; regexValues?: string[] };
279 /** Match on a named HTTP header. */
280 httpHeader?: {
281 name: string;
282 values?: string[];
283 regexValues?: string[];
284 };
285 /** Match on query-string key/value pairs. Supports `*` and `?` wildcards. */
286 queryString?: { values: { key?: string; value: string }[] };
287 /** Match on the HTTP request method (GET, POST, ...). */
288 httpRequestMethod?: { values: string[]; regexValues?: string[] };
289 /** Match on the source IP CIDR. */
290 sourceIp?: { values: string[] };
291}
292
293/**
294 * Serialize a list of {@link ListenerRuleCondition} into the wire
295 * `RuleCondition[]` shape AWS expects.
296 */
297export const serializeConditions = (
298 conditions: ListenerRuleCondition[],
299): elbv2.RuleCondition[] =>
300 conditions.flatMap((condition): elbv2.RuleCondition[] => {
301 const out: elbv2.RuleCondition[] = [];
302 if (condition.hostHeader) {
303 out.push({
304 Field: "host-header",
305 HostHeaderConfig: {
306 Values: condition.hostHeader.values,
307 RegexValues: condition.hostHeader.regexValues,
308 },
309 });
310 }
311 if (condition.pathPattern) {
312 out.push({
313 Field: "path-pattern",
314 PathPatternConfig: {
315 Values: condition.pathPattern.values,
316 RegexValues: condition.pathPattern.regexValues,
317 },
318 });
319 }
320 if (condition.httpHeader) {
321 out.push({
322 Field: "http-header",
323 HttpHeaderConfig: {
324 HttpHeaderName: condition.httpHeader.name,
325 Values: condition.httpHeader.values,
326 RegexValues: condition.httpHeader.regexValues,
327 },
328 });
329 }
330 if (condition.queryString) {
331 out.push({
332 Field: "query-string",
333 QueryStringConfig: {
334 Values: condition.queryString.values.map((v) => ({
335 Key: v.key,
336 Value: v.value,
337 })),
338 },
339 });
340 }
341 if (condition.httpRequestMethod) {
342 out.push({
343 Field: "http-request-method",
344 HttpRequestMethodConfig: {
345 Values: condition.httpRequestMethod.values,
346 },
347 });
348 }
349 if (condition.sourceIp) {
350 out.push({
351 Field: "source-ip",
352 SourceIpConfig: { Values: condition.sourceIp.values },
353 });
354 }
355 return out;
356 });