import * as cdk from "aws-cdk-lib"; import * as lambda from "aws-cdk-lib/aws-lambda"; import * as apigwv2 from "aws-cdk-lib/aws-apigatewayv2"; import * as integrations from "aws-cdk-lib/aws-apigatewayv2-integrations"; import * as authorizers from "aws-cdk-lib/aws-apigatewayv2-authorizers"; import * as ssm from "aws-cdk-lib/aws-ssm"; import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager"; import * as ec2 from "aws-cdk-lib/aws-ec2"; import * as events from "aws-cdk-lib/aws-events"; import * as targets from "aws-cdk-lib/aws-events-targets"; import * as route53 from "aws-cdk-lib/aws-route53"; import * as route53Targets from "aws-cdk-lib/aws-route53-targets"; import * as acm from "aws-cdk-lib/aws-certificatemanager"; import * as logs from "aws-cdk-lib/aws-logs"; import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch"; import * as cwactions from "aws-cdk-lib/aws-cloudwatch-actions"; import * as sns from "aws-cdk-lib/aws-sns"; import { Construct } from "constructs"; import * as path from "path"; export class DoorUnlockStack extends cdk.Stack { constructor(scope: Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); const elementsApiKeyParam = ssm.StringParameter.fromSecureStringParameterAttributes( this, "ElementsApiKey", { parameterName: "/seahaven/door-unlock/elements-api-key" } ); const authTokenParam = ssm.StringParameter.fromSecureStringParameterAttributes( this, "AuthToken", { parameterName: "/seahaven/door-unlock/auth-token" } ); // forceDynamicReference: the stack only needs the parameter for grantRead // (ARN, built from the name); without it, fromStringParameterName injects // an unreferenced AWS::SSM::Parameter::Value CloudFormation parameter. const doorIdParam = ssm.StringParameter.fromStringParameterAttributes( this, "DoorId", { parameterName: "/seahaven/door-unlock/door-id", forceDynamicReference: true, } ); const unlockHandler = new lambda.Function(this, "UnlockHandler", { functionName: "door-unlock-api-unlock", runtime: lambda.Runtime.NODEJS_24_X, architecture: lambda.Architecture.ARM_64, handler: "unlock-handler.handler", code: lambda.Code.fromAsset(path.join(__dirname, "../lambda/unlock"), { bundling: { image: lambda.Runtime.NODEJS_24_X.bundlingImage, local: { tryBundle(outputDir: string) { const { execSync } = require("child_process"); execSync( `esbuild ${path.join(__dirname, "../lambda/unlock/unlock-handler.ts")} --bundle --platform=node --target=node24 --outfile=${path.join(outputDir, "unlock-handler.js")} --external:@aws-sdk/*` ); return true; }, }, }, }), environment: { ELEMENTS_API_KEY_PARAM: "/seahaven/door-unlock/elements-api-key", AUTH_TOKEN_PARAM: "/seahaven/door-unlock/auth-token", DOOR_ID_PARAM: "/seahaven/door-unlock/door-id", }, timeout: cdk.Duration.seconds(20), memorySize: 128, logRetention: logs.RetentionDays.TWO_MONTHS, }); const lockdownHandler = new lambda.Function(this, "LockdownHandler", { functionName: "door-unlock-api-lockdown", runtime: lambda.Runtime.NODEJS_24_X, architecture: lambda.Architecture.ARM_64, handler: "lockdown-handler.handler", code: lambda.Code.fromAsset(path.join(__dirname, "../lambda/lockdown"), { bundling: { image: lambda.Runtime.NODEJS_24_X.bundlingImage, local: { tryBundle(outputDir: string) { const { execSync } = require("child_process"); execSync( `esbuild ${path.join(__dirname, "../lambda/lockdown/lockdown-handler.ts")} --bundle --platform=node --target=node24 --outfile=${path.join(outputDir, "lockdown-handler.js")} --external:@aws-sdk/*` ); return true; }, }, }, }), environment: { ELEMENTS_API_KEY_PARAM: "/seahaven/door-unlock/elements-api-key", AUTH_TOKEN_PARAM: "/seahaven/door-unlock/auth-token", }, timeout: cdk.Duration.seconds(15), memorySize: 128, logRetention: logs.RetentionDays.TWO_MONTHS, }); elementsApiKeyParam.grantRead(unlockHandler); authTokenParam.grantRead(unlockHandler); doorIdParam.grantRead(unlockHandler); elementsApiKeyParam.grantRead(lockdownHandler); authTokenParam.grantRead(lockdownHandler); // Gateway authorizer (INFRA-99): validates the same `?token=` query-string // value the Yealink XML Browser keys already send, so it is transparent to // the phones while rejecting unauthenticated callers at the gateway. const authorizerHandler = new lambda.Function(this, "AuthorizerHandler", { functionName: "door-unlock-api-authorizer", runtime: lambda.Runtime.NODEJS_24_X, architecture: lambda.Architecture.ARM_64, handler: "authorizer-handler.handler", code: lambda.Code.fromAsset(path.join(__dirname, "../lambda/authorizer"), { bundling: { image: lambda.Runtime.NODEJS_24_X.bundlingImage, local: { tryBundle(outputDir: string) { const { execSync } = require("child_process"); execSync( `esbuild ${path.join(__dirname, "../lambda/authorizer/authorizer-handler.ts")} --bundle --platform=node --target=node24 --outfile=${path.join(outputDir, "authorizer-handler.js")} --external:@aws-sdk/*` ); return true; }, }, }, }), environment: { AUTH_TOKEN_PARAM: authTokenParam.parameterName, }, // APIGW HTTP API authorizers have a hard 10s limit; keep margin so the // gateway returns its own 500 rather than racing the Lambda timeout. The // token is cached in module scope, so warm invocations never hit SSM. timeout: cdk.Duration.seconds(8), memorySize: 128, logRetention: logs.RetentionDays.TWO_MONTHS, }); authTokenParam.grantRead(authorizerHandler); const tokenAuthorizer = new authorizers.HttpLambdaAuthorizer( "DoorUnlockTokenAuthorizer", authorizerHandler, { authorizerName: "door-unlock-api-token-authorizer", // The Yealink phones send the token in the query string; scope the // identity source there. A request with no `token` query param is // rejected by the gateway before the authorizer Lambda is invoked. identitySource: ["$request.querystring.token"], responseTypes: [authorizers.HttpLambdaResponseType.SIMPLE], // Authorizer result caching keyed on the identity source (the token). // 5 min keeps repeated phone presses fast without a long stale window. resultsCacheTtl: cdk.Duration.minutes(5), } ); const vpc = ec2.Vpc.fromLookup(this, "SeaHavenVpc", { vpcId: "vpc-0d3d4b67bd0cf8a68", }); const privateSubnet1 = ec2.Subnet.fromSubnetId( this, "PrivateSubnet1", "subnet-04e38c507e96f1926" ); const privateSubnet2 = ec2.Subnet.fromSubnetId( this, "PrivateSubnet2", "subnet-0a0b4fc6f296dfba5" ); const pollerSg = new ec2.SecurityGroup(this, "PollerSecurityGroup", { vpc, securityGroupName: "door-unlock-api-poller", description: "Lockdown poller - outbound to Elements API and phone LAN", allowAllOutbound: false, }); pollerSg.addEgressRule( ec2.Peer.anyIpv4(), ec2.Port.tcp(443), "HTTPS to Elements API and SSM via NAT" ); pollerSg.addEgressRule( ec2.Peer.ipv4("10.10.0.0/16"), ec2.Port.tcp(443), "HTTPS to phone LAN via VPN" ); // forceDynamicReference for the same reason as DoorId above. const phoneIpsParam = ssm.StringParameter.fromStringParameterAttributes( this, "PhoneIps", { parameterName: "/seahaven/door-unlock/phone-ips", forceDynamicReference: true } ); const phonePasswordSecret = secretsmanager.Secret.fromSecretNameV2( this, "PhonePassword", "door-unlock-api/phone-password" ); const pollerHandler = new lambda.Function(this, "LockdownPoller", { functionName: "door-unlock-api-lockdown-poller", runtime: lambda.Runtime.NODEJS_24_X, architecture: lambda.Architecture.ARM_64, handler: "lockdown-poller.handler", code: lambda.Code.fromAsset(path.join(__dirname, "../lambda/poller"), { bundling: { image: lambda.Runtime.NODEJS_24_X.bundlingImage, local: { tryBundle(outputDir: string) { const { execSync } = require("child_process"); execSync( `esbuild ${path.join(__dirname, "../lambda/poller/lockdown-poller.ts")} --bundle --platform=node --target=node24 --outfile=${path.join(outputDir, "lockdown-poller.js")} --external:@aws-sdk/*` ); return true; }, }, }, }), environment: { ELEMENTS_API_KEY_PARAM: "/seahaven/door-unlock/elements-api-key", PHONE_IPS_PARAM: "/seahaven/door-unlock/phone-ips", PHONE_PASSWORD_SECRET: "door-unlock-api/phone-password", }, vpc, vpcSubnets: { subnets: [privateSubnet1, privateSubnet2] }, securityGroups: [pollerSg], timeout: cdk.Duration.seconds(75), memorySize: 128, logRetention: logs.RetentionDays.TWO_MONTHS, }); elementsApiKeyParam.grantRead(pollerHandler); phoneIpsParam.grantRead(pollerHandler); phonePasswordSecret.grantRead(pollerHandler); new events.Rule(this, "LockdownPollerSchedule", { ruleName: "door-unlock-api-lockdown-poller-schedule", schedule: events.Schedule.rate(cdk.Duration.minutes(1)), targets: [new targets.LambdaFunction(pollerHandler)], }); const threeCxDomainSecret = secretsmanager.Secret.fromSecretNameV2( this, "ThreeCxDomain", "afterhours-shift-manager/3cx-domain" ); const threeCxClientIdSecret = secretsmanager.Secret.fromSecretNameV2( this, "ThreeCxClientId", "afterhours-shift-manager/3cx-client-id" ); const threeCxClientSecret = secretsmanager.Secret.fromSecretNameV2( this, "ThreeCxClientSecret", "afterhours-shift-manager/3cx-client-secret" ); const blfSyncHandler = new lambda.Function(this, "BlfSyncHandler", { functionName: "door-unlock-api-blf-sync", runtime: lambda.Runtime.NODEJS_24_X, architecture: lambda.Architecture.ARM_64, handler: "blf-sync-handler.handler", code: lambda.Code.fromAsset(path.join(__dirname, "../lambda/blf-sync"), { bundling: { image: lambda.Runtime.NODEJS_24_X.bundlingImage, local: { tryBundle(outputDir: string) { const { execSync } = require("child_process"); execSync( `esbuild ${path.join(__dirname, "../lambda/blf-sync/blf-sync-handler.ts")} --bundle --platform=node --target=node24 --outfile=${path.join(outputDir, "blf-sync-handler.js")} --external:@aws-sdk/*` ); return true; }, }, }, }), environment: { THREE_CX_DOMAIN_SECRET: "afterhours-shift-manager/3cx-domain", THREE_CX_CLIENT_ID_SECRET: "afterhours-shift-manager/3cx-client-id", THREE_CX_CLIENT_SECRET_SECRET: "afterhours-shift-manager/3cx-client-secret", DRY_RUN: "false", }, timeout: cdk.Duration.seconds(60), memorySize: 256, logRetention: logs.RetentionDays.TWO_MONTHS, }); threeCxDomainSecret.grantRead(blfSyncHandler); threeCxClientIdSecret.grantRead(blfSyncHandler); threeCxClientSecret.grantRead(blfSyncHandler); // 05:00 ET during EDT (09:00 UTC). new events.Rule(this, "BlfSyncSchedule", { ruleName: "door-unlock-api-blf-sync-schedule", schedule: events.Schedule.cron({ minute: "0", hour: "9" }), enabled: true, targets: [new targets.LambdaFunction(blfSyncHandler)], }); const httpApi = new apigwv2.HttpApi(this, "DoorUnlockApi", { apiName: "door-unlock-api", }); const defaultStage = httpApi.defaultStage!.node.defaultChild as apigwv2.CfnStage; defaultStage.addPropertyOverride("DefaultRouteSettings", { ThrottlingBurstLimit: 5, ThrottlingRateLimit: 2, }); // Access logging (audit M-18). Throttling above was already present. const apiAccessLogGroup = new logs.LogGroup(this, "ApiAccessLogGroup", { logGroupName: "/aws/apigateway/door-unlock-api", retention: logs.RetentionDays.THREE_MONTHS, removalPolicy: cdk.RemovalPolicy.DESTROY, }); defaultStage.addPropertyOverride("AccessLogSettings", { DestinationArn: apiAccessLogGroup.logGroupArn, Format: JSON.stringify({ requestId: "$context.requestId", ip: "$context.identity.sourceIp", requestTime: "$context.requestTime", method: "$context.httpMethod", routeKey: "$context.routeKey", status: "$context.status", protocol: "$context.protocol", responseLength: "$context.responseLength", integrationError: "$context.integrationErrorMessage", }), }); httpApi.addRoutes({ path: "/unlock", methods: [apigwv2.HttpMethod.GET], integration: new integrations.HttpLambdaIntegration( "UnlockIntegration", unlockHandler ), authorizer: tokenAuthorizer, }); const lockdownIntegration = new integrations.HttpLambdaIntegration( "LockdownIntegration", lockdownHandler ); httpApi.addRoutes({ path: "/lockdown", methods: [apigwv2.HttpMethod.GET], integration: lockdownIntegration, authorizer: tokenAuthorizer, }); httpApi.addRoutes({ path: "/lockdown/status", methods: [apigwv2.HttpMethod.GET], integration: lockdownIntegration, authorizer: tokenAuthorizer, }); const hostedZone = route53.HostedZone.fromHostedZoneAttributes( this, "SeaHavenZone", { hostedZoneId: "Z06652411XKH89KTZD3XA", zoneName: "seahaven.com", } ); const certificate = acm.Certificate.fromCertificateArn( this, "WildcardCert", "arn:aws:acm:us-east-1:328440206208:certificate/a66c0994-90d4-410a-a1d9-5595c2a3fae3" ); const domainName = new apigwv2.DomainName(this, "DoorUnlockDomain", { domainName: "doorunlock.seahaven.com", certificate, }); new apigwv2.ApiMapping(this, "DoorUnlockMapping", { api: httpApi, domainName, }); new route53.ARecord(this, "DoorUnlockARecord", { zone: hostedZone, recordName: "doorunlock", target: route53.RecordTarget.fromAlias( new route53Targets.ApiGatewayv2DomainProperties( domainName.regionalDomainName, domainName.regionalHostedZoneId ) ), }); new cdk.CfnOutput(this, "ApiUrl", { value: `https://doorunlock.seahaven.com/unlock`, }); new cdk.CfnOutput(this, "LockdownApiUrl", { value: `https://doorunlock.seahaven.com/lockdown`, }); // ── CloudWatch error alarms (INFRA-101) ────────────────────────────────── // Import the cross-stack site-alerts SNS topic. This topic is managed by // seahaven-account-baseline and encrypted with alias/seahaven-alarm-topics // (CMK). Never use alias/aws/sns here — CloudWatch cannot publish to topics // using the AWS-managed SNS key (silent publish failure). // ALARM state only; no OK/recovery actions per Sea Haven preference. const siteAlerts = sns.Topic.fromTopicArn( this, "SiteAlerts", "arn:aws:sns:us-east-1:328440206208:site-alerts" ); interface AlarmSpec { readonly id: string; readonly fn: lambda.Function; readonly alarmName: string; readonly description: string; } const alarmSpecs: AlarmSpec[] = [ { id: "UnlockErrors", fn: unlockHandler, alarmName: "door-unlock-api-unlock-errors", description: "door-unlock-api-unlock Lambda is erroring — physical door unlock calls may be failing", }, { id: "LockdownErrors", fn: lockdownHandler, alarmName: "door-unlock-api-lockdown-errors", description: "door-unlock-api-lockdown Lambda is erroring — lockdown commands may not be executing", }, { id: "AuthorizerErrors", fn: authorizerHandler, alarmName: "door-unlock-api-authorizer-errors", description: "door-unlock-api-authorizer Lambda is erroring — all API requests will be rejected", }, { id: "PollerErrors", fn: pollerHandler, alarmName: "door-unlock-api-lockdown-poller-errors", description: "door-unlock-api-lockdown-poller Lambda is erroring — lockdown state polling may be broken", }, { id: "BlfSyncErrors", fn: blfSyncHandler, alarmName: "door-unlock-api-blf-sync-errors", description: "door-unlock-api-blf-sync Lambda is erroring — department BLF updates may not be applying", }, ]; for (const spec of alarmSpecs) { const alarm = new cloudwatch.Alarm(this, spec.id, { alarmName: spec.alarmName, alarmDescription: spec.description, metric: spec.fn.metricErrors({ period: cdk.Duration.minutes(5), statistic: "Sum", }), threshold: 0, comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD, evaluationPeriods: 1, treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING, }); // ALARM-only: addAlarmAction only (no addOkAction / addInsufficientDataAction) alarm.addAlarmAction(new cwactions.SnsAction(siteAlerts)); } } }