// infra/lib/ledgerflow-stack.js // AWS CDK Stack — deploys all LedgerFlow infrastructure // Run: cd infra && npx cdk deploy const cdk = require("aws-cdk-lib"); const lambda = require("aws-cdk-lib/aws-lambda"); const nodejsFn = require("aws-cdk-lib/aws-lambda-nodejs"); const apigwv2 = require("aws-cdk-lib/aws-apigatewayv2"); const integ = require("aws-cdk-lib/aws-apigatewayv2-integrations"); const auth = require("aws-cdk-lib/aws-apigatewayv2-authorizers"); const dynamo = require("aws-cdk-lib/aws-dynamodb"); const iam = require("aws-cdk-lib/aws-iam"); const s3 = require("aws-cdk-lib/aws-s3"); const s3deploy = require("aws-cdk-lib/aws-s3-deployment"); const cf = require("aws-cdk-lib/aws-cloudfront"); const origins = require("aws-cdk-lib/aws-cloudfront-origins"); const acm = require("aws-cdk-lib/aws-certificatemanager"); const route53 = require("aws-cdk-lib/aws-route53"); const targets = require("aws-cdk-lib/aws-route53-targets"); const logs = require("aws-cdk-lib/aws-logs"); const path = require("path"); class LedgerFlowStack extends cdk.Stack { constructor(scope, id, props) { super(scope, id, props); const env = props?.env?.account ? "prod" : "dev"; // ── DynamoDB Tables ─────────────────────────────────────────────────────── const posTable = new dynamo.Table(this, "POsTable", { tableName: "ledgerflow-pos", partitionKey: { name: "id", type: dynamo.AttributeType.STRING }, billingMode: dynamo.BillingMode.PAY_PER_REQUEST, removalPolicy: cdk.RemovalPolicy.RETAIN, pointInTimeRecovery: true, }); posTable.addGlobalSecondaryIndex({ indexName: "poNumber-index", partitionKey: { name: "poNumber", type: dynamo.AttributeType.STRING }, }); const invoicesTable = new dynamo.Table(this, "InvoicesTable", { tableName: "ledgerflow-invoices", partitionKey: { name: "id", type: dynamo.AttributeType.STRING }, billingMode: dynamo.BillingMode.PAY_PER_REQUEST, removalPolicy: cdk.RemovalPolicy.RETAIN, pointInTimeRecovery: true, }); invoicesTable.addGlobalSecondaryIndex({ indexName: "poId-index", partitionKey: { name: "poId", type: dynamo.AttributeType.STRING }, }); invoicesTable.addGlobalSecondaryIndex({ indexName: "status-index", partitionKey: { name: "status", type: dynamo.AttributeType.STRING }, }); const ediTxTable = new dynamo.Table(this, "EDITxTable", { tableName: "ledgerflow-edi-transactions", partitionKey: { name: "id", type: dynamo.AttributeType.STRING }, billingMode: dynamo.BillingMode.PAY_PER_REQUEST, removalPolicy: cdk.RemovalPolicy.RETAIN, timeToLiveAttribute: "ttl", // auto-expire old TX records after 1 year }); const sessionsTable = new dynamo.Table(this, "SessionsTable", { tableName: "ledgerflow-sessions", partitionKey: { name: "userId", type: dynamo.AttributeType.STRING }, billingMode: dynamo.BillingMode.PAY_PER_REQUEST, removalPolicy: cdk.RemovalPolicy.DESTROY, timeToLiveAttribute: "ttl", }); const settingsTable = new dynamo.Table(this, "SettingsTable", { tableName: "ledgerflow-settings", partitionKey: { name: "userId", type: dynamo.AttributeType.STRING }, billingMode: dynamo.BillingMode.PAY_PER_REQUEST, removalPolicy: cdk.RemovalPolicy.RETAIN, pointInTimeRecovery: true, }); // ── S3 Buckets (EDI file exchange) ──────────────────────────────────────── const ediInputBucket = new s3.Bucket(this, "EDIInputBucket", { bucketName: `ledgerflow-edi-input-${this.account}`, removalPolicy: cdk.RemovalPolicy.RETAIN, versioned: true, lifecycleRules: [{ expiration: cdk.Duration.days(90), id: "expire-old-edi" }], }); const ediOutputBucket = new s3.Bucket(this, "EDIOutputBucket", { bucketName: `ledgerflow-edi-output-${this.account}`, removalPolicy: cdk.RemovalPolicy.RETAIN, versioned: true, lifecycleRules: [{ expiration: cdk.Duration.days(365), id: "expire-old-output" }], }); // ── Shared Lambda Environment ───────────────────────────────────────────── const commonEnv = { NODE_ENV: env, AWS_NODEJS_CONNECTION_REUSE_ENABLED: "1", POS_TABLE: posTable.tableName, INVOICES_TABLE: invoicesTable.tableName, EDI_TX_TABLE: ediTxTable.tableName, SESSIONS_TABLE: sessionsTable.tableName, SETTINGS_TABLE: settingsTable.tableName, EDI_INPUT_BUCKET: ediInputBucket.bucketName, EDI_OUTPUT_BUCKET: ediOutputBucket.bucketName, // Set these via SSM Parameter Store or Secrets Manager in production: // GOOGLE_CLIENT_ID, EDI_PARTNERSHIP_ID, EDI_TRANSFORMER_ID, // EDI_SENDER_ID, EDI_RECEIVER_ID, ALLOWED_DOMAINS, ALLOWED_ORIGIN GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID || "", ALLOWED_ORIGIN: process.env.ALLOWED_ORIGIN || "*", ALLOWED_DOMAINS: process.env.ALLOWED_DOMAINS || "", EDI_PARTNERSHIP_ID: process.env.EDI_PARTNERSHIP_ID || "", EDI_TRANSFORMER_ID: process.env.EDI_TRANSFORMER_ID || "", EDI_SENDER_ID: process.env.EDI_SENDER_ID || "", EDI_RECEIVER_ID: process.env.EDI_RECEIVER_ID || "", }; const lambdaDefaults = { runtime: lambda.Runtime.NODEJS_20_X, architecture: lambda.Architecture.ARM_64, // Graviton2 — faster + cheaper memorySize: 512, timeout: cdk.Duration.seconds(30), logRetention: logs.RetentionDays.THIRTY_DAYS, bundling: { minify: false, sourceMap: true }, }; // ── Lambda Functions (NodejsFunction — esbuild bundles deps) ───────────── const authorizerFn = new nodejsFn.NodejsFunction(this, "AuthorizerFn", { ...lambdaDefaults, functionName: "ledgerflow-authorizer", entry: path.join(__dirname, "../../lambdas/authorizer/index.js"), handler: "handler", environment: { ...commonEnv }, description: "Google JWT Lambda Authorizer", }); sessionsTable.grantWriteData(authorizerFn); const authFn = new nodejsFn.NodejsFunction(this, "AuthFn", { ...lambdaDefaults, functionName: "ledgerflow-auth", entry: path.join(__dirname, "../../lambdas/auth/index.js"), handler: "handler", environment: { ...commonEnv }, description: "Auth endpoints (/auth/me, /auth/config, /auth/logout)", }); sessionsTable.grantReadWriteData(authFn); const posFn = new nodejsFn.NodejsFunction(this, "POsFn", { ...lambdaDefaults, functionName: "ledgerflow-pos", entry: path.join(__dirname, "../../lambdas/pos/index.js"), handler: "handler", environment: { ...commonEnv }, description: "Purchase Orders CRUD + DynamoDB import", }); posTable.grantReadWriteData(posFn); settingsTable.grantReadData(posFn); // External purchase-orders table (Coupa POs) — grant read to POs and EDI lambdas const purchaseOrdersTable = dynamo.Table.fromTableName(this, "ExternalPOTable", "purchase-orders"); purchaseOrdersTable.grantReadData(posFn); const invoicesFn = new nodejsFn.NodejsFunction(this, "InvoicesFn", { ...lambdaDefaults, functionName: "ledgerflow-invoices", entry: path.join(__dirname, "../../lambdas/invoices/index.js"), handler: "handler", environment: { ...commonEnv }, description: "Invoices CRUD", }); invoicesTable.grantReadWriteData(invoicesFn); posTable.grantReadWriteData(invoicesFn); // needs to update PO billed amount const ediFn = new nodejsFn.NodejsFunction(this, "EDIFn", { ...lambdaDefaults, functionName: "ledgerflow-edi", entry: path.join(__dirname, "../../lambdas/edi/index.js"), handler: "handler", environment: { ...commonEnv }, description: "AWS B2B EDI submission + transaction history", timeout: cdk.Duration.seconds(60), // EDI calls can take a few seconds }); ediTxTable.grantReadWriteData(ediFn); invoicesTable.grantReadWriteData(ediFn); settingsTable.grantReadData(ediFn); purchaseOrdersTable.grantReadData(ediFn); ediInputBucket.grantReadWrite(ediFn); ediOutputBucket.grantReadWrite(ediFn); const settingsFn = new nodejsFn.NodejsFunction(this, "SettingsFn", { ...lambdaDefaults, functionName: "ledgerflow-settings", entry: path.join(__dirname, "../../lambdas/settings/index.js"), handler: "handler", environment: { ...commonEnv }, description: "User settings (config, invCounter)", }); settingsTable.grantReadWriteData(settingsFn); // Allow EDI Lambda to call AWS B2B Data Interchange ediFn.addToRolePolicy(new iam.PolicyStatement({ actions: [ "b2bi:StartTransformerJob", "b2bi:GetTransformerJob", "b2bi:ListTransformerJobs", "b2bi:CreateTransformer", "b2bi:GetTransformer", ], resources: ["*"], })); // ── API Gateway HTTP API ────────────────────────────────────────────────── const httpApi = new apigwv2.HttpApi(this, "LedgerFlowAPI", { apiName: "ledgerflow-api", description: "LedgerFlow B2B Accounting API", corsPreflight: { allowOrigins: [ process.env.ALLOWED_ORIGIN || "*", "http://localhost:3000", ], allowMethods: [apigwv2.CorsHttpMethod.ANY], allowHeaders: ["Content-Type", "Authorization"], maxAge: cdk.Duration.days(1), }, }); // Lambda Authorizer (JWT) const jwtAuthorizer = new auth.HttpLambdaAuthorizer("GoogleJWTAuthorizer", authorizerFn, { authorizerName: "google-jwt", responseTypes: [auth.HttpLambdaResponseType.SIMPLE], identitySource: ["$request.header.Authorization"], resultsCacheTtl: cdk.Duration.minutes(5), }); // ── Route Definitions ───────────────────────────────────────────────────── const apiMethods = [ apigwv2.HttpMethod.GET, apigwv2.HttpMethod.POST, apigwv2.HttpMethod.PUT, apigwv2.HttpMethod.PATCH, apigwv2.HttpMethod.DELETE, ]; const addRoutes = (routePath, fn, useAuth = true) => { const integration = new integ.HttpLambdaIntegration(`${fn.node.id}-integ`, fn); const opts = useAuth ? { authorizer: jwtAuthorizer } : {}; httpApi.addRoutes({ path: routePath, methods: apiMethods, integration, ...opts }); httpApi.addRoutes({ path: `${routePath}/{proxy+}`, methods: apiMethods, integration, ...opts }); }; // Public routes (no auth) addRoutes("/auth/config", authFn, false); addRoutes("/auth/me", authFn, false); // Google token is self-validating addRoutes("/auth/logout", authFn, false); // Protected routes addRoutes("/pos", posFn); addRoutes("/invoices", invoicesFn); addRoutes("/edi", ediFn); addRoutes("/settings", settingsFn); // ── Frontend (S3 + CloudFront + Route 53 + ACM) ───────────────────────── const domainName = "ledgerflow.seahaven.com"; const hostedZone = route53.HostedZone.fromHostedZoneAttributes(this, "SeahavenZone", { hostedZoneId: "Z06652411XKH89KTZD3XA", zoneName: "seahaven.com", }); const certificate = acm.Certificate.fromCertificateArn(this, "FrontendCert", "arn:aws:acm:us-east-1:328440206208:certificate/a66c0994-90d4-410a-a1d9-5595c2a3fae3", ); const siteBucket = new s3.Bucket(this, "FrontendBucket", { bucketName: `ledgerflow-frontend-${this.account}`, removalPolicy: cdk.RemovalPolicy.DESTROY, autoDeleteObjects: true, blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, }); const distribution = new cf.Distribution(this, "FrontendCDN", { defaultBehavior: { origin: origins.S3BucketOrigin.withOriginAccessControl(siteBucket), viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, cachePolicy: cf.CachePolicy.CACHING_OPTIMIZED, }, domainNames: [domainName], certificate, defaultRootObject: "login.html", errorResponses: [ { httpStatus: 403, responseHttpStatus: 200, responsePagePath: "/login.html" }, { httpStatus: 404, responseHttpStatus: 200, responsePagePath: "/login.html" }, ], }); // Deploy frontend files to S3 and invalidate CloudFront new s3deploy.BucketDeployment(this, "DeployFrontend", { sources: [s3deploy.Source.asset(path.join(__dirname, "../../../ledgerflow_frontend"))], destinationBucket: siteBucket, distribution, distributionPaths: ["/*"], }); // DNS records new route53.ARecord(this, "FrontendARecord", { zone: hostedZone, recordName: "ledgerflow", target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution)), }); new route53.AaaaRecord(this, "FrontendAAAARecord", { zone: hostedZone, recordName: "ledgerflow", target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution)), }); // ── Outputs ─────────────────────────────────────────────────────────────── new cdk.CfnOutput(this, "ApiUrl", { value: httpApi.apiEndpoint, description: "API Gateway endpoint URL — set as VITE_API_URL in the frontend", exportName: "LedgerFlowApiUrl", }); new cdk.CfnOutput(this, "FrontendUrl", { value: `https://${domainName}`, description: "Frontend URL", }); new cdk.CfnOutput(this, "DistributionId", { value: distribution.distributionId, description: "CloudFront distribution ID", }); new cdk.CfnOutput(this, "FrontendBucketName", { value: siteBucket.bucketName }); new cdk.CfnOutput(this, "EDIInputBucketName", { value: ediInputBucket.bucketName }); new cdk.CfnOutput(this, "EDIOutputBucketName", { value: ediOutputBucket.bucketName }); } } module.exports = { LedgerFlowStack };