import fs from 'fs'; import path from 'path'; import { randomUUID } from 'crypto'; import { defineBackend, } from '@aws-amplify/backend'; import { Duration, Fn, RemovalPolicy, NestedStack } from "aws-cdk-lib"; import { UserPool } from 'aws-cdk-lib/aws-cognito'; import { FunctionUrlAuthType, IFunction, Runtime, } from 'aws-cdk-lib/aws-lambda'; import { NodejsFunction, OutputFormat } from 'aws-cdk-lib/aws-lambda-nodejs'; import { Bucket, BlockPublicAccess } from 'aws-cdk-lib/aws-s3'; import { Table, AttributeType, BillingMode } from 'aws-cdk-lib/aws-dynamodb'; import { AnyPrincipal, PolicyStatement } from 'aws-cdk-lib/aws-iam'; import { Rule, RuleTargetInput, Schedule } from 'aws-cdk-lib/aws-events'; import { LambdaFunction as LambdaFunctionTarget } from 'aws-cdk-lib/aws-events-targets'; import { SESClient, VerifyEmailIdentityCommand, GetIdentityVerificationAttributesCommand, VerifyDomainIdentityCommand } from '@aws-sdk/client-ses'; import { TableDefinition, indexName, DeploymentConfig } from 'wirejs-resources'; import { TableIndexes } from './constructs/table-indexes'; import { RealtimeService } from './constructs/realtime-service'; // @ts-ignore import generatedResources from './generated-resources'; import { copyFileSync } from 'fs'; import { config, cwd } from 'process'; const __filename = import.meta.url.replace(/^file:/, ''); const __dirname = path.dirname(__filename); const generated: any[] = generatedResources; const CONFIG_PATH = path.join(cwd(), 'deployment-config.ts'); const APP_ID = process.env.AWS_APP_ID ?? process.env.PWD?.replace(/[^a-zA-Z0-9-_]/g, '_'); const BRANCH_ID = process.env.AWS_BRANCH ?? process.env.USER ?? 'anonymous'; const TABLE_NAME_PREFIX = `${APP_ID}-${BRANCH_ID}-`; /** * Simple secret ID used for backend jobs to authenticate themselves with * each other. I.e., for API to trigger background jobs. */ const SELF_INVOCATION_ID = randomUUID(); /** * Amplify resources */ const backend = defineBackend({}); copyFileSync( path.join(__dirname, 'api', 'handler.ts'), path.join(cwd(), 'api', 'wirejs-handler.ts') ); let cfg: DeploymentConfig = {}; if (fs.existsSync(CONFIG_PATH)) { cfg = (await import(CONFIG_PATH)).default as DeploymentConfig; console.log("\nDeployment config found: ", JSON.stringify(cfg), "\n"); } else { console.log('\nNo deployment config found. Using defaults.\n') } /** * Cognito resources for AuthenticationService */ function isAuthenticationService(resource: any): resource is { type: 'AuthenticationService'; options: { absoluteId: string }; } { return resource.type === 'AuthenticationService'; } type CognitoResources = { absoluteId: string; userPool: UserPool; clientId: string; }; const cognitoResources: CognitoResources[] = []; for (const resource of generated) { if (isAuthenticationService(resource)) { const sanitizedId = resource.options.absoluteId.replace(/[^a-zA-Z0-9-_]/g, '_'); const userPool = new UserPool(backend.stack, `UserPool_${sanitizedId}`, { signInAliases: { email: true }, passwordPolicy: { minLength: 8, requireLowercase: false, requireSymbols: false, requireUppercase: false, requireDigits: false, }, selfSignUpEnabled: true, }); const userPoolClient = userPool.addClient('UserPoolClient', { authFlows: { userPassword: true, adminUserPassword: true, }, }); cognitoResources.push({ absoluteId: resource.options.absoluteId, userPool, clientId: userPoolClient.userPoolClientId, }); } } const api = new NodejsFunction(backend.stack, 'ApiHandler', { runtime: { 20: Runtime.NODEJS_20_X, 22: Runtime.NODEJS_22_X, 24: Runtime.NODEJS_24_X, }[cfg.runtimeNodeVersion ?? 24], memorySize: cfg.runtimeDesiredMemoryMB ?? 2 * 1024, handler: 'handler', entry: path.join(cwd(), 'api', 'wirejs-handler.ts'), bundling: { nodeModules: cfg.bundleNodeModules ?? ['jsdom'], format: cfg.bundleFormat === 'esm' ? OutputFormat.ESM : OutputFormat.CJS, minify: Boolean(cfg.bundleMinify), }, depsLockFilePath: path.join(cwd(), 'package-lock.json'), timeout: Duration.seconds(cfg.runtimeTimeoutSeconds ?? 15 * 60), }); api.role?.addToPrincipalPolicy(new PolicyStatement({ actions: ['lambda:InvokeFunction'], resources: [ `arn:${backend.stack.partition}:lambda:${backend.stack.region}:${backend.stack.account}:function:*`, ], })); const allLambdas: IFunction[] = [ api ]; /** * CDK resources */ const bucket = new Bucket(backend.stack, 'data', { blockPublicAccess: BlockPublicAccess.BLOCK_ALL, versioned: true, removalPolicy: RemovalPolicy.RETAIN, }); bucket.grantReadWrite(api); function isRealtimeService(resource: any): resource is { type: 'RealtimeService'; options: { namespace: string; }; } { return resource.type === 'RealtimeService'; } if (generated.some(isRealtimeService)) { const realtimeStack = new NestedStack(backend.stack, 'realtime', { description: 'Realtime service for distributed resources', }); const realtime = new RealtimeService(realtimeStack, 'realtime', { appId: APP_ID!, branchId: BRANCH_ID, publisher: api, bucket: bucket.bucketName, namespaces: generated .filter(isRealtimeService) .map(r => r.options.namespace), }); bucket.grantReadWrite(realtime.authHandler); allLambdas.push(realtime.authHandler); } /** * DDB Tables */ function isDistributedTable(resource: any): resource is { type: 'DistributedTable'; options: TableDefinition; } { return resource.type === 'DistributedTable'; } const PKFieldTypes = { 'string': AttributeType.STRING, 'number': AttributeType.NUMBER, } const allTablesStack = new NestedStack(backend.stack, 'tables', { description: 'DynamoDB tables for distributed resources', }); // TODO: Need to get AttributeType from customer definition. for (const resource of generated) { if (isDistributedTable(resource)) { const tableStack = new NestedStack(allTablesStack, resource.options.absoluteId, { description: `DynamoDB table for ${resource.options.absoluteId}` }); const sanitizedId = resource.options.absoluteId.replace(/[^a-zA-Z0-9-_]/g, '_'); const tableName = `${TABLE_NAME_PREFIX}${sanitizedId}` const table = new Table(tableStack, sanitizedId, { tableName, partitionKey: { name: resource.options.partitionKey.field, type: PKFieldTypes[resource.options.partitionKey.type], }, sortKey: resource.options.sortKey ? { name: resource.options.sortKey.field, type: PKFieldTypes[resource.options.sortKey.type], } : undefined, removalPolicy: RemovalPolicy.RETAIN, billingMode: BillingMode.PAY_PER_REQUEST, pointInTimeRecovery: true, timeToLiveAttribute: resource.options.ttlAttribute, }); new TableIndexes(tableStack, `${tableName}Indexes`, { tableName, indexes: (resource.options.indexes ?? []).map((index) => ({ indexName: indexName(index as any), partitionKey: { name: index.partition.field, type: PKFieldTypes[index.partition.type as keyof typeof PKFieldTypes], }, sortKey: index.sort ? { name: index.sort.field, type: PKFieldTypes[index.sort.type as keyof typeof PKFieldTypes], } : undefined, })) } ); for (const lambda of allLambdas) { table.grantReadWriteData(lambda); // indexes created by custom resource and require explicit // permissions to be added to the lambda role (apparently). lambda.addToRolePolicy(new PolicyStatement({ actions: [ "dynamodb:Query", "dynamodb:Scan", "dynamodb:GetItem", "dynamodb:BatchGetItem", ], resources: [ table.tableArn, `${table.tableArn}/index/*`, ], })); } } } /** * Converts a standard 5-field cron expression to an AWS EventBridge Schedule. * * Standard cron: "minute hour day-of-month month day-of-week" * EventBridge cron: "minute hour day-of-month month day-of-week year" * EventBridge additionally requires that day-of-month and day-of-week cannot * both be specified at the same time (one must be `?`). */ function cronExpressionToSchedule(expression: string): Schedule { const [minute, hour, dayOfMonth, month, dayOfWeek] = expression.trim().split(/\s+/); // CDK's Schedule.cron() does not accept both `day` and `weekDay` simultaneously. // If dayOfWeek is specified (not '*' or '?'), use it; otherwise use dayOfMonth. const useWeekDay = dayOfWeek !== '*' && dayOfWeek !== '?'; return Schedule.cron({ minute, hour, month, ...(useWeekDay ? { weekDay: dayOfWeek } : { day: dayOfMonth }), }); } /** * EventBridge rules for CronJobs */ function isCronJob(resource: any): resource is { type: 'CronJob'; options: { absoluteId: string; schedule: string }; } { return resource.type === 'CronJob'; } for (const resource of generated) { if (isCronJob(resource)) { const sanitizedId = resource.options.absoluteId.replace(/[^a-zA-Z0-9-_]/g, '_'); new Rule(backend.stack, `CronJob_${sanitizedId}`, { schedule: cronExpressionToSchedule(resource.options.schedule), targets: [ new LambdaFunctionTarget(api, { event: RuleTargetInput.fromObject({ source: 'wirejs-cron', 'wirejs-cron-id': resource.options.absoluteId, }), }), ], }); } } /** * Lambda environment vars */ api.addEnvironment( 'BUCKET', bucket.bucketName, ); if (cognitoResources.length > 0) { const clientIdMap: Record = {}; for (const { absoluteId, userPool, clientId } of cognitoResources) { clientIdMap[absoluteId] = clientId; userPool.grant(api, 'cognito-idp:AdminInitiateAuth', 'cognito-idp:AdminRespondToAuthChallenge', ); } api.addEnvironment('COGNITO_CLIENT_IDS', Fn.toJsonString(clientIdMap)); } api.addEnvironment( 'TABLE_NAME_PREFIX', TABLE_NAME_PREFIX ); api.addEnvironment( 'SELF_INVOCATION_ID', SELF_INVOCATION_ID ); /** * Client configuration */ const apiUrl = api.addFunctionUrl({ authType: FunctionUrlAuthType.NONE, }); apiUrl.grantInvokeUrl(new AnyPrincipal()); api.addToRolePolicy(new PolicyStatement({ actions: [ 'bedrock:InvokeModel', 'bedrock:InvokeModelWithResponseStream', ], resources: [ 'arn:aws:bedrock:*' ] })); /** * SES permissions for EmailSender resources */ function isEmailSender(resource: any): resource is { type: 'EmailSender'; options: { absoluteId: string; from?: string; fromDomain?: string; type: 'email' | 'domain'; }; } { return resource.type === 'EmailSender'; } const emailSenderResources = generated.filter(isEmailSender); if (emailSenderResources.length > 0) { // Extract both email addresses and domains for verification const emailAddresses = emailSenderResources .filter(r => r.options.type === 'email' && r.options.from) .map(r => r.options.from!); const domains = emailSenderResources .filter(r => r.options.type === 'domain' && r.options.fromDomain) .map(r => r.options.fromDomain!); // Add SES permissions for all identity types api.addToRolePolicy(new PolicyStatement({ actions: [ 'ses:SendEmail', 'ses:SendRawEmail', ], resources: [ `arn:${backend.stack.partition}:ses:${backend.stack.region}:${backend.stack.account}:identity/*`, ], })); // Initiate SES verification for emails and domains during deployment. const sesClient = new SESClient(); const verificationResults: { identity: string; type: 'email' | 'domain'; status: string }[] = []; // Verify individual email addresses for (const address of emailAddresses) { try { // Check current verification status first const statusResult = await sesClient.send( new GetIdentityVerificationAttributesCommand({ Identities: [address] }) ); const currentStatus = statusResult.VerificationAttributes?.[address]?.VerificationStatus; if (currentStatus === 'Success') { verificationResults.push({ identity: address, type: 'email', status: 'already verified ✅' }); } else { // Trigger a (new) verification email await sesClient.send(new VerifyEmailIdentityCommand({ EmailAddress: address })); verificationResults.push({ identity: address, type: 'email', status: 'verification email sent 📧' }); } } catch (err: any) { verificationResults.push({ identity: address, type: 'email', status: `could not initiate automatically — ${err?.message ?? err}` }); } } // Verify domains for (const domain of domains) { try { // Check current verification status first const statusResult = await sesClient.send( new GetIdentityVerificationAttributesCommand({ Identities: [domain] }) ); const currentStatus = statusResult.VerificationAttributes?.[domain]?.VerificationStatus; if (currentStatus === 'Success') { verificationResults.push({ identity: domain, type: 'domain', status: 'already verified ✅' }); } else { // Trigger domain verification (requires DNS setup) await sesClient.send(new VerifyDomainIdentityCommand({ Domain: domain })); verificationResults.push({ identity: domain, type: 'domain', status: 'initiated - DNS setup required 🌐' }); } } catch (err: any) { verificationResults.push({ identity: domain, type: 'domain', status: `could not initiate automatically — ${err?.message ?? err}` }); } } // Enhanced console output console.log(` ⚠️ AWS SES Email and Domain Verification ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Your app uses EmailSender with the following identities: EMAIL ADDRESSES: ${verificationResults.filter(r => r.type === 'email').map(r => ` 📧 ${r.identity} — ${r.status}`).join('\n') || ' (none)'} DOMAINS: ${verificationResults.filter(r => r.type === 'domain').map(r => ` 🌐 ${r.identity} — ${r.status}`).join('\n') || ' (none)'} NEXT STEPS: ${verificationResults.some(r => r.type === 'email' && r.status.includes('sent')) ? '📧 Check your inbox and click verification links for email addresses.\n' : ''}${verificationResults.some(r => r.type === 'domain' && r.status.includes('DNS')) ? '🌐 Set up DNS records for domain verification (see AWS SES console).\n' : ''} ⚠️ AWS SES starts in "sandbox" mode — you must also verify recipient addresses or request production access to send to arbitrary recipients. USEFUL LINKS: 📖 Verify identities: https://console.aws.amazon.com/ses/home?region=${backend.stack.region}#/verified-identities 🚀 Request production: https://console.aws.amazon.com/ses/home?region=${backend.stack.region}#/account 📚 Documentation: https://docs.aws.amazon.com/ses/latest/dg/request-production-access.html See docs/amplify.md in your project for more details. ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ `); } backend.addOutput({ custom: { api: apiUrl.url } });