1import * as cip from "@distilled.cloud/aws/cognito-identity-provider";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import type { Providers } from "../Providers.ts";
10
11export interface UserPoolDomainProps {
12 /**
13 * The ID of the user pool the domain serves. Changing this triggers a
14 * replacement.
15 */
16 userPoolId: string;
17 /**
18 * The domain prefix (for `<prefix>.auth.<region>.amazoncognito.com`) or
19 * the full custom domain name when `certificateArn` is set. Prefixes must
20 * be lowercase alphanumeric/hyphens and globally unique per region. If
21 * omitted, a deterministic prefix is generated from the app, stage, and
22 * logical ID. Changing this triggers a replacement.
23 */
24 domain?: string;
25 /**
26 * ARN of an ACM certificate in us-east-1 for a custom domain. Custom
27 * domains can take 15-60 minutes to distribute.
28 */
29 certificateArn?: string;
30 /**
31 * The branding version served by the domain: `1` for hosted UI (classic),
32 * `2` for managed login.
33 * @default 2
34 */
35 managedLoginVersion?: number;
36}
37
38export interface UserPoolDomain extends Resource<
39 "AWS.Cognito.UserPoolDomain",
40 UserPoolDomainProps,
41 {
42 /** The domain prefix or full custom domain name. */
43 domain: string;
44 /** The ID of the user pool the domain serves. */
45 userPoolId: string;
46 /**
47 * The CloudFront distribution domain fronting the hosted endpoint —
48 * for custom domains, point a DNS alias record here.
49 */
50 cloudFrontDomain: string | undefined;
51 },
52 never,
53 Providers
54> {}
55
56/**
57 * A domain for an Amazon Cognito user pool's managed login and OAuth 2.0
58 * authorization server. Cognito-prefix domains
59 * (`<prefix>.auth.<region>.amazoncognito.com`) provision in seconds; custom
60 * domains require an ACM certificate in us-east-1 and can take 15-60 minutes.
61 * ### Creating a Domain
62 * **Example:** Cognito-Prefix Domain
63 * ```typescript
64 * import * as Cognito from "alchemy/AWS/Cognito";
65 *
66 * const pool = yield* Cognito.UserPool("Users", {});
67 * const domain = yield* Cognito.UserPoolDomain("AuthDomain", {
68 * userPoolId: pool.userPoolId,
69 * });
70 * ```
71 *
72 * **Example:** Explicit Prefix
73 * ```typescript
74 * const domain = yield* Cognito.UserPoolDomain("AuthDomain", {
75 * userPoolId: pool.userPoolId,
76 * domain: "my-app-auth",
77 * });
78 * ```
79 *
80 * ### Custom Domains
81 * **Example:** Custom Domain with an ACM Certificate
82 * ```typescript
83 * const domain = yield* Cognito.UserPoolDomain("AuthDomain", {
84 * userPoolId: pool.userPoolId,
85 * domain: "auth.example.com",
86 * certificateArn: certificate.certificateArn, // must be us-east-1
87 * });
88 * ```
89 *
90 * @resource
91 */
92export const UserPoolDomain = Resource<UserPoolDomain>(
93 "AWS.Cognito.UserPoolDomain",
94);
95
96class DomainNotActive extends Data.TaggedError("DomainNotActive")<{
97 readonly domain: string;
98 readonly status: string | undefined;
99}> {}
100
101/**
102 * Cognito rejects domain prefixes containing the reserved words below, and
103 * requires lowercase alphanumerics/hyphens. Deterministically sanitize the
104 * generated physical name.
105 */
106const sanitizeDomainPrefix = (name: string) => {
107 let sanitized = name.toLowerCase().replaceAll(/[^a-z0-9-]/g, "-");
108 for (const [reserved, replacement] of [
109 ["amazon", "amzn"],
110 ["cognito", "cgnto"],
111 ["aws", "ws"],
112 ] as const) {
113 while (sanitized.includes(reserved)) {
114 sanitized = sanitized.replaceAll(reserved, replacement);
115 }
116 }
117 return sanitized.replaceAll(/^-+|-+$/g, "").slice(0, 63);
118};
119
120/**
121 * Bounded retry while the domain has not yet reached ACTIVE. Explicitly
122 * typed so the conditional `Retry.Return` type never leaks into declaration
123 * emit (which would widen the provider layer to an `unknown` R).
124 */
125const retryWhileNotActive = <A, E extends { _tag: string }, R>(
126 self: Effect.Effect<A, E, R>,
127): Effect.Effect<A, E, R> =>
128 Effect.retry(self, {
129 while: (e) => e._tag === "DomainNotActive",
130 schedule: Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(30)]),
131 });
132
133/** Bounded wait for the domain to reach ACTIVE (up to ~60s). */
134const waitUntilActive = (domain: string) =>
135 retryWhileNotActive(
136 cip.describeUserPoolDomain({ Domain: domain }).pipe(
137 Effect.flatMap((r) => {
138 const description = r.DomainDescription;
139 if (
140 description?.UserPoolId !== undefined &&
141 (description.Status === "ACTIVE" || description.Status === undefined)
142 ) {
143 return Effect.succeed(description);
144 }
145 return Effect.fail(
146 new DomainNotActive({ domain, status: description?.Status }),
147 );
148 }),
149 ),
150 );
151
152export const UserPoolDomainProvider = () =>
153 Provider.effect(
154 UserPoolDomain,
155 Effect.gen(function* () {
156 const createDomain = Effect.fn(function* (
157 id: string,
158 props: Pick<UserPoolDomainProps, "domain">,
159 ) {
160 if (props.domain) return props.domain;
161 const generated = yield* createPhysicalName({
162 id,
163 maxLength: 63,
164 lowercase: true,
165 });
166 return sanitizeDomainPrefix(generated);
167 });
168
169 const describeDomain = Effect.fn(function* (domain: string) {
170 const result = yield* cip
171 .describeUserPoolDomain({ Domain: domain })
172 .pipe(
173 Effect.catchTag("ResourceNotFoundException", () =>
174 Effect.succeed({} as cip.DescribeUserPoolDomainResponse),
175 ),
176 );
177 const description = result.DomainDescription;
178 // Cognito returns an empty description (no UserPoolId) for a
179 // non-existent domain rather than a NotFound error.
180 return description?.UserPoolId === undefined ? undefined : description;
181 });
182
183 const attributesOf = (description: cip.DomainDescriptionType) => ({
184 domain: description.Domain!,
185 userPoolId: description.UserPoolId!,
186 cloudFrontDomain: description.CloudFrontDistribution,
187 });
188
189 return UserPoolDomain.Provider.of({
190 stables: ["domain", "userPoolId"],
191
192 // Sub-resource keyed entirely by its user pool (userPoolId) with no global
193 // enumeration API of its own — nuke reaches it through the parent's
194 // deletion, so enumeration returns empty per the ProviderService
195 // doctrine.
196 list: () => Effect.succeed([]),
197
198 read: Effect.fn(function* ({ id, olds, output }) {
199 const domain =
200 output?.domain ?? (yield* createDomain(id, olds ?? {}));
201 const observed = yield* describeDomain(domain);
202 return observed === undefined ? undefined : attributesOf(observed);
203 }),
204
205 diff: Effect.fn(function* ({ id, news, olds }) {
206 if (!isResolved(news)) return undefined;
207 const oldDomain = yield* createDomain(id, olds ?? {});
208 const newDomain = yield* createDomain(id, news ?? {});
209 if (oldDomain !== newDomain) return { action: "replace" } as const;
210 if (olds?.userPoolId !== news?.userPoolId) {
211 return { action: "replace" } as const;
212 }
213 }),
214
215 reconcile: Effect.fn(function* ({ id, news, output, session }) {
216 const domain = output?.domain ?? (yield* createDomain(id, news));
217
218 // 1. OBSERVE
219 let observed = yield* describeDomain(domain);
220
221 // 2. ENSURE
222 if (observed === undefined) {
223 yield* cip.createUserPoolDomain({
224 Domain: domain,
225 UserPoolId: news.userPoolId,
226 ManagedLoginVersion: news.managedLoginVersion,
227 CustomDomainConfig:
228 news.certificateArn === undefined
229 ? undefined
230 : { CertificateArn: news.certificateArn },
231 });
232 observed = yield* waitUntilActive(domain);
233 } else {
234 // 3. SYNC — branding version / certificate are mutable in place.
235 const drift =
236 (news.managedLoginVersion !== undefined &&
237 observed.ManagedLoginVersion !== news.managedLoginVersion) ||
238 (news.certificateArn !== undefined &&
239 observed.CustomDomainConfig?.CertificateArn !==
240 news.certificateArn);
241 if (drift) {
242 yield* cip.updateUserPoolDomain({
243 Domain: domain,
244 UserPoolId: news.userPoolId,
245 ManagedLoginVersion: news.managedLoginVersion,
246 CustomDomainConfig:
247 news.certificateArn === undefined
248 ? undefined
249 : { CertificateArn: news.certificateArn },
250 });
251 observed = yield* waitUntilActive(domain);
252 }
253 }
254
255 yield* session.note(domain);
256 return attributesOf({ ...observed, Domain: domain });
257 }),
258
259 delete: Effect.fn(function* ({ output }) {
260 yield* cip
261 .deleteUserPoolDomain({
262 Domain: output.domain,
263 UserPoolId: output.userPoolId,
264 })
265 .pipe(
266 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
267 // The domain briefly rejects deletion while still provisioning
268 // or updating; InvalidParameterException surfaces that window.
269 (self) => retryWhileTransitioning(self),
270 );
271 }),
272 });
273 }),
274 );
275
276/**
277 * Bounded retry over the delete-while-transitioning window. Explicitly typed
278 * (see {@link retryWhileNotActive}) to keep declaration emit clean.
279 */
280const retryWhileTransitioning = <A, E extends { _tag: string }, R>(
281 self: Effect.Effect<A, E, R>,
282): Effect.Effect<A, E, R> =>
283 Effect.retry(self, {
284 while: (e) =>
285 e._tag === "InvalidParameterException" ||
286 e._tag === "ConcurrentModificationException",
287 schedule: Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(10)]),
288 });