diff --git a/Directory.Build.props b/Directory.Build.props index cbf1825..ac5eda0 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 2.3.0 + 2.4.0 MIT diff --git a/Directory.Packages.props b/Directory.Packages.props index 0f19abc..3b59b5e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,7 +7,7 @@ - + diff --git a/src/LayeredCraft.Cdk.Constructs/CognitoUserPoolConstruct.cs b/src/LayeredCraft.Cdk.Constructs/CognitoUserPoolConstruct.cs index 65ac0a6..43493fc 100644 --- a/src/LayeredCraft.Cdk.Constructs/CognitoUserPoolConstruct.cs +++ b/src/LayeredCraft.Cdk.Constructs/CognitoUserPoolConstruct.cs @@ -2,6 +2,8 @@ using Amazon.CDK; using Amazon.CDK.AWS.CertificateManager; using Amazon.CDK.AWS.Cognito; +using Amazon.CDK.AWS.IAM; +using Amazon.CDK.AWS.Lambda; using Amazon.CDK.AWS.Route53; using Amazon.CDK.AWS.Route53.Targets; using Constructs; @@ -63,6 +65,16 @@ public CognitoUserPoolConstruct( RemovalPolicy = props.RemovalPolicy, }); + if (props.PostConfirmationTrigger is not null) + { + UserPool.AddTrigger(UserPoolOperation.POST_CONFIRMATION, props.PostConfirmationTrigger); + props.PostConfirmationTrigger.AddToRolePolicy(new PolicyStatement(new PolicyStatementProps + { + Actions = ["cognito-idp:AdminAddUserToGroup"], + Resources = ["*"], + })); + } + var resourceServers = CreateResourceServers(props); ResourceServers = resourceServers; diff --git a/src/LayeredCraft.Cdk.Constructs/Models/CognitoUserPoolConstructProps.cs b/src/LayeredCraft.Cdk.Constructs/Models/CognitoUserPoolConstructProps.cs index 9f43e13..f531168 100644 --- a/src/LayeredCraft.Cdk.Constructs/Models/CognitoUserPoolConstructProps.cs +++ b/src/LayeredCraft.Cdk.Constructs/Models/CognitoUserPoolConstructProps.cs @@ -1,5 +1,6 @@ using Amazon.CDK; using Amazon.CDK.AWS.Cognito; +using Amazon.CDK.AWS.Lambda; namespace LayeredCraft.Cdk.Constructs.Models; @@ -21,6 +22,7 @@ public interface ICognitoUserPoolConstructProps IReadOnlyList AppClients { get; } ICognitoUserPoolDomainProps? Domain { get; } IReadOnlyCollection? Groups { get; } + IFunction? PostConfirmationTrigger { get; } } public sealed record CognitoUserPoolConstructProps : ICognitoUserPoolConstructProps @@ -40,4 +42,5 @@ public sealed record CognitoUserPoolConstructProps : ICognitoUserPoolConstructPr public IReadOnlyList AppClients { get; init; } = []; public ICognitoUserPoolDomainProps? Domain { get; init; } public IReadOnlyCollection? Groups { get; init; } = []; + public IFunction? PostConfirmationTrigger { get; init; } = null; } diff --git a/src/LayeredCraft.Cdk.Constructs/StaticSiteConstruct.cs b/src/LayeredCraft.Cdk.Constructs/StaticSiteConstruct.cs index 81aa0de..f96f622 100644 --- a/src/LayeredCraft.Cdk.Constructs/StaticSiteConstruct.cs +++ b/src/LayeredCraft.Cdk.Constructs/StaticSiteConstruct.cs @@ -72,6 +72,27 @@ public StaticSiteConstruct(Construct scope, string id, IStaticSiteConstructProps SubjectAlternativeNames = props.AlternateDomains, }); + // CloudFront Function: rewrite SPA routes (no file extension) to /index.html + // so client-side routes are handled by the app rather than returning 404. + // Requests with a file extension pass through unchanged, preserving normal + // asset serving and ensuring API 404s (served via the /api/* behavior) are + // never affected — they never reach this default behavior. + var spaRewriteFunction = new Amazon.CDK.AWS.CloudFront.Function(this, $"{id}-spa-rewrite", + new Amazon.CDK.AWS.CloudFront.FunctionProps + { + Code = FunctionCode.FromInline(""" + function handler(event) { + var uri = event.request.uri; + if (!uri.match(/\.[a-zA-Z0-9]+$/)) { + event.request.uri = '/index.html'; + } + return event.request; + } + """), + Runtime = FunctionRuntime.JS_2_0, + Comment = "Rewrite SPA routes to /index.html for client-side routing" + }); + // Create CloudFront distribution for global content delivery var distribution = new Distribution(this, $"{id}-cdn", new DistributionProps { @@ -83,7 +104,15 @@ public StaticSiteConstruct(Construct scope, string id, IStaticSiteConstructProps ProtocolPolicy = OriginProtocolPolicy.HTTP_ONLY }), AllowedMethods = AllowedMethods.ALLOW_GET_HEAD, - Compress = true + Compress = true, + FunctionAssociations = + [ + new FunctionAssociation + { + Function = spaRewriteFunction, + EventType = FunctionEventType.VIEWER_REQUEST + } + ] }, Certificate = certificate, ErrorResponses =