1import * as cip from "@distilled.cloud/aws/cognito-identity-provider";
2import * as Effect from "effect/Effect";
3import * as Redacted from "effect/Redacted";
4import { isResolved } from "../../Diff.ts";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import type { Providers } from "../Providers.ts";
9
10export interface UserProps {
11 /**
12 * The ID of the user pool the user belongs to. Changing this triggers a
13 * replacement.
14 */
15 userPoolId: string;
16 /**
17 * The username. If omitted, a deterministic physical name is generated
18 * from the app, stage, and logical ID. Changing this triggers a
19 * replacement. For pools with `usernameAttributes` this must be a value
20 * of one of those attributes (e.g. an email address).
21 */
22 username?: string;
23 /**
24 * User attributes as name → value pairs (e.g.
25 * `{ email: "a@b.com", email_verified: "true" }`). Attributes declared
26 * here are kept in sync; removing a previously declared attribute deletes
27 * it from the user.
28 */
29 attributes?: Record<string, string>;
30 /**
31 * A permanent password for the user, set via `AdminSetUserPassword`
32 * (`Permanent: true`), which moves the user to `CONFIRMED`. Without it
33 * the user is created in `FORCE_CHANGE_PASSWORD` state. Must satisfy the
34 * pool's password policy. Wrap with `Redacted.make(...)` so the value
35 * never leaks into logs or state output.
36 */
37 password?: Redacted.Redacted<string>;
38 /**
39 * Whether the user account is enabled.
40 * @default true
41 */
42 enabled?: boolean;
43}
44
45export interface User extends Resource<
46 "AWS.Cognito.User",
47 UserProps,
48 {
49 /** The username. */
50 username: string;
51 /** The ID of the user pool the user belongs to. */
52 userPoolId: string;
53 /** The user's stable unique identifier (the `sub` attribute). */
54 sub: string;
55 /** The user's status, e.g. `CONFIRMED` or `FORCE_CHANGE_PASSWORD`. */
56 userStatus: string;
57 },
58 never,
59 Providers
60> {}
61
62/**
63 * A user within an Amazon Cognito user pool, created administratively via
64 * `AdminCreateUser`. The invitation message is always suppressed
65 * (`MessageAction: SUPPRESS`) — declaratively managed users never trigger
66 * invite emails/SMS; set a permanent `password` to make the account usable
67 * immediately.
68 * ### Creating Users
69 * **Example:** Basic User
70 * ```typescript
71 * import * as Cognito from "alchemy/AWS/Cognito";
72 *
73 * const pool = yield* Cognito.UserPool("Users", {});
74 * const user = yield* Cognito.User("Admin", {
75 * userPoolId: pool.userPoolId,
76 * attributes: { email: "admin@example.com", email_verified: "true" },
77 * });
78 * ```
79 *
80 * **Example:** Confirmed User with a Permanent Password
81 * ```typescript
82 * import * as Redacted from "effect/Redacted";
83 *
84 * const user = yield* Cognito.User("ServiceAccount", {
85 * userPoolId: pool.userPoolId,
86 * username: "service-account",
87 * password: Redacted.make("A-Str0ng-Passw0rd!"),
88 * attributes: { email: "svc@example.com", email_verified: "true" },
89 * });
90 * // user.userStatus === "CONFIRMED"
91 * ```
92 *
93 * @resource
94 */
95export const User = Resource<User>("AWS.Cognito.User");
96
97const plain = (
98 value: string | Redacted.Redacted<string> | undefined,
99): string | undefined =>
100 value === undefined
101 ? undefined
102 : typeof value === "string"
103 ? value
104 : Redacted.value(value);
105
106/** Observed user attributes as a plain name → value record. */
107const attributeRecordOf = (
108 attributes: cip.AttributeType[] | undefined,
109): Record<string, string> =>
110 Object.fromEntries(
111 (attributes ?? []).flatMap((attribute) => {
112 const value = plain(attribute.Value);
113 return value === undefined ? [] : [[attribute.Name, value] as const];
114 }),
115 );
116
117export const UserProvider = () =>
118 Provider.effect(
119 User,
120 Effect.gen(function* () {
121 const createName = Effect.fn(function* (
122 id: string,
123 props: Pick<UserProps, "username">,
124 ) {
125 return (
126 props.username ?? (yield* createPhysicalName({ id, maxLength: 128 }))
127 );
128 });
129
130 const getUser = Effect.fn(function* (
131 userPoolId: string,
132 username: string,
133 ) {
134 return yield* cip
135 .adminGetUser({ UserPoolId: userPoolId, Username: username })
136 .pipe(
137 Effect.catchTag(
138 ["UserNotFoundException", "ResourceNotFoundException"],
139 () => Effect.succeed(undefined),
140 ),
141 );
142 });
143
144 const attributesOf = (
145 username: string,
146 userPoolId: string,
147 observed: cip.AdminGetUserResponse,
148 ) => ({
149 username,
150 userPoolId,
151 sub: attributeRecordOf(observed.UserAttributes).sub ?? "",
152 userStatus: observed.UserStatus ?? "UNKNOWN",
153 });
154
155 return User.Provider.of({
156 stables: ["username", "userPoolId", "sub"],
157
158 // Sub-resource keyed entirely by its user pool (userPoolId) with no
159 // global enumeration API of its own — nuke reaches it through the
160 // parent's deletion, so enumeration returns empty per the
161 // ProviderService doctrine.
162 list: () => Effect.succeed([]),
163
164 read: Effect.fn(function* ({ id, olds, output }) {
165 const userPoolId = output?.userPoolId ?? olds?.userPoolId;
166 if (userPoolId === undefined) return undefined;
167 const username =
168 output?.username ?? (yield* createName(id, olds ?? {}));
169 const observed = yield* getUser(userPoolId, username);
170 return observed === undefined
171 ? undefined
172 : attributesOf(username, userPoolId, observed);
173 }),
174
175 diff: Effect.fn(function* ({ id, news, olds }) {
176 if (!isResolved(news)) return undefined;
177 const oldName = yield* createName(id, olds ?? {});
178 const newName = yield* createName(id, news ?? {});
179 if (oldName !== newName || olds?.userPoolId !== news?.userPoolId) {
180 return { action: "replace" } as const;
181 }
182 }),
183
184 reconcile: Effect.fn(function* ({ id, news, olds, output, session }) {
185 const username = output?.username ?? (yield* createName(id, news));
186 const userPoolId = news.userPoolId;
187 const desiredAttributes = news.attributes ?? {};
188
189 // 1. OBSERVE — cloud state is authoritative.
190 let observed = yield* getUser(userPoolId, username);
191
192 // 2. ENSURE — create when missing (invitation always suppressed);
193 // tolerate the create race.
194 if (observed === undefined) {
195 yield* cip
196 .adminCreateUser({
197 UserPoolId: userPoolId,
198 Username: username,
199 MessageAction: "SUPPRESS",
200 UserAttributes: Object.entries(desiredAttributes).map(
201 ([Name, Value]) => ({ Name, Value }),
202 ),
203 })
204 .pipe(
205 Effect.catchTag("UsernameExistsException", () => Effect.void),
206 );
207 observed = yield* getUser(userPoolId, username);
208 } else {
209 // 3. SYNC ATTRIBUTES — upsert declared attributes that drifted
210 // from the OBSERVED values; delete attributes that were
211 // previously declared but no longer are (`olds` is only the
212 // hint for which attributes we manage — never for values).
213 const observedAttributes = attributeRecordOf(
214 observed.UserAttributes,
215 );
216 const upsert = Object.entries(desiredAttributes).filter(
217 ([name, value]) => observedAttributes[name] !== value,
218 );
219 if (upsert.length > 0) {
220 yield* cip.adminUpdateUserAttributes({
221 UserPoolId: userPoolId,
222 Username: username,
223 UserAttributes: upsert.map(([Name, Value]) => ({
224 Name,
225 Value,
226 })),
227 });
228 }
229 const removed = Object.keys(olds?.attributes ?? {}).filter(
230 (name) =>
231 desiredAttributes[name] === undefined &&
232 observedAttributes[name] !== undefined,
233 );
234 if (removed.length > 0) {
235 yield* cip.adminDeleteUserAttributes({
236 UserPoolId: userPoolId,
237 Username: username,
238 UserAttributeNames: removed,
239 });
240 }
241 }
242
243 if (observed === undefined) {
244 return yield* Effect.die(
245 `Cognito user ${username} not observable after create`,
246 );
247 }
248
249 // 3b. SYNC PASSWORD — a permanent password confirms the account.
250 // Passwords are write-only, so `olds` is the only drift signal;
251 // fresh creates and adoptions always apply it.
252 if (
253 news.password !== undefined &&
254 (output === undefined ||
255 olds === undefined ||
256 plain(olds.password) !== plain(news.password))
257 ) {
258 yield* cip.adminSetUserPassword({
259 UserPoolId: userPoolId,
260 Username: username,
261 Password: news.password,
262 Permanent: true,
263 });
264 }
265
266 // 3c. SYNC ENABLED — diff against the OBSERVED flag.
267 const desiredEnabled = news.enabled ?? true;
268 if ((observed.Enabled ?? true) !== desiredEnabled) {
269 yield* desiredEnabled
270 ? cip.adminEnableUser({
271 UserPoolId: userPoolId,
272 Username: username,
273 })
274 : cip.adminDisableUser({
275 UserPoolId: userPoolId,
276 Username: username,
277 });
278 }
279
280 // 4. RETURN fresh attributes (status/attributes may have changed
281 // during sync).
282 const final = (yield* getUser(userPoolId, username)) ?? observed;
283 yield* session.note(username);
284 return attributesOf(username, userPoolId, final);
285 }),
286
287 delete: Effect.fn(function* ({ output }) {
288 yield* cip
289 .adminDeleteUser({
290 UserPoolId: output.userPoolId,
291 Username: output.username,
292 })
293 .pipe(
294 Effect.catchTag(
295 ["UserNotFoundException", "ResourceNotFoundException"],
296 () => Effect.void,
297 ),
298 );
299 }),
300 });
301 }),
302 );