1import * as cip from "@distilled.cloud/aws/cognito-identity-provider";
2import * as Data from "effect/Data";
3import type * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import { createPhysicalName } from "../../PhysicalName.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { createInternalTags, diffTags, hasAlchemyTags } from "../../Tags.ts";
12import { toWireDays } from "../../Util/Duration.ts";
13import type { Providers } from "../Providers.ts";
14
15/**
16 * Password complexity requirements enforced by the user pool at sign-up and
17 * password change.
18 */
19export interface UserPoolPasswordPolicy {
20 /**
21 * Minimum password length (6-99).
22 * @default 8
23 */
24 minimumLength?: number;
25 /**
26 * Require at least one uppercase letter.
27 * @default true
28 */
29 requireUppercase?: boolean;
30 /**
31 * Require at least one lowercase letter.
32 * @default true
33 */
34 requireLowercase?: boolean;
35 /**
36 * Require at least one number.
37 * @default true
38 */
39 requireNumbers?: boolean;
40 /**
41 * Require at least one symbol.
42 * @default true
43 */
44 requireSymbols?: boolean;
45 /**
46 * Number of previous passwords a user cannot reuse. Requires the
47 * `ESSENTIALS` or `PLUS` feature tier.
48 */
49 passwordHistorySize?: number;
50 /**
51 * How long an admin-set temporary password stays valid, e.g.
52 * `"7 days"` (0-365 days). Rounded to whole days on the wire
53 * (`TemporaryPasswordValidityDays`).
54 * @default 7 days
55 */
56 temporaryPasswordValidity?: Duration.Input;
57}
58
59/**
60 * A custom schema attribute added to the user pool. Custom attributes are
61 * automatically prefixed with `custom:` by Cognito. Attributes can be added
62 * to an existing pool but never removed or changed — removing or modifying a
63 * declared attribute triggers a replacement.
64 */
65export interface UserPoolSchemaAttribute {
66 /**
67 * Attribute name (without the `custom:` prefix; Cognito adds it).
68 * 1-20 characters.
69 */
70 name: string;
71 /**
72 * The data type of the attribute.
73 * @default "String"
74 */
75 attributeDataType?: "String" | "Number" | "DateTime" | "Boolean";
76 /**
77 * Whether users can change the value after it is set.
78 * @default true
79 */
80 mutable?: boolean;
81 /**
82 * Whether the attribute is required at sign-up. Custom attributes cannot
83 * be required.
84 * @default false
85 */
86 required?: boolean;
87 /**
88 * Min/max length constraints for `String` attributes (stringified numbers,
89 * matching the Cognito wire format).
90 */
91 stringConstraints?: { minLength?: string; maxLength?: string };
92 /**
93 * Min/max value constraints for `Number` attributes (stringified numbers,
94 * matching the Cognito wire format).
95 */
96 numberConstraints?: { minValue?: string; maxValue?: string };
97 /**
98 * Developer-only attributes can only be modified with IAM credentials
99 * (prefixed `dev:` in addition to `custom:`).
100 * @default false
101 */
102 developerOnlyAttribute?: boolean;
103}
104
105/**
106 * The user pool Lambda trigger slots that take a plain function ARN — the
107 * string-valued keys of the pool's `LambdaConfig`. The versioned custom
108 * sender slots are configured separately: `CustomEmailSender` through
109 * {@link UserPoolProps.customEmailSender} (+ `kmsKeyId`); `CustomSMSSender`
110 * is not modelled yet (an observed one is preserved, never cleared).
111 */
112export type UserPoolTriggerName =
113 | "PreSignUp"
114 | "PostConfirmation"
115 | "PreAuthentication"
116 | "PostAuthentication"
117 | "CustomMessage"
118 | "DefineAuthChallenge"
119 | "CreateAuthChallenge"
120 | "VerifyAuthChallengeResponse"
121 | "PreTokenGeneration"
122 | "UserMigration";
123
124const USER_POOL_TRIGGER_NAMES: readonly UserPoolTriggerName[] = [
125 "PreSignUp",
126 "PostConfirmation",
127 "PreAuthentication",
128 "PostAuthentication",
129 "CustomMessage",
130 "DefineAuthChallenge",
131 "CreateAuthChallenge",
132 "VerifyAuthChallengeResponse",
133 "PreTokenGeneration",
134 "UserMigration",
135];
136
137/**
138 * Lambda trigger configuration for the pool: each key is a trigger slot,
139 * each value the ARN of the Lambda function Cognito invokes for it.
140 * Usually populated through the trigger event source
141 * (`Cognito.onPreSignUp(pool, ...)` etc.) rather than declared directly.
142 */
143export interface UserPoolLambdaConfig extends Partial<
144 Record<UserPoolTriggerName, string>
145> {}
146
147/**
148 * The binding contract of a user pool: event sources contribute
149 * `LambdaConfig` trigger entries (trigger slot → function ARN) that the
150 * provider merges with `props.lambdaConfig` and syncs onto the pool.
151 */
152export interface UserPoolBinding {
153 /** Trigger entries injected by `Cognito.onUserPoolTrigger` and friends. */
154 lambdaConfig?: UserPoolLambdaConfig;
155}
156
157/**
158 * Two different Lambda functions were registered for the same user pool
159 * trigger slot — Cognito supports exactly one function per trigger.
160 */
161export class ConflictingUserPoolTrigger extends Data.TaggedError(
162 "ConflictingUserPoolTrigger",
163)<{
164 readonly trigger: string;
165 readonly functionArns: readonly string[];
166}> {}
167
168/**
169 * The user pool's configuration is internally inconsistent (e.g. a custom
170 * email sender without the KMS key Cognito needs to encrypt the codes it
171 * hands to the function). Raised before any API call is made.
172 */
173export class InvalidUserPoolConfiguration extends Data.TaggedError(
174 "InvalidUserPoolConfiguration",
175)<{
176 readonly reason: string;
177}> {}
178
179/**
180 * A first-factor sign-in method for choice-based authentication.
181 */
182export type UserPoolAuthFactor =
183 | "PASSWORD"
184 | "EMAIL_OTP"
185 | "SMS_OTP"
186 | "WEB_AUTHN";
187
188/**
189 * The sign-in policy of the pool — which first-factor authentication
190 * methods users may start with (choice-based authentication). Requires the
191 * `ESSENTIALS` or `PLUS` feature tier.
192 */
193export interface UserPoolSignInPolicy {
194 /**
195 * First-factor sign-in methods the pool allows. `PASSWORD` is the
196 * classic username + password flow; `EMAIL_OTP` / `SMS_OTP` send a
197 * one-time code (email OTP is delivered through `customEmailSender` when
198 * one is configured); `WEB_AUTHN` is passkey sign-in.
199 * Passwordless factors require the `ESSENTIALS` or `PLUS` tier.
200 */
201 allowedFirstAuthFactors?: UserPoolAuthFactor[];
202}
203
204/**
205 * The `CustomEmailSender` Lambda trigger: Cognito invokes this function
206 * instead of sending email itself, passing the verification / OTP code
207 * encrypted with `kmsKeyId` (decrypt it with the AWS Encryption SDK).
208 * Requires {@link UserPoolProps.kmsKeyId}. The function must also grant
209 * `cognito-idp.amazonaws.com` invoke access (`AWS.Lambda.Permission`).
210 */
211export interface UserPoolCustomEmailSender {
212 /** ARN of the Lambda function that delivers the pool's email messages. */
213 lambdaArn: string;
214 /**
215 * The custom sender event version the function understands.
216 * @default "V1_0"
217 */
218 lambdaVersion?: "V1_0";
219}
220
221/**
222 * How the user pool delivers its email messages (verification codes, OTPs,
223 * invitations): either Cognito's built-in, rate-limited delivery
224 * (`COGNITO_DEFAULT`) or your own verified Amazon SES identity
225 * (`DEVELOPER`). With `DEVELOPER`, the SES identity's policy must grant
226 * `cognito-idp.amazonaws.com` permission to send
227 * (`SES.EmailIdentityPolicy`).
228 */
229export interface UserPoolEmailConfiguration {
230 /**
231 * Which sending account delivers the pool's email. `COGNITO_DEFAULT`
232 * uses Cognito's built-in delivery (limited daily volume);
233 * `DEVELOPER` sends through your own SES identity (`sourceArn`
234 * required) at your SES quota.
235 * @default "COGNITO_DEFAULT"
236 */
237 emailSendingAccount?: "COGNITO_DEFAULT" | "DEVELOPER";
238 /**
239 * ARN of a verified SES email identity (address or domain) in a
240 * supported region. Required when `emailSendingAccount` is `DEVELOPER`;
241 * with `COGNITO_DEFAULT` it customizes the FROM address while Cognito
242 * still handles delivery.
243 */
244 sourceArn?: string;
245 /**
246 * The FROM address, either bare (`no-reply@example.com`) or with a
247 * friendly display name (`"My App <no-reply@example.com>"`). Must be
248 * covered by the `sourceArn` identity.
249 */
250 from?: string;
251 /**
252 * The destination for replies to the pool's messages.
253 */
254 replyToEmailAddress?: string;
255 /**
256 * Name of an SES configuration set applied to email sent by the pool
257 * (event publishing, IP pool selection). `DEVELOPER` only.
258 */
259 configurationSet?: string;
260}
261
262/**
263 * An account recovery mechanism with its priority (1 is highest).
264 */
265export interface UserPoolRecoveryMechanism {
266 /** Recovery channel. */
267 name: "verified_email" | "verified_phone_number" | "admin_only";
268 /** Priority of this mechanism; 1 is tried first. */
269 priority: number;
270}
271
272export interface UserPoolProps {
273 /**
274 * Name of the user pool. If omitted, a deterministic physical name is
275 * generated from the app, stage, and logical ID.
276 */
277 poolName?: string;
278 /**
279 * Password complexity policy for the pool.
280 */
281 passwordPolicy?: UserPoolPasswordPolicy;
282 /**
283 * Attributes users may sign in with *instead of* a username
284 * (`email` and/or `phone_number`). When set, Cognito generates an
285 * immutable UUID username and the listed attributes become sign-in
286 * identifiers. Mutually exclusive with `aliasAttributes`.
287 * Changing this triggers a replacement.
288 */
289 usernameAttributes?: ("email" | "phone_number")[];
290 /**
291 * Attributes that may be used as sign-in aliases *in addition to* the
292 * username (`email`, `phone_number`, `preferred_username`). Mutually
293 * exclusive with `usernameAttributes`. Changing this triggers a
294 * replacement.
295 */
296 aliasAttributes?: ("email" | "phone_number" | "preferred_username")[];
297 /**
298 * Attributes Cognito verifies automatically by sending a code
299 * (`email` and/or `phone_number`). `phone_number` requires SMS (SNS)
300 * configuration.
301 */
302 autoVerifiedAttributes?: ("email" | "phone_number")[];
303 /**
304 * Custom schema attributes. Attributes may be added to an existing pool;
305 * removing or changing a declared attribute triggers a replacement.
306 */
307 schema?: UserPoolSchemaAttribute[];
308 /**
309 * Choice-based authentication: the first-factor sign-in methods users may
310 * start with (`PASSWORD`, `EMAIL_OTP`, `SMS_OTP`, `WEB_AUTHN`). Requires
311 * the `ESSENTIALS` or `PLUS` tier when any passwordless factor is listed.
312 * When omitted the observed policy is left untouched.
313 */
314 signInPolicy?: UserPoolSignInPolicy;
315 /**
316 * Multi-factor authentication mode. `ON` requires SMS or TOTP setup for
317 * every user; avoid SMS-based MFA unless the account has SNS spend
318 * entitlements.
319 * @default "OFF"
320 */
321 mfaConfiguration?: "OFF" | "OPTIONAL" | "ON";
322 /**
323 * Account recovery mechanisms in priority order.
324 * @default verified_email then verified_phone_number
325 */
326 accountRecovery?: UserPoolRecoveryMechanism[];
327 /**
328 * When true the pool cannot be deleted until protection is disabled.
329 * @default false
330 */
331 deletionProtection?: boolean;
332 /**
333 * When true, only administrators (via `AdminCreateUser`) can create
334 * users — public `SignUp` is disabled.
335 * @default false
336 */
337 adminCreateUserOnly?: boolean;
338 /**
339 * Whether usernames are case sensitive. Changing this triggers a
340 * replacement.
341 * @default true (the API default)
342 */
343 usernameCaseSensitive?: boolean;
344 /**
345 * The feature tier of the user pool.
346 * @default "ESSENTIALS"
347 */
348 tier?: "LITE" | "ESSENTIALS" | "PLUS";
349 /**
350 * Lambda trigger configuration (trigger slot → function ARN). Merged with
351 * trigger entries injected through the binding contract by
352 * `Cognito.onUserPoolTrigger` / `onPreSignUp` / etc. — prefer those over
353 * declaring ARNs here, since the event source also creates the invoke
354 * Permission and registers the runtime handler.
355 */
356 lambdaConfig?: UserPoolLambdaConfig;
357 /**
358 * Route the pool's email messages (sign-up / OTP / recovery codes,
359 * temporary passwords) through your own Lambda function instead of
360 * Cognito's built-in delivery. Requires `kmsKeyId`. Omitting this on an
361 * existing pool clears the custom sender (Cognito falls back to its own
362 * email delivery); ordinary triggers and the other pool settings are
363 * never affected by adding or removing it.
364 */
365 customEmailSender?: UserPoolCustomEmailSender;
366 /**
367 * How the pool delivers its email messages: Cognito's built-in delivery
368 * (`COGNITO_DEFAULT`, the service default) or a verified SES identity
369 * (`DEVELOPER`) — no custom sender Lambda required. When omitted, an
370 * observed email configuration is preserved rather than reset to the
371 * service default.
372 */
373 emailConfiguration?: UserPoolEmailConfiguration;
374 /**
375 * ARN of the symmetric KMS key Cognito uses to encrypt the codes it
376 * passes to the custom sender function(s) (`LambdaConfig.KMSKeyID`).
377 * The principal running the deploy must hold `kms:CreateGrant` on the
378 * key — Cognito creates a grant against it when the pool is configured —
379 * and the sender function's role needs `kms:Decrypt`.
380 */
381 kmsKeyId?: string;
382 /**
383 * Tags to apply to the user pool. Merged with internal Alchemy tags.
384 */
385 tags?: Record<string, string>;
386}
387
388export interface UserPool extends Resource<
389 "AWS.Cognito.UserPool",
390 UserPoolProps,
391 {
392 /** The generated ID of the user pool, e.g. `us-west-2_AbCdEfGhI`. */
393 userPoolId: string;
394 /** The ARN of the user pool. */
395 userPoolArn: string;
396 /** The name of the user pool. */
397 userPoolName: string;
398 },
399 UserPoolBinding,
400 Providers
401> {}
402
403/**
404 * An Amazon Cognito user pool — a managed user directory that handles
405 * sign-up, sign-in, and token issuance (OIDC-compliant JWTs) for your
406 * application.
407 * ### Creating a User Pool
408 * **Example:** Basic User Pool
409 * ```typescript
410 * import * as Cognito from "alchemy/AWS/Cognito";
411 *
412 * const pool = yield* Cognito.UserPool("Users", {});
413 * ```
414 *
415 * **Example:** Email Sign-In with Password Policy
416 * ```typescript
417 * const pool = yield* Cognito.UserPool("Users", {
418 * usernameAttributes: ["email"],
419 * autoVerifiedAttributes: ["email"],
420 * passwordPolicy: {
421 * minimumLength: 12,
422 * requireSymbols: false,
423 * },
424 * });
425 * ```
426 *
427 * **Example:** Admin-Only User Creation
428 * ```typescript
429 * const pool = yield* Cognito.UserPool("Users", {
430 * adminCreateUserOnly: true,
431 * accountRecovery: [{ name: "admin_only", priority: 1 }],
432 * });
433 * ```
434 *
435 * ### Custom Attributes
436 * **Example:** Pool with Custom Schema Attributes
437 * ```typescript
438 * const pool = yield* Cognito.UserPool("Users", {
439 * schema: [
440 * { name: "tenantId", mutable: false },
441 * { name: "plan", attributeDataType: "String" },
442 * ],
443 * });
444 * ```
445 *
446 * ### Sending Email Through SES
447 * **Example:** OTP and Verification Email from a Verified SES Identity
448 * ```typescript
449 * const identity = yield* SES.EmailIdentity("Sender", {
450 * emailIdentity: "mail.example.com",
451 * });
452 * // allow Cognito to send through the identity
453 * yield* SES.EmailIdentityPolicy("CognitoSend", {
454 * emailIdentity: identity.emailIdentity,
455 * policyName: "cognito",
456 * policy: {
457 * Version: "2012-10-17",
458 * Statement: [{
459 * Effect: "Allow",
460 * Principal: { Service: "cognito-idp.amazonaws.com" },
461 * Action: ["ses:SendEmail", "ses:SendRawEmail"],
462 * Resource: identity.identityArn,
463 * }],
464 * },
465 * });
466 * const pool = yield* Cognito.UserPool("Users", {
467 * usernameAttributes: ["email"],
468 * autoVerifiedAttributes: ["email"],
469 * emailConfiguration: {
470 * emailSendingAccount: "DEVELOPER",
471 * sourceArn: identity.identityArn,
472 * from: "My App <no-reply@mail.example.com>",
473 * replyToEmailAddress: "support@example.com",
474 * },
475 * });
476 * ```
477 *
478 * ### Email OTP with a Custom Email Sender
479 * **Example:** Passwordless Email OTP Delivered by Your Own Lambda
480 * ```typescript
481 * const key = yield* KMS.Key("CodeKey", {});
482 * const sender = yield* Lambda.Function("EmailSender", {
483 * main: import.meta.url,
484 * });
485 * yield* Lambda.Permission("CognitoInvoke", {
486 * functionName: sender.functionName,
487 * action: "lambda:InvokeFunction",
488 * principal: "cognito-idp.amazonaws.com",
489 * });
490 * const pool = yield* Cognito.UserPool("Auth", {
491 * tier: "ESSENTIALS",
492 * usernameAttributes: ["email"],
493 * signInPolicy: { allowedFirstAuthFactors: ["PASSWORD", "EMAIL_OTP"] },
494 * customEmailSender: { lambdaArn: sender.functionArn },
495 * kmsKeyId: key.keyArn,
496 * });
497 * ```
498 *
499 * ### App Clients and Auth
500 * **Example:** Pool with an App Client
501 * ```typescript
502 * const pool = yield* Cognito.UserPool("Users", {});
503 * const client = yield* Cognito.UserPoolClient("Web", {
504 * userPoolId: pool.userPoolId,
505 * explicitAuthFlows: ["ALLOW_USER_PASSWORD_AUTH", "ALLOW_REFRESH_TOKEN_AUTH"],
506 * });
507 * ```
508 *
509 * @resource
510 */
511export const UserPool = Resource<UserPool>("AWS.Cognito.UserPool");
512
513/** Map camelCase password policy props to the Cognito wire shape (the wire
514 * unit for `TemporaryPasswordValidityDays` is whole days). */
515const toWirePasswordPolicy = (policy: UserPoolPasswordPolicy | undefined) =>
516 policy === undefined
517 ? undefined
518 : {
519 MinimumLength: policy.minimumLength,
520 RequireUppercase: policy.requireUppercase,
521 RequireLowercase: policy.requireLowercase,
522 RequireNumbers: policy.requireNumbers,
523 RequireSymbols: policy.requireSymbols,
524 PasswordHistorySize: policy.passwordHistorySize,
525 TemporaryPasswordValidityDays: toWireDays(
526 policy.temporaryPasswordValidity,
527 ),
528 };
529
530const toWireSchemaAttribute = (attribute: UserPoolSchemaAttribute) => ({
531 Name: attribute.name,
532 AttributeDataType: attribute.attributeDataType ?? "String",
533 Mutable: attribute.mutable ?? true,
534 Required: attribute.required ?? false,
535 DeveloperOnlyAttribute: attribute.developerOnlyAttribute,
536 StringAttributeConstraints:
537 attribute.stringConstraints === undefined
538 ? undefined
539 : {
540 MinLength: attribute.stringConstraints.minLength,
541 MaxLength: attribute.stringConstraints.maxLength,
542 },
543 NumberAttributeConstraints:
544 attribute.numberConstraints === undefined
545 ? undefined
546 : {
547 MinValue: attribute.numberConstraints.minValue,
548 MaxValue: attribute.numberConstraints.maxValue,
549 },
550});
551
552const toWireAccountRecovery = (
553 mechanisms: UserPoolRecoveryMechanism[] | undefined,
554) =>
555 mechanisms === undefined
556 ? undefined
557 : {
558 RecoveryMechanisms: mechanisms.map((m) => ({
559 Name: m.name,
560 Priority: m.priority,
561 })),
562 };
563
564const toWireSignInPolicy = (policy: UserPoolSignInPolicy | undefined) =>
565 policy === undefined
566 ? undefined
567 : { AllowedFirstAuthFactors: policy.allowedFirstAuthFactors };
568
569const toWireEmailConfiguration = (
570 config: UserPoolEmailConfiguration | undefined,
571): cip.EmailConfigurationType | undefined =>
572 config === undefined
573 ? undefined
574 : {
575 EmailSendingAccount: config.emailSendingAccount ?? "COGNITO_DEFAULT",
576 SourceArn: config.sourceArn,
577 From: config.from,
578 ReplyToEmailAddress: config.replyToEmailAddress,
579 ConfigurationSet: config.configurationSet,
580 };
581
582/** Observed/desired email configuration with the service default filled in,
583 * for order- and absence-insensitive comparison (`JSON.stringify` drops the
584 * `undefined` members on both sides). */
585const normalizedEmailConfiguration = (
586 config: cip.EmailConfigurationType | undefined,
587) => ({
588 emailSendingAccount: config?.EmailSendingAccount ?? "COGNITO_DEFAULT",
589 sourceArn: config?.SourceArn,
590 from: config?.From,
591 replyToEmailAddress: config?.ReplyToEmailAddress,
592 configurationSet: config?.ConfigurationSet,
593});
594
595const toWireCustomEmailSender = (
596 sender: UserPoolCustomEmailSender | undefined,
597): cip.CustomEmailLambdaVersionConfigType | undefined =>
598 sender === undefined
599 ? undefined
600 : {
601 LambdaArn: sender.lambdaArn,
602 LambdaVersion: sender.lambdaVersion ?? "V1_0",
603 };
604
605const PASSWORDLESS_AUTH_FACTORS: readonly UserPoolAuthFactor[] = [
606 "EMAIL_OTP",
607 "SMS_OTP",
608 "WEB_AUTHN",
609];
610
611/**
612 * Reject configurations Cognito would refuse (or silently mangle) before
613 * any API call is made.
614 */
615const validateProps = (props: UserPoolProps) =>
616 props.customEmailSender !== undefined && props.kmsKeyId === undefined
617 ? Effect.fail(
618 new InvalidUserPoolConfiguration({
619 reason:
620 "customEmailSender requires kmsKeyId — Cognito encrypts the codes it passes to the custom sender with that KMS key",
621 }),
622 )
623 : props.emailConfiguration?.emailSendingAccount === "DEVELOPER" &&
624 props.emailConfiguration.sourceArn === undefined
625 ? Effect.fail(
626 new InvalidUserPoolConfiguration({
627 reason:
628 "emailConfiguration with emailSendingAccount DEVELOPER requires sourceArn — the ARN of the verified SES identity Cognito sends from",
629 }),
630 )
631 : props.tier === "LITE" &&
632 (props.signInPolicy?.allowedFirstAuthFactors ?? []).some((factor) =>
633 PASSWORDLESS_AUTH_FACTORS.includes(factor),
634 )
635 ? Effect.fail(
636 new InvalidUserPoolConfiguration({
637 reason:
638 "passwordless first-auth factors (EMAIL_OTP / SMS_OTP / WEB_AUTHN) require the ESSENTIALS or PLUS tier, not LITE",
639 }),
640 )
641 : Effect.void;
642
643const tagRecordOf = (
644 tags: { [key: string]: string | undefined } | undefined,
645): Record<string, string> =>
646 Object.fromEntries(
647 Object.entries(tags ?? {}).filter(
648 (entry): entry is [string, string] => entry[1] !== undefined,
649 ),
650 );
651
652/**
653 * The wire name an observed pool attribute would have for a declared custom
654 * schema attribute (Cognito prefixes `custom:`, or `dev:custom:` for
655 * developer-only attributes).
656 */
657const customAttributeWireName = (attribute: UserPoolSchemaAttribute) =>
658 attribute.developerOnlyAttribute
659 ? `dev:custom:${attribute.name}`
660 : `custom:${attribute.name}`;
661
662const schemaAttributeChanged = (
663 before: UserPoolSchemaAttribute,
664 after: UserPoolSchemaAttribute,
665) =>
666 JSON.stringify(toWireSchemaAttribute(before)) !==
667 JSON.stringify(toWireSchemaAttribute(after));
668
669export const UserPoolProvider = () =>
670 Provider.effect(
671 UserPool,
672 Effect.gen(function* () {
673 const createName = Effect.fn(function* (
674 id: string,
675 props: Pick<UserPoolProps, "poolName">,
676 ) {
677 return (
678 props.poolName ?? (yield* createPhysicalName({ id, maxLength: 128 }))
679 );
680 });
681
682 const describePool = Effect.fn(function* (userPoolId: string) {
683 return yield* cip.describeUserPool({ UserPoolId: userPoolId }).pipe(
684 Effect.map((r) => r.UserPool),
685 Effect.catchTag("ResourceNotFoundException", () =>
686 Effect.succeed(undefined),
687 ),
688 );
689 });
690
691 /**
692 * Find a pool by exact name. Pool names are not unique, so return every
693 * candidate — callers disambiguate by ownership tags.
694 */
695 const findPoolsByName = Effect.fn(function* (name: string) {
696 const pages = yield* cip.listUserPools
697 .pages({ MaxResults: 60 })
698 .pipe(Stream.runCollect);
699 const candidates = Array.from(pages)
700 .flatMap((page) => page.UserPools ?? [])
701 .filter((pool) => pool.Name === name && pool.Id !== undefined);
702 return yield* Effect.forEach(
703 candidates,
704 (candidate) => describePool(candidate.Id!),
705 { concurrency: 3 },
706 ).pipe(
707 Effect.map((pools) => pools.filter((pool) => pool !== undefined)),
708 );
709 });
710
711 const attributesOf = (pool: cip.UserPoolType) => ({
712 userPoolId: pool.Id!,
713 userPoolArn: pool.Arn!,
714 userPoolName: pool.Name!,
715 });
716
717 /**
718 * The pool's desired trigger slots: `props.lambdaConfig` merged with
719 * the trigger entries contributed through the binding contract
720 * (`Cognito.onUserPoolTrigger` and friends). Fails when two different
721 * function ARNs target the same trigger slot.
722 */
723 const resolveTriggers = Effect.fn(function* (
724 news: UserPoolProps,
725 bindings: ReadonlyArray<UserPoolBinding | { data?: UserPoolBinding }>,
726 ) {
727 const merged: Partial<Record<UserPoolTriggerName, string>> = {};
728 const contributions = [
729 news.lambdaConfig,
730 ...bindings.map(
731 (binding) =>
732 (binding as { data?: UserPoolBinding }).data?.lambdaConfig ??
733 (binding as UserPoolBinding).lambdaConfig,
734 ),
735 ];
736 for (const config of contributions) {
737 if (config === undefined) continue;
738 for (const trigger of USER_POOL_TRIGGER_NAMES) {
739 const arn = config[trigger];
740 if (arn === undefined) continue;
741 const existing = merged[trigger];
742 if (existing !== undefined && existing !== arn) {
743 return yield* Effect.fail(
744 new ConflictingUserPoolTrigger({
745 trigger,
746 functionArns: [existing, arn],
747 }),
748 );
749 }
750 merged[trigger] = arn;
751 }
752 }
753 return merged as UserPoolLambdaConfig;
754 });
755
756 /**
757 * The full desired `LambdaConfig` wire shape: the string trigger slots
758 * plus the versioned `CustomEmailSender` + `KMSKeyID` from props. The
759 * provider owns `LambdaConfig` outright, so a trigger or custom sender
760 * removed from the program is cleared on the pool. The one exception
761 * is `CustomSMSSender`, which the props cannot express yet: an
762 * observed one (and the KMS key it needs) is carried over rather than
763 * wiped by an unrelated update. Returns `undefined` when nothing is
764 * desired (omitting `LambdaConfig` clears it on both create and
765 * update).
766 */
767 const desiredLambdaConfig = (
768 news: UserPoolProps,
769 triggers: UserPoolLambdaConfig,
770 observed: cip.LambdaConfigType | undefined,
771 ): cip.LambdaConfigType | undefined => {
772 const customSMSSender = observed?.CustomSMSSender;
773 const config: cip.LambdaConfigType = {
774 ...triggers,
775 CustomEmailSender: toWireCustomEmailSender(news.customEmailSender),
776 CustomSMSSender: customSMSSender,
777 KMSKeyID:
778 news.kmsKeyId ??
779 (customSMSSender !== undefined ? observed?.KMSKeyID : undefined),
780 };
781 const present = Object.fromEntries(
782 Object.entries(config).filter(([, value]) => value !== undefined),
783 ) as cip.LambdaConfigType;
784 return Object.keys(present).length > 0 ? present : undefined;
785 };
786
787 /** True when the managed parts of the observed `LambdaConfig` differ
788 * from the desired one. */
789 const lambdaConfigDrifted = (
790 desired: cip.LambdaConfigType | undefined,
791 observed: cip.LambdaConfigType | undefined,
792 ) => {
793 for (const trigger of USER_POOL_TRIGGER_NAMES) {
794 if (desired?.[trigger] !== observed?.[trigger]) return true;
795 }
796 if (
797 desired?.CustomEmailSender?.LambdaArn !==
798 observed?.CustomEmailSender?.LambdaArn ||
799 desired?.CustomEmailSender?.LambdaVersion !==
800 observed?.CustomEmailSender?.LambdaVersion
801 ) {
802 return true;
803 }
804 return desired?.KMSKeyID !== observed?.KMSKeyID;
805 };
806
807 /** The update body sent to `updateUserPool` — always the full desired
808 * state, because Cognito resets any omitted field to its default.
809 * Props the user left undefined are "don't care" (see `hasDrift`), so
810 * their OBSERVED value is echoed back rather than dropped — an update
811 * to one setting must not reset another to its default. */
812 const desiredUpdate = (
813 news: UserPoolProps,
814 observed: cip.UserPoolType,
815 lambdaConfig: cip.LambdaConfigType | undefined,
816 ): Omit<cip.UpdateUserPoolRequest, "UserPoolId"> => {
817 const PasswordPolicy =
818 news.passwordPolicy === undefined
819 ? observed.Policies?.PasswordPolicy
820 : toWirePasswordPolicy(news.passwordPolicy);
821 const SignInPolicy =
822 news.signInPolicy === undefined
823 ? observed.Policies?.SignInPolicy
824 : toWireSignInPolicy(news.signInPolicy);
825 return {
826 LambdaConfig: lambdaConfig,
827 // updateUserPool resets an omitted EmailConfiguration to the
828 // service default — echo the OBSERVED configuration back when the
829 // prop is undefined so unrelated updates never clear it.
830 EmailConfiguration:
831 news.emailConfiguration === undefined
832 ? observed.EmailConfiguration
833 : toWireEmailConfiguration(news.emailConfiguration),
834 Policies:
835 PasswordPolicy === undefined && SignInPolicy === undefined
836 ? undefined
837 : { PasswordPolicy, SignInPolicy },
838 DeletionProtection: news.deletionProtection ? "ACTIVE" : "INACTIVE",
839 AutoVerifiedAttributes:
840 news.autoVerifiedAttributes ?? observed.AutoVerifiedAttributes,
841 MfaConfiguration: news.mfaConfiguration ?? "OFF",
842 AdminCreateUserConfig:
843 news.adminCreateUserOnly === undefined
844 ? observed.AdminCreateUserConfig === undefined
845 ? undefined
846 : {
847 // never echo the deprecated UnusedAccountValidityDays —
848 // Cognito rejects it next to TemporaryPasswordValidityDays
849 AllowAdminCreateUserOnly:
850 observed.AdminCreateUserConfig.AllowAdminCreateUserOnly,
851 InviteMessageTemplate:
852 observed.AdminCreateUserConfig.InviteMessageTemplate,
853 }
854 : { AllowAdminCreateUserOnly: news.adminCreateUserOnly },
855 AccountRecoverySetting:
856 news.accountRecovery === undefined
857 ? observed.AccountRecoverySetting
858 : toWireAccountRecovery(news.accountRecovery),
859 UserPoolTier: news.tier ?? observed.UserPoolTier,
860 };
861 };
862
863 /** Canonicalize a recovery-mechanism list for order-insensitive
864 * comparison. */
865 const canonicalRecovery = (
866 mechanisms: {
867 priority: number | undefined;
868 name: string | undefined;
869 }[],
870 ) =>
871 mechanisms
872 .map((m) => `${m.priority}:${m.name}`)
873 .sort()
874 .join(",");
875
876 /** Full password policy with the Cognito API defaults filled in, so a
877 * partial desired policy compares fairly against the observed one.
878 * Operates on the wire shape — `temporaryPasswordValidityDays` is a
879 * whole number of days (desired durations are converted first). */
880 const normalizedPasswordPolicy = (
881 policy: Omit<UserPoolPasswordPolicy, "temporaryPasswordValidity"> & {
882 temporaryPasswordValidityDays?: number;
883 },
884 ): Required<
885 Omit<
886 UserPoolPasswordPolicy,
887 "passwordHistorySize" | "temporaryPasswordValidity"
888 >
889 > & {
890 passwordHistorySize: number | undefined;
891 temporaryPasswordValidityDays: number;
892 } => ({
893 minimumLength: policy.minimumLength ?? 8,
894 requireUppercase: policy.requireUppercase ?? true,
895 requireLowercase: policy.requireLowercase ?? true,
896 requireNumbers: policy.requireNumbers ?? true,
897 requireSymbols: policy.requireSymbols ?? true,
898 passwordHistorySize: policy.passwordHistorySize,
899 temporaryPasswordValidityDays:
900 policy.temporaryPasswordValidityDays ?? 7,
901 });
902
903 /** True when the observed pool differs from the desired mutable state.
904 * Props the user left undefined are "don't care" and never drift —
905 * except `LambdaConfig`, which the provider owns outright (bindings
906 * contribute to it), so a trigger or custom sender removed from the
907 * program is drift that clears the observed entry. */
908 const hasDrift = (
909 news: UserPoolProps,
910 observed: cip.UserPoolType,
911 lambdaConfig: cip.LambdaConfigType | undefined,
912 ) => {
913 if (lambdaConfigDrifted(lambdaConfig, observed.LambdaConfig)) {
914 return true;
915 }
916 if (
917 news.signInPolicy?.allowedFirstAuthFactors !== undefined &&
918 [...news.signInPolicy.allowedFirstAuthFactors].sort().join(",") !==
919 [
920 ...(observed.Policies?.SignInPolicy?.AllowedFirstAuthFactors ??
921 []),
922 ]
923 .sort()
924 .join(",")
925 ) {
926 return true;
927 }
928 if (news.passwordPolicy !== undefined) {
929 const desired = normalizedPasswordPolicy({
930 ...news.passwordPolicy,
931 temporaryPasswordValidityDays: toWireDays(
932 news.passwordPolicy.temporaryPasswordValidity,
933 ),
934 });
935 const actual = normalizedPasswordPolicy({
936 minimumLength: observed.Policies?.PasswordPolicy?.MinimumLength,
937 requireUppercase:
938 observed.Policies?.PasswordPolicy?.RequireUppercase,
939 requireLowercase:
940 observed.Policies?.PasswordPolicy?.RequireLowercase,
941 requireNumbers: observed.Policies?.PasswordPolicy?.RequireNumbers,
942 requireSymbols: observed.Policies?.PasswordPolicy?.RequireSymbols,
943 passwordHistorySize:
944 observed.Policies?.PasswordPolicy?.PasswordHistorySize,
945 temporaryPasswordValidityDays:
946 observed.Policies?.PasswordPolicy?.TemporaryPasswordValidityDays,
947 });
948 if (JSON.stringify(desired) !== JSON.stringify(actual)) return true;
949 }
950 if (
951 (news.deletionProtection ? "ACTIVE" : "INACTIVE") !==
952 (observed.DeletionProtection ?? "INACTIVE")
953 ) {
954 return true;
955 }
956 if (
957 news.autoVerifiedAttributes !== undefined &&
958 [...news.autoVerifiedAttributes].sort().join(",") !==
959 [...(observed.AutoVerifiedAttributes ?? [])].sort().join(",")
960 ) {
961 return true;
962 }
963 if (
964 (news.mfaConfiguration ?? "OFF") !==
965 (observed.MfaConfiguration ?? "OFF")
966 ) {
967 return true;
968 }
969 if (
970 news.emailConfiguration !== undefined &&
971 JSON.stringify(
972 normalizedEmailConfiguration(
973 toWireEmailConfiguration(news.emailConfiguration),
974 ),
975 ) !==
976 JSON.stringify(
977 normalizedEmailConfiguration(observed.EmailConfiguration),
978 )
979 ) {
980 return true;
981 }
982 if (
983 news.adminCreateUserOnly !== undefined &&
984 news.adminCreateUserOnly !==
985 (observed.AdminCreateUserConfig?.AllowAdminCreateUserOnly ?? false)
986 ) {
987 return true;
988 }
989 if (
990 news.accountRecovery !== undefined &&
991 canonicalRecovery(news.accountRecovery) !==
992 canonicalRecovery(
993 (observed.AccountRecoverySetting?.RecoveryMechanisms ?? []).map(
994 (m) => ({ priority: m.Priority, name: m.Name }),
995 ),
996 )
997 ) {
998 return true;
999 }
1000 if (news.tier !== undefined && news.tier !== observed.UserPoolTier) {
1001 return true;
1002 }
1003 return false;
1004 };
1005
1006 return UserPool.Provider.of({
1007 stables: ["userPoolId", "userPoolArn"],
1008
1009 list: () =>
1010 Effect.gen(function* () {
1011 const pages = yield* cip.listUserPools
1012 .pages({ MaxResults: 60 })
1013 .pipe(Stream.runCollect);
1014 const descriptions = Array.from(pages).flatMap(
1015 (page) => page.UserPools ?? [],
1016 );
1017 const pools = yield* Effect.forEach(
1018 descriptions.filter((d) => d.Id !== undefined),
1019 (d) => describePool(d.Id!),
1020 { concurrency: 5 },
1021 );
1022 return pools
1023 .filter((pool) => pool !== undefined)
1024 .map((pool) => attributesOf(pool));
1025 }),
1026
1027 read: Effect.fn(function* ({ id, olds, output }) {
1028 if (output?.userPoolId !== undefined) {
1029 const pool = yield* describePool(output.userPoolId);
1030 if (pool === undefined) return undefined;
1031 const attrs = attributesOf(pool);
1032 const tags = tagRecordOf(pool.UserPoolTags);
1033 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
1034 }
1035 const name = yield* createName(id, olds ?? {});
1036 const pools = yield* findPoolsByName(name);
1037 if (pools.length === 0) return undefined;
1038 for (const pool of pools) {
1039 if (yield* hasAlchemyTags(id, tagRecordOf(pool.UserPoolTags))) {
1040 return attributesOf(pool);
1041 }
1042 }
1043 return Unowned(attributesOf(pools[0]!));
1044 }),
1045
1046 diff: Effect.fn(function* ({ id, news, olds }) {
1047 if (!isResolved(news)) return undefined;
1048 const oldName = yield* createName(id, olds ?? {});
1049 const newName = yield* createName(id, news ?? {});
1050 if (oldName !== newName) return { action: "replace" } as const;
1051 // sign-in configuration is immutable
1052 if (
1053 JSON.stringify(olds?.usernameAttributes ?? []) !==
1054 JSON.stringify(news?.usernameAttributes ?? []) ||
1055 JSON.stringify(olds?.aliasAttributes ?? []) !==
1056 JSON.stringify(news?.aliasAttributes ?? []) ||
1057 (olds?.usernameCaseSensitive ?? true) !==
1058 (news?.usernameCaseSensitive ?? true)
1059 ) {
1060 return { action: "replace" } as const;
1061 }
1062 // schema attributes are add-only: removal or mutation ⇒ replace
1063 const oldSchema = olds?.schema ?? [];
1064 const newSchema = news?.schema ?? [];
1065 for (const before of oldSchema) {
1066 const after = newSchema.find((a) => a.name === before.name);
1067 if (after === undefined || schemaAttributeChanged(before, after)) {
1068 return { action: "replace" } as const;
1069 }
1070 }
1071 }),
1072
1073 reconcile: Effect.fn(function* ({
1074 id,
1075 news = {},
1076 output,
1077 session,
1078 bindings,
1079 }) {
1080 yield* validateProps(news);
1081 const name = output?.userPoolName ?? (yield* createName(id, news));
1082 const internalTags = yield* createInternalTags(id);
1083 const desiredTags = { ...news.tags, ...internalTags };
1084 const triggers = yield* resolveTriggers(news, bindings);
1085
1086 // 1. OBSERVE — output.userPoolId is only a cache; fall back to a
1087 // name search so out-of-band deletes and adoption converge.
1088 let observed =
1089 output?.userPoolId !== undefined
1090 ? yield* describePool(output.userPoolId)
1091 : undefined;
1092 if (observed === undefined) {
1093 // Recover from state loss by name — only take over a pool that
1094 // carries our ownership tags (adoption flows arrive with output
1095 // set, not through this path).
1096 const candidates = yield* findPoolsByName(name);
1097 for (const candidate of candidates) {
1098 if (
1099 yield* hasAlchemyTags(id, tagRecordOf(candidate.UserPoolTags))
1100 ) {
1101 observed = candidate;
1102 break;
1103 }
1104 }
1105 }
1106
1107 // 2. ENSURE — create when missing.
1108 if (observed === undefined) {
1109 observed = yield* cip
1110 .createUserPool({
1111 PoolName: name,
1112 LambdaConfig: desiredLambdaConfig(news, triggers, undefined),
1113 Policies:
1114 news.passwordPolicy === undefined &&
1115 news.signInPolicy === undefined
1116 ? undefined
1117 : {
1118 PasswordPolicy: toWirePasswordPolicy(
1119 news.passwordPolicy,
1120 ),
1121 SignInPolicy: toWireSignInPolicy(news.signInPolicy),
1122 },
1123 DeletionProtection: news.deletionProtection
1124 ? "ACTIVE"
1125 : "INACTIVE",
1126 EmailConfiguration: toWireEmailConfiguration(
1127 news.emailConfiguration,
1128 ),
1129 UsernameAttributes: news.usernameAttributes,
1130 AliasAttributes: news.aliasAttributes,
1131 AutoVerifiedAttributes: news.autoVerifiedAttributes,
1132 Schema: news.schema?.map(toWireSchemaAttribute),
1133 MfaConfiguration: news.mfaConfiguration ?? "OFF",
1134 AdminCreateUserConfig:
1135 news.adminCreateUserOnly === undefined
1136 ? undefined
1137 : { AllowAdminCreateUserOnly: news.adminCreateUserOnly },
1138 AccountRecoverySetting: toWireAccountRecovery(
1139 news.accountRecovery,
1140 ),
1141 UsernameConfiguration:
1142 news.usernameCaseSensitive === undefined
1143 ? undefined
1144 : { CaseSensitive: news.usernameCaseSensitive },
1145 UserPoolTier: news.tier,
1146 UserPoolTags: desiredTags,
1147 })
1148 .pipe(Effect.map((r) => r.UserPool!));
1149 } else {
1150 // 3. SYNC — updateUserPool resets omitted fields to defaults, so
1151 // the body is always the full desired mutable state; skip the
1152 // call entirely when nothing drifted.
1153 const lambdaConfig = desiredLambdaConfig(
1154 news,
1155 triggers,
1156 observed.LambdaConfig,
1157 );
1158 if (hasDrift(news, observed, lambdaConfig)) {
1159 yield* cip.updateUserPool({
1160 UserPoolId: observed.Id!,
1161 ...desiredUpdate(news, observed, lambdaConfig),
1162 });
1163 }
1164
1165 // 3b. SYNC SCHEMA — custom attributes are add-only.
1166 const observedNames = new Set(
1167 (observed.SchemaAttributes ?? [])
1168 .map((attribute) => attribute.Name)
1169 .filter((n): n is string => n !== undefined),
1170 );
1171 const missing = (news.schema ?? []).filter(
1172 (attribute) =>
1173 !observedNames.has(customAttributeWireName(attribute)) &&
1174 !observedNames.has(attribute.name),
1175 );
1176 if (missing.length > 0) {
1177 yield* cip.addCustomAttributes({
1178 UserPoolId: observed.Id!,
1179 CustomAttributes: missing.map(toWireSchemaAttribute),
1180 });
1181 }
1182 }
1183
1184 const userPoolId = observed.Id!;
1185 const userPoolArn = observed.Arn!;
1186
1187 // 3c. SYNC TAGS — diff against OBSERVED cloud tags so adoption
1188 // converges (never olds/output).
1189 const observedTags = yield* cip
1190 .listTagsForResource({ ResourceArn: userPoolArn })
1191 .pipe(
1192 Effect.map((r) => tagRecordOf(r.Tags)),
1193 Effect.catchTag("ResourceNotFoundException", () =>
1194 Effect.succeed({} as Record<string, string>),
1195 ),
1196 );
1197 const { upsert, removed } = diffTags(observedTags, desiredTags);
1198 if (upsert.length > 0) {
1199 yield* cip.tagResource({
1200 ResourceArn: userPoolArn,
1201 Tags: Object.fromEntries(upsert.map((t) => [t.Key, t.Value])),
1202 });
1203 }
1204 if (removed.length > 0) {
1205 yield* cip.untagResource({
1206 ResourceArn: userPoolArn,
1207 TagKeys: removed,
1208 });
1209 }
1210
1211 yield* session.note(userPoolId);
1212 return { userPoolId, userPoolArn, userPoolName: name };
1213 }),
1214
1215 delete: Effect.fn(function* ({ output }) {
1216 yield* cip
1217 .deleteUserPool({ UserPoolId: output.userPoolId })
1218 .pipe(
1219 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
1220 );
1221 }),
1222 });
1223 }),
1224 );