349 lines
15 KiB
JavaScript
349 lines
15 KiB
JavaScript
|
|
// 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 };
|