1import * as cip from "@distilled.cloud/aws/cognito-identity-provider";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import type { Providers } from "../Providers.ts";
8
9/**
10 * An image asset (logo, favicon, background, …) attached to a managed login
11 * branding style, per color mode.
12 */
13export interface ManagedLoginBrandingAsset {
14 /** Which slot of the managed login pages the asset fills. */
15 category:
16 | "FAVICON_ICO"
17 | "FAVICON_SVG"
18 | "EMAIL_GRAPHIC"
19 | "SMS_GRAPHIC"
20 | "AUTH_APP_GRAPHIC"
21 | "PASSWORD_GRAPHIC"
22 | "PASSKEY_GRAPHIC"
23 | "PAGE_HEADER_LOGO"
24 | "PAGE_HEADER_BACKGROUND"
25 | "PAGE_FOOTER_LOGO"
26 | "PAGE_FOOTER_BACKGROUND"
27 | "PAGE_BACKGROUND"
28 | "FORM_BACKGROUND"
29 | "FORM_LOGO"
30 | "IDP_BUTTON_ICON";
31 /** The color scheme the asset applies to. */
32 colorMode: "LIGHT" | "DARK" | "DYNAMIC";
33 /** The file type of the asset. */
34 extension: "ICO" | "JPEG" | "PNG" | "SVG" | "WEBP";
35 /** The image file, as bytes (max 2 MB). */
36 bytes?: Uint8Array;
37 /** For `IDP_BUTTON_ICON` assets, the identity provider the icon is for. */
38 resourceId?: string;
39}
40
41export interface ManagedLoginBrandingProps {
42 /**
43 * The ID of the user pool the branding style belongs to. Changing this
44 * triggers a replacement.
45 */
46 userPoolId: string;
47 /**
48 * The ID of the app client the branding style is assigned to. Each app
49 * client can have exactly one style. Changing this triggers a
50 * replacement.
51 */
52 clientId: string;
53 /**
54 * Apply Cognito's default branding instead of custom `settings` /
55 * `assets`. This is what makes a fresh pool's managed login pages render
56 * without a one-time console step.
57 * @default true when neither `settings` nor `assets` is provided
58 */
59 useCognitoProvidedValues?: boolean;
60 /**
61 * The branding settings JSON document (colors, component styles, …) in
62 * the shape produced by the managed login branding designer / returned by
63 * `DescribeManagedLoginBranding`.
64 */
65 settings?: Record<string, unknown>;
66 /**
67 * Image assets for the branding style (logos, favicons, backgrounds),
68 * one per category + color mode.
69 */
70 assets?: ManagedLoginBrandingAsset[];
71}
72
73export interface ManagedLoginBranding extends Resource<
74 "AWS.Cognito.ManagedLoginBranding",
75 ManagedLoginBrandingProps,
76 {
77 /** The generated ID of the branding style. */
78 managedLoginBrandingId: string;
79 /** The ID of the user pool the style belongs to. */
80 userPoolId: string;
81 /** The ID of the app client the style is assigned to. */
82 clientId: string;
83 },
84 never,
85 Providers
86> {}
87
88/**
89 * A managed login branding style for an Amazon Cognito user pool app
90 * client. Assigning a style (even just Cognito's provided defaults) is what
91 * activates the hosted managed login pages — a `UserPoolDomain` with
92 * `managedLoginVersion: 2` serves them end-to-end without any console step.
93 * ### Activating Managed Login
94 * **Example:** Default Branding for a Hosted Login Domain
95 * ```typescript
96 * import * as Cognito from "alchemy/AWS/Cognito";
97 *
98 * const pool = yield* Cognito.UserPool("Users", {});
99 * const client = yield* Cognito.UserPoolClient("Web", {
100 * userPoolId: pool.userPoolId,
101 * callbackUrls: ["https://example.com/callback"],
102 * allowedOAuthFlowsUserPoolClient: true,
103 * allowedOAuthFlows: ["code"],
104 * allowedOAuthScopes: ["openid", "email"],
105 * });
106 * const domain = yield* Cognito.UserPoolDomain("AuthDomain", {
107 * userPoolId: pool.userPoolId,
108 * managedLoginVersion: 2,
109 * });
110 * yield* Cognito.ManagedLoginBranding("Branding", {
111 * userPoolId: pool.userPoolId,
112 * clientId: client.clientId,
113 * });
114 * ```
115 *
116 * ### Custom Branding
117 * **Example:** Custom Settings and a Logo Asset
118 * ```typescript
119 * yield* Cognito.ManagedLoginBranding("Branding", {
120 * userPoolId: pool.userPoolId,
121 * clientId: client.clientId,
122 * settings: brandingSettings, // designer-exported JSON document
123 * assets: [{
124 * category: "FORM_LOGO",
125 * colorMode: "LIGHT",
126 * extension: "PNG",
127 * bytes: logoBytes,
128 * }],
129 * });
130 * ```
131 *
132 * @resource
133 */
134export const ManagedLoginBranding = Resource<ManagedLoginBranding>(
135 "AWS.Cognito.ManagedLoginBranding",
136);
137
138const toWireAssets = (
139 assets: ManagedLoginBrandingAsset[] | undefined,
140): cip.AssetType[] | undefined =>
141 assets?.map((asset) => ({
142 Category: asset.category,
143 ColorMode: asset.colorMode,
144 Extension: asset.extension,
145 Bytes: asset.bytes,
146 ResourceId: asset.resourceId,
147 }));
148
149/** Canonicalize an asset list for order-insensitive comparison; bytes are
150 * folded to base64. */
151const canonicalAssets = (assets: cip.AssetType[] | undefined) =>
152 (assets ?? [])
153 .map((asset) =>
154 [
155 asset.Category,
156 asset.ColorMode,
157 asset.Extension,
158 asset.ResourceId ?? "",
159 asset.Bytes === undefined
160 ? ""
161 : Buffer.from(asset.Bytes).toString("base64"),
162 ].join(":"),
163 )
164 .sort()
165 .join(",");
166
167/**
168 * Whether the style should carry Cognito's provided default values: an
169 * explicit prop wins, otherwise defaults to `true` exactly when no custom
170 * settings/assets are declared.
171 */
172const desiredUseProvidedValues = (news: ManagedLoginBrandingProps) =>
173 news.useCognitoProvidedValues ??
174 (news.settings === undefined && news.assets === undefined);
175
176/**
177 * Bounded retry over the concurrent-modification window (branding updates
178 * can race domain/pool operations). Explicitly typed so the conditional
179 * `Retry.Return` type never leaks into declaration emit.
180 */
181const retryWhileConcurrent = <A, E extends { _tag: string }, R>(
182 self: Effect.Effect<A, E, R>,
183): Effect.Effect<A, E, R> =>
184 Effect.retry(self, {
185 while: (e) => e._tag === "ConcurrentModificationException",
186 schedule: Schedule.max([Schedule.fixed("2 seconds"), Schedule.recurs(10)]),
187 });
188
189export const ManagedLoginBrandingProvider = () =>
190 Provider.effect(
191 ManagedLoginBranding,
192 Effect.gen(function* () {
193 const describeById = Effect.fn(function* (
194 userPoolId: string,
195 managedLoginBrandingId: string,
196 ) {
197 return yield* cip
198 .describeManagedLoginBranding({
199 UserPoolId: userPoolId,
200 ManagedLoginBrandingId: managedLoginBrandingId,
201 })
202 .pipe(
203 Effect.map((r) => r.ManagedLoginBranding),
204 Effect.catchTag("ResourceNotFoundException", () =>
205 Effect.succeed(undefined),
206 ),
207 );
208 });
209
210 const describeByClient = Effect.fn(function* (
211 userPoolId: string,
212 clientId: string,
213 ) {
214 return yield* cip
215 .describeManagedLoginBrandingByClient({
216 UserPoolId: userPoolId,
217 ClientId: clientId,
218 })
219 .pipe(
220 Effect.map((r) => r.ManagedLoginBranding),
221 Effect.catchTag("ResourceNotFoundException", () =>
222 Effect.succeed(undefined),
223 ),
224 );
225 });
226
227 return ManagedLoginBranding.Provider.of({
228 stables: ["managedLoginBrandingId", "userPoolId", "clientId"],
229
230 // Sub-resource keyed entirely by its user pool (userPoolId) with no
231 // global enumeration API of its own — nuke reaches it through the
232 // parent's deletion, so enumeration returns empty per the
233 // ProviderService doctrine.
234 list: () => Effect.succeed([]),
235
236 read: Effect.fn(function* ({ olds, output }) {
237 const userPoolId = output?.userPoolId ?? olds?.userPoolId;
238 const clientId = output?.clientId ?? olds?.clientId;
239 if (userPoolId === undefined) return undefined;
240 const observed =
241 output?.managedLoginBrandingId !== undefined
242 ? yield* describeById(userPoolId, output.managedLoginBrandingId)
243 : clientId !== undefined
244 ? yield* describeByClient(userPoolId, clientId)
245 : undefined;
246 if (observed?.ManagedLoginBrandingId === undefined) return undefined;
247 return {
248 managedLoginBrandingId: observed.ManagedLoginBrandingId,
249 userPoolId,
250 clientId: clientId!,
251 };
252 }),
253
254 diff: Effect.fn(function* ({ news, olds }) {
255 if (!isResolved(news)) return undefined;
256 if (
257 olds?.userPoolId !== news?.userPoolId ||
258 olds?.clientId !== news?.clientId
259 ) {
260 return { action: "replace" } as const;
261 }
262 }),
263
264 reconcile: Effect.fn(function* ({ news, output, session }) {
265 const { userPoolId, clientId } = news;
266 const useProvided = desiredUseProvidedValues(news);
267
268 // 1. OBSERVE — output.managedLoginBrandingId is only a cache;
269 // fall back to the by-client lookup so state loss converges.
270 let observed =
271 output?.managedLoginBrandingId !== undefined
272 ? yield* describeById(userPoolId, output.managedLoginBrandingId)
273 : undefined;
274 if (observed === undefined) {
275 observed = yield* describeByClient(userPoolId, clientId);
276 }
277
278 // 2. ENSURE — create when missing; a branding created out-of-band
279 // (or by a concurrent run) for the same client surfaces as
280 // ManagedLoginBrandingExistsException and is taken over.
281 if (observed === undefined) {
282 observed = yield* cip
283 .createManagedLoginBranding({
284 UserPoolId: userPoolId,
285 ClientId: clientId,
286 UseCognitoProvidedValues: useProvided,
287 Settings: news.settings,
288 Assets: toWireAssets(news.assets),
289 })
290 .pipe(
291 Effect.map((r) => r.ManagedLoginBranding),
292 Effect.catchTag("ManagedLoginBrandingExistsException", () =>
293 describeByClient(userPoolId, clientId),
294 ),
295 );
296 }
297
298 const managedLoginBrandingId = observed?.ManagedLoginBrandingId!;
299
300 // 3. SYNC — diff observed style against desired; skip the update
301 // entirely on no-op.
302 const drift =
303 (observed?.UseCognitoProvidedValues ?? false) !== useProvided ||
304 (news.settings !== undefined &&
305 JSON.stringify(news.settings) !==
306 JSON.stringify(observed?.Settings)) ||
307 (news.assets !== undefined &&
308 canonicalAssets(toWireAssets(news.assets)) !==
309 canonicalAssets(observed?.Assets));
310 if (drift) {
311 yield* cip
312 .updateManagedLoginBranding({
313 UserPoolId: userPoolId,
314 ManagedLoginBrandingId: managedLoginBrandingId,
315 UseCognitoProvidedValues: useProvided,
316 Settings: news.settings,
317 Assets: toWireAssets(news.assets),
318 })
319 .pipe(retryWhileConcurrent);
320 }
321
322 yield* session.note(managedLoginBrandingId);
323 return { managedLoginBrandingId, userPoolId, clientId };
324 }),
325
326 delete: Effect.fn(function* ({ output }) {
327 yield* cip
328 .deleteManagedLoginBranding({
329 UserPoolId: output.userPoolId,
330 ManagedLoginBrandingId: output.managedLoginBrandingId,
331 })
332 .pipe(
333 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
334 retryWhileConcurrent,
335 );
336 }),
337 });
338 }),
339 );