AWSTemplateFormatVersion: "2010-09-09" Description: > mervinc-live - the dynamic half of mervinc.com: a live-traffic API, a Claude agent on Amazon Bedrock, and the bill. Both functions sit behind the site's existing CloudFront distribution through an Origin Access Control, so neither function URL answers anyone but CloudFront. Deployed by infra/live-stack.sh, which also wires the /api/* routes into the distribution. Parameters: DistributionId: Type: String Description: The site's CloudFront distribution (created by bootstrap.sh) ArtifactsBucket: Type: String LiveCodeKey: Type: String AgentCodeKey: Type: String ToolsCodeKey: Type: String AgentModel: Type: String Default: us.anthropic.claude-sonnet-4-6 Description: Bedrock inference profile the agent calls FastModel: Type: String Default: us.anthropic.claude-haiku-4-5-20251001-v1:0 Description: Bedrock inference profile for the build crew's quick stages FleetKeyHash: Type: String NoEcho: true Description: SHA-256 (hex) of the key the owner's build hooks send with fleet events FlagsKeyHash: Type: String NoEcho: true Description: SHA-256 (hex) of the owner's flags key; the key itself never leaves the owner's devices NtfyErrorsTopic: Type: String NoEcho: true # the topic name works like a password; passed in by live-stack.sh, never published Description: ntfy topic for mervinc errors, crashes and outages (separate from activity) NtfyTopic: Type: String NoEcho: true # the topic name works like a password; passed in by live-stack.sh, never published Description: ntfy topic for mervinc activity (visits, agent questions, builds, receipts, votes) AlertEmail: Type: String Description: Who hears about it when the month runs over budget MonthlyBudgetUsd: Type: Number Default: 25 Resources: # ---------------------------------------------------------------- data Table: Type: AWS::DynamoDB::Table Properties: TableName: mervinc-live BillingMode: PAY_PER_REQUEST AttributeDefinitions: - { AttributeName: pk, AttributeType: S } - { AttributeName: sk, AttributeType: S } KeySchema: - { AttributeName: pk, KeyType: HASH } - { AttributeName: sk, KeyType: RANGE } TimeToLiveSpecification: { AttributeName: ttl, Enabled: true } Tags: [{ Key: project, Value: mervinc }] # ---------------------------------------------------------------- live api LiveRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - { Effect: Allow, Principal: { Service: lambda.amazonaws.com }, Action: sts:AssumeRole } ManagedPolicyArns: - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole Policies: - PolicyName: live PolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Action: [dynamodb:GetItem, dynamodb:PutItem, dynamodb:UpdateItem, dynamodb:Query, dynamodb:DeleteItem] Resource: !GetAtt Table.Arn - Effect: Allow Action: execute-api:ManageConnections Resource: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${FleetSocket}/live/POST/@connections/* - Effect: Allow Action: [ce:GetCostAndUsage, ce:GetCostForecast] Resource: "*" - Effect: Allow Action: polly:SynthesizeSpeech Resource: "*" LiveLogs: Type: AWS::Logs::LogGroup Properties: { LogGroupName: /aws/lambda/mervinc-live, RetentionInDays: 14 } LiveFunction: Type: AWS::Lambda::Function DependsOn: LiveLogs Properties: FunctionName: mervinc-live Runtime: nodejs22.x Architectures: [arm64] Handler: index.handler MemorySize: 256 Timeout: 15 Role: !GetAtt LiveRole.Arn Code: { S3Bucket: !Ref ArtifactsBucket, S3Key: !Ref LiveCodeKey } Environment: Variables: TABLE: !Ref Table NTFY_ERRORS_TOPIC: !Ref NtfyErrorsTopic WS_ENDPOINT: !Sub https://${FleetSocket}.execute-api.${AWS::Region}.amazonaws.com/live FLEET_KEY_HASH: !Ref FleetKeyHash Tags: [{ Key: project, Value: mervinc }] LiveUrl: Type: AWS::Lambda::Url Properties: TargetFunctionArn: !GetAtt LiveFunction.Arn AuthType: AWS_IAM LiveUrlPermission: Type: AWS::Lambda::Permission Properties: FunctionName: !Ref LiveFunction Action: lambda:InvokeFunctionUrl Principal: cloudfront.amazonaws.com SourceArn: !Sub arn:aws:cloudfront::${AWS::AccountId}:distribution/${DistributionId} FunctionUrlAuthType: AWS_IAM LiveInvokePermission: Type: AWS::Lambda::Permission Properties: FunctionName: !Ref LiveFunction Action: lambda:InvokeFunction Principal: cloudfront.amazonaws.com SourceArn: !Sub arn:aws:cloudfront::${AWS::AccountId}:distribution/${DistributionId} # Cost Explorer refreshes a few times a day and bills a cent a call; twice a # day is as fresh as the data gets. CostSchedule: Type: AWS::Events::Rule Properties: Name: mervinc-live-cost ScheduleExpression: cron(15 3,15 * * ? *) Targets: - Id: live Arn: !GetAtt LiveFunction.Arn Input: '{"job":"cost"}' CostSchedulePermission: Type: AWS::Lambda::Permission Properties: FunctionName: !Ref LiveFunction Action: lambda:InvokeFunction Principal: events.amazonaws.com SourceArn: !GetAtt CostSchedule.Arn # ---------------------------------------------------------------- agent AgentRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - { Effect: Allow, Principal: { Service: lambda.amazonaws.com }, Action: sts:AssumeRole } ManagedPolicyArns: - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole Policies: - PolicyName: agent PolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Action: [dynamodb:GetItem, dynamodb:PutItem, dynamodb:UpdateItem, dynamodb:Query] Resource: !GetAtt Table.Arn # A cross-region inference profile routes to the model in any of # its regions, so the grant names the profile and the model. - Effect: Allow Action: [bedrock:InvokeModel, bedrock:InvokeModelWithResponseStream] Resource: - !Sub arn:aws:bedrock:${AWS::Region}:${AWS::AccountId}:inference-profile/${AgentModel} - !Sub arn:aws:bedrock:${AWS::Region}:${AWS::AccountId}:inference-profile/${FastModel} - arn:aws:bedrock:*::foundation-model/anthropic.* - Effect: Allow Action: bedrock:ApplyGuardrail Resource: !GetAtt AgentGuardrail.GuardrailArn AgentLogs: Type: AWS::Logs::LogGroup Properties: { LogGroupName: /aws/lambda/mervinc-agent, RetentionInDays: 14 } AgentFunction: Type: AWS::Lambda::Function DependsOn: AgentLogs Properties: FunctionName: mervinc-agent Runtime: nodejs22.x Architectures: [arm64] Handler: index.handler MemorySize: 512 Timeout: 90 Role: !GetAtt AgentRole.Arn Code: { S3Bucket: !Ref ArtifactsBucket, S3Key: !Ref AgentCodeKey } Environment: Variables: TABLE: !Ref Table NTFY_ERRORS_TOPIC: !Ref NtfyErrorsTopic MODEL: !Ref AgentModel NTFY_TOPIC: !Ref NtfyTopic DAILY_QUESTIONS: "150" DAILY_USD: "5" DAILY_PER_VISITOR: "10" GUARDRAIL_ID: !GetAtt AgentGuardrail.GuardrailId GUARDRAIL_VERSION: !GetAtt AgentGuardrailVersion.Version Tags: [{ Key: project, Value: mervinc }] AgentUrl: Type: AWS::Lambda::Url Properties: TargetFunctionArn: !GetAtt AgentFunction.Arn AuthType: AWS_IAM InvokeMode: RESPONSE_STREAM AgentUrlPermission: Type: AWS::Lambda::Permission Properties: FunctionName: !Ref AgentFunction Action: lambda:InvokeFunctionUrl Principal: cloudfront.amazonaws.com SourceArn: !Sub arn:aws:cloudfront::${AWS::AccountId}:distribution/${DistributionId} FunctionUrlAuthType: AWS_IAM AgentInvokePermission: Type: AWS::Lambda::Permission Properties: FunctionName: !Ref AgentFunction Action: lambda:InvokeFunction Principal: cloudfront.amazonaws.com SourceArn: !Sub arn:aws:cloudfront::${AWS::AccountId}:distribution/${DistributionId} # ---------------------------------------------------------------- build crew # Same bundle as the agent, second handler: five agents build a prototype # app from a visitor's one-line brief and stream it to build.html. BuildLogs: Type: AWS::Logs::LogGroup Properties: { LogGroupName: /aws/lambda/mervinc-build, RetentionInDays: 14 } BuildFunction: Type: AWS::Lambda::Function DependsOn: BuildLogs Properties: FunctionName: mervinc-build Runtime: nodejs22.x Architectures: [arm64] Handler: build.handler MemorySize: 512 Timeout: 150 Role: !GetAtt AgentRole.Arn Code: { S3Bucket: !Ref ArtifactsBucket, S3Key: !Ref AgentCodeKey } Environment: Variables: TABLE: !Ref Table NTFY_ERRORS_TOPIC: !Ref NtfyErrorsTopic STRONG_MODEL: !Ref AgentModel FAST_MODEL: !Ref FastModel DAILY_BUILDS: "40" DAILY_BUILD_USD: "6" DAILY_BUILDS_PER_VISITOR: "3" NTFY_TOPIC: !Ref NtfyTopic Tags: [{ Key: project, Value: mervinc }] BuildUrl: Type: AWS::Lambda::Url Properties: TargetFunctionArn: !GetAtt BuildFunction.Arn AuthType: AWS_IAM InvokeMode: RESPONSE_STREAM BuildUrlPermission: Type: AWS::Lambda::Permission Properties: FunctionName: !Ref BuildFunction Action: lambda:InvokeFunctionUrl Principal: cloudfront.amazonaws.com SourceArn: !Sub arn:aws:cloudfront::${AWS::AccountId}:distribution/${DistributionId} FunctionUrlAuthType: AWS_IAM BuildInvokePermission: Type: AWS::Lambda::Permission Properties: FunctionName: !Ref BuildFunction Action: lambda:InvokeFunction Principal: cloudfront.amazonaws.com SourceArn: !Sub arn:aws:cloudfront::${AWS::AccountId}:distribution/${DistributionId} # ---------------------------------------------------------------- tools # The hands-on demos: email health, white-label trace, Textract receipts, and # the status board's probes (EventBridge, every five minutes). ToolsRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - { Effect: Allow, Principal: { Service: lambda.amazonaws.com }, Action: sts:AssumeRole } ManagedPolicyArns: - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole - arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess Policies: - PolicyName: tools PolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Action: [dynamodb:GetItem, dynamodb:PutItem, dynamodb:UpdateItem, dynamodb:Query] Resource: !GetAtt Table.Arn - Effect: Allow Action: textract:AnalyzeExpense Resource: "*" # The AWS page's live layer: this account's own metrics and its own traces. - Effect: Allow Action: [cloudwatch:GetMetricData, xray:BatchGetTraces] Resource: "*" ToolsLogs: Type: AWS::Logs::LogGroup Properties: { LogGroupName: /aws/lambda/mervinc-tools, RetentionInDays: 14 } ToolsFunction: Type: AWS::Lambda::Function DependsOn: ToolsLogs Properties: FunctionName: mervinc-tools Runtime: nodejs22.x Architectures: [arm64] Handler: index.handler MemorySize: 512 Timeout: 30 Role: !GetAtt ToolsRole.Arn Code: { S3Bucket: !Ref ArtifactsBucket, S3Key: !Ref ToolsCodeKey } TracingConfig: { Mode: Active } Environment: Variables: DIST_ID: !Ref DistributionId WS_API_ID: !Ref FleetSocket TABLE: !Ref Table NTFY_ERRORS_TOPIC: !Ref NtfyErrorsTopic NTFY_TOPIC: !Ref NtfyTopic RECEIPTS_PER_VISITOR: "5" RECEIPTS_PER_DAY: "120" LOOKUPS_PER_VISITOR: "40" Tags: [{ Key: project, Value: mervinc }] ToolsUrl: Type: AWS::Lambda::Url Properties: TargetFunctionArn: !GetAtt ToolsFunction.Arn AuthType: AWS_IAM ToolsUrlPermission: Type: AWS::Lambda::Permission Properties: FunctionName: !Ref ToolsFunction Action: lambda:InvokeFunctionUrl Principal: cloudfront.amazonaws.com SourceArn: !Sub arn:aws:cloudfront::${AWS::AccountId}:distribution/${DistributionId} FunctionUrlAuthType: AWS_IAM ToolsInvokePermission: Type: AWS::Lambda::Permission Properties: FunctionName: !Ref ToolsFunction Action: lambda:InvokeFunction Principal: cloudfront.amazonaws.com SourceArn: !Sub arn:aws:cloudfront::${AWS::AccountId}:distribution/${DistributionId} StatusSchedule: Type: AWS::Events::Rule Properties: Name: mervinc-status-probes ScheduleExpression: rate(5 minutes) Targets: - Id: tools Arn: !GetAtt ToolsFunction.Arn Input: '{"job":"status"}' StatusSchedulePermission: Type: AWS::Lambda::Permission Properties: FunctionName: !Ref ToolsFunction Action: lambda:InvokeFunction Principal: events.amazonaws.com SourceArn: !GetAtt StatusSchedule.Arn # ---------------------------------------------------------------- alarms # Crashes and timeouts never reach a function's own catch block. CloudWatch # counts them; each alarm publishes here; the tools function turns the # message into an alert on the errors topic, and the owner's email gets a # copy in case the tools function is the one that is down. AlarmTopic: Type: AWS::SNS::Topic Properties: TopicName: mervinc-alarms Subscription: - { Protocol: lambda, Endpoint: !GetAtt ToolsFunction.Arn } - { Protocol: email, Endpoint: !Ref AlertEmail } AlarmTopicPermission: Type: AWS::Lambda::Permission Properties: FunctionName: !Ref ToolsFunction Action: lambda:InvokeFunction Principal: sns.amazonaws.com SourceArn: !Ref AlarmTopic LiveErrorsAlarm: Type: AWS::CloudWatch::Alarm Properties: AlarmName: mervinc-live-errors AlarmDescription: mervinc-live is failing (crash, timeout or unhandled error) Namespace: AWS/Lambda MetricName: Errors Dimensions: [{ Name: FunctionName, Value: mervinc-live }] Statistic: Sum Period: 300 EvaluationPeriods: 1 Threshold: 1 ComparisonOperator: GreaterThanOrEqualToThreshold TreatMissingData: notBreaching AlarmActions: [!Ref AlarmTopic] OKActions: [!Ref AlarmTopic] AgentErrorsAlarm: Type: AWS::CloudWatch::Alarm Properties: AlarmName: mervinc-agent-errors AlarmDescription: mervinc-agent is failing (crash, timeout or unhandled error) Namespace: AWS/Lambda MetricName: Errors Dimensions: [{ Name: FunctionName, Value: mervinc-agent }] Statistic: Sum Period: 300 EvaluationPeriods: 1 Threshold: 1 ComparisonOperator: GreaterThanOrEqualToThreshold TreatMissingData: notBreaching AlarmActions: [!Ref AlarmTopic] OKActions: [!Ref AlarmTopic] BuildErrorsAlarm: Type: AWS::CloudWatch::Alarm Properties: AlarmName: mervinc-build-errors AlarmDescription: mervinc-build is failing (crash, timeout or unhandled error) Namespace: AWS/Lambda MetricName: Errors Dimensions: [{ Name: FunctionName, Value: mervinc-build }] Statistic: Sum Period: 300 EvaluationPeriods: 1 Threshold: 1 ComparisonOperator: GreaterThanOrEqualToThreshold TreatMissingData: notBreaching AlarmActions: [!Ref AlarmTopic] OKActions: [!Ref AlarmTopic] ToolsErrorsAlarm: Type: AWS::CloudWatch::Alarm Properties: AlarmName: mervinc-tools-errors AlarmDescription: mervinc-tools is failing (crash, timeout or unhandled error) Namespace: AWS/Lambda MetricName: Errors Dimensions: [{ Name: FunctionName, Value: mervinc-tools }] Statistic: Sum Period: 300 EvaluationPeriods: 1 Threshold: 1 ComparisonOperator: GreaterThanOrEqualToThreshold TreatMissingData: notBreaching AlarmActions: [!Ref AlarmTopic] OKActions: [!Ref AlarmTopic] FlagsErrorsAlarm: Type: AWS::CloudWatch::Alarm Properties: AlarmName: mervinc-flags-errors AlarmDescription: mervinc-flags is failing (crash, timeout or unhandled error) Namespace: AWS/Lambda MetricName: Errors Dimensions: [{ Name: FunctionName, Value: mervinc-flags }] Statistic: Sum Period: 300 EvaluationPeriods: 1 Threshold: 1 ComparisonOperator: GreaterThanOrEqualToThreshold TreatMissingData: notBreaching AlarmActions: [!Ref AlarmTopic] OKActions: [!Ref AlarmTopic] AgentThrottlesAlarm: Type: AWS::CloudWatch::Alarm Properties: AlarmName: mervinc-agent-throttles AlarmDescription: mervinc-agent is being throttled (visitors are getting turned away) Namespace: AWS/Lambda MetricName: Throttles Dimensions: [{ Name: FunctionName, Value: mervinc-agent }] Statistic: Sum Period: 300 EvaluationPeriods: 1 Threshold: 1 ComparisonOperator: GreaterThanOrEqualToThreshold TreatMissingData: notBreaching AlarmActions: [!Ref AlarmTopic] BuildThrottlesAlarm: Type: AWS::CloudWatch::Alarm Properties: AlarmName: mervinc-build-throttles AlarmDescription: mervinc-build is being throttled (visitors are getting turned away) Namespace: AWS/Lambda MetricName: Throttles Dimensions: [{ Name: FunctionName, Value: mervinc-build }] Statistic: Sum Period: 300 EvaluationPeriods: 1 Threshold: 1 ComparisonOperator: GreaterThanOrEqualToThreshold TreatMissingData: notBreaching AlarmActions: [!Ref AlarmTopic] # ---------------------------------------------------------------- guardrail # Checked on every question before the model sees it, and on every answer # after. The verdict is streamed to the page with the rest of the trace. AgentGuardrail: Type: AWS::Bedrock::Guardrail Properties: Name: mervinc-agent Description: Ask my agent on mervinc.com BlockedInputMessaging: "That question was stopped by the guardrail before it reached the model." BlockedOutputsMessaging: "The answer was stopped by the guardrail." ContentPolicyConfig: FiltersConfig: - { Type: HATE, InputStrength: HIGH, OutputStrength: HIGH } - { Type: INSULTS, InputStrength: HIGH, OutputStrength: HIGH } - { Type: SEXUAL, InputStrength: HIGH, OutputStrength: HIGH } - { Type: VIOLENCE, InputStrength: HIGH, OutputStrength: HIGH } - { Type: MISCONDUCT, InputStrength: HIGH, OutputStrength: HIGH } - { Type: PROMPT_ATTACK, InputStrength: HIGH, OutputStrength: NONE } SensitiveInformationPolicyConfig: PiiEntitiesConfig: - { Type: CREDIT_DEBIT_CARD_NUMBER, Action: BLOCK } - { Type: AWS_SECRET_KEY, Action: BLOCK } - { Type: AWS_ACCESS_KEY, Action: BLOCK } - { Type: PASSWORD, Action: BLOCK } Tags: [{ Key: project, Value: mervinc }] AgentGuardrailVersion: Type: AWS::Bedrock::GuardrailVersion Properties: GuardrailIdentifier: !GetAtt AgentGuardrail.GuardrailId Description: v1 # ---------------------------------------------------------------- fleet socket # The build log's live panel: pages hold a WebSocket open here, and every # agent event the live function receives is pushed to all of them. FleetSocket: Type: AWS::ApiGatewayV2::Api Properties: Name: mervinc-fleet ProtocolType: WEBSOCKET RouteSelectionExpression: $request.body.action FleetSocketIntegration: Type: AWS::ApiGatewayV2::Integration Properties: ApiId: !Ref FleetSocket IntegrationType: AWS_PROXY IntegrationUri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${LiveFunction.Arn}/invocations FleetConnectRoute: Type: AWS::ApiGatewayV2::Route Properties: ApiId: !Ref FleetSocket RouteKey: $connect Target: !Sub integrations/${FleetSocketIntegration} FleetDisconnectRoute: Type: AWS::ApiGatewayV2::Route Properties: ApiId: !Ref FleetSocket RouteKey: $disconnect Target: !Sub integrations/${FleetSocketIntegration} FleetDefaultRoute: Type: AWS::ApiGatewayV2::Route Properties: ApiId: !Ref FleetSocket RouteKey: $default Target: !Sub integrations/${FleetSocketIntegration} FleetSocketStage: Type: AWS::ApiGatewayV2::Stage Properties: ApiId: !Ref FleetSocket StageName: live AutoDeploy: true DefaultRouteSettings: { ThrottlingBurstLimit: 40, ThrottlingRateLimit: 20 } FleetSocketPermission: Type: AWS::Lambda::Permission Properties: FunctionName: !Ref LiveFunction Action: lambda:InvokeFunction Principal: apigateway.amazonaws.com SourceArn: !Sub arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${FleetSocket}/* FleetOriginRequestPolicy: Type: AWS::CloudFront::OriginRequestPolicy Properties: OriginRequestPolicyConfig: Name: mervinc-fleet-key Comment: Forwards only the fleet hook key header CookiesConfig: { CookieBehavior: none } QueryStringsConfig: { QueryStringBehavior: none } HeadersConfig: HeaderBehavior: whitelist Headers: [x-fleet-key] # ---------------------------------------------------------------- edge flags # Feature flags and the re:Invent banner, read at the edge by the # mervinc-edge-hello CloudFront Function and written by mervinc-flags. FlagsStore: Type: AWS::CloudFront::KeyValueStore Properties: Name: mervinc-flags Comment: Feature flags and the event banner for mervinc.com FlagsRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - { Effect: Allow, Principal: { Service: lambda.amazonaws.com }, Action: sts:AssumeRole } ManagedPolicyArns: - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole Policies: - PolicyName: flags PolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Action: [cloudfront-keyvaluestore:DescribeKeyValueStore, cloudfront-keyvaluestore:GetKey, cloudfront-keyvaluestore:UpdateKeys, cloudfront-keyvaluestore:PutKey, cloudfront-keyvaluestore:ListKeys] Resource: !GetAtt FlagsStore.Arn # Password hash, session secret, failed-login counts and the activity log. - Effect: Allow Action: [dynamodb:GetItem, dynamodb:PutItem, dynamodb:UpdateItem, dynamodb:Query, dynamodb:BatchGetItem, dynamodb:DeleteItem] Resource: !GetAtt Table.Arn FlagsLogs: Type: AWS::Logs::LogGroup Properties: { LogGroupName: /aws/lambda/mervinc-flags, RetentionInDays: 14 } FlagsFunction: Type: AWS::Lambda::Function DependsOn: FlagsLogs Properties: FunctionName: mervinc-flags Runtime: nodejs22.x Architectures: [arm64] Handler: flags.handler MemorySize: 512 Timeout: 15 Role: !GetAtt FlagsRole.Arn Code: { S3Bucket: !Ref ArtifactsBucket, S3Key: !Ref AgentCodeKey } Environment: Variables: KVS_ARN: !GetAtt FlagsStore.Arn FLAGS_KEY_HASH: !Ref FlagsKeyHash TABLE: !Ref Table Tags: [{ Key: project, Value: mervinc }] FlagsUrl: Type: AWS::Lambda::Url Properties: TargetFunctionArn: !GetAtt FlagsFunction.Arn AuthType: AWS_IAM FlagsUrlPermission: Type: AWS::Lambda::Permission Properties: FunctionName: !Ref FlagsFunction Action: lambda:InvokeFunctionUrl Principal: cloudfront.amazonaws.com SourceArn: !Sub arn:aws:cloudfront::${AWS::AccountId}:distribution/${DistributionId} FunctionUrlAuthType: AWS_IAM FlagsInvokePermission: Type: AWS::Lambda::Permission Properties: FunctionName: !Ref FlagsFunction Action: lambda:InvokeFunction Principal: cloudfront.amazonaws.com SourceArn: !Sub arn:aws:cloudfront::${AWS::AccountId}:distribution/${DistributionId} # ---------------------------------------------------------------- edge LambdaOac: Type: AWS::CloudFront::OriginAccessControl Properties: OriginAccessControlConfig: Name: mervinc-lambda-oac Description: CloudFront signs every request to the mervinc function URLs OriginAccessControlOriginType: lambda SigningBehavior: always SigningProtocol: sigv4 # Everything CloudFront knows about the viewer, and nothing the viewer could # use to confuse the origin. Host must never be forwarded to a function URL. ApiOriginRequestPolicy: Type: AWS::CloudFront::OriginRequestPolicy Properties: OriginRequestPolicyConfig: Name: mervinc-api-viewer-geo Comment: CloudFront viewer geo and TLS headers (OAC carries the payload hash itself) CookiesConfig: { CookieBehavior: none } QueryStringsConfig: { QueryStringBehavior: none } HeadersConfig: HeaderBehavior: whitelist Headers: - CloudFront-Viewer-Address - CloudFront-Viewer-City - CloudFront-Viewer-Country - CloudFront-Viewer-Country-Region-Name - CloudFront-Viewer-Latitude - CloudFront-Viewer-Longitude - CloudFront-Viewer-Time-Zone - CloudFront-Viewer-Http-Version - CloudFront-Viewer-TLS - user-agent # The flags switchboard needs one header and nothing about the viewer. FlagsOriginRequestPolicy: Type: AWS::CloudFront::OriginRequestPolicy Properties: OriginRequestPolicyConfig: Name: mervinc-flags-key Comment: The owner's session or key header, plus the viewer address for login rate limits CookiesConfig: { CookieBehavior: none } QueryStringsConfig: { QueryStringBehavior: none } HeadersConfig: HeaderBehavior: whitelist Headers: [x-flags-key, CloudFront-Viewer-Address] # Every page: Server-Timing (the edge strip reads it) and the security # headers. The CSP allows the two inline scripts on the site by hash (the # theme bootstrap in every , and the Flash loader on demonstrated.html), # never 'unsafe-inline'; infra/csp-check.py refuses a deploy whose pages carry # an inline script that is not listed here. 'wasm-unsafe-eval' is for the # self-hosted Ruffle player; connect-src names the visit beacon and the build # log's WebSocket. SiteHeadersPolicy: Type: AWS::CloudFront::ResponseHeadersPolicy Properties: ResponseHeadersPolicyConfig: Name: mervinc-site-server-timing Comment: Server-Timing plus security headers for the pages ServerTimingHeadersConfig: { Enabled: true, SamplingRate: 100 } SecurityHeadersConfig: StrictTransportSecurity: { AccessControlMaxAgeSec: 63072000, IncludeSubdomains: true, Preload: true, Override: true } ContentTypeOptions: { Override: true } FrameOptions: { FrameOption: DENY, Override: true } ReferrerPolicy: { ReferrerPolicy: strict-origin-when-cross-origin, Override: true } ContentSecurityPolicy: Override: true ContentSecurityPolicy: !Join ["; ", [ "default-src 'self'", "script-src 'self' 'sha256-0EnIFqnD1TvNUtHavOkgGWHkpS4YX1AnxBNQKjq221E=' 'sha256-m/jDy+KtXK96iJBOHr/FyeIuctJvDw6G9iG+3gCpKds=' 'wasm-unsafe-eval'", "style-src 'self' 'unsafe-inline'", "font-src 'self'", "img-src 'self' data: blob:", "media-src 'self' data: blob:", !Sub "connect-src 'self' https://api.supersonicrentals.ca wss://${FleetSocket}.execute-api.${AWS::Region}.amazonaws.com", "worker-src 'self' blob:", "frame-src 'self' blob:", "object-src 'none'", "base-uri 'self'", "form-action 'self'", "frame-ancestors 'none'", "upgrade-insecure-requests"]] CustomHeadersConfig: Items: - { Header: Permissions-Policy, Value: "camera=(self), microphone=(self), geolocation=(), payment=(), usb=(), browsing-topics=()", Override: true } - { Header: Cross-Origin-Opener-Policy, Value: same-origin, Override: true } RemoveHeadersConfig: Items: [{ Header: Server }] # build.html renders the generated app in a sandboxed srcdoc iframe, which # inherits the page's CSP, so the app's own inline script needs # 'unsafe-inline' here. The archived client demos under assets/work/ (static, # no input, kept as originally shipped) are served under this policy too. The iframe is sandboxed without same-origin and the # app carries its own no-network CSP, so it still cannot reach anything. BuildHeadersPolicy: Type: AWS::CloudFront::ResponseHeadersPolicy Properties: ResponseHeadersPolicyConfig: Name: mervinc-site-build-page Comment: Security headers for build.html (sandboxed generated apps need inline script) ServerTimingHeadersConfig: { Enabled: true, SamplingRate: 100 } SecurityHeadersConfig: StrictTransportSecurity: { AccessControlMaxAgeSec: 63072000, IncludeSubdomains: true, Preload: true, Override: true } ContentTypeOptions: { Override: true } FrameOptions: { FrameOption: DENY, Override: true } ReferrerPolicy: { ReferrerPolicy: strict-origin-when-cross-origin, Override: true } ContentSecurityPolicy: Override: true ContentSecurityPolicy: !Join ["; ", [ "default-src 'self'", "script-src 'self' 'unsafe-inline'", "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", "font-src 'self' https://fonts.gstatic.com data:", "img-src 'self' data: blob:", "media-src 'self' data: blob:", !Sub "connect-src 'self' https://api.supersonicrentals.ca wss://${FleetSocket}.execute-api.${AWS::Region}.amazonaws.com", "frame-src 'self' blob:", "object-src 'none'", "base-uri 'self'", "form-action 'self'", "frame-ancestors 'none'", "upgrade-insecure-requests"]] CustomHeadersConfig: Items: - { Header: Permissions-Policy, Value: "camera=(), microphone=(self), geolocation=(), payment=(), usb=(), browsing-topics=()", Override: true } - { Header: Cross-Origin-Opener-Policy, Value: same-origin, Override: true } RemoveHeadersConfig: Items: [{ Header: Server }] # ---------------------------------------------------------------- money Budget: Type: AWS::Budgets::Budget Properties: Budget: BudgetName: mervinc-monthly BudgetType: COST TimeUnit: MONTHLY BudgetLimit: { Amount: !Ref MonthlyBudgetUsd, Unit: USD } NotificationsWithSubscribers: - Notification: { NotificationType: ACTUAL, ComparisonOperator: GREATER_THAN, Threshold: 80, ThresholdType: PERCENTAGE } Subscribers: [{ SubscriptionType: EMAIL, Address: !Ref AlertEmail }] - Notification: { NotificationType: FORECASTED, ComparisonOperator: GREATER_THAN, Threshold: 100, ThresholdType: PERCENTAGE } Subscribers: [{ SubscriptionType: EMAIL, Address: !Ref AlertEmail }] Outputs: LiveUrlDomain: Value: !Select [2, !Split ["/", !GetAtt LiveUrl.FunctionUrl]] AgentUrlDomain: Value: !Select [2, !Split ["/", !GetAtt AgentUrl.FunctionUrl]] BuildUrlDomain: Value: !Select [2, !Split ["/", !GetAtt BuildUrl.FunctionUrl]] ToolsUrlDomain: Value: !Select [2, !Split ["/", !GetAtt ToolsUrl.FunctionUrl]] FlagsUrlDomain: Value: !Select [2, !Split ["/", !GetAtt FlagsUrl.FunctionUrl]] FlagsStoreArn: Value: !GetAtt FlagsStore.Arn FlagsOriginRequestPolicyId: Value: !Ref FlagsOriginRequestPolicy FleetSocketUrl: Value: !Sub wss://${FleetSocket}.execute-api.${AWS::Region}.amazonaws.com/live FleetOriginRequestPolicyId: Value: !Ref FleetOriginRequestPolicy LambdaOacId: Value: !GetAtt LambdaOac.Id ApiOriginRequestPolicyId: Value: !Ref ApiOriginRequestPolicy SiteHeadersPolicyId: Value: !Ref SiteHeadersPolicy BuildHeadersPolicyId: Value: !Ref BuildHeadersPolicy