@venturekit/infra 0.0.0-dev.20260515022321 → 0.0.0-dev.20260515033134

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cdk/stack.js DELETED
@@ -1,3084 +0,0 @@
1
- /**
2
- * VentureStack — CDK Stack for VentureKit applications
3
- *
4
- * Translates VentureKit infrastructure intents into CloudFormation resources.
5
- * This is the single entry point for `vk deploy`.
6
- *
7
- * Consumers never see this file — it's invoked by the generated CDK app in .vk/cdk/.
8
- */
9
- import * as cdk from 'aws-cdk-lib';
10
- import * as ec2 from 'aws-cdk-lib/aws-ec2';
11
- import * as rds from 'aws-cdk-lib/aws-rds';
12
- import * as s3 from 'aws-cdk-lib/aws-s3';
13
- import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
14
- import * as cloudfrontOrigins from 'aws-cdk-lib/aws-cloudfront-origins';
15
- import * as sqs from 'aws-cdk-lib/aws-sqs';
16
- import * as cognito from 'aws-cdk-lib/aws-cognito';
17
- import * as elasticache from 'aws-cdk-lib/aws-elasticache';
18
- import * as events from 'aws-cdk-lib/aws-events';
19
- import * as targets from 'aws-cdk-lib/aws-events-targets';
20
- import * as lambda from 'aws-cdk-lib/aws-lambda';
21
- import * as lambdaEventSources from 'aws-cdk-lib/aws-lambda-event-sources';
22
- import * as apigatewayv2 from 'aws-cdk-lib/aws-apigatewayv2';
23
- import * as apigatewayv2Integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations';
24
- import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
25
- import * as logs from 'aws-cdk-lib/aws-logs';
26
- import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch';
27
- import * as cloudwatchActions from 'aws-cdk-lib/aws-cloudwatch-actions';
28
- import * as sns from 'aws-cdk-lib/aws-sns';
29
- import * as snsSubscriptions from 'aws-cdk-lib/aws-sns-subscriptions';
30
- import * as ses from 'aws-cdk-lib/aws-ses';
31
- import * as ssm from 'aws-cdk-lib/aws-ssm';
32
- import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
33
- import * as acm from 'aws-cdk-lib/aws-certificatemanager';
34
- import * as route53 from 'aws-cdk-lib/aws-route53';
35
- import * as route53Targets from 'aws-cdk-lib/aws-route53-targets';
36
- import * as iam from 'aws-cdk-lib/aws-iam';
37
- import { MigrationRunner, hasMigrations } from './migration-runner.js';
38
- import { resolveEnvConfig, DATA_SAFETY_CONFIG } from '@venturekit/core';
39
- import * as path from 'path';
40
- import * as fs from 'fs';
41
- import * as os from 'os';
42
- import { createRequire } from 'module';
43
- /**
44
- * Filename → HTTP method mapping. Aliases (`list`, `create`, `update`,
45
- * `remove`) match the local dev server (`@venturekit/cli` →
46
- * `local-server.ts#fileNameToMethod`) so route discovery is consistent
47
- * between `vk dev` and `vk deploy`.
48
- */
49
- const ROUTE_FILENAME_TO_METHOD = {
50
- get: 'GET',
51
- list: 'GET',
52
- post: 'POST',
53
- create: 'POST',
54
- put: 'PUT',
55
- update: 'PUT',
56
- patch: 'PATCH',
57
- delete: 'DELETE',
58
- remove: 'DELETE',
59
- };
60
- /**
61
- * Recursively discover HTTP route files under `src/routes/`.
62
- *
63
- * Walks the directory tree applying the same filesystem convention as the
64
- * local dev server:
65
- * - File name (without extension) determines the HTTP method via
66
- * {@link ROUTE_FILENAME_TO_METHOD}. Files whose name doesn't match a
67
- * known method (e.g. `helpers.ts`, `_middleware.ts`) are silently
68
- * skipped — they're treated as colocated implementation detail.
69
- * - Directory `[param]` becomes `{param}` in the API path and `param`
70
- * in the slug. Plain directories appear verbatim in both.
71
- * - Test/spec/declaration files (`*.test.ts`, `*.spec.ts`, `*.d.ts`)
72
- * are skipped.
73
- *
74
- * Exported so unit tests can assert the convention without spinning up a
75
- * full CDK stack, and so external tooling can reuse it.
76
- */
77
- export function discoverRouteFiles(routesDir) {
78
- const routes = [];
79
- if (!fs.existsSync(routesDir))
80
- return routes;
81
- function scan(dir, apiPath, slug) {
82
- const entries = fs.readdirSync(dir, { withFileTypes: true });
83
- for (const entry of entries) {
84
- const fullPath = path.join(dir, entry.name);
85
- if (entry.isDirectory()) {
86
- const isParam = entry.name.startsWith('[') && entry.name.endsWith(']');
87
- const paramName = isParam
88
- ? entry.name.slice(1, -1)
89
- : entry.name;
90
- const apiSegment = isParam ? `{${paramName}}` : entry.name;
91
- scan(fullPath, `${apiPath}/${apiSegment}`, slug ? `${slug}-${paramName}` : paramName);
92
- continue;
93
- }
94
- if (!entry.isFile())
95
- continue;
96
- if (!/\.(ts|js)$/.test(entry.name))
97
- continue;
98
- if (entry.name.endsWith('.d.ts'))
99
- continue;
100
- const baseName = entry.name.replace(/\.(ts|js)$/, '');
101
- // Skip colocated tests/specs to keep the deploy flow honest.
102
- if (baseName.endsWith('.test') || baseName.endsWith('.spec'))
103
- continue;
104
- const method = ROUTE_FILENAME_TO_METHOD[baseName.toLowerCase()];
105
- if (!method)
106
- continue;
107
- routes.push({
108
- method,
109
- apiPath: apiPath || '/',
110
- slug: slug ? `${slug}-${baseName.toLowerCase()}` : baseName.toLowerCase(),
111
- handlerFile: fullPath,
112
- });
113
- }
114
- }
115
- scan(routesDir, '', '');
116
- return routes;
117
- }
118
- /**
119
- * djb2 string hash. Stable across Node versions and platforms because
120
- * it operates on UTF-16 code units returned by `charCodeAt`. Used for
121
- * route → nested-stack slot assignment, where the only requirement
122
- * is a deterministic, well-distributed mapping. Do not change the
123
- * algorithm: every existing deployed VentureKit stack has its
124
- * route-Lambda slots derived from this exact hash, and altering it
125
- * would force a one-shot mass migration of every nested-stack route.
126
- */
127
- function djb2Hash(input) {
128
- let h = 5381;
129
- for (let i = 0; i < input.length; i++) {
130
- h = ((h << 5) + h + input.charCodeAt(i)) | 0;
131
- }
132
- return h >>> 0;
133
- }
134
- /**
135
- * Map a route slug to a nested-stack slot index in `[0, numSlots)`.
136
- * `numSlots` MUST be a power of two so the bitwise-AND modulo
137
- * distributes the hash output evenly. Exported for unit tests
138
- * asserting bucketing stability across route-set changes.
139
- */
140
- export function stableSlotFor(slug, numSlots) {
141
- return djb2Hash(slug) & (numSlots - 1);
142
- }
143
- /**
144
- * Lazily-loaded esbuild module. We avoid a top-level import so unit
145
- * tests that set `_skipBundling: true` (which short-circuits before any
146
- * bundling happens) don't pay the import cost or require esbuild to be
147
- * installed in their sandbox. `createRequire` works around the fact
148
- * that this file is ESM and esbuild's package exports are CJS-shaped.
149
- */
150
- let esbuildModule;
151
- function loadEsbuild() {
152
- if (!esbuildModule) {
153
- const requireFromHere = createRequire(import.meta.url);
154
- esbuildModule = requireFromHere('esbuild');
155
- }
156
- return esbuildModule;
157
- }
158
- /**
159
- * Bundle a Lambda handler in-process via esbuild's synchronous API.
160
- *
161
- * Replaces an earlier Docker-based bundling pipeline that spun up a
162
- * SAM Node 20 image, ran `npm install -g esbuild`, and bundled the
163
- * handler — taking ~30 s per Lambda. With 100+ handlers in a project
164
- * that meant over an hour of synth time and a long tail of Docker-
165
- * related failure modes (UID/HOME/permission edge cases on GitHub-
166
- * hosted runners). In-process bundling collapses the entire sequence
167
- * to a single `esbuild.buildSync` call: sub-second per handler,
168
- * native Node module resolution against the consumer's `node_modules`,
169
- * no Docker daemon dependency.
170
- *
171
- * The asset is the temp directory holding the bundled `index.js`, so
172
- * CDK uploads only the bundled output to S3 — never the source tree.
173
- *
174
- * Modules baked into the Lambda runtime (`@aws-sdk/*`) are kept
175
- * external so they aren't redundantly included in the bundle.
176
- */
177
- function bundleHandlerCodeWithEsbuild(handlerFile, projectDir) {
178
- const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'venturekit-bundle-'));
179
- const outfile = path.join(outDir, 'index.js');
180
- loadEsbuild().buildSync({
181
- entryPoints: [handlerFile],
182
- bundle: true,
183
- platform: 'node',
184
- target: 'node20',
185
- outfile,
186
- external: ['@aws-sdk/*'],
187
- absWorkingDir: projectDir,
188
- logLevel: 'error',
189
- });
190
- return lambda.Code.fromAsset(outDir);
191
- }
192
- /**
193
- * Inline Lambda stub used in unit tests when `_skipBundling: true`.
194
- * Satisfies API Gateway's contract; tests assert on resource shape,
195
- * not on Lambda execution.
196
- */
197
- function inlineStubLambdaCode() {
198
- return lambda.Code.fromInline("exports.main = async () => ({ statusCode: 200, body: '{\"stub\":true}' });");
199
- }
200
- /**
201
- * Holds a chunk of HTTP route Lambdas in a separate CloudFormation
202
- * template so the parent VentureStack stays under CloudFormation's hard
203
- * 500-resource per-stack limit.
204
- *
205
- * Each route translates to ~5 CFN resources (Function, Permission,
206
- * Integration, Route, log-retention custom resource), so projects with
207
- * more than ~80 routes need this sharding to be deployable at all.
208
- *
209
- * The route Lambdas reference the parent stack's API Gateway, IAM role,
210
- * security group, and VPC via standard CDK cross-stack references —
211
- * CloudFormation wires the Outputs/Parameters automatically. The
212
- * helpers (`toLambdaRuntime`, `toLogRetention`, `shortHash`) are
213
- * replicated here intentionally rather than extracted to a shared
214
- * module: they are short, change rarely, and keeping the diff localized
215
- * makes the fix easier to audit and revert.
216
- */
217
- class RoutesNestedStack extends cdk.NestedStack {
218
- constructor(scope, id, props) {
219
- super(scope, id, props);
220
- for (const route of props.routes) {
221
- this.createRouteFunction(route, props);
222
- }
223
- }
224
- createRouteFunction(route, props) {
225
- const { api, role, securityGroup, vpc, envConfig, ventureBaseEnv } = props;
226
- const lambdaConfig = envConfig.lambda;
227
- const resourceId = `route-${route.slug}`;
228
- // Deliberately omit `functionName` for routes living in a nested
229
- // stack. The slot a route lands in is derived from a hash of its
230
- // slug (see the bucketing logic in `VentureStack`), but the slot
231
- // count itself grows monotonically as the project's route count
232
- // grows. Each time the slot count changes — and any time the hash
233
- // distribution moves a route across an existing slot boundary —
234
- // CloudFormation has to migrate the route's Lambda from one
235
- // nested stack to another. With an explicit physical name CFN
236
- // refuses to create the new copy until the old one is deleted, but
237
- // CFN updates nested stacks in parallel, so we'd deadlock with a
238
- // `route-foo already exists in stack ...` error. Letting CDK
239
- // auto-generate the function name avoids the collision: the new
240
- // Lambda gets a unique name, comes up cleanly, and the old one is
241
- // deleted as part of the same change set.
242
- //
243
- // Operators discover route Lambdas via the `Description` field
244
- // below (`<METHOD> <api-path>`) and CloudWatch log groups still
245
- // resolve via the function ARN attached to API Gateway, so the
246
- // loss of the predictable name is mostly cosmetic.
247
- const fn = new lambda.Function(this, resourceId, {
248
- runtime: this.toLambdaRuntime(envConfig),
249
- architecture: this.toLambdaArchitecture(envConfig),
250
- handler: 'index.main',
251
- code: props.skipBundling
252
- ? inlineStubLambdaCode()
253
- : bundleHandlerCodeWithEsbuild(route.handlerFile, props.projectDir),
254
- memorySize: lambdaConfig.memoryMb,
255
- timeout: cdk.Duration.seconds(lambdaConfig.timeoutSec),
256
- description: `${route.method} ${route.apiPath}`,
257
- environment: {
258
- ...ventureBaseEnv,
259
- ...lambdaConfig.environmentVariables,
260
- },
261
- logRetention: this.toLogRetention(lambdaConfig.logRetentionDays),
262
- tracing: lambdaConfig.tracingEnabled
263
- ? lambda.Tracing.ACTIVE
264
- : lambda.Tracing.DISABLED,
265
- reservedConcurrentExecutions: lambdaConfig.reservedConcurrency,
266
- role,
267
- ...(vpc && securityGroup
268
- ? {
269
- vpc,
270
- vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
271
- securityGroups: [securityGroup],
272
- }
273
- : {}),
274
- });
275
- new apigatewayv2.HttpRoute(this, `${resourceId}-route`, {
276
- httpApi: api,
277
- routeKey: apigatewayv2.HttpRouteKey.with(route.apiPath, apigatewayv2.HttpMethod[route.method]),
278
- integration: new apigatewayv2Integrations.HttpLambdaIntegration(`${resourceId}-integration`, fn),
279
- });
280
- }
281
- toLambdaRuntime(envConfig) {
282
- switch (envConfig.lambda.runtime) {
283
- case 'nodejs22.x':
284
- return lambda.Runtime.NODEJS_22_X;
285
- case 'nodejs18.x':
286
- return lambda.Runtime.NODEJS_18_X;
287
- default:
288
- return lambda.Runtime.NODEJS_20_X;
289
- }
290
- }
291
- toLambdaArchitecture(envConfig) {
292
- return envConfig.lambda.architecture === 'x86_64'
293
- ? lambda.Architecture.X86_64
294
- : lambda.Architecture.ARM_64;
295
- }
296
- toLogRetention(days) {
297
- if (days <= 1)
298
- return logs.RetentionDays.ONE_DAY;
299
- if (days <= 3)
300
- return logs.RetentionDays.THREE_DAYS;
301
- if (days <= 5)
302
- return logs.RetentionDays.FIVE_DAYS;
303
- if (days <= 7)
304
- return logs.RetentionDays.ONE_WEEK;
305
- if (days <= 14)
306
- return logs.RetentionDays.TWO_WEEKS;
307
- if (days <= 30)
308
- return logs.RetentionDays.ONE_MONTH;
309
- if (days <= 60)
310
- return logs.RetentionDays.TWO_MONTHS;
311
- if (days <= 90)
312
- return logs.RetentionDays.THREE_MONTHS;
313
- if (days <= 120)
314
- return logs.RetentionDays.FOUR_MONTHS;
315
- if (days <= 150)
316
- return logs.RetentionDays.FIVE_MONTHS;
317
- if (days <= 180)
318
- return logs.RetentionDays.SIX_MONTHS;
319
- if (days <= 365)
320
- return logs.RetentionDays.ONE_YEAR;
321
- return logs.RetentionDays.INFINITE;
322
- }
323
- shortHash(input) {
324
- let h = 5381;
325
- for (let i = 0; i < input.length; i++) {
326
- h = ((h << 5) + h + input.charCodeAt(i)) | 0;
327
- }
328
- return (h >>> 0).toString(16).padStart(6, '0').slice(0, 6);
329
- }
330
- }
331
- /**
332
- * Discover migration directories shipped by installed `@venturekit/*`
333
- * packages.
334
- *
335
- * Convention: a package opts in by declaring a
336
- * `"vk": { "migrations": "<relative-dir>" }` field in its own
337
- * `package.json` (e.g. `@venturekit/notify`, `@venturekit/auth`).
338
- *
339
- * Resolution walks the project's `package.json` dependencies, then
340
- * uses `createRequire(projectDir/package.json)` to locate each
341
- * `@venturekit/*` package's own `package.json`. This works under pnpm
342
- * (hoisted), npm (flat), and yarn pnp.
343
- *
344
- * Returns absolute paths in declaration order. Missing packages,
345
- * missing `vk.migrations` field, and non-existent dirs are silently
346
- * skipped — we only add a dir that actually exists on disk so a
347
- * stripped publish (e.g. without the `migrations/` artifact) doesn't
348
- * break `vk deploy`. A warning is logged for the "opted-in but missing
349
- * on disk" case so the operator notices.
350
- *
351
- * MUST stay in lockstep with `@venturekit/cli`'s
352
- * `discoverPackageMigrationDirs` in `commands/migrate.ts` so a local
353
- * `vk migrate` and a `vk deploy` apply the exact same merged schema.
354
- */
355
- /**
356
- * Locate `<name>/package.json` for a venturekit package installed in
357
- * `projectDir`'s dependency tree.
358
- *
359
- * We can't use `require.resolve(`${name}/package.json`)` because most
360
- * venturekit packages ship a strict `"exports"` map that doesn't include
361
- * `"./package.json"`, which makes Node's resolver throw
362
- * `ERR_PACKAGE_PATH_NOT_EXPORTED`. Instead we walk `node_modules`
363
- * directories upward from `projectDir` and check for a real file on
364
- * disk — works under pnpm (hoisted + symlinked), npm flat, and yarn
365
- * classic.
366
- *
367
- * MUST stay in lockstep with the same helper in `@venturekit/cli`'s
368
- * `commands/migrate.ts` so a `vk deploy` and a local `vk migrate`
369
- * discover the same set of package migrations.
370
- */
371
- function resolvePackageJsonPath(projectDir, name) {
372
- let dir = projectDir;
373
- // eslint-disable-next-line no-constant-condition
374
- while (true) {
375
- const candidate = path.join(dir, 'node_modules', name, 'package.json');
376
- if (fs.existsSync(candidate))
377
- return candidate;
378
- const parent = path.dirname(dir);
379
- if (parent === dir)
380
- return null;
381
- dir = parent;
382
- }
383
- }
384
- function discoverPackageMigrationDirs(projectDir) {
385
- const dirs = [];
386
- const projectPkgPath = path.join(projectDir, 'package.json');
387
- let projectPkg;
388
- try {
389
- projectPkg = JSON.parse(fs.readFileSync(projectPkgPath, 'utf8'));
390
- }
391
- catch {
392
- return dirs;
393
- }
394
- const candidates = new Set([
395
- ...Object.keys(projectPkg.dependencies ?? {}),
396
- ...Object.keys(projectPkg.devDependencies ?? {}),
397
- ]);
398
- for (const name of candidates) {
399
- if (!name.startsWith('@venturekit/'))
400
- continue;
401
- const pkgJsonPath = resolvePackageJsonPath(projectDir, name);
402
- if (!pkgJsonPath)
403
- continue;
404
- let pkgJson;
405
- try {
406
- pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
407
- }
408
- catch {
409
- continue;
410
- }
411
- const rel = pkgJson.vk?.migrations;
412
- if (typeof rel !== 'string' || rel.length === 0)
413
- continue;
414
- const abs = path.join(path.dirname(pkgJsonPath), rel);
415
- if (!fs.existsSync(abs)) {
416
- console.warn(`[venturekit] ${name} declares vk.migrations='${rel}' but ${abs} does not exist — skipping.`);
417
- continue;
418
- }
419
- dirs.push(abs);
420
- }
421
- return dirs;
422
- }
423
- export class VentureStack extends cdk.Stack {
424
- outputs = {};
425
- /** Resolved environment configuration driving all resource sizing */
426
- envConfig;
427
- /** Data safety configuration for removal policies */
428
- dataSafetyConfig;
429
- /** Whether this is a free-tier deployment */
430
- isFreeTier;
431
- /** Standard tags applied to all taggable resources */
432
- standardTags;
433
- /**
434
- * Per-project HMAC secret used to sign internal Lambda-to-Lambda calls.
435
- * Auto-generated on first deploy and preserved across subsequent deploys by
436
- * CloudFormation. Injected as `VENTURE_INTERNAL_HMAC_SECRET` into every
437
- * Lambda function the stack creates, so `invoke()` callers and handler
438
- * receivers share the same key.
439
- */
440
- internalHmacSecret;
441
- /** Base environment variables applied to every VentureKit Lambda. */
442
- ventureBaseEnv;
443
- /** @internal — when true, all Lambda code uses an inline stub instead of Docker bundling. Tests only. */
444
- skipBundling;
445
- /**
446
- * Absolute path to the consumer project's root (the directory that
447
- * holds `package.json` + `node_modules`). Used as the bundling asset
448
- * root so esbuild can resolve handler imports against the project's
449
- * installed dependencies.
450
- */
451
- projectDir;
452
- /**
453
- * Cognito User Pools created by `createAuth`, kept around so the
454
- * shared Lambda execution role can be granted admin actions on them
455
- * once the role exists (the role is created later in the constructor,
456
- * after every infrastructure intent has been processed).
457
- */
458
- userPools = [];
459
- /**
460
- * Secrets Manager placeholders provisioned by `createAuth` for
461
- * `AuthIntent.federated` providers (Google / Facebook / Apple OAuth
462
- * client id + secret). Tracked so the shared Lambda role can be
463
- * granted `secretsmanager:GetSecretValue` on each one once the role
464
- * exists. The ARNs are also injected as
465
- * `COGNITO_FEDERATED_<PROVIDER>_SECRET_ARN` env vars on every Lambda.
466
- */
467
- federatedAuthSecrets = [];
468
- /**
469
- * S3 buckets created by `createStorage`, kept around so the shared
470
- * Lambda execution role can be granted `s3:Get*` / `s3:Put*` on them
471
- * once the role exists, and so the *first* bucket's name can be
472
- * injected as `VENTURE_STORAGE_BUCKET` for `createStorageClientFromEnv()`.
473
- * Without this wiring, every Lambda calling `@venturekit/storage` would
474
- * throw `Storage bucket not configured` at runtime even though the
475
- * bucket exists in CloudFormation.
476
- */
477
- storageBuckets = [];
478
- /**
479
- * Notify configurations created by `createNotify`. Tracked so the
480
- * shared Lambda role can be granted `ses:SendEmail` on the
481
- * configuration set + identity ARNs once the role exists, and so
482
- * env vars (`VENTURE_NOTIFY_FROM`, `VENTURE_NOTIFY_CONFIG_SET`, …)
483
- * land on every Lambda — `@venturekit/notify`'s
484
- * `createSesEmailProvider` reads them at cold start. Also drives
485
- * the auto-provisioning of dispatcher + bounce-handler Lambdas
486
- * after the rest of the stack is wired (we need the shared role
487
- * + VPC + SG to exist first).
488
- */
489
- notifyConfigs = [];
490
- /**
491
- * Absolute paths to `migrations/` directories shipped by installed
492
- * `@venturekit/*` packages (e.g. `@venturekit/notify`,
493
- * `@venturekit/auth`). Resolved once at the top of the constructor
494
- * via declarative `vk.migrations` discovery so `createDatabase` can
495
- * pass the merged list to `MigrationRunner` without re-walking
496
- * `node_modules` per database.
497
- *
498
- * Discovery mirrors the CLI side
499
- * (`@venturekit/cli`'s `discoverPackageMigrationDirs`) so a `vk deploy`
500
- * and a local `vk migrate` always materialize the same merged schema.
501
- * Empty when no installed venturekit package opts in via
502
- * `"vk": { "migrations": "<dir>" }` in its `package.json`.
503
- */
504
- packageMigrationsDirs = [];
505
- constructor(scope, id, props) {
506
- super(scope, id, props);
507
- const { projectName, stage, routesDir, functionsDir, queuesDir, cronsDir, infrastructure, projectDir, preset } = props;
508
- this.skipBundling = props._skipBundling === true;
509
- this.projectDir = projectDir;
510
- // --- Resolve environment configuration ---
511
- // Priority: explicit envConfig > resolve from preset > fallback to 'micro'.
512
- // We pass the literal `stage` to resolveEnvConfig — any stage name
513
- // the project declared (including custom ones like `preview`) is
514
- // valid. Unknown stages get conservative defaults (dataSafety
515
- // 'standard') inside resolveEnvConfig, not here.
516
- this.envConfig = props.envConfig ?? resolveEnvConfig(stage, { preset: preset ?? 'micro' });
517
- this.isFreeTier = preset === 'free' || this.envConfig.dataSafety === 'relaxed' && preset === 'free';
518
- this.dataSafetyConfig = DATA_SAFETY_CONFIG[this.envConfig.dataSafety];
519
- // --- Well-Architected: Operational Excellence — consistent tagging ---
520
- this.standardTags = {
521
- Project: projectName,
522
- Stage: stage,
523
- ManagedBy: 'venturekit',
524
- Preset: preset ?? 'micro',
525
- };
526
- Object.entries(this.standardTags).forEach(([key, value]) => {
527
- cdk.Tags.of(this).add(key, value);
528
- });
529
- // --- Well-Architected: Security — per-project HMAC secret for internal calls ---
530
- // CloudFormation auto-generates a 64-char alphanumeric value on first deploy
531
- // and preserves it across stack updates, so warm Lambdas don't get kicked off
532
- // by a secret rotation every deploy. The secret's VALUE is injected as an
533
- // env var on every Lambda — not just a reference — so no runtime IAM call.
534
- this.internalHmacSecret = new secretsmanager.Secret(this, 'internalHmacSecret', {
535
- secretName: `venturekit/${projectName}/${stage}/internal-hmac`,
536
- description: 'VentureKit internal Lambda-to-Lambda HMAC signing key. Auto-generated.',
537
- generateSecretString: {
538
- passwordLength: 64,
539
- excludePunctuation: true,
540
- excludeCharacters: '"\'\\/',
541
- requireEachIncludedType: false,
542
- includeSpace: false,
543
- },
544
- removalPolicy: this.toRemovalPolicy(),
545
- });
546
- // Centralized base env vars applied to every Lambda in the stack.
547
- this.ventureBaseEnv = {
548
- VENTURE_PROJECT_NAME: projectName,
549
- VENTURE_STAGE: stage,
550
- VENTURE_INTERNAL_HMAC_SECRET: this.internalHmacSecret.secretValue.unsafeUnwrap(),
551
- NODE_OPTIONS: '--enable-source-maps',
552
- };
553
- const lambdaConfig = this.envConfig.lambda;
554
- const vpcConfig = this.envConfig.vpc;
555
- const apiConfig = this.envConfig.api;
556
- const obsConfig = this.envConfig.observability;
557
- // --- Shared VPC ---
558
- // Well-Architected: Security — network isolation for databases and caches
559
- // Free tier: skip VPC entirely (NAT gateways are not free)
560
- // Stable logical ID 'Vpc' ensures preset changes don't replace the VPC
561
- let vpc;
562
- const needsVpc = !this.isFreeTier && (lambdaConfig.vpcEnabled ||
563
- (infrastructure?.databases && infrastructure.databases.length > 0) ||
564
- (infrastructure?.caches && infrastructure.caches.length > 0));
565
- if (needsVpc) {
566
- const subnetConfig = [
567
- { name: 'public', subnetType: ec2.SubnetType.PUBLIC, cidrMask: vpcConfig.publicSubnets.cidrMask },
568
- { name: 'private', subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, cidrMask: vpcConfig.privateSubnets.cidrMask },
569
- ];
570
- if (vpcConfig.isolatedSubnets) {
571
- subnetConfig.push({
572
- name: 'isolated',
573
- subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
574
- cidrMask: vpcConfig.isolatedSubnets.cidrMask,
575
- });
576
- }
577
- // Well-Architected: Cost — pick the NAT provider per preset. Dev
578
- // and small-prod presets (nano/micro/medium) use `fck-nat` EC2
579
- // instances (~$3-25/mo per AZ) instead of managed NAT Gateways
580
- // (~$33/mo per AZ + $0.045/GB). Large defaults to NAT GW for SLA
581
- // and zero-ops reasons. `none` skips NAT entirely (free preset).
582
- //
583
- // fck-nat is the community-maintained AL2023 NAT AMI, owned by
584
- // AWS account `568608671756`. The AMI is published in every
585
- // commercial region (verified against the fck-nat packer
586
- // manifest). We pin to ARM64 — 20% cheaper and matches the
587
- // `t4g.*` default for `natInstanceType`.
588
- const natGateways = vpcConfig.natType === 'none' ? 0 : vpcConfig.natGateways;
589
- let natGatewayProvider;
590
- if (vpcConfig.natType === 'instance' && natGateways > 0) {
591
- natGatewayProvider = ec2.NatProvider.instanceV2({
592
- instanceType: new ec2.InstanceType(vpcConfig.natInstanceType ?? 't4g.nano'),
593
- machineImage: ec2.MachineImage.lookup({
594
- name: 'fck-nat-al2023-*-arm64-ebs',
595
- owners: ['568608671756'],
596
- }),
597
- });
598
- }
599
- // natType: 'gateway' leaves natGatewayProvider undefined, which
600
- // is CDK's signal to provision managed NAT Gateways.
601
- vpc = new ec2.Vpc(this, 'Vpc', {
602
- maxAzs: vpcConfig.maxAzs,
603
- natGateways,
604
- natGatewayProvider,
605
- ipAddresses: ec2.IpAddresses.cidr(vpcConfig.cidr),
606
- subnetConfiguration: subnetConfig,
607
- // Well-Architected: Security — restrict default SG
608
- restrictDefaultSecurityGroup: true,
609
- });
610
- // Well-Architected: Security — VPC flow logs for network monitoring
611
- if (vpcConfig.flowLogsEnabled) {
612
- vpc.addFlowLog('FlowLog', {
613
- destination: ec2.FlowLogDestination.toCloudWatchLogs(new logs.LogGroup(this, 'VpcFlowLogGroup', {
614
- logGroupName: `/venturekit/${projectName}/${stage}/vpc-flow-logs`,
615
- retention: this.toLogRetention(vpcConfig.flowLogsRetentionDays),
616
- removalPolicy: this.toRemovalPolicy(),
617
- })),
618
- trafficType: ec2.FlowLogTrafficType.ALL,
619
- });
620
- }
621
- // Gateway VPC endpoints (S3, DynamoDB) — always on.
622
- //
623
- // These are *free* (no hourly charge, no per-GB charge) and are
624
- // the only correct way to keep VPC-internal S3/DynamoDB traffic
625
- // off the NAT path. The migration runner's CodeBuild source
626
- // download targets `cdk-hnb659fds-assets-<account>-<region>` in
627
- // S3 and previously routed through the `t4g.nano` fck-nat
628
- // instance shipped with the `nano` preset — a single small EC2
629
- // that's a single point of failure and timed out under
630
- // realistic CDK deploys (tracked as the "DOWNLOAD_SOURCE i/o
631
- // timeout" report). With the gateway endpoint installed, the
632
- // route table sends S3-prefix-list traffic straight to the
633
- // gateway endpoint, bypassing NAT entirely.
634
- //
635
- // The `enableVpcEndpoints` flag below still gates the
636
- // *expensive* Interface endpoints (Secrets Manager, ECR, Logs,
637
- // ~$7.20/mo each) — that's a real cost knob worth keeping off
638
- // the cheap presets.
639
- vpc.addGatewayEndpoint('S3Endpoint', {
640
- service: ec2.GatewayVpcEndpointAwsService.S3,
641
- });
642
- vpc.addGatewayEndpoint('DynamoEndpoint', {
643
- service: ec2.GatewayVpcEndpointAwsService.DYNAMODB,
644
- });
645
- // Interface endpoints (paid) — opt-in via preset.
646
- //
647
- // Each interface endpoint costs ~$0.01/hr per AZ (~$7.20/mo per AZ)
648
- // plus $0.01/GB processed, so the flag stays off on `free` and
649
- // `nano` and on for `micro`/`medium`/`large`. The single most
650
- // important endpoint to wire is Secrets Manager: every VPC-attached
651
- // route Lambda calls `applyDbSecretToEnv()` on cold start, and
652
- // without an SM endpoint that call has to traverse NAT — which
653
- // is a single-AZ `t4g.nano` fck-nat instance on the small presets
654
- // and a real reliability hazard (a hung NAT eats the entire
655
- // Lambda timeout with no diagnostic output).
656
- if (vpcConfig.enableVpcEndpoints) {
657
- // Dedicated SG for the endpoints. Allow inbound HTTPS from the
658
- // VPC CIDR only — interface endpoints are addressable via
659
- // private DNS names that resolve to ENIs inside this VPC, so
660
- // restricting ingress to the CIDR is both sufficient and
661
- // tighter than the alternative of sharing the Lambda SG (which
662
- // doesn't exist yet at this point in `constructor()` anyway).
663
- const endpointSg = new ec2.SecurityGroup(this, 'VpcEndpointsSg', {
664
- vpc,
665
- description: `Interface endpoint SG for ${projectName}-${stage}`,
666
- allowAllOutbound: false,
667
- });
668
- endpointSg.addIngressRule(ec2.Peer.ipv4(vpc.vpcCidrBlock), ec2.Port.tcp(443), 'HTTPS from anywhere in the VPC');
669
- // Secrets Manager — the confirmed cold-start dependency for
670
- // every Lambda that does any DB access. `privateDnsEnabled`
671
- // makes `secretsmanager.<region>.amazonaws.com` resolve to the
672
- // endpoint ENI inside the VPC, so existing AWS SDK clients
673
- // (with no code changes) automatically use the endpoint.
674
- vpc.addInterfaceEndpoint('SecretsManagerEndpoint', {
675
- service: ec2.InterfaceVpcEndpointAwsService.SECRETS_MANAGER,
676
- securityGroups: [endpointSg],
677
- subnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
678
- privateDnsEnabled: true,
679
- });
680
- // Future endpoints to consider here, gated by feature flags so
681
- // small environments don't pay for what they don't use:
682
- // - `LOGS`: only needed if your handlers call CloudWatch Logs
683
- // APIs directly. Lambda's own log delivery does not flow
684
- // through the function's network.
685
- // - `SQS`: only needed if you call SQS from inside the VPC.
686
- // Queue consumers are *triggered* by the Lambda service
687
- // itself, which doesn't need a VPC endpoint either.
688
- // - `KMS`: needed if you call KMS from handler code (e.g. for
689
- // envelope encryption). Cognito + RDS encryption use KMS
690
- // internally without traversing your network.
691
- // - `STS`: needed if handlers assume cross-account roles.
692
- }
693
- }
694
- // --- Resolve VentureKit-shipped migrations ---
695
- // Discover every installed `@venturekit/*` package that opts in to
696
- // shipping migrations via a `"vk": { "migrations": "<dir>" }` field
697
- // in its own `package.json` (e.g. `@venturekit/notify` ships
698
- // `vk_notify_*.sql`, `@venturekit/auth` ships `vk_auth_*.sql`).
699
- //
700
- // We resolve the dirs once here so every database below receives
701
- // the same merged set — declaring multiple databases doesn't
702
- // multiply the schema.
703
- //
704
- // Resolution: walk the project's `package.json` dependencies,
705
- // then ask `createRequire(projectDir)` where each `@venturekit/*`
706
- // package's own `package.json` lives. This survives pnpm hoisted
707
- // layouts, npm flat layouts, and yarn pnp (which returns virtual
708
- // paths require can still stat).
709
- //
710
- // This is the deploy-side mirror of `@venturekit/cli`'s
711
- // `discoverPackageMigrationDirs` — keeping them in lockstep means
712
- // `vk migrate` (local) and `vk deploy` always apply the same
713
- // merged schema.
714
- this.packageMigrationsDirs.push(...discoverPackageMigrationDirs(projectDir));
715
- // --- Databases ---
716
- // We collect the RDS instances (alongside the source intent) so the
717
- // route-Lambda block below can:
718
- // 1. grant ingress on the DB port via a shared security group, and
719
- // 2. wire the *primary* DB's secret ARN + endpoint into the Lambda
720
- // env so handlers can authenticate (`@venturekit/data`'s
721
- // `applyDbSecretToEnv` resolves user/password at cold start).
722
- // Without #2, every route handler would 503 the moment it tried to
723
- // open a pg.Pool.
724
- // Both DatabaseInstance and DatabaseInstanceFromSnapshot extend the
725
- // (un-exported) DatabaseInstanceNew base, sharing the surface we use
726
- // here: `.secret`, `.connections`, `.dbInstanceEndpoint{Address,Port}`,
727
- // `.node`. The union keeps the snapshot-restore branch type-safe
728
- // without needing a cast.
729
- const dbInstances = [];
730
- if (infrastructure?.databases) {
731
- for (const db of infrastructure.databases) {
732
- if (this.isFreeTier) {
733
- this.createDynamoDBTable(db, projectName, stage);
734
- }
735
- else {
736
- const instance = this.createDatabase(db, projectName, stage, vpc, projectDir, props.vkCliVersion ?? 'latest');
737
- dbInstances.push({ instance, intent: db });
738
- }
739
- }
740
- }
741
- // --- Storage ---
742
- if (infrastructure?.storage) {
743
- for (const storage of infrastructure.storage) {
744
- this.createStorage(storage, projectName, stage);
745
- }
746
- }
747
- // --- Auth ---
748
- if (infrastructure?.auth) {
749
- for (const auth of infrastructure.auth) {
750
- this.createAuth(auth, projectName, stage);
751
- }
752
- }
753
- // --- Notify (SES + outbox + auto-provisioned dispatcher cron) ---
754
- // We provision SES identity + configuration set + events SNS topic
755
- // here, but defer IAM grants and the dispatcher / bounce-handler
756
- // Lambda creation until after the shared Lambda role exists below
757
- // (so we can reuse the same role for least-privilege wiring).
758
- if (infrastructure?.notify) {
759
- for (const notify of infrastructure.notify) {
760
- this.createNotify(notify, projectName, stage);
761
- }
762
- }
763
- // --- Queues ---
764
- if (infrastructure?.queues) {
765
- for (const queue of infrastructure.queues) {
766
- this.createQueue(queue, projectName, stage);
767
- }
768
- }
769
- // --- Caches ---
770
- // Free tier: skip ElastiCache (not free). Use DynamoDB-based caching
771
- // via createDefaultRateLimiter() from @venturekit/runtime instead.
772
- if (infrastructure?.caches && !this.isFreeTier) {
773
- for (const cache of infrastructure.caches) {
774
- this.createCache(cache, projectName, stage, vpc);
775
- }
776
- }
777
- // Always create the rate-limit DynamoDB table. It's on the always-free
778
- // tier, has zero cost at zero load, and is what `createDefaultRateLimiter()`
779
- // falls back to in every environment. Shipping the table unconditionally
780
- // means `rateLimitMiddleware()` "just works" on first deploy regardless of
781
- // preset, and the fail-closed default on store errors (SEC-S15) won't
782
- // surprise anyone with a 503 because the table was missing.
783
- this.createRateLimitTable(projectName, stage);
784
- // --- Monitoring ---
785
- if (infrastructure?.monitoring) {
786
- for (const mon of infrastructure.monitoring) {
787
- this.createMonitoring(mon, projectName, stage);
788
- }
789
- }
790
- // --- Secrets ---
791
- if (infrastructure?.secrets) {
792
- for (const secret of infrastructure.secrets) {
793
- this.createSecret(secret, projectName, stage);
794
- }
795
- }
796
- // --- API Gateway + Lambda routes ---
797
- // Well-Architected: Performance — throttling protects downstream services
798
- // Well-Architected: Security — CORS preflight with explicit origins in prod
799
- const api = new apigatewayv2.HttpApi(this, 'Api', {
800
- apiName: `${projectName}-${stage}`,
801
- corsPreflight: apiConfig.cors ? {
802
- allowOrigins: apiConfig.cors.allowOrigins,
803
- allowMethods: apiConfig.cors.allowMethods.map((m) => apigatewayv2.CorsHttpMethod[m.toUpperCase()] ?? apigatewayv2.CorsHttpMethod.ANY),
804
- allowHeaders: apiConfig.cors.allowHeaders,
805
- allowCredentials: apiConfig.cors.allowCredentials,
806
- maxAge: cdk.Duration.seconds(apiConfig.cors.maxAgeSec),
807
- } : {
808
- allowOrigins: ['*'],
809
- allowMethods: [
810
- apigatewayv2.CorsHttpMethod.GET,
811
- apigatewayv2.CorsHttpMethod.POST,
812
- apigatewayv2.CorsHttpMethod.PUT,
813
- apigatewayv2.CorsHttpMethod.DELETE,
814
- apigatewayv2.CorsHttpMethod.PATCH,
815
- apigatewayv2.CorsHttpMethod.OPTIONS,
816
- ],
817
- allowHeaders: ['*'],
818
- },
819
- disableExecuteApiEndpoint: false,
820
- });
821
- // Well-Architected: Reliability — RETAIN the HttpApi under strict
822
- // dataSafety. The `*.execute-api.{region}.amazonaws.com` URL is unique
823
- // to the API ID, so deleting the API invalidates every consumer that
824
- // hardcoded that URL (mobile apps shipped with the URL baked in,
825
- // partner integrations, webhook subscribers, etc.). Custom-domain
826
- // mappings live on this same API ID — losing the API also breaks the
827
- // mapping until manually re-attached. RETAIN means a deleted CFN
828
- // stack leaves the API in place for `cdk import` recovery.
829
- const cfnApi = api.node.defaultChild;
830
- if (this.dataSafetyConfig.removalPolicy === 'retain') {
831
- cfnApi.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.RETAIN;
832
- cfnApi.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN;
833
- }
834
- // Well-Architected: Operational Excellence — access logging for API
835
- const apiLogGroup = new logs.LogGroup(this, 'ApiAccessLog', {
836
- logGroupName: `/venturekit/${projectName}/${stage}/api-access`,
837
- retention: this.toLogRetention(obsConfig.logs.retentionDays),
838
- removalPolicy: this.toRemovalPolicy(),
839
- });
840
- const defaultStage = api.defaultStage?.node.defaultChild;
841
- if (defaultStage) {
842
- defaultStage.accessLogSettings = {
843
- destinationArn: apiLogGroup.logGroupArn,
844
- format: JSON.stringify({
845
- requestId: '$context.requestId',
846
- ip: '$context.identity.sourceIp',
847
- method: '$context.httpMethod',
848
- path: '$context.path',
849
- status: '$context.status',
850
- latency: '$context.responseLatency',
851
- integrationLatency: '$context.integrationLatency',
852
- }),
853
- };
854
- defaultStage.defaultRouteSettings = {
855
- detailedMetricsEnabled: apiConfig.detailedMetricsEnabled,
856
- throttlingRateLimit: apiConfig.throttleRateLimit,
857
- throttlingBurstLimit: apiConfig.throttleBurstLimit,
858
- };
859
- }
860
- new cdk.CfnOutput(this, 'ApiUrl', {
861
- value: api.apiEndpoint,
862
- description: 'API Gateway URL',
863
- });
864
- // --- Custom Domains ---
865
- //
866
- // An empty-string `certificateArn` is the "pending" sentinel:
867
- // `vk cert request` writes the ARN into the user's config file
868
- // after DNS-based validation completes, and the first deploy
869
- // (before the cert is ready) wants to succeed without trying to
870
- // create a zombie ACM cert. Skipping the domain lets the API
871
- // remain reachable at the auto-generated API Gateway URL; the
872
- // next deploy — after `vk cert request` persists the ARN — wires
873
- // the custom domain in.
874
- if (infrastructure?.domains) {
875
- for (const domain of infrastructure.domains) {
876
- if (domain.certificateArn === '') {
877
- new cdk.CfnOutput(this, `${projectName}-${domain.id}-domain-pending`, {
878
- value: `Run \`vk cert request --domain ${domain.domain}\` to activate.`,
879
- description: `Custom domain ${domain.domain} is pending — no ACM cert ARN yet.`,
880
- });
881
- continue;
882
- }
883
- this.createDomain(domain, projectName, stage, api);
884
- }
885
- }
886
- // --- File-system Lambda discovery (routes + functions + queues + crons) ---
887
- //
888
- // Convention (mirrors `cli/src/utils/local-server.ts`):
889
- // src/routes/health/get.ts → GET /health
890
- // src/routes/users/[id]/put.ts → PUT /users/{id}
891
- // src/functions/process-orders.ts → standalone Lambda
892
- // src/queues/<name>.ts → SQS queue + consumer Lambda
893
- // src/crons/<name>.ts → EventBridge rule + Lambda
894
- //
895
- // Every discovered handler becomes its own Lambda function. All four
896
- // kinds share a single IAM role and (when there's a VPC) a single
897
- // security group — both because it keeps the CFN resource count
898
- // linear in route count rather than O(N × constants), and because
899
- // *every* Lambda kind needs the same baseline access to RDS and
900
- // Secrets Manager. Routes serve user requests; queue consumers and
901
- // cron handlers run async background work that's at least as DB-
902
- // hungry. Splitting role/SG by kind would force operators to wire
903
- // the DB SG ingress N times instead of once.
904
- //
905
- // Trade-off: the shared role accumulates per-Lambda grants from CDK
906
- // helpers like `queue.grantConsumeMessages(fn)`. A route Lambda
907
- // therefore *technically* has SQS receive perms even though it
908
- // never tries to use them. We accept that least-privilege drift
909
- // because the alternative — one role per Lambda — multiplies role
910
- // count by ~Nx and pushes large projects (~80 routes) over CFN's
911
- // 500-resource limit per stack.
912
- const resolvedRoutesDir = path.resolve(projectDir, routesDir);
913
- const resolvedFunctionsDir = functionsDir
914
- ? path.resolve(projectDir, functionsDir)
915
- : path.resolve(projectDir, 'src/functions');
916
- const resolvedQueuesDir = queuesDir
917
- ? path.resolve(projectDir, queuesDir)
918
- : path.resolve(projectDir, 'src/queues');
919
- const resolvedCronsDir = cronsDir
920
- ? path.resolve(projectDir, cronsDir)
921
- : path.resolve(projectDir, 'src/crons');
922
- const discoveredRoutes = fs.existsSync(resolvedRoutesDir)
923
- ? discoverRouteFiles(resolvedRoutesDir)
924
- : [];
925
- const discoveredFunctions = fs.existsSync(resolvedFunctionsDir)
926
- ? this.discoverFunctionFiles(resolvedFunctionsDir)
927
- : [];
928
- const discoveredQueues = fs.existsSync(resolvedQueuesDir)
929
- ? this.discoverFunctionFiles(resolvedQueuesDir)
930
- : [];
931
- const discoveredCrons = fs.existsSync(resolvedCronsDir)
932
- ? this.discoverFunctionFiles(resolvedCronsDir)
933
- : [];
934
- const hasAnyLambda = discoveredRoutes.length > 0
935
- || discoveredFunctions.length > 0
936
- || discoveredQueues.length > 0
937
- || discoveredCrons.length > 0;
938
- let lambdaRole;
939
- let lambdaSg;
940
- if (hasAnyLambda) {
941
- // Stack-wide execution role. `AWSLambdaBasicExecutionRole` covers
942
- // CloudWatch Logs writes; the VPC variant is bolted on below when
943
- // a VPC exists so the Lambda can attach an ENI on cold start.
944
- lambdaRole = new iam.Role(this, 'LambdaRole', {
945
- assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
946
- description: `Shared execution role for ${projectName}-${stage} Lambdas`,
947
- managedPolicies: [
948
- iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole'),
949
- ],
950
- });
951
- if (vpc) {
952
- lambdaRole.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaVPCAccessExecutionRole'));
953
- lambdaSg = new ec2.SecurityGroup(this, 'LambdaSg', {
954
- vpc,
955
- description: `Shared SG for ${projectName}-${stage} Lambdas`,
956
- allowAllOutbound: true,
957
- });
958
- // Open the DB port for every database from the shared SG. We
959
- // use `connections.allowDefaultPortFrom` rather than hard-
960
- // coding 5432/3306 so the same code path works for Postgres
961
- // *and* MySQL projects (and any future engine `createDatabase`
962
- // adds). One ingress rule per DB, regardless of how many
963
- // Lambdas the stack creates.
964
- for (const { instance } of dbInstances) {
965
- instance.connections.allowDefaultPortFrom(lambdaSg, `${projectName}-${stage} Lambdas`);
966
- }
967
- }
968
- // Wire the *primary* database's credentials into every Lambda's
969
- // env so `@venturekit/data`'s `getPool()` (and the `query()` /
970
- // `withTransaction()` entry points that wrap it) finds a complete
971
- // config. The non-secret slots (host, port, name, ssl) are always
972
- // wired the same way; the *username + password* delivery flips
973
- // based on whether the VPC has a Secrets Manager interface
974
- // endpoint:
975
- //
976
- // - **Default — VPC has SM endpoint, OR Lambda is not VPC-attached.**
977
- // Expose `DB_SECRET_ARN`. Runtime fetches user/password via
978
- // `applyDbSecretToEnv()` on cold start. Standard, supports
979
- // rotation without a redeploy, secret value never lands in
980
- // the CloudFormation template or Lambda env config.
981
- //
982
- // - **Inline mode — VPC-attached AND `enableVpcEndpoints: false`
983
- // (today only the `nano` preset).**
984
- // CloudFormation resolves the secret value at deploy time and
985
- // injects `DB_USER` + `DB_PASSWORD` as plain Lambda env vars.
986
- // `applyDbSecretToEnv()` short-circuits when both are set, so
987
- // the SM round-trip is skipped entirely. This eliminates the
988
- // hard runtime dependency on a single-AZ fck-nat instance
989
- // reaching `secretsmanager.<region>.amazonaws.com`. Trade-offs:
990
- // rotation requires a `vk deploy`, and the password is visible
991
- // to anyone holding `lambda:GetFunctionConfiguration` IAM —
992
- // acceptable for dev/preview, not for prod (which should run
993
- // on a preset that turns endpoints on).
994
- //
995
- // "Primary" = the first database intent in `infrastructure.databases`.
996
- // This matches the local-dev convention `vk` already uses to
997
- // populate `DATABASE_URL` from docker-compose; multi-DB projects
998
- // can read the additional secret ARNs from CFN outputs and call
999
- // `applyDbSecretToEnv` themselves.
1000
- if (dbInstances.length > 0) {
1001
- const { instance: primaryDb, intent: primaryIntent } = dbInstances[0];
1002
- if (primaryDb.secret) {
1003
- this.ventureBaseEnv['DB_HOST'] = primaryDb.dbInstanceEndpointAddress;
1004
- this.ventureBaseEnv['DB_PORT'] = primaryDb.dbInstanceEndpointPort;
1005
- this.ventureBaseEnv['DB_NAME'] = primaryIntent.name || primaryIntent.id;
1006
- // RDS enforces TLS on every modern engine; flipping this here
1007
- // means consumers don't have to remember to set it themselves.
1008
- this.ventureBaseEnv['DB_SSL'] = 'true';
1009
- // Inline mode kicks in only when the Lambda will be VPC-attached
1010
- // (a VPC exists) AND no SM interface endpoint is being created.
1011
- // Without VPC attachment the Lambda reaches SM via the public
1012
- // internet using its execution-role IAM, so the standard ARN
1013
- // path stays optimal there.
1014
- const inlineDbCredentials = vpc !== undefined && !vpcConfig.enableVpcEndpoints;
1015
- if (inlineDbCredentials) {
1016
- // `secretValueFromJson(...).unsafeUnwrap()` is the same
1017
- // CloudFormation-resolved-at-deploy-time pattern already
1018
- // used above for `VENTURE_INTERNAL_HMAC_SECRET`. The token
1019
- // becomes a `Fn::Join`/`{{resolve:secretsmanager:...}}` in
1020
- // the synthesized template; CFN substitutes the literal
1021
- // value when creating/updating the Lambda's Environment
1022
- // block, so no IAM call is needed at runtime.
1023
- this.ventureBaseEnv['DB_USER'] = primaryDb.secret
1024
- .secretValueFromJson('username').unsafeUnwrap();
1025
- this.ventureBaseEnv['DB_PASSWORD'] = primaryDb.secret
1026
- .secretValueFromJson('password').unsafeUnwrap();
1027
- // Deliberately *not* setting DB_SECRET_ARN: with both
1028
- // DB_USER and DB_PASSWORD pre-populated, `applyDbSecretToEnv()`
1029
- // is a no-op — but if the ARN is set anyway, an operator
1030
- // skimming the env vars might think rotation works without
1031
- // a redeploy. Keeping it absent makes the contract explicit.
1032
- }
1033
- else {
1034
- this.ventureBaseEnv['DB_SECRET_ARN'] = primaryDb.secret.secretArn;
1035
- // Only grant when the runtime actually fetches. Inline mode
1036
- // doesn't need it and the principle of least privilege
1037
- // says don't hand out IAM you don't use.
1038
- primaryDb.secret.grantRead(lambdaRole);
1039
- }
1040
- }
1041
- }
1042
- // Grant the shared Lambda role the baseline admin Cognito actions
1043
- // every non-trivial auth flow needs. The public surface
1044
- // (`SignUp`, `InitiateAuth`, `RespondToAuthChallenge`,
1045
- // `ChangePassword`, …) is anonymous and works without IAM, but
1046
- // self-service register flows that auto-confirm the new user, and
1047
- // any admin/staff-invite console, hit the `Admin*` API which
1048
- // does require IAM. Without this grant a `POST /auth/register`
1049
- // route that calls `AdminConfirmSignUp` after `SignUp` fails at
1050
- // runtime with `AccessDeniedException`.
1051
- //
1052
- // Grants are scoped to each declared pool's ARN (via
1053
- // `userPool.grant`, which builds an inline policy on the role
1054
- // pinned to `userPool.userPoolArn`) — never `Resource: *`.
1055
- //
1056
- // The action set covers the admin operations VentureKit-shaped
1057
- // apps actually call from Lambda handlers: confirming/creating
1058
- // users on registration or invite, flipping enabled status,
1059
- // updating attributes (e.g. `email_verified`, `custom:role`),
1060
- // reading user state, force-resetting passwords, and the
1061
- // admin-flow auth pair used by some server-side login surfaces.
1062
- // List-side reads (`ListUsers`, `AdminListGroupsForUser`) round
1063
- // out the set so user-management consoles don't have to ship a
1064
- // second grant.
1065
- for (const pool of this.userPools) {
1066
- pool.grant(lambdaRole, 'cognito-idp:AdminConfirmSignUp', 'cognito-idp:AdminCreateUser', 'cognito-idp:AdminDeleteUser', 'cognito-idp:AdminDisableUser', 'cognito-idp:AdminEnableUser', 'cognito-idp:AdminGetUser', 'cognito-idp:AdminInitiateAuth', 'cognito-idp:AdminListGroupsForUser', 'cognito-idp:AdminRespondToAuthChallenge', 'cognito-idp:AdminSetUserPassword', 'cognito-idp:AdminUpdateUserAttributes', 'cognito-idp:AdminUserGlobalSignOut', 'cognito-idp:ListUsers');
1067
- }
1068
- // Federated identity-provider client-credential secrets — created
1069
- // by `createAuth` when an `AuthIntent` declares
1070
- // `federated: ['google', 'facebook', …]`. Granting `GetSecretValue`
1071
- // here (rather than at the secret's creation site) keeps the
1072
- // grant tied to the same shared Lambda role used for the Cognito
1073
- // admin actions above, so a single `signInAsFederatedUser` call
1074
- // can both fetch the OAuth credentials and mint Cognito tokens.
1075
- for (const secret of this.federatedAuthSecrets) {
1076
- secret.grantRead(lambdaRole);
1077
- }
1078
- // Wire every storage bucket into the shared Lambda role + env.
1079
- //
1080
- // - IAM: `bucket.grantReadWrite(lambdaRole)` produces an inline
1081
- // policy scoped to the bucket ARN (and `${arn}/*`), covering the
1082
- // `s3:Get*` / `s3:Put*` / `s3:DeleteObject` / `s3:List*` actions
1083
- // that `@venturekit/storage`'s presign + put/get/list paths need.
1084
- // Never `Resource: *` — least privilege per declared intent.
1085
- //
1086
- // - Env: `VENTURE_STORAGE_BUCKET` points at the *first* bucket so
1087
- // `createStorageClientFromEnv()` (the zero-config entry point
1088
- // most handlers use) finds a complete config without an explicit
1089
- // `{ bucket }` override. Per-intent `STORAGE_<ID>_BUCKET` vars
1090
- // mirror the local-dev convention in `cli/src/utils/docker-compose.ts`
1091
- // (`envVars[\`STORAGE_${id.toUpperCase()}_BUCKET\`] = ...`) so
1092
- // multi-bucket projects can read each bucket name without an
1093
- // extra round-trip to CloudFormation outputs. "Primary" =
1094
- // the first storage intent in `infrastructure.storage`, matching
1095
- // how the same CLI sets `VENTURE_STORAGE_BUCKET` to
1096
- // `intents.storage[0].id` for `vk dev`.
1097
- if (this.storageBuckets.length > 0) {
1098
- const primary = this.storageBuckets[0];
1099
- this.ventureBaseEnv['VENTURE_STORAGE_BUCKET'] = primary.bucket.bucketName;
1100
- if (primary.distribution) {
1101
- // Primary CDN domain — read by zero-config callers that don't
1102
- // know the intent id (mirrors `VENTURE_STORAGE_BUCKET`). When
1103
- // an alias is configured, prefer it so URLs end up at the
1104
- // operator-chosen hostname (e.g. `cdn.example.com`) instead
1105
- // of the `*.cloudfront.net` default.
1106
- this.ventureBaseEnv['VENTURE_STORAGE_CDN_URL'] = primary.cdnAlias
1107
- ? `https://${primary.cdnAlias}`
1108
- : `https://${primary.distribution.distributionDomainName}`;
1109
- }
1110
- for (const { bucket, intent, distribution, cdnAlias } of this.storageBuckets) {
1111
- // Lambda env var keys must match `[a-zA-Z][a-zA-Z0-9_]+`, so
1112
- // normalize any non-alphanumerics in the intent id (e.g.
1113
- // `user-uploads` -> `USER_UPLOADS`). Without this, a hyphenated
1114
- // id would cause CloudFormation to reject the function update.
1115
- const idKey = String(intent.id).toUpperCase().replace(/[^A-Z0-9_]/g, '_');
1116
- this.ventureBaseEnv[`STORAGE_${idKey}_BUCKET`] = bucket.bucketName;
1117
- if (distribution) {
1118
- // Per-intent CDN domain — handlers that resolve a stored
1119
- // S3 key to a public URL (e.g. `publicUrlForKey()`) read
1120
- // this to build `https://<dist>/<key>` without an explicit
1121
- // override. Prefer the alternate domain (e.g.
1122
- // `cdn.example.com`) when set; fall back to the auto-
1123
- // generated `*.cloudfront.net` hostname otherwise.
1124
- this.ventureBaseEnv[`STORAGE_${idKey}_CDN_URL`] = cdnAlias
1125
- ? `https://${cdnAlias}`
1126
- : `https://${distribution.distributionDomainName}`;
1127
- }
1128
- bucket.grantReadWrite(lambdaRole);
1129
- }
1130
- }
1131
- // Wire every notify configuration into the shared Lambda role + env.
1132
- //
1133
- // - IAM: `ses:SendEmail` + `ses:SendRawEmail` scoped to the SES
1134
- // identity ARN and the configuration set ARN — never `Resource: *`.
1135
- // `secretsmanager:GetSecretValue` is granted on the WhatsApp
1136
- // token secret when the channel is enabled.
1137
- // - Env: the *first* notify config drives the unscoped vars
1138
- // (`VENTURE_NOTIFY_FROM`, …) so handlers using the default
1139
- // `notify` instance work without per-id config. Per-id vars
1140
- // (`NOTIFY_<ID>_FROM`) mirror the storage convention.
1141
- // - Auto-provisioning: dispatcher cron + bounce-handler Lambda
1142
- // are created here too because they share `lambdaRole` and need
1143
- // the same VPC/SG. The Lambdas import a stub from
1144
- // `@venturekit/notify/runtime/dispatcher-stub.ts`; the consumer
1145
- // provides their notify-client module path via
1146
- // `VENTURE_NOTIFY_CLIENT_MODULE` env (default `'./lib/notify.js'`,
1147
- // resolved against the bundled handler dir).
1148
- if (this.notifyConfigs.length > 0) {
1149
- const primary = this.notifyConfigs[0];
1150
- this.ventureBaseEnv['VENTURE_NOTIFY_FROM'] = primary.intent.defaultFrom ?? '';
1151
- this.ventureBaseEnv['VENTURE_NOTIFY_CONFIG_SET'] = primary.configurationSetName;
1152
- this.ventureBaseEnv['VENTURE_NOTIFY_EVENTS_TOPIC_ARN'] = primary.eventsTopic.topicArn;
1153
- if (primary.intent.whatsapp?.phoneNumberId) {
1154
- this.ventureBaseEnv['VENTURE_NOTIFY_WHATSAPP_PHONE_ID'] = String(primary.intent.whatsapp.phoneNumberId);
1155
- this.ventureBaseEnv['VENTURE_NOTIFY_WHATSAPP_API_VERSION'] =
1156
- primary.intent.whatsapp.apiVersion ?? 'v21.0';
1157
- }
1158
- if (primary.whatsappTokenSecret) {
1159
- this.ventureBaseEnv['VENTURE_NOTIFY_WHATSAPP_TOKEN_SECRET'] =
1160
- primary.whatsappTokenSecret.secretArn;
1161
- }
1162
- for (const cfg of this.notifyConfigs) {
1163
- const idKey = String(cfg.intent.id).toUpperCase().replace(/[^A-Z0-9_]/g, '_');
1164
- this.ventureBaseEnv[`NOTIFY_${idKey}_FROM`] = cfg.intent.defaultFrom ?? '';
1165
- this.ventureBaseEnv[`NOTIFY_${idKey}_CONFIG_SET`] = cfg.configurationSetName;
1166
- this.ventureBaseEnv[`NOTIFY_${idKey}_EVENTS_TOPIC_ARN`] = cfg.eventsTopic.topicArn;
1167
- // Grant SES sending permissions. `ses:SendEmail` /
1168
- // `SendRawEmail` are required by SESv2's SendEmail API; the
1169
- // resource-level scope is the identity ARN (`arn:aws:ses:…:identity/<from>`)
1170
- // *plus* the configuration set ARN (Send is dual-resource in
1171
- // SESv2 — both the identity AND the config set must allow).
1172
- lambdaRole.addToPolicy(new iam.PolicyStatement({
1173
- actions: ['ses:SendEmail', 'ses:SendRawEmail'],
1174
- resources: [
1175
- cfg.identityArn,
1176
- `arn:${cdk.Aws.PARTITION}:ses:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:configuration-set/${cfg.configurationSetName}`,
1177
- ],
1178
- }));
1179
- if (cfg.whatsappTokenSecret) {
1180
- cfg.whatsappTokenSecret.grantRead(lambdaRole);
1181
- }
1182
- }
1183
- // Auto-provision dispatcher + bounce-handler Lambdas now that
1184
- // the role + VPC + SG exist. Wrapped in a single helper so the
1185
- // constructor stays readable.
1186
- this.provisionNotifyHandlers(projectName, stage, projectDir, lambdaRole, lambdaSg, vpc);
1187
- }
1188
- }
1189
- // --- HTTP Routes (file-system discovered) ---
1190
- //
1191
- // Each discovered file becomes one Lambda + HttpLambdaIntegration +
1192
- // HttpRoute. Method-name aliases (list/create/update/remove) are
1193
- // resolved upstream in `discoverRouteFiles`.
1194
- if (discoveredRoutes.length > 0) {
1195
- // De-duplicate: a misconfigured `src/routes/` (e.g. both
1196
- // `users/get.ts` and `users/list.ts`) would otherwise fail
1197
- // synth with a duplicate-route-key error from API Gateway.
1198
- // Surface the conflict early with a clear message instead.
1199
- const seenRouteKeys = new Set();
1200
- for (const route of discoveredRoutes) {
1201
- const routeKey = `${route.method} ${route.apiPath}`;
1202
- if (seenRouteKeys.has(routeKey)) {
1203
- throw new Error(`[venturekit] Duplicate HTTP route detected: ${routeKey} ` +
1204
- `(at ${route.handlerFile}). Method aliases like 'get' + 'list' ` +
1205
- `or 'post' + 'create' both resolve to the same HTTP method on ` +
1206
- `the same path — pick one per directory.`);
1207
- }
1208
- seenRouteKeys.add(routeKey);
1209
- }
1210
- // CloudFormation enforces a hard 500-resources-per-stack limit.
1211
- // Each route here translates to ~5 CFN resources (Function,
1212
- // Permission, Integration, Route, log-retention custom resource),
1213
- // so a stack with more than ~80 routes can't be deployed without
1214
- // sharding. Below the threshold we keep the simpler in-stack
1215
- // layout so existing test assertions (which inspect the parent
1216
- // template only) still find route Lambdas there. At or above the
1217
- // threshold we shard routes across nested stacks; CDK wires the
1218
- // cross-stack references for `api`, `role`, `lambdaSg`, `vpc`
1219
- // automatically via SSM Parameter passthrough.
1220
- const NESTED_STACK_THRESHOLD = 80;
1221
- const ROUTES_PER_NESTED_STACK = 50;
1222
- // Fixed, immutable nested-stack slot count.
1223
- //
1224
- // The bucketing function `stableSlotFor(slug, numSlots)` is a
1225
- // pure function of these two inputs. As long as `numSlots`
1226
- // never changes, every previously-deployed route keeps its
1227
- // slot — which means CloudFormation never has to migrate a
1228
- // route's `ApiGatewayV2::Route` resource across nested stack
1229
- // boundaries between deploys.
1230
- //
1231
- // That invariant is load-bearing. `ApiGatewayV2::Route` carries
1232
- // a unique `(method, path)` key at the API Gateway level, not
1233
- // just the CloudFormation level. If the same route key lives in
1234
- // two nested stacks (old + new) at the same moment, the create
1235
- // call fails with `Route with key <METHOD> <path> already exists
1236
- // for this API` because CFN updates nested stacks in parallel
1237
- // and has no cross-stack delete-before-create ordering.
1238
- //
1239
- // 16 slots × ~80 routes/slot (the CloudFormation per-stack 500-
1240
- // resource limit at ~5 CFN resources per route) gives ~1280
1241
- // routes of headroom — comfortably above anything we've seen
1242
- // in practice. Raising this number later would force a
1243
- // one-shot mass migration of every nested-stack route in every
1244
- // already-deployed project; don't do it casually.
1245
- const NUM_NESTED_STACK_SLOTS = 16;
1246
- if (discoveredRoutes.length <= NESTED_STACK_THRESHOLD) {
1247
- for (const route of discoveredRoutes) {
1248
- this.createRouteFunction(route, projectName, stage, api, lambdaRole, lambdaSg, vpc);
1249
- }
1250
- }
1251
- else {
1252
- // Stable hash-based bucketing into a fixed, never-changing slot
1253
- // pool.
1254
- //
1255
- // The previous index-based scheme (`routes.slice(i, i+50)`)
1256
- // sliced the discovered-route array into fixed 50-route windows.
1257
- // Adding, removing, or renaming any single route shifted every
1258
- // later route by one position, which moved a tail of routes
1259
- // across a 50-route boundary into a different `RoutesN` nested
1260
- // stack. Combined with the unique `(method, path)` key carried
1261
- // by `ApiGatewayV2::Route`, this caused
1262
- // `Route with key <METHOD> <path> already exists for this API`
1263
- // deploy failures — CFN updates nested stacks in parallel, so
1264
- // the new route resource tries to register at API Gateway
1265
- // before the old one in the other nested stack is deleted.
1266
- //
1267
- // Hashing the slug into a fixed slot pool pins each route to a
1268
- // slot that's determined the first time it's deployed and
1269
- // never moves. Adding or removing siblings doesn't reshuffle
1270
- // anything; changing slot count is the only event that would —
1271
- // which is exactly why `NUM_NESTED_STACK_SLOTS` is a hard-coded
1272
- // constant rather than something derived from the route count.
1273
- const numSlots = NUM_NESTED_STACK_SLOTS;
1274
- const buckets = Array.from({ length: numSlots }, () => []);
1275
- for (const route of discoveredRoutes) {
1276
- const slot = stableSlotFor(route.slug, numSlots);
1277
- buckets[slot].push(route);
1278
- }
1279
- // Only materialise non-empty slots; an empty slot would synth an
1280
- // empty nested CloudFormation stack, which CDK rejects. Slot
1281
- // index (1-based, padded so the displayed name is stable as the
1282
- // bucket count grows) becomes the construct ID, ensuring a
1283
- // route's logical path stays the same as long as `numSlots`
1284
- // doesn't change.
1285
- buckets.forEach((batch, idx) => {
1286
- if (batch.length === 0)
1287
- return;
1288
- new RoutesNestedStack(this, `Routes${idx + 1}`, {
1289
- routes: batch,
1290
- projectName,
1291
- stage,
1292
- api,
1293
- role: lambdaRole,
1294
- securityGroup: lambdaSg,
1295
- vpc,
1296
- envConfig: this.envConfig,
1297
- ventureBaseEnv: this.ventureBaseEnv,
1298
- projectDir: this.projectDir,
1299
- skipBundling: this.skipBundling,
1300
- });
1301
- });
1302
- }
1303
- }
1304
- // --- Standalone Lambda Functions ---
1305
- // Discover functions from src/functions/ and create Lambda functions.
1306
- // Naming convention: {project}-{stage}-fn-{name}
1307
- if (discoveredFunctions.length > 0) {
1308
- const functionIntents = infrastructure?.functions ?? [];
1309
- for (const fnFile of discoveredFunctions) {
1310
- const intent = functionIntents.find((f) => f.id === fnFile.name);
1311
- this.createStandaloneFunction(fnFile.name, fnFile.handlerFile, projectName, stage, projectDir, intent, lambdaRole, lambdaSg, vpc);
1312
- }
1313
- }
1314
- // --- Queue Consumer Lambda Functions ---
1315
- // Discover handlers from src/queues/ and wire them to SQS queues.
1316
- // Naming convention: {project}-{stage}-queue-{name}
1317
- if (discoveredQueues.length > 0) {
1318
- const queueIntents = infrastructure?.queues ?? [];
1319
- for (const qFile of discoveredQueues) {
1320
- const intent = queueIntents.find((q) => q.id === qFile.name);
1321
- this.createQueueConsumer(qFile.name, qFile.handlerFile, projectName, stage, projectDir, intent, lambdaRole, lambdaSg, vpc);
1322
- }
1323
- }
1324
- // --- Cron/Scheduled Task Lambda Functions ---
1325
- // Discover handlers from src/crons/ and wire them to EventBridge rules.
1326
- // Naming convention: {project}-{stage}-cron-{name}
1327
- if (discoveredCrons.length > 0) {
1328
- const scheduleIntents = infrastructure?.schedules ?? [];
1329
- for (const cFile of discoveredCrons) {
1330
- const intent = scheduleIntents.find((s) => s.id === cFile.name);
1331
- this.createCronHandler(cFile.name, cFile.handlerFile, projectName, stage, projectDir, intent, lambdaRole, lambdaSg, vpc);
1332
- }
1333
- }
1334
- }
1335
- /**
1336
- * Well-Architected: Reliability — RDS with stable logical ID so preset
1337
- * changes (e.g. nano→medium) perform an in-place instance class modify,
1338
- * not a replacement. Data is preserved because the CloudFormation resource
1339
- * identity stays the same.
1340
- *
1341
- * Well-Architected: Security — encrypted at rest, credentials in Secrets Manager
1342
- * Well-Architected: Reliability — automated backups, optional multi-AZ
1343
- * Well-Architected: Performance — instance type driven by intent.size
1344
- * Well-Architected: Operational Excellence — auto minor version upgrades
1345
- */
1346
- createDatabase(intent, projectName, stage, vpc, projectDir, vkCliVersion) {
1347
- // Stable logical ID: 'db-{intent.id}' — never changes when preset changes
1348
- const logicalId = `db-${intent.id}`;
1349
- const instanceType = this.sizeToInstanceType(intent.size || 'small');
1350
- const engine = intent.type === 'mysql'
1351
- ? rds.DatabaseInstanceEngine.mysql({ version: rds.MysqlEngineVersion.VER_8_0 })
1352
- : rds.DatabaseInstanceEngine.postgres({ version: rds.PostgresEngineVersion.VER_16 });
1353
- // Well-Architected: Security — place DB in isolated subnets when available
1354
- const subnetType = vpc.isolatedSubnets?.length
1355
- ? ec2.SubnetType.PRIVATE_ISOLATED
1356
- : ec2.SubnetType.PRIVATE_WITH_EGRESS;
1357
- // Common props shared between fresh-create and snapshot-restore
1358
- // paths. Pulled out so the two branches stay aligned on
1359
- // dataSafety / VPC / observability config.
1360
- const commonDbProps = {
1361
- engine,
1362
- instanceType,
1363
- vpc,
1364
- vpcSubnets: { subnetType },
1365
- // Well-Architected: Reliability — use dataSafety for removal policy
1366
- removalPolicy: this.toRemovalPolicy(),
1367
- deletionProtection: this.dataSafetyConfig.deletionProtection,
1368
- multiAz: intent.highAvailability ?? false,
1369
- // Well-Architected: Security — encryption at rest
1370
- storageEncrypted: intent.encrypted ?? true,
1371
- // Well-Architected: Reliability — automated backups
1372
- backupRetention: intent.backups !== false
1373
- ? cdk.Duration.days(intent.backupRetentionDays ?? 7)
1374
- : cdk.Duration.days(0),
1375
- // Well-Architected: Operational Excellence — auto minor version upgrades
1376
- autoMinorVersionUpgrade: true,
1377
- // Well-Architected: Performance — performance insights for medium+ presets
1378
- enablePerformanceInsights: intent.performanceInsights ?? this.envConfig.lambda.memoryMb >= 512,
1379
- // Well-Architected: Reliability — enhanced monitoring for medium+ presets
1380
- monitoringInterval: this.envConfig.lambda.memoryMb >= 512
1381
- ? cdk.Duration.seconds(60)
1382
- : undefined,
1383
- };
1384
- // Branch: snapshot-restore vs fresh-create.
1385
- //
1386
- // RDS doesn't expose the original master password from a snapshot,
1387
- // so `SnapshotCredentials.fromGeneratedSecret` rotates to a fresh
1388
- // password at restore time and stores it in Secrets Manager —
1389
- // identical surface to the fresh-create path's `Credentials.from
1390
- // GeneratedSecret`. Application code reads `instance.secret` either
1391
- // way, so the migration runner and Lambda env wiring downstream
1392
- // see no difference.
1393
- //
1394
- // `databaseName` is intentionally omitted on the snapshot branch:
1395
- // the database name is preserved from the snapshot, and passing
1396
- // a value here is rejected by RDS as conflicting input.
1397
- const instance = intent.restoreFromSnapshot
1398
- ? new rds.DatabaseInstanceFromSnapshot(this, logicalId, {
1399
- ...commonDbProps,
1400
- snapshotIdentifier: intent.restoreFromSnapshot,
1401
- credentials: rds.SnapshotCredentials.fromGeneratedSecret('postgres'),
1402
- })
1403
- : new rds.DatabaseInstance(this, logicalId, {
1404
- ...commonDbProps,
1405
- databaseName: intent.name || intent.id,
1406
- credentials: rds.Credentials.fromGeneratedSecret('postgres'),
1407
- });
1408
- // Well-Architected: Reliability — prevent accidental replacement
1409
- // CFN UpdateReplacePolicy ensures data safety on resource property changes
1410
- const cfnInstance = instance.node.defaultChild;
1411
- if (this.dataSafetyConfig.removalPolicy === 'retain') {
1412
- cfnInstance.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN;
1413
- }
1414
- // Well-Architected: Reliability — retain the master credentials secret
1415
- // alongside the DB it unlocks. Without this, a stack delete schedules
1416
- // the secret for permanent deletion (default 7-day window) while the
1417
- // DB itself survives via RemovalPolicy.RETAIN. Operators recovering
1418
- // an orphaned DB then face a useless instance whose master password
1419
- // is gone — they have to `aws rds modify-db-instance
1420
- // --master-user-password` to a new value, which invalidates every
1421
- // application credential rotation that referenced the original
1422
- // secret. Applying RETAIN here keeps the DB and its credentials in
1423
- // sync, so `cdk import` (or `vk deploy --import-existing-resources`)
1424
- // can reattach both cleanly. Mirrored UpdateReplacePolicy keeps the
1425
- // secret safe across CFN-driven replacements too.
1426
- //
1427
- // ─── Subtle: don't use `instance.secret` ──────────────────────────
1428
- // `instance.secret` returns a `SecretTargetAttachment` — CDK's L2
1429
- // wrapper around `AWS::SecretsManager::SecretTargetAttachment`,
1430
- // which is just metadata pointing the secret at the DB. Applying
1431
- // RETAIN to *that* only retains the attachment metadata; the
1432
- // underlying `AWS::SecretsManager::Secret` (the resource that
1433
- // holds the generated password) still goes through CFN's default
1434
- // delete-on-stack-delete flow. `Credentials.fromGeneratedSecret`
1435
- // / `SnapshotCredentials.fromGeneratedSecret` create the actual
1436
- // Secret as a sibling child of the DB construct under the
1437
- // well-known id `'Secret'`, so we walk to it directly.
1438
- if (this.dataSafetyConfig.removalPolicy === 'retain') {
1439
- const dbSecret = instance.node.tryFindChild('Secret');
1440
- if (dbSecret) {
1441
- dbSecret.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN);
1442
- const cfnSecret = dbSecret.node.defaultChild;
1443
- if (cfnSecret) {
1444
- cfnSecret.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN;
1445
- }
1446
- }
1447
- }
1448
- new cdk.CfnOutput(this, `${logicalId}-endpoint`, {
1449
- value: instance.dbInstanceEndpointAddress,
1450
- description: `Database endpoint for ${intent.id}`,
1451
- });
1452
- new cdk.CfnOutput(this, `${logicalId}-secret-arn`, {
1453
- value: instance.secret?.secretArn || '',
1454
- description: `Database secret ARN for ${intent.id}`,
1455
- });
1456
- // --- Auto-run migrations on every deploy ---
1457
- // The in-VPC CodeBuild runner closes the long-standing gap where
1458
- // public CI runners can't reach a private RDS. We wire it only for
1459
- // Postgres (the only engine `@venturekit/data` has a runner for) and
1460
- // only when the consumer has at least one `.sql` file in
1461
- // `db/migrations/`. Failures roll back the stack via the custom
1462
- // resource, making migrations atomic with `vk deploy`.
1463
- if (intent.type === 'postgres' && instance.secret) {
1464
- const dbDir = path.join(projectDir, 'db');
1465
- if (hasMigrations(dbDir)) {
1466
- // The runner's CodeBuild job lives inside this stack's VPC so it
1467
- // can reach the private RDS, but its buildspec still needs public
1468
- // egress to `npm install -g @venturekit/cli` and to ship logs +
1469
- // build updates to the AWS control plane. When `natType: 'none'`
1470
- // the private subnets have no default route at all — every
1471
- // outbound call from the build times out and CFN reports a bare
1472
- // "Migration build ... ended with status FAILED" on first
1473
- // deploy. Skipping the runner here, with a loud warning pointing
1474
- // at the operator's two options, keeps the deploy green and
1475
- // surfaces the actual decision (enable a NAT provider, or
1476
- // migrate manually via a tunnel). Every preset except `free`
1477
- // now ships a NAT provider by default (instance for
1478
- // nano/micro/medium, gateway for large), so this branch is only
1479
- // reached when the operator explicitly opts out with
1480
- // `natType: 'none'`.
1481
- if (this.envConfig.vpc.natType === 'none') {
1482
- console.warn(`[venturekit] Skipping auto-migrations for db "${intent.id}": ` +
1483
- `natType is 'none', so the in-VPC migration runner can't ` +
1484
- `reach npmjs.com to install @venturekit/cli. Fix: set ` +
1485
- `'infrastructure.vpc.natType: "instance"' (or "gateway") in ` +
1486
- `vk.config.ts. Until then, run \`vk migrate -e ${stage}\` ` +
1487
- `manually once the DB is reachable (e.g. via SSM tunnel).`);
1488
- }
1489
- else {
1490
- new MigrationRunner(this, `migration-runner-${intent.id}`, {
1491
- projectName,
1492
- stage,
1493
- dbId: intent.id,
1494
- vpc,
1495
- dbInstance: instance,
1496
- dbSecret: instance.secret,
1497
- dbName: intent.name || intent.id,
1498
- dbDir,
1499
- // Merge in any `migrations/` dirs shipped by installed
1500
- // `@venturekit/*` packages (resolved at the top of the
1501
- // constructor via `discoverPackageMigrationDirs`). The
1502
- // runner's `mergeDbDir` overlays them on top of the
1503
- // project's own SQL files; collisions throw at synth
1504
- // time (we never silently overwrite).
1505
- extraMigrationsDirs: this.packageMigrationsDirs,
1506
- vkCliVersion,
1507
- logRetentionDays: this.envConfig.observability.logs.retentionDays,
1508
- });
1509
- }
1510
- }
1511
- }
1512
- else if (intent.type === 'mysql') {
1513
- // MySQL projects: no auto-migration runner yet because
1514
- // `@venturekit/data` only ships a Postgres SQL runner. Operators
1515
- // must still run migrations manually via a tunnel.
1516
- console.warn(`[venturekit] MySQL auto-migrations are not yet supported (db: ${intent.id}). ` +
1517
- `Run \`vk migrate\` manually after deploy until @venturekit/data adds a MySQL runner.`);
1518
- }
1519
- return instance;
1520
- }
1521
- /**
1522
- * Well-Architected: Security — block public access by default, SSE encryption
1523
- * Well-Architected: Reliability — versioning for backups, dataSafety removal policy
1524
- * Well-Architected: Cost — lifecycle rules for logs/backups to reduce storage cost
1525
- * Well-Architected: Sustainability — intelligent tiering for infrequent access
1526
- */
1527
- createStorage(intent, projectName, stage) {
1528
- // Stable logical ID: 'storage-{intent.id}'
1529
- const logicalId = `storage-${intent.id}`;
1530
- const bucket = new s3.Bucket(this, logicalId, {
1531
- bucketName: `${projectName}-${intent.id}-${stage}`,
1532
- versioned: intent.versioned ?? intent.purpose === 'backups',
1533
- removalPolicy: this.toRemovalPolicy(),
1534
- autoDeleteObjects: this.dataSafetyConfig.removalPolicy === 'destroy',
1535
- // Well-Architected: Security — encryption at rest
1536
- encryption: s3.BucketEncryption.S3_MANAGED,
1537
- // Well-Architected: Security — bucket is always fully private.
1538
- // When `intent.cdn === true` we still keep BlockPublicAccess.BLOCK_ALL
1539
- // and front the bucket with a CloudFront distribution that uses an
1540
- // Origin Access Control (OAC). CloudFront accesses S3 via the
1541
- // service principal under a `aws:SourceArn` condition added by
1542
- // `S3BucketOrigin.withOriginAccessControl()` to the bucket policy,
1543
- // so anonymous direct-S3 GETs stay forbidden.
1544
- blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
1545
- // Well-Architected: Security — enforce SSL
1546
- enforceSSL: true,
1547
- cors: intent.corsOrigins
1548
- ? [
1549
- {
1550
- allowedOrigins: intent.corsOrigins,
1551
- allowedMethods: [
1552
- s3.HttpMethods.GET,
1553
- s3.HttpMethods.PUT,
1554
- s3.HttpMethods.POST,
1555
- s3.HttpMethods.DELETE,
1556
- ],
1557
- allowedHeaders: ['*'],
1558
- },
1559
- ]
1560
- : undefined,
1561
- // Well-Architected: Cost — lifecycle rules for logs and backups
1562
- lifecycleRules: intent.purpose === 'logs'
1563
- ? [{ expiration: cdk.Duration.days(intent.retentionDays ?? 90), id: 'log-expiry' }]
1564
- : intent.purpose === 'backups'
1565
- ? [
1566
- { transitions: [{ storageClass: s3.StorageClass.INFREQUENT_ACCESS, transitionAfter: cdk.Duration.days(30) }], id: 'backup-ia' },
1567
- { transitions: [{ storageClass: s3.StorageClass.GLACIER, transitionAfter: cdk.Duration.days(90) }], id: 'backup-glacier' },
1568
- ]
1569
- : [],
1570
- });
1571
- // Well-Architected: Reliability — UpdateReplacePolicy for stateful resources
1572
- const cfnBucket = bucket.node.defaultChild;
1573
- if (this.dataSafetyConfig.removalPolicy === 'retain') {
1574
- cfnBucket.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN;
1575
- }
1576
- new cdk.CfnOutput(this, `${logicalId}-bucket-name`, {
1577
- value: bucket.bucketName,
1578
- description: `S3 bucket name for ${intent.id}`,
1579
- });
1580
- new cdk.CfnOutput(this, `${logicalId}-bucket-arn`, {
1581
- value: bucket.bucketArn,
1582
- description: `S3 bucket ARN for ${intent.id}`,
1583
- });
1584
- // Optional CloudFront distribution in front of the (still-private)
1585
- // bucket. Enabled by `intent.cdn === true`. The distribution uses an
1586
- // Origin Access Control (OAC), which is the modern replacement for
1587
- // OAI: `S3BucketOrigin.withOriginAccessControl(bucket)` auto-creates
1588
- // the OAC resource and appends a bucket-policy statement granting
1589
- // `s3:GetObject` to the CloudFront service principal under an
1590
- // `aws:SourceArn` condition pinned to this distribution. Result:
1591
- // anonymous viewers can GET image bytes through the CDN, but direct
1592
- // S3 URLs continue to return 403.
1593
- //
1594
- // The distribution's domain name is exposed as a CfnOutput and is
1595
- // injected into every Lambda as `STORAGE_<ID>_CDN_URL` (and as
1596
- // `VENTURE_STORAGE_CDN_URL` for the primary intent) in the
1597
- // role-wiring block below, so application code can build public
1598
- // URLs without an extra Outputs lookup.
1599
- let distribution;
1600
- let cdnAlias;
1601
- if (intent.cdn === true) {
1602
- // Optional alternate domain (CNAME) + ACM cert. CloudFront only
1603
- // accepts certs from `us-east-1`, so the ARN MUST come from there
1604
- // regardless of which region the rest of the stack runs in. The
1605
- // CLI helper for this is:
1606
- //
1607
- // vk cert request --domain cdn.example.com --region us-east-1
1608
- //
1609
- // followed by pasting the issued ARN into `cdnCertificateArn`.
1610
- // An empty-string `cdnCertificateArn` is the same "pending"
1611
- // sentinel used by `DomainIntent.certificateArn`: skip the alias
1612
- // and let the distribution serve at its auto-generated
1613
- // `*.cloudfront.net` hostname until the next deploy.
1614
- const aliasReady = typeof intent.cdnDomain === 'string' &&
1615
- intent.cdnDomain.length > 0 &&
1616
- typeof intent.cdnCertificateArn === 'string' &&
1617
- intent.cdnCertificateArn.length > 0;
1618
- let aliasProps = {};
1619
- if (aliasReady) {
1620
- aliasProps = {
1621
- domainNames: [intent.cdnDomain],
1622
- certificate: acm.Certificate.fromCertificateArn(this, `${logicalId}-cdn-cert`, intent.cdnCertificateArn),
1623
- };
1624
- cdnAlias = intent.cdnDomain;
1625
- }
1626
- else if (typeof intent.cdnDomain === 'string' &&
1627
- intent.cdnDomain.length > 0) {
1628
- // Domain declared but cert ARN not filled in yet — surface a
1629
- // hint so operators know exactly what to run, mirroring the
1630
- // pending-domain CfnOutput in `--- Custom Domains ---`.
1631
- new cdk.CfnOutput(this, `${logicalId}-cdn-alias-pending`, {
1632
- value: `Run \`vk cert request --domain ${intent.cdnDomain} --region us-east-1\` to activate.`,
1633
- description: `CDN alias ${intent.cdnDomain} is pending — no ACM cert ARN yet.`,
1634
- });
1635
- }
1636
- distribution = new cloudfront.Distribution(this, `${logicalId}-cdn`, {
1637
- comment: `${projectName}-${intent.id}-${stage}`,
1638
- defaultBehavior: {
1639
- origin: cloudfrontOrigins.S3BucketOrigin.withOriginAccessControl(bucket),
1640
- viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
1641
- allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD_OPTIONS,
1642
- cachedMethods: cloudfront.CachedMethods.CACHE_GET_HEAD_OPTIONS,
1643
- cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
1644
- compress: true,
1645
- },
1646
- priceClass: cloudfront.PriceClass.PRICE_CLASS_100,
1647
- enabled: true,
1648
- ...aliasProps,
1649
- });
1650
- // Well-Architected: Reliability — RETAIN the distribution under
1651
- // strict dataSafety. Distributions are slow to create (~15-30 min
1652
- // for the global edge rollout), have a stable `*.cloudfront.net`
1653
- // domain that consumers may cache, and re-issuing the ACM cert
1654
- // requires re-doing DNS validation. RETAIN means an accidental
1655
- // stack delete leaves the distribution in place for `cdk import`.
1656
- if (this.dataSafetyConfig.removalPolicy === 'retain') {
1657
- const cfnDistribution = distribution.node.defaultChild;
1658
- cfnDistribution.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.RETAIN;
1659
- cfnDistribution.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN;
1660
- }
1661
- new cdk.CfnOutput(this, `${logicalId}-cdn-domain`, {
1662
- value: distribution.distributionDomainName,
1663
- description: `CloudFront domain for ${intent.id}`,
1664
- });
1665
- if (cdnAlias) {
1666
- // Reminder for operators: the alias only resolves once the
1667
- // matching DNS record points at the distribution. Print the
1668
- // exact target so this isn't lost in the CFN output noise.
1669
- new cdk.CfnOutput(this, `${logicalId}-cdn-alias`, {
1670
- value: `${cdnAlias} → ${distribution.distributionDomainName} (create a CNAME / Cloudflare proxy record)`,
1671
- description: `CDN alias for ${intent.id}`,
1672
- });
1673
- }
1674
- }
1675
- // Tracked for post-role wiring: the shared Lambda execution role is
1676
- // created later in the constructor (only when the stack actually has
1677
- // Lambdas), so we record the bucket here and grant + inject env vars
1678
- // alongside the database / Cognito wiring once the role exists.
1679
- this.storageBuckets.push({ bucket, intent, distribution, cdnAlias });
1680
- }
1681
- /**
1682
- * Well-Architected: Security — Cognito with advanced security features,
1683
- * stable logical ID for safe preset upgrades.
1684
- * Well-Architected: Reliability — dataSafety-driven removal policy
1685
- */
1686
- createAuth(intent, projectName, stage) {
1687
- // Stable logical ID: 'auth-{intent.id}'
1688
- const logicalId = `auth-${intent.id}`;
1689
- const passwordPolicy = intent.passwordStrength === 'strong'
1690
- ? {
1691
- minLength: 12,
1692
- requireLowercase: true,
1693
- requireUppercase: true,
1694
- requireDigits: true,
1695
- requireSymbols: true,
1696
- }
1697
- : {
1698
- minLength: 8,
1699
- requireLowercase: true,
1700
- requireUppercase: true,
1701
- requireDigits: true,
1702
- requireSymbols: false,
1703
- };
1704
- // Custom user attributes — declared as mutable strings so the same
1705
- // names show up as `custom:<name>` claims in the issued JWTs (and so
1706
- // `vk dev`'s cognito-local provisioning, which mirrors this Schema,
1707
- // matches production exactly). All app code that reads
1708
- // `custom:tenantId` etc. relies on these being declared on the pool.
1709
- const customAttrNames = Array.isArray(intent.customAttributes)
1710
- ? intent.customAttributes
1711
- : [];
1712
- const customAttributes = {};
1713
- for (const name of customAttrNames) {
1714
- customAttributes[name] = new cognito.StringAttribute({
1715
- minLen: 1,
1716
- maxLen: 2048,
1717
- mutable: true,
1718
- });
1719
- }
1720
- const userPool = new cognito.UserPool(this, logicalId, {
1721
- userPoolName: `${projectName}-${intent.id}-${stage}`,
1722
- // Security default: self-signup disabled unless the intent explicitly
1723
- // opts in. Most production apps use invite flows; opt in with
1724
- // `allowSignUp: true` on the auth intent if you run a consumer product.
1725
- selfSignUpEnabled: intent.allowSignUp ?? false,
1726
- // `phone` flips on Cognito's `phone_number` alias attribute so a
1727
- // single user can authenticate with either email or phone without
1728
- // a duplicate account. `signInWith: ['email', 'phone']` therefore
1729
- // mirrors the §1 user model where buyers register with one and
1730
- // log in with the other interchangeably.
1731
- signInAliases: {
1732
- email: intent.signInWith?.includes('email') ?? true,
1733
- phone: intent.signInWith?.includes('phone') ?? false,
1734
- username: intent.signInWith?.includes('username') ?? false,
1735
- },
1736
- autoVerify: {
1737
- email: intent.signInWith?.includes('email') ?? true,
1738
- phone: intent.signInWith?.includes('phone') ?? false,
1739
- },
1740
- passwordPolicy,
1741
- mfa: intent.mfa === 'required'
1742
- ? cognito.Mfa.REQUIRED
1743
- : intent.mfa === 'optional'
1744
- ? cognito.Mfa.OPTIONAL
1745
- : cognito.Mfa.OFF,
1746
- // Well-Architected: Reliability — dataSafety removal policy
1747
- removalPolicy: this.toRemovalPolicy(),
1748
- // Well-Architected: Reliability — native deletion protection on top of
1749
- // RETAIN. `DeletionPolicy=Retain` only blocks CFN-driven deletes; the
1750
- // user pool can still be deleted via console / API / CLI. Cognito's
1751
- // native deletionProtection flag blocks that path too. Both layers
1752
- // must be explicitly disabled before the pool can be deleted, and
1753
- // every flip is CloudTrail-logged. Losing a Cognito User Pool means
1754
- // losing every user identity + password hash — the most catastrophic
1755
- // recoverable state in a VentureKit deploy, since users must re-
1756
- // register via password reset.
1757
- deletionProtection: this.dataSafetyConfig.removalPolicy === 'retain',
1758
- // Well-Architected: Security — advanced security mode for medium+ presets
1759
- advancedSecurityMode: this.envConfig.lambda.memoryMb >= 512
1760
- ? cognito.AdvancedSecurityMode.ENFORCED
1761
- : undefined,
1762
- // Well-Architected: Security — account recovery via verified email
1763
- accountRecovery: cognito.AccountRecovery.EMAIL_ONLY,
1764
- ...(customAttrNames.length > 0 ? { customAttributes } : {}),
1765
- });
1766
- // Stash the pool so the shared Lambda role (created later in the
1767
- // constructor, see `if (hasAnyLambda)`) can be granted admin
1768
- // Cognito actions scoped to this pool's ARN. Without that grant,
1769
- // any handler that calls `AdminConfirmSignUp`, `AdminCreateUser`
1770
- // etc. fails at runtime with `AccessDeniedException` — the public
1771
- // `SignUp` / `InitiateAuth` calls work without IAM, but the admin
1772
- // surface that self-service register flows almost always need
1773
- // does not.
1774
- this.userPools.push(userPool);
1775
- // Well-Architected: Reliability — prevent accidental replacement
1776
- const cfnPool = userPool.node.defaultChild;
1777
- if (this.dataSafetyConfig.removalPolicy === 'retain') {
1778
- cfnPool.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN;
1779
- }
1780
- // Create clients
1781
- const clients = intent.clients && intent.clients.length > 0
1782
- ? intent.clients
1783
- : [{ name: 'default' }];
1784
- // When the intent declares federated providers, the federated
1785
- // sign-in helper in `@venturekit/auth/server` mints session tokens
1786
- // through `AdminInitiateAuth` after creating/looking up the user
1787
- // via `AdminCreateUser` / `AdminGetUser`. That flow needs
1788
- // `ALLOW_ADMIN_USER_PASSWORD_AUTH` enabled on the app client —
1789
- // password sign-in keeps using the public `USER_PASSWORD_AUTH`
1790
- // path so we leave both turned on.
1791
- const federatedProviders = Array.isArray(intent.federated)
1792
- ? intent.federated
1793
- : [];
1794
- const enableAdminAuth = federatedProviders.length > 0;
1795
- let primaryClientId = '';
1796
- for (const client of clients) {
1797
- const appClient = userPool.addClient(client.name, {
1798
- authFlows: {
1799
- userPassword: true,
1800
- userSrp: true,
1801
- ...(enableAdminAuth ? { adminUserPassword: true } : {}),
1802
- },
1803
- // Well-Architected: Security — prevent user existence errors
1804
- preventUserExistenceErrors: true,
1805
- });
1806
- if (!primaryClientId) {
1807
- primaryClientId = appClient.userPoolClientId;
1808
- }
1809
- }
1810
- new cdk.CfnOutput(this, `${logicalId}-user-pool-id`, {
1811
- value: userPool.userPoolId,
1812
- description: `Cognito User Pool ID for ${intent.id}`,
1813
- });
1814
- new cdk.CfnOutput(this, `${logicalId}-client-id`, {
1815
- value: primaryClientId,
1816
- description: `Cognito Client ID for ${intent.id}`,
1817
- });
1818
- // Auto-inject the user-pool and app-client IDs into every Lambda's
1819
- // environment so `@venturekit/auth/server`'s `loadAuthServerConfig()`
1820
- // resolves without manual operator intervention. We use the FIRST
1821
- // declared `auth` intent as the canonical pool — multi-pool projects
1822
- // are rare, and consumers can always override with explicit
1823
- // `envVars` entries on the intent.
1824
- //
1825
- // Lambdas already have `AWS_REGION` set automatically by Lambda, and
1826
- // `loadAuthServerConfig()` falls back to it when `COGNITO_REGION`
1827
- // isn't present, so we don't set the region here. (Setting
1828
- // `AWS_REGION` ourselves is rejected by Lambda as a reserved key.)
1829
- if (!('COGNITO_USER_POOL_ID' in this.ventureBaseEnv)) {
1830
- this.ventureBaseEnv['COGNITO_USER_POOL_ID'] = userPool.userPoolId;
1831
- this.ventureBaseEnv['COGNITO_APP_CLIENT_ID'] = primaryClientId;
1832
- }
1833
- // ── Federated identity providers ──────────────────────────────
1834
- // For each declared third-party provider create a Secrets Manager
1835
- // placeholder holding `{clientId, clientSecret}`. Operators paste
1836
- // the real values from the Google Cloud / Meta for Developers /
1837
- // Apple Developer console after the first deploy via
1838
- // aws secretsmanager put-secret-value --secret-id <arn> \
1839
- // --secret-string '{"clientId":"…","clientSecret":"…"}'
1840
- //
1841
- // The ARN is injected as `COGNITO_FEDERATED_<PROVIDER>_SECRET_ARN`
1842
- // on every Lambda; `secretsmanager:GetSecretValue` is granted on
1843
- // the secret in the shared-Lambda-role wiring later in the
1844
- // constructor (next to the user-pool admin grants).
1845
- //
1846
- // VentureKit deliberately does NOT enable the Cognito Hosted UI
1847
- // or wire these as Cognito Identity Providers — your app owns the
1848
- // login screen. The SPA performs the OAuth dance with the IdP
1849
- // directly and posts the resulting id_token / access_token to
1850
- // your API; `signInAsFederatedUser` from `@venturekit/auth/server`
1851
- // verifies it and mints Cognito session tokens via
1852
- // `AdminInitiateAuth`.
1853
- for (const provider of federatedProviders) {
1854
- const secret = new secretsmanager.Secret(this, `${logicalId}-federated-${provider}`, {
1855
- secretName: `venturekit/${projectName}/${stage}/auth/${intent.id}/${provider}`,
1856
- description: `OAuth client credentials for ${provider} federated sign-in ` +
1857
- `on auth pool '${intent.id}'. Populate after first deploy.`,
1858
- secretObjectValue: {
1859
- clientId: cdk.SecretValue.unsafePlainText('PLACEHOLDER'),
1860
- clientSecret: cdk.SecretValue.unsafePlainText('PLACEHOLDER'),
1861
- },
1862
- // Well-Architected: Reliability — federated provider credentials
1863
- // are operator-set values pasted from the third-party developer
1864
- // console (Google Cloud, Meta for Developers, Apple Developer).
1865
- // They can be re-generated by the operator but require manually
1866
- // revisiting that console; we'd rather not force that on every
1867
- // stack-replace event. Promote to RETAIN under strict dataSafety
1868
- // so accidental stack deletes don't wipe them.
1869
- removalPolicy: this.toRemovalPolicy(),
1870
- });
1871
- this.federatedAuthSecrets.push(secret);
1872
- const envVar = `COGNITO_FEDERATED_${provider.toUpperCase()}_SECRET_ARN`;
1873
- this.ventureBaseEnv[envVar] = secret.secretArn;
1874
- new cdk.CfnOutput(this, `${logicalId}-federated-${provider}-secret-arn`, {
1875
- value: secret.secretArn,
1876
- description: `Populate with the ${provider} OAuth client credentials: ` +
1877
- `aws secretsmanager put-secret-value --secret-id <arn> ` +
1878
- `--secret-string '{"clientId":"…","clientSecret":"…"}'`,
1879
- });
1880
- }
1881
- }
1882
- /**
1883
- * Provision SES + outbox infrastructure for one `NotifyIntent`.
1884
- *
1885
- * Resources created here:
1886
- * - One SES email or domain identity (drives DKIM + verified-from).
1887
- * - One SES configuration set with an event destination publishing
1888
- * bounce / complaint / delivery / open / click events to an
1889
- * SNS topic.
1890
- * - The SNS topic itself (`{project}-{stage}-notify-{id}-events`).
1891
- * - A Secrets Manager secret for the WhatsApp Cloud API token,
1892
- * when `'whatsapp'` is in `channels`. The value is left as a
1893
- * placeholder; the operator pastes the Meta token after first
1894
- * deploy via the AWS console (the secret rotation knob would be
1895
- * a Phase 2 enhancement once token rotation is part of the API).
1896
- * - When `domain` AND `hostedZoneId` are both set, Route53 CNAME
1897
- * records for the three SES DKIM tokens, plus an MX + TXT record
1898
- * on the MAIL FROM subdomain. Without `hostedZoneId`, the
1899
- * records are emitted as stack outputs and the operator
1900
- * publishes them manually.
1901
- *
1902
- * Resources NOT created here: dispatcher cron Lambda, bounce-handler
1903
- * Lambda, IAM grants on the shared role. Those need the role + VPC
1904
- * + SG which only exist later in the constructor — see
1905
- * `provisionNotifyHandlers` below.
1906
- */
1907
- createNotify(intent, projectName, stage) {
1908
- const logicalId = `notify-${intent.id}`;
1909
- const channels = Array.isArray(intent.channels) ? intent.channels : [];
1910
- // Stable, predictable resource names. Avoid hashing — operators
1911
- // often grep CloudFormation outputs / IAM policies for the
1912
- // intent id and silent hashes break that workflow.
1913
- const configurationSetName = intent.configurationSetName ?? `${projectName}-${stage}-notify-${intent.id}`;
1914
- // ── SNS events topic ────────────────────────────────────────────
1915
- // Receives every SES event for this configuration set. Subscribed
1916
- // to by the bounce-handler Lambda (provisioned later) and
1917
- // optionally by external observability tools. Encryption at rest
1918
- // with the AWS-managed key — events occasionally contain
1919
- // recipient PII (bounce diagnostic emails) which we don't want
1920
- // sitting unencrypted in case of an account compromise.
1921
- const eventsTopic = new sns.Topic(this, `${logicalId}-events`, {
1922
- topicName: `${projectName}-${stage}-notify-${intent.id}-events`,
1923
- displayName: `VentureKit notify events for ${intent.id} (${stage})`,
1924
- masterKey: undefined, // AWS-managed encryption is fine; KMS CMK
1925
- // would add cost + key-policy maintenance
1926
- // for a topic only consumed by our own
1927
- // Lambda + the operator's SNS console.
1928
- });
1929
- // Well-Architected: Reliability — RETAIN the events topic under
1930
- // strict dataSafety. SNS topics carry durable subscriptions (the
1931
- // bounce-handler Lambda is one; operators may attach additional
1932
- // subscribers via the console for observability tools or paging).
1933
- // Deleting the topic silently drops every subscription; re-creating
1934
- // the topic does NOT restore them. RETAIN means an accidental stack
1935
- // delete leaves the topic + subscriptions in place for recovery.
1936
- if (this.dataSafetyConfig.removalPolicy === 'retain') {
1937
- const cfnEventsTopic = eventsTopic.node.defaultChild;
1938
- cfnEventsTopic.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.RETAIN;
1939
- cfnEventsTopic.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN;
1940
- }
1941
- new cdk.CfnOutput(this, `${logicalId}-events-topic-arn`, {
1942
- value: eventsTopic.topicArn,
1943
- description: `SES events SNS topic for notify '${intent.id}'`,
1944
- });
1945
- // ── SES email identity ──────────────────────────────────────────
1946
- // Two paths depending on what the intent declared:
1947
- // - `domain` set → verify the whole domain. SES creates
1948
- // DKIM tokens; we publish the matching
1949
- // CNAMEs in Route53 when `hostedZoneId`
1950
- // is set.
1951
- // - `defaultFrom` only → verify a single email address. SES
1952
- // sends a verification email to it;
1953
- // sending is blocked until the
1954
- // recipient clicks through. Useful for
1955
- // sandbox / dev where the operator
1956
- // doesn't own the domain DNS.
1957
- let identityArn;
1958
- if (intent.domain) {
1959
- const domainIdentity = new ses.EmailIdentity(this, `${logicalId}-domain`, {
1960
- identity: ses.Identity.domain(intent.domain),
1961
- configurationSet: undefined, // attached to per-send command, not identity
1962
- dkimSigning: true,
1963
- feedbackForwarding: false, // we use the configuration set
1964
- // event destination instead — no need
1965
- // for SES to email the operator on
1966
- // every bounce.
1967
- });
1968
- identityArn = `arn:${cdk.Aws.PARTITION}:ses:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:identity/${intent.domain}`;
1969
- // CDK's `EmailIdentity` exposes the three DKIM token pairs as
1970
- // discrete properties (`dkimDnsTokenName1/2/3` +
1971
- // `dkimDnsTokenValue1/2/3`), not as arrays. We tuple them up
1972
- // here so the loop below stays uniform.
1973
- const dkimTokens = [
1974
- { name: domainIdentity.dkimDnsTokenName1, value: domainIdentity.dkimDnsTokenValue1 },
1975
- { name: domainIdentity.dkimDnsTokenName2, value: domainIdentity.dkimDnsTokenValue2 },
1976
- { name: domainIdentity.dkimDnsTokenName3, value: domainIdentity.dkimDnsTokenValue3 },
1977
- ];
1978
- // DKIM token outputs — operator publishes these as CNAMEs in DNS
1979
- // unless we own the zone (Route53 path below).
1980
- dkimTokens.forEach((tok, i) => {
1981
- new cdk.CfnOutput(this, `${logicalId}-dkim-${i + 1}-record`, {
1982
- value: `${tok.name}.${intent.domain} CNAME ${tok.value}.dkim.amazonses.com`,
1983
- description: `DKIM CNAME #${i + 1} for ${intent.domain} — publish in DNS`,
1984
- });
1985
- });
1986
- if (intent.hostedZoneId) {
1987
- const zone = route53.HostedZone.fromHostedZoneAttributes(this, `${logicalId}-zone`, {
1988
- hostedZoneId: intent.hostedZoneId,
1989
- zoneName: intent.domain,
1990
- });
1991
- // Auto-publish the three DKIM CNAMEs. Route53 charges $0.50
1992
- // per hosted zone + $0.40 per million queries — these records
1993
- // are queried only by SES so the cost is negligible.
1994
- dkimTokens.forEach((tok, i) => {
1995
- new route53.CnameRecord(this, `${logicalId}-dkim-record-${i + 1}`, {
1996
- zone,
1997
- // The token names from CDK are unqualified (e.g. `abc123`);
1998
- // SES requires the full `<token>._domainkey.<domain>` form.
1999
- // Route53 appends the zone automatically when `recordName`
2000
- // is unqualified, so we just pass the SES-style label.
2001
- recordName: `${tok.name}._domainkey`,
2002
- domainName: `${tok.value}.dkim.amazonses.com`,
2003
- ttl: cdk.Duration.minutes(5),
2004
- });
2005
- });
2006
- // Custom MAIL FROM subdomain (`mail.<domain>`) improves DMARC
2007
- // alignment by making the SMTP envelope-sender match the From
2008
- // domain. SES requires an MX record on the subdomain pointing
2009
- // at `feedback-smtp.<region>.amazonses.com` plus a TXT record
2010
- // for SPF. We don't auto-enable in Phase 1 — turning it on
2011
- // mid-life would temporarily break sending if the records
2012
- // aren't published — but emit the recommendation as an output.
2013
- new cdk.CfnOutput(this, `${logicalId}-mail-from-suggestion`, {
2014
- value: `mail.${intent.domain}`,
2015
- description: `Suggested MAIL FROM subdomain — enable in SES console for SPF alignment`,
2016
- });
2017
- }
2018
- new cdk.CfnOutput(this, `${logicalId}-domain-identity`, {
2019
- value: intent.domain,
2020
- description: `SES domain identity for notify '${intent.id}'`,
2021
- });
2022
- }
2023
- else if (intent.defaultFrom) {
2024
- new ses.EmailIdentity(this, `${logicalId}-email`, {
2025
- identity: ses.Identity.email(intent.defaultFrom),
2026
- // No DKIM signing on single-address identities — SES doesn't
2027
- // support DKIM without a verified domain.
2028
- feedbackForwarding: false,
2029
- });
2030
- identityArn = `arn:${cdk.Aws.PARTITION}:ses:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:identity/${intent.defaultFrom}`;
2031
- new cdk.CfnOutput(this, `${logicalId}-email-identity`, {
2032
- value: intent.defaultFrom,
2033
- description: `SES email identity for notify '${intent.id}'. ` +
2034
- `Verify by clicking the SES email sent to this address on first deploy.`,
2035
- });
2036
- }
2037
- else {
2038
- throw new Error(`[venturekit] notify intent '${intent.id}' must declare either ` +
2039
- `'defaultFrom' (single-address SES identity) or 'domain' (DomainIdentity).`);
2040
- }
2041
- // ── Configuration set + event destination ───────────────────────
2042
- // The configuration set is attached at send-time (every
2043
- // SendEmailCommand carries `ConfigurationSetName`). Its event
2044
- // destination publishes selected event types to the SNS topic
2045
- // above. We subscribe to bounce/complaint always (suppression
2046
- // logic depends on them), plus delivery/open/click for
2047
- // observability. Reject — SES rejecting a send for content
2048
- // reasons — is implicit (the SendEmail call throws synchronously
2049
- // and the dispatcher captures it without needing the event).
2050
- const configSet = new ses.ConfigurationSet(this, `${logicalId}-config-set`, {
2051
- configurationSetName,
2052
- sendingEnabled: true,
2053
- reputationMetrics: true, // surface deliverability metrics in
2054
- // CloudWatch — free, helpful for
2055
- // operators chasing soft-bounce rates.
2056
- tlsPolicy: ses.ConfigurationSetTlsPolicy.REQUIRE,
2057
- });
2058
- new ses.ConfigurationSetEventDestination(this, `${logicalId}-event-dest`, {
2059
- configurationSet: configSet,
2060
- destination: ses.EventDestination.snsTopic(eventsTopic),
2061
- events: [
2062
- ses.EmailSendingEvent.SEND,
2063
- ses.EmailSendingEvent.REJECT,
2064
- ses.EmailSendingEvent.BOUNCE,
2065
- ses.EmailSendingEvent.COMPLAINT,
2066
- ses.EmailSendingEvent.DELIVERY,
2067
- ses.EmailSendingEvent.OPEN,
2068
- ses.EmailSendingEvent.CLICK,
2069
- ],
2070
- enabled: true,
2071
- });
2072
- new cdk.CfnOutput(this, `${logicalId}-config-set-name`, {
2073
- value: configurationSetName,
2074
- description: `SES configuration set name for notify '${intent.id}'`,
2075
- });
2076
- // ── WhatsApp token secret ───────────────────────────────────────
2077
- let whatsappTokenSecret;
2078
- if (channels.includes('whatsapp')) {
2079
- if (!intent.whatsapp?.phoneNumberId) {
2080
- throw new Error(`[venturekit] notify intent '${intent.id}' has 'whatsapp' channel ` +
2081
- `but no 'whatsapp.phoneNumberId'. Set it from Meta Business Suite ` +
2082
- `→ WhatsApp → API Setup.`);
2083
- }
2084
- whatsappTokenSecret = new secretsmanager.Secret(this, `${logicalId}-whatsapp-token`, {
2085
- secretName: `venturekit/${projectName}/${stage}/notify-${intent.id}-whatsapp-token`,
2086
- description: `Meta WhatsApp Cloud API access token for notify '${intent.id}'. ` +
2087
- `Set the value via the Secrets Manager console after first deploy.`,
2088
- // The secret is created with a placeholder — the operator
2089
- // pastes the real Meta token via the console. Auto-rotation
2090
- // is not wired (Meta tokens are long-lived; rotation is a
2091
- // Phase 2 concern).
2092
- secretStringValue: cdk.SecretValue.unsafePlainText('PLACEHOLDER_SET_VALUE_AFTER_DEPLOY'),
2093
- removalPolicy: this.toRemovalPolicy(),
2094
- });
2095
- new cdk.CfnOutput(this, `${logicalId}-whatsapp-token-arn`, {
2096
- value: whatsappTokenSecret.secretArn,
2097
- description: `Set the value of this secret to your Meta WhatsApp access token: ` +
2098
- `aws secretsmanager put-secret-value --secret-id <arn> --secret-string '<token>'`,
2099
- });
2100
- }
2101
- this.notifyConfigs.push({
2102
- intent,
2103
- configurationSetName,
2104
- eventsTopic,
2105
- identityArn,
2106
- whatsappTokenSecret,
2107
- });
2108
- }
2109
- /**
2110
- * Auto-provision the dispatcher cron Lambda + the bounce-handler
2111
- * Lambda subscribed to the SES events SNS topic, for every notify
2112
- * intent.
2113
- *
2114
- * Implementation notes:
2115
- *
2116
- * - The Lambdas import a stub shipped inside `@venturekit/notify`
2117
- * (`runtime/dispatcher-stub.ts`, `runtime/bounce-handler-stub.ts`).
2118
- * At synth time we copy the stub into the project's `.vk/` dir
2119
- * alongside a generated `notify-client-import.ts` shim that
2120
- * re-exports the consumer's `notify` instance from
2121
- * `VENTURE_NOTIFY_CLIENT_MODULE` (default `src/lib/notify.ts`).
2122
- * esbuild then bundles the whole closure into the Lambda — the
2123
- * consumer's notify config + every provider + the @venturekit
2124
- * runtime, all in one deploy artefact.
2125
- *
2126
- * - We re-use the shared Lambda role for these handlers. Permissions:
2127
- * - SES + secrets grants are already attached above
2128
- * (`provisionNotifyHandlers` is called from the same block).
2129
- * - SNS topic subscription + invocation perms are added by CDK
2130
- * when we wire `topic.addSubscription(LambdaSubscription)`.
2131
- *
2132
- * - VPC attachment: yes, when the project has a VPC. The dispatcher
2133
- * needs DB access (Postgres outbox + preferences); the bounce
2134
- * handler also writes `notification_event_log`.
2135
- */
2136
- provisionNotifyHandlers(projectName, stage, projectDir, role, securityGroup, vpc) {
2137
- // Resolve the path to the stubs inside the consumer's
2138
- // `node_modules/@venturekit/notify`. We use Node's resolver via a
2139
- // module-relative `require.resolve` pinned at the consumer's
2140
- // `package.json`. If `@venturekit/notify` isn't a project
2141
- // dependency we throw with a helpful message — declaring a
2142
- // notify intent in vk.config.ts without installing the runtime
2143
- // package is a frequent first-time-user mistake.
2144
- const projectRequire = createRequire(path.join(projectDir, 'package.json'));
2145
- let dispatcherStubSrc;
2146
- let bounceStubSrc;
2147
- try {
2148
- dispatcherStubSrc = projectRequire.resolve('@venturekit/notify/runtime/dispatcher-stub');
2149
- bounceStubSrc = projectRequire.resolve('@venturekit/notify/runtime/bounce-handler-stub');
2150
- }
2151
- catch (err) {
2152
- throw new Error(`[venturekit] notify intent declared but '@venturekit/notify' is not ` +
2153
- `installed in the project. Run \`pnpm add @venturekit/notify\` ` +
2154
- `(or your equivalent) and redeploy. Underlying error: ${err.message}`);
2155
- }
2156
- // Materialize copies of the stubs in `.vk/` so esbuild can resolve
2157
- // their `import { notify } from '<projectClientModule>'` against
2158
- // the project's source tree. Writing to `.vk/` keeps the stubs out
2159
- // of the consumer's `src/` (and out of `git status` if they
2160
- // gitignore `.vk/`, which the init template already does).
2161
- const vkDir = path.join(projectDir, '.vk');
2162
- fs.mkdirSync(vkDir, { recursive: true });
2163
- for (const cfg of this.notifyConfigs) {
2164
- const id = String(cfg.intent.id);
2165
- const dispatchRateMinutes = cfg.intent.dispatchRateMinutes && cfg.intent.dispatchRateMinutes >= 1
2166
- ? cfg.intent.dispatchRateMinutes
2167
- : 1;
2168
- const batchSize = cfg.intent.dispatchBatchSize && cfg.intent.dispatchBatchSize >= 1
2169
- ? cfg.intent.dispatchBatchSize
2170
- : 50;
2171
- // Per-intent stub copies — the consumer might declare multiple
2172
- // notify intents (separate domains for marketing vs
2173
- // transactional, say), each with its own client module. Naming
2174
- // by id prevents collisions.
2175
- const dispatchStubPath = path.join(vkDir, `notify-${id}-dispatcher.ts`);
2176
- const bounceStubPath = path.join(vkDir, `notify-${id}-bounce.ts`);
2177
- fs.copyFileSync(dispatcherStubSrc, dispatchStubPath);
2178
- fs.copyFileSync(bounceStubSrc, bounceStubPath);
2179
- // --- Dispatcher cron Lambda --------------------------------
2180
- const dispatcherFnId = `notify-${id}-dispatcher`;
2181
- const dispatcherDlq = new sqs.Queue(this, `${dispatcherFnId}-dlq`, {
2182
- queueName: `${projectName}-${stage}-${dispatcherFnId}-dlq`,
2183
- retentionPeriod: cdk.Duration.days(14),
2184
- encryption: sqs.QueueEncryption.SQS_MANAGED,
2185
- // Well-Architected: Reliability — queues hold accepted-but-not-yet-
2186
- // processed work. CDK's default removalPolicy for sqs.Queue is
2187
- // DESTROY, which would silently delete the DLQ and all its failed-
2188
- // message diagnostics on stack delete. Under dataSafety='strict' we
2189
- // promote to RETAIN so the queue + messages survive any stack churn.
2190
- removalPolicy: this.toRemovalPolicy(),
2191
- });
2192
- const dispatcherFn = new lambda.Function(this, dispatcherFnId, {
2193
- functionName: `${projectName}-${stage}-${dispatcherFnId}`,
2194
- runtime: this.toLambdaRuntime(),
2195
- architecture: this.toLambdaArchitecture(),
2196
- handler: 'index.main',
2197
- code: this.bundleHandlerCode(dispatchStubPath),
2198
- memorySize: this.envConfig.lambda.memoryMb,
2199
- timeout: cdk.Duration.seconds(60),
2200
- description: `Notify dispatcher for '${id}' — drains the outbox.`,
2201
- environment: {
2202
- ...this.ventureBaseEnv,
2203
- VENTURE_NOTIFY_BATCH_SIZE: String(batchSize),
2204
- },
2205
- logRetention: this.toLogRetention(this.envConfig.observability.logs.retentionDays),
2206
- tracing: this.envConfig.lambda.tracingEnabled
2207
- ? lambda.Tracing.ACTIVE
2208
- : lambda.Tracing.DISABLED,
2209
- deadLetterQueue: dispatcherDlq,
2210
- deadLetterQueueEnabled: true,
2211
- role,
2212
- ...(vpc && securityGroup
2213
- ? {
2214
- vpc,
2215
- vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
2216
- securityGroups: [securityGroup],
2217
- }
2218
- : {}),
2219
- });
2220
- new events.Rule(this, `${dispatcherFnId}-rule`, {
2221
- ruleName: `${projectName}-${stage}-${dispatcherFnId}`,
2222
- schedule: events.Schedule.rate(cdk.Duration.minutes(dispatchRateMinutes)),
2223
- enabled: true,
2224
- targets: [new targets.LambdaFunction(dispatcherFn)],
2225
- });
2226
- new cdk.CfnOutput(this, `${dispatcherFnId}-arn`, {
2227
- value: dispatcherFn.functionArn,
2228
- description: `Notify dispatcher Lambda ARN for '${id}'`,
2229
- });
2230
- // --- Bounce handler Lambda ---------------------------------
2231
- const bounceFnId = `notify-${id}-bounce-handler`;
2232
- const bounceDlq = new sqs.Queue(this, `${bounceFnId}-dlq`, {
2233
- queueName: `${projectName}-${stage}-${bounceFnId}-dlq`,
2234
- retentionPeriod: cdk.Duration.days(14),
2235
- encryption: sqs.QueueEncryption.SQS_MANAGED,
2236
- // Same rationale as the dispatcher DLQ above: bounced/complained
2237
- // notifications are the canonical record of what failed and why;
2238
- // dropping them on stack delete loses the audit trail.
2239
- removalPolicy: this.toRemovalPolicy(),
2240
- });
2241
- const bounceFn = new lambda.Function(this, bounceFnId, {
2242
- functionName: `${projectName}-${stage}-${bounceFnId}`,
2243
- runtime: this.toLambdaRuntime(),
2244
- architecture: this.toLambdaArchitecture(),
2245
- handler: 'index.main',
2246
- code: this.bundleHandlerCode(bounceStubPath),
2247
- memorySize: this.envConfig.lambda.memoryMb,
2248
- timeout: cdk.Duration.seconds(30),
2249
- description: `Notify bounce/complaint handler for '${id}'.`,
2250
- environment: { ...this.ventureBaseEnv },
2251
- logRetention: this.toLogRetention(this.envConfig.observability.logs.retentionDays),
2252
- tracing: this.envConfig.lambda.tracingEnabled
2253
- ? lambda.Tracing.ACTIVE
2254
- : lambda.Tracing.DISABLED,
2255
- deadLetterQueue: bounceDlq,
2256
- deadLetterQueueEnabled: true,
2257
- role,
2258
- ...(vpc && securityGroup
2259
- ? {
2260
- vpc,
2261
- vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
2262
- securityGroups: [securityGroup],
2263
- }
2264
- : {}),
2265
- });
2266
- cfg.eventsTopic.addSubscription(new snsSubscriptions.LambdaSubscription(bounceFn));
2267
- new cdk.CfnOutput(this, `${bounceFnId}-arn`, {
2268
- value: bounceFn.functionArn,
2269
- description: `Notify bounce-handler Lambda ARN for '${id}'`,
2270
- });
2271
- }
2272
- }
2273
- /**
2274
- * Well-Architected: Reliability — DLQ by default, stable logical ID
2275
- * Well-Architected: Security — encryption at rest with SQS managed keys
2276
- * Well-Architected: Performance — visibility timeout derived from handler timeout
2277
- */
2278
- createQueue(intent, projectName, stage) {
2279
- // Stable logical ID: 'queue-{intent.id}'
2280
- const logicalId = `queue-${intent.id}`;
2281
- // Well-Architected: Reliability — always create a DLQ for message safety
2282
- const dlq = new sqs.Queue(this, `${logicalId}-dlq`, {
2283
- queueName: `${projectName}-${intent.id}-dlq-${stage}${intent.type === 'fifo' ? '.fifo' : ''}`,
2284
- fifo: intent.type === 'fifo',
2285
- retentionPeriod: cdk.Duration.days(14),
2286
- // Well-Architected: Security — encryption at rest
2287
- encryption: sqs.QueueEncryption.SQS_MANAGED,
2288
- // Well-Architected: Reliability — DLQs are the durable record of every
2289
- // message a consumer Lambda failed to process. Default CDK removalPolicy
2290
- // for sqs.Queue is DESTROY; under dataSafety='strict' we promote to
2291
- // RETAIN so investigation of past failures survives any redeploy or
2292
- // stack-delete event.
2293
- removalPolicy: this.toRemovalPolicy(),
2294
- });
2295
- const queue = new sqs.Queue(this, logicalId, {
2296
- queueName: `${projectName}-${intent.id}-${stage}${intent.type === 'fifo' ? '.fifo' : ''}`,
2297
- fifo: intent.type === 'fifo',
2298
- visibilityTimeout: intent.visibilityTimeoutSeconds
2299
- ? cdk.Duration.seconds(intent.visibilityTimeoutSeconds)
2300
- : cdk.Duration.seconds((intent.timeout ?? 30) * 6),
2301
- retentionPeriod: cdk.Duration.days(intent.retentionDays ?? 4),
2302
- // Well-Architected: Security — encryption at rest
2303
- encryption: sqs.QueueEncryption.SQS_MANAGED,
2304
- // Well-Architected: Reliability — DLQ captures failed messages
2305
- deadLetterQueue: {
2306
- queue: dlq,
2307
- maxReceiveCount: intent.maxReceiveCount ?? 3,
2308
- },
2309
- // Well-Architected: Reliability — primary work queue holds accepted
2310
- // jobs that have not yet been processed. CDK's default is DESTROY,
2311
- // which would lose every in-flight message on any stack delete or
2312
- // logical-ID rename. Under dataSafety='strict' we promote to RETAIN.
2313
- // Combined with the upcoming multi-stack messaging-tier split, the
2314
- // queue then survives even an app-stack tear-down — new ESMs in the
2315
- // redeployed app stack resume consumption from where the old one
2316
- // stopped.
2317
- removalPolicy: this.toRemovalPolicy(),
2318
- });
2319
- new cdk.CfnOutput(this, `${logicalId}-queue-url`, {
2320
- value: queue.queueUrl,
2321
- description: `SQS Queue URL for ${intent.id}`,
2322
- });
2323
- new cdk.CfnOutput(this, `${logicalId}-dlq-url`, {
2324
- value: dlq.queueUrl,
2325
- description: `SQS DLQ URL for ${intent.id}`,
2326
- });
2327
- }
2328
- /**
2329
- * Well-Architected: Security — VPC-only access, SG least-privilege
2330
- * Well-Architected: Performance — cache node type driven by intent.size
2331
- * Well-Architected: Reliability — encrypted in transit, snapshot retention for prod
2332
- */
2333
- createCache(intent, projectName, stage, vpc) {
2334
- // Stable logical ID: 'cache-{intent.id}'
2335
- const logicalId = `cache-${intent.id}`;
2336
- const securityGroup = new ec2.SecurityGroup(this, `${logicalId}-sg`, {
2337
- vpc,
2338
- description: `Security group for ${projectName}-${intent.id} Redis`,
2339
- // Well-Architected: Security — restrict outbound to VPC only
2340
- allowAllOutbound: false,
2341
- });
2342
- securityGroup.addIngressRule(ec2.Peer.ipv4(vpc.vpcCidrBlock), ec2.Port.tcp(6379), 'Allow Redis access from VPC');
2343
- // Well-Architected: Security — use isolated subnets when available
2344
- const subnetIds = vpc.isolatedSubnets?.length
2345
- ? vpc.isolatedSubnets.map((s) => s.subnetId)
2346
- : vpc.privateSubnets.map((s) => s.subnetId);
2347
- const subnetGroup = new elasticache.CfnSubnetGroup(this, `${logicalId}-subnet-group`, {
2348
- description: `Subnet group for ${projectName}-${intent.id}`,
2349
- subnetIds,
2350
- });
2351
- const cacheNodeType = this.sizeToCacheNodeType(intent.size || 'small');
2352
- const redis = new elasticache.CfnCacheCluster(this, logicalId, {
2353
- engine: 'redis',
2354
- cacheNodeType,
2355
- numCacheNodes: 1,
2356
- vpcSecurityGroupIds: [securityGroup.securityGroupId],
2357
- cacheSubnetGroupName: subnetGroup.ref,
2358
- // Well-Architected: Security — encrypt in transit
2359
- transitEncryptionEnabled: true,
2360
- // Well-Architected: Reliability — snapshots for prod
2361
- snapshotRetentionLimit: this.envConfig.dataSafety === 'strict' ? 7 : 0,
2362
- // Well-Architected: Operational Excellence — auto minor version upgrade
2363
- autoMinorVersionUpgrade: true,
2364
- });
2365
- new cdk.CfnOutput(this, `${logicalId}-endpoint`, {
2366
- value: redis.attrRedisEndpointAddress,
2367
- description: `Redis endpoint for ${intent.id}`,
2368
- });
2369
- }
2370
- createMonitoring(intent, projectName, stage) {
2371
- const resourceName = `${projectName}-${intent.id}`;
2372
- // SNS topic for notifications
2373
- let topic;
2374
- if (intent.notificationArn) {
2375
- topic = sns.Topic.fromTopicArn(this, `${resourceName}-topic`, intent.notificationArn);
2376
- }
2377
- // Create alarms
2378
- if (intent.alarms) {
2379
- for (const alarm of intent.alarms) {
2380
- const comparisonMap = {
2381
- gt: cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
2382
- gte: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
2383
- lt: cloudwatch.ComparisonOperator.LESS_THAN_THRESHOLD,
2384
- lte: cloudwatch.ComparisonOperator.LESS_THAN_OR_EQUAL_TO_THRESHOLD,
2385
- };
2386
- const metric = new cloudwatch.Metric({
2387
- namespace: alarm.namespace || 'AWS/Lambda',
2388
- metricName: alarm.metric,
2389
- period: cdk.Duration.seconds(alarm.periodSeconds ?? 300),
2390
- statistic: alarm.statistic || 'Average',
2391
- dimensionsMap: alarm.dimensions,
2392
- });
2393
- const cwAlarm = new cloudwatch.Alarm(this, `${resourceName}-${alarm.name}`, {
2394
- alarmName: `${projectName}-${stage}-${alarm.name}`,
2395
- alarmDescription: alarm.description,
2396
- metric,
2397
- threshold: alarm.threshold,
2398
- evaluationPeriods: alarm.evaluationPeriods ?? 1,
2399
- comparisonOperator: comparisonMap[alarm.comparison] || cloudwatch.ComparisonOperator.GREATER_THAN_THRESHOLD,
2400
- });
2401
- if (topic) {
2402
- cwAlarm.addAlarmAction(new cloudwatchActions.SnsAction(topic));
2403
- }
2404
- new cdk.CfnOutput(this, `${resourceName}-${alarm.name}-arn`, {
2405
- value: cwAlarm.alarmArn,
2406
- description: `Alarm ARN for ${alarm.name}`,
2407
- });
2408
- }
2409
- }
2410
- // Create dashboard
2411
- if (intent.dashboard) {
2412
- const dashboard = new cloudwatch.Dashboard(this, `${resourceName}-dashboard`, {
2413
- dashboardName: `${projectName}-${stage}-${intent.id}`,
2414
- });
2415
- new cdk.CfnOutput(this, `${resourceName}-dashboard-name`, {
2416
- value: dashboard.dashboardName,
2417
- description: `Dashboard name for ${intent.id}`,
2418
- });
2419
- }
2420
- }
2421
- createSecret(intent, projectName, stage) {
2422
- const resourceName = `${projectName}-${intent.id}`;
2423
- if (intent.store === 'secretsmanager') {
2424
- const secret = new secretsmanager.Secret(this, resourceName, {
2425
- secretName: `${projectName}/${stage}/${intent.id}`,
2426
- description: intent.description || `Secret for ${intent.id}`,
2427
- // Well-Architected: Reliability — operator-declared secrets hold
2428
- // values the operator pasted in manually (API keys, third-party
2429
- // integration tokens). They cannot be re-generated by VentureKit
2430
- // and an accidental delete forces a manual rotation of every
2431
- // downstream credential. RETAIN under strict dataSafety.
2432
- removalPolicy: this.toRemovalPolicy(),
2433
- });
2434
- new cdk.CfnOutput(this, `${resourceName}-secret-arn`, {
2435
- value: secret.secretArn,
2436
- description: `Secret ARN for ${intent.id}`,
2437
- });
2438
- }
2439
- else {
2440
- // SSM Parameter Store
2441
- const param = new ssm.StringParameter(this, resourceName, {
2442
- parameterName: `/${projectName}/${stage}/${intent.id}`,
2443
- description: intent.description || `Parameter for ${intent.id}`,
2444
- stringValue: 'PLACEHOLDER — set value after deploy',
2445
- tier: ssm.ParameterTier.STANDARD,
2446
- });
2447
- // SSM parameters don't carry their own DeletionPolicy attribute
2448
- // (the resource type doesn't support it in CFN), and AWS doesn't
2449
- // expose a "deletion protection" flag for them either. Operators
2450
- // relying on these for production secret-like config should
2451
- // either move to Secrets Manager via `store: 'secretsmanager'`
2452
- // (which we now RETAIN above) or accept that an accidental
2453
- // stack delete loses the value.
2454
- new cdk.CfnOutput(this, `${resourceName}-param-name`, {
2455
- value: param.parameterName,
2456
- description: `SSM Parameter name for ${intent.id}`,
2457
- });
2458
- }
2459
- }
2460
- createDomain(intent, projectName, stage, api) {
2461
- const resourceName = `${projectName}-${intent.id}`;
2462
- // Certificate: use provided ARN or create a new one
2463
- let certificate;
2464
- if (intent.certificateArn) {
2465
- certificate = acm.Certificate.fromCertificateArn(this, `${resourceName}-cert`, intent.certificateArn);
2466
- }
2467
- else if (intent.hostedZoneId) {
2468
- // Auto-provision with DNS validation via Route53
2469
- const hostedZone = route53.HostedZone.fromHostedZoneAttributes(this, `${resourceName}-zone`, {
2470
- hostedZoneId: intent.hostedZoneId,
2471
- zoneName: intent.domain.split('.').slice(-2).join('.'),
2472
- });
2473
- certificate = new acm.Certificate(this, `${resourceName}-cert`, {
2474
- domainName: intent.domain,
2475
- validation: acm.CertificateValidation.fromDns(hostedZone),
2476
- });
2477
- }
2478
- else {
2479
- // Certificate without DNS validation (manual)
2480
- certificate = new acm.Certificate(this, `${resourceName}-cert`, {
2481
- domainName: intent.domain,
2482
- });
2483
- }
2484
- // Custom domain name for API Gateway
2485
- const domainName = new apigatewayv2.DomainName(this, `${resourceName}-domain`, {
2486
- domainName: intent.domain,
2487
- certificate,
2488
- });
2489
- // Well-Architected: Reliability — RETAIN both the domain name and the
2490
- // mapping under strict dataSafety. The custom domain is the *public*
2491
- // contract with the world; deleting it forces DNS reconfiguration on
2492
- // every client/integration. RETAIN means an accidental stack delete
2493
- // leaves the domain claimed to this account and ready for `cdk
2494
- // import`. The ApiMapping rides along — without it the domain points
2495
- // nowhere, but mappings recreate cleanly so RETAIN is mostly a
2496
- // convenience for the recovery path.
2497
- if (this.dataSafetyConfig.removalPolicy === 'retain') {
2498
- const cfnDomain = domainName.node.defaultChild;
2499
- cfnDomain.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.RETAIN;
2500
- cfnDomain.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN;
2501
- }
2502
- // API mapping
2503
- const apiMapping = new apigatewayv2.ApiMapping(this, `${resourceName}-mapping`, {
2504
- api,
2505
- domainName,
2506
- stage: api.defaultStage,
2507
- });
2508
- if (this.dataSafetyConfig.removalPolicy === 'retain') {
2509
- const cfnMapping = apiMapping.node.defaultChild;
2510
- cfnMapping.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.RETAIN;
2511
- cfnMapping.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN;
2512
- }
2513
- // Route53 A record if hosted zone provided
2514
- if (intent.hostedZoneId) {
2515
- const hostedZone = route53.HostedZone.fromHostedZoneAttributes(this, `${resourceName}-zone-record`, {
2516
- hostedZoneId: intent.hostedZoneId,
2517
- zoneName: intent.domain.split('.').slice(-2).join('.'),
2518
- });
2519
- new route53.ARecord(this, `${resourceName}-a-record`, {
2520
- zone: hostedZone,
2521
- recordName: intent.domain,
2522
- target: route53.RecordTarget.fromAlias(new route53Targets.ApiGatewayv2DomainProperties(domainName.regionalDomainName, domainName.regionalHostedZoneId)),
2523
- });
2524
- }
2525
- new cdk.CfnOutput(this, `${resourceName}-domain-name`, {
2526
- value: intent.domain,
2527
- description: `Custom domain for ${intent.id}`,
2528
- });
2529
- new cdk.CfnOutput(this, `${resourceName}-regional-domain`, {
2530
- value: domainName.regionalDomainName,
2531
- description: `Regional domain name for ${intent.id} (use for DNS CNAME if not using Route53)`,
2532
- });
2533
- }
2534
- /**
2535
- * Free-tier alternative to RDS: create a DynamoDB table.
2536
- * DynamoDB offers 25 GB + 25 RCU/WCU in the always-free tier.
2537
- *
2538
- * Well-Architected: Security — encryption at rest (default)
2539
- * Well-Architected: Reliability — PITR for strict dataSafety
2540
- * Well-Architected: Cost — PAY_PER_REQUEST for unpredictable workloads
2541
- */
2542
- createDynamoDBTable(intent, projectName, stage) {
2543
- // Stable logical ID: 'dynamo-{intent.id}'
2544
- const logicalId = `dynamo-${intent.id}`;
2545
- const table = new dynamodb.Table(this, logicalId, {
2546
- tableName: `${projectName}-${intent.id}-${stage}`,
2547
- partitionKey: { name: 'pk', type: dynamodb.AttributeType.STRING },
2548
- sortKey: { name: 'sk', type: dynamodb.AttributeType.STRING },
2549
- billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
2550
- removalPolicy: this.toRemovalPolicy(),
2551
- // Well-Architected: Reliability — PITR for strict data safety
2552
- pointInTimeRecovery: this.envConfig.dataSafety === 'strict',
2553
- // Well-Architected: Security — encryption at rest (AWS managed key)
2554
- encryption: dynamodb.TableEncryption.AWS_MANAGED,
2555
- timeToLiveAttribute: 'ttl',
2556
- // Well-Architected: Operational Excellence — contributor insights for medium+
2557
- contributorInsightsEnabled: this.envConfig.lambda.memoryMb >= 512,
2558
- // Well-Architected: Reliability — native deletion protection on top of
2559
- // RETAIN. DeletionPolicy=Retain only blocks CFN-driven deletes; turning
2560
- // on deletionProtectionEnabled also blocks the AWS console / API / CLI
2561
- // "delete table" path. Together they make accidental data loss require
2562
- // two explicit operator actions (disable protection AND override the
2563
- // deletion policy), each loggable in CloudTrail.
2564
- deletionProtection: this.dataSafetyConfig.removalPolicy === 'retain',
2565
- });
2566
- // Well-Architected: Reliability — prevent accidental replacement
2567
- const cfnTable = table.node.defaultChild;
2568
- if (this.dataSafetyConfig.removalPolicy === 'retain') {
2569
- cfnTable.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN;
2570
- }
2571
- // GSI for common access patterns
2572
- table.addGlobalSecondaryIndex({
2573
- indexName: 'gsi1',
2574
- partitionKey: { name: 'gsi1pk', type: dynamodb.AttributeType.STRING },
2575
- sortKey: { name: 'gsi1sk', type: dynamodb.AttributeType.STRING },
2576
- projectionType: dynamodb.ProjectionType.ALL,
2577
- });
2578
- new cdk.CfnOutput(this, `${logicalId}-table-name`, {
2579
- value: table.tableName,
2580
- description: `DynamoDB table name for ${intent.id} (free-tier)`,
2581
- });
2582
- new cdk.CfnOutput(this, `${logicalId}-table-arn`, {
2583
- value: table.tableArn,
2584
- description: `DynamoDB table ARN for ${intent.id} (free-tier)`,
2585
- });
2586
- }
2587
- /**
2588
- * Free-tier rate limit table using DynamoDB (always-free tier).
2589
- * Used by createDefaultRateLimiter() from @venturekit/runtime.
2590
- */
2591
- createRateLimitTable(projectName, stage) {
2592
- const logicalId = 'RateLimitTable';
2593
- // Per-stack table name so multiple environments (preview/prod) and
2594
- // multiple projects in the same AWS account don't collide on the
2595
- // single global `venturekit-rate-limits` name. The runtime store
2596
- // reads `VENTURE_RATE_LIMIT_TABLE` (see @venturekit/runtime
2597
- // rate-limit-store), so we plumb the resolved name through the
2598
- // base Lambda env to keep `createDefaultRateLimiter()` working
2599
- // without per-app configuration.
2600
- const tableName = `${projectName}-rate-limits-${stage}`;
2601
- const table = new dynamodb.Table(this, logicalId, {
2602
- tableName,
2603
- partitionKey: { name: 'pk', type: dynamodb.AttributeType.STRING },
2604
- billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
2605
- // Well-Architected: Reliability — rate-limit counters are per-user
2606
- // state. Hardcoding DESTROY would erase every user's current rate-
2607
- // limit window on any stack churn, surfacing as a simultaneous
2608
- // "all users get a fresh quota" event — minor but observable under
2609
- // load and not what dataSafety='strict' implies. Track dataSafety
2610
- // the same way every other stateful resource does.
2611
- removalPolicy: this.toRemovalPolicy(),
2612
- timeToLiveAttribute: 'ttl',
2613
- encryption: dynamodb.TableEncryption.AWS_MANAGED,
2614
- // Native AWS deletion protection alongside RETAIN. See the
2615
- // user-declared DDB path above for the two-layer rationale.
2616
- deletionProtection: this.dataSafetyConfig.removalPolicy === 'retain',
2617
- });
2618
- // Well-Architected: Reliability — UpdateReplacePolicy=Retain so that a
2619
- // property change requiring REPLACE (e.g. changing the partition-key
2620
- // schema, which CFN executes as delete-then-create) preserves the old
2621
- // table rather than dropping every active rate-limit window. Matches
2622
- // the user-declared DDB path.
2623
- const cfnRateLimitTable = table.node.defaultChild;
2624
- if (this.dataSafetyConfig.removalPolicy === 'retain') {
2625
- cfnRateLimitTable.cfnOptions.updateReplacePolicy = cdk.CfnDeletionPolicy.RETAIN;
2626
- }
2627
- this.ventureBaseEnv['VENTURE_RATE_LIMIT_TABLE'] = table.tableName;
2628
- new cdk.CfnOutput(this, `${logicalId}-table-name`, {
2629
- value: table.tableName,
2630
- description: 'DynamoDB rate limit table (free-tier)',
2631
- });
2632
- }
2633
- // ─── Helper methods ────────────────────────────────────────────────
2634
- /**
2635
- * Map ResourceSize to RDS instance type.
2636
- * Uses Graviton (T4G) instances for cost efficiency.
2637
- * Changing size triggers an in-place modify, NOT a replacement.
2638
- */
2639
- sizeToInstanceType(size) {
2640
- switch (size) {
2641
- case 'xlarge':
2642
- return ec2.InstanceType.of(ec2.InstanceClass.R6G, ec2.InstanceSize.LARGE);
2643
- case 'large':
2644
- return ec2.InstanceType.of(ec2.InstanceClass.R6G, ec2.InstanceSize.MEDIUM);
2645
- case 'medium':
2646
- return ec2.InstanceType.of(ec2.InstanceClass.T4G, ec2.InstanceSize.SMALL);
2647
- default: // 'small'
2648
- return ec2.InstanceType.of(ec2.InstanceClass.T4G, ec2.InstanceSize.MICRO);
2649
- }
2650
- }
2651
- /**
2652
- * Map ResourceSize to ElastiCache node type.
2653
- * Uses Graviton (T4G) instances for cost efficiency.
2654
- * Changing size triggers an in-place modify, NOT a replacement.
2655
- */
2656
- sizeToCacheNodeType(size) {
2657
- switch (size) {
2658
- case 'xlarge':
2659
- return 'cache.r6g.large';
2660
- case 'large':
2661
- return 'cache.r6g.medium';
2662
- case 'medium':
2663
- return 'cache.t4g.small';
2664
- default: // 'small'
2665
- return 'cache.t4g.micro';
2666
- }
2667
- }
2668
- /**
2669
- * Convert dataSafety config to CDK RemovalPolicy.
2670
- * Well-Architected: Reliability — prevent accidental data loss.
2671
- */
2672
- toRemovalPolicy() {
2673
- return this.dataSafetyConfig.removalPolicy === 'retain'
2674
- ? cdk.RemovalPolicy.RETAIN
2675
- : cdk.RemovalPolicy.DESTROY;
2676
- }
2677
- /**
2678
- * Convert days to CDK logs.RetentionDays enum.
2679
- * Well-Architected: Cost — don't over-retain logs.
2680
- */
2681
- toLogRetention(days) {
2682
- if (days <= 1)
2683
- return logs.RetentionDays.ONE_DAY;
2684
- if (days <= 3)
2685
- return logs.RetentionDays.THREE_DAYS;
2686
- if (days <= 5)
2687
- return logs.RetentionDays.FIVE_DAYS;
2688
- if (days <= 7)
2689
- return logs.RetentionDays.ONE_WEEK;
2690
- if (days <= 14)
2691
- return logs.RetentionDays.TWO_WEEKS;
2692
- if (days <= 30)
2693
- return logs.RetentionDays.ONE_MONTH;
2694
- if (days <= 60)
2695
- return logs.RetentionDays.TWO_MONTHS;
2696
- if (days <= 90)
2697
- return logs.RetentionDays.THREE_MONTHS;
2698
- if (days <= 120)
2699
- return logs.RetentionDays.FOUR_MONTHS;
2700
- if (days <= 150)
2701
- return logs.RetentionDays.FIVE_MONTHS;
2702
- if (days <= 180)
2703
- return logs.RetentionDays.SIX_MONTHS;
2704
- if (days <= 365)
2705
- return logs.RetentionDays.ONE_YEAR;
2706
- return logs.RetentionDays.INFINITE;
2707
- }
2708
- /**
2709
- * Resolve Lambda runtime from preset config.
2710
- */
2711
- toLambdaRuntime() {
2712
- switch (this.envConfig.lambda.runtime) {
2713
- case 'nodejs22.x':
2714
- return lambda.Runtime.NODEJS_22_X;
2715
- case 'nodejs18.x':
2716
- return lambda.Runtime.NODEJS_18_X;
2717
- default:
2718
- return lambda.Runtime.NODEJS_20_X;
2719
- }
2720
- }
2721
- /**
2722
- * Resolve Lambda architecture from preset config.
2723
- */
2724
- toLambdaArchitecture() {
2725
- return this.envConfig.lambda.architecture === 'x86_64'
2726
- ? lambda.Architecture.X86_64
2727
- : lambda.Architecture.ARM_64;
2728
- }
2729
- /**
2730
- * Recursively discover function files under the functions directory.
2731
- * Returns an array of { name, handlerFile } where name is the
2732
- * dash-delimited path (e.g. `order/create-order.ts` → `order-create-order`).
2733
- */
2734
- discoverFunctionFiles(dir, baseDir) {
2735
- const root = baseDir ?? dir;
2736
- const results = [];
2737
- if (!fs.existsSync(dir))
2738
- return results;
2739
- const entries = fs.readdirSync(dir, { withFileTypes: true });
2740
- for (const entry of entries) {
2741
- const fullPath = path.join(dir, entry.name);
2742
- if (entry.isDirectory()) {
2743
- results.push(...this.discoverFunctionFiles(fullPath, root));
2744
- }
2745
- else if (/\.(ts|js)$/.test(entry.name) && !entry.name.endsWith('.d.ts')) {
2746
- const relativePath = path.relative(root, fullPath);
2747
- const name = relativePath
2748
- .replace(/\.(ts|js)$/, '')
2749
- .replace(/[\\/]/g, '-');
2750
- results.push({ name, handlerFile: fullPath });
2751
- }
2752
- }
2753
- return results;
2754
- }
2755
- // ─── Lambda function factories ─────────────────────────────────────
2756
- /**
2757
- * Build the `lambda.Code` for a handler file.
2758
- *
2759
- * Production path: bundle the handler in-process via esbuild's
2760
- * synchronous API — see `bundleHandlerCodeWithEsbuild` for the
2761
- * rationale (no Docker, native Node module resolution, sub-second
2762
- * per handler).
2763
- *
2764
- * Test path (`_skipBundling: true`): return an inline stub so unit
2765
- * tests can synth Lambdas without invoking esbuild.
2766
- */
2767
- bundleHandlerCode(handlerFile) {
2768
- if (this.skipBundling) {
2769
- return inlineStubLambdaCode();
2770
- }
2771
- return bundleHandlerCodeWithEsbuild(handlerFile, this.projectDir);
2772
- }
2773
- /**
2774
- * Create one Lambda + HTTP API integration + HttpRoute for a discovered
2775
- * route file.
2776
- *
2777
- * Function name: `{project}-{stage}-route-{slug}`. Lambda function names
2778
- * are capped at 64 chars; we truncate + append a 6-char content hash if
2779
- * the full name overflows so deeply nested routes
2780
- * (e.g. `promoter/groups/[id]/images/[imageId]/delete.ts`) still fit.
2781
- *
2782
- * Well-Architected: Performance — memory/timeout from preset config
2783
- * Well-Architected: Sustainability — ARM64 Graviton by default
2784
- * Well-Architected: Reliability — VPC-attached when DBs exist so routes
2785
- * can reach RDS without timing out at the API Gateway 30 s deadline
2786
- * Well-Architected: Cost — shared role + SG across all route Lambdas
2787
- * keeps CFN resource count linear in the number of routes
2788
- */
2789
- createRouteFunction(route, projectName, stage, api, role, securityGroup, vpc) {
2790
- const lambdaConfig = this.envConfig.lambda;
2791
- // Cap function name at 64 chars (Lambda hard limit). Append a content
2792
- // hash on overflow so two deeply nested routes that share a prefix
2793
- // don't collide.
2794
- const rawName = `${projectName}-${stage}-route-${route.slug}`;
2795
- const functionName = rawName.length <= 64
2796
- ? rawName
2797
- : `${rawName.slice(0, 56)}-${this.shortHash(rawName)}`;
2798
- const resourceId = `route-${route.slug}`;
2799
- // Construct ID may collide if two slugs sanitize to the same value,
2800
- // but `discoverRouteFiles` already enforces filesystem-uniqueness, so
2801
- // any collision implies a bug in the slug builder — not a runtime
2802
- // concern.
2803
- const fn = new lambda.Function(this, resourceId, {
2804
- functionName,
2805
- runtime: this.toLambdaRuntime(),
2806
- architecture: this.toLambdaArchitecture(),
2807
- handler: 'index.main',
2808
- code: this.bundleHandlerCode(route.handlerFile),
2809
- memorySize: lambdaConfig.memoryMb,
2810
- timeout: cdk.Duration.seconds(lambdaConfig.timeoutSec),
2811
- description: `${route.method} ${route.apiPath}`,
2812
- environment: {
2813
- ...this.ventureBaseEnv,
2814
- ...lambdaConfig.environmentVariables,
2815
- },
2816
- logRetention: this.toLogRetention(lambdaConfig.logRetentionDays),
2817
- tracing: lambdaConfig.tracingEnabled
2818
- ? lambda.Tracing.ACTIVE
2819
- : lambda.Tracing.DISABLED,
2820
- reservedConcurrentExecutions: lambdaConfig.reservedConcurrency,
2821
- role,
2822
- // Attach to the VPC iff one exists. Route Lambdas without a VPC
2823
- // can't reach RDS, but the free preset and DB-less projects don't
2824
- // need one — paying ENI cold-start tax there would be a
2825
- // regression.
2826
- ...(vpc && securityGroup
2827
- ? {
2828
- vpc,
2829
- vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
2830
- securityGroups: [securityGroup],
2831
- }
2832
- : {}),
2833
- });
2834
- // Route the matching API Gateway HTTP method + path at the Lambda.
2835
- // `HttpRouteKey.with` builds the `${METHOD} ${path}` string that API
2836
- // Gateway uses internally; passing path + method separately keeps us
2837
- // honest if AWS ever changes the format.
2838
- new apigatewayv2.HttpRoute(this, `${resourceId}-route`, {
2839
- httpApi: api,
2840
- routeKey: apigatewayv2.HttpRouteKey.with(route.apiPath, apigatewayv2.HttpMethod[route.method]),
2841
- integration: new apigatewayv2Integrations.HttpLambdaIntegration(`${resourceId}-integration`, fn),
2842
- });
2843
- }
2844
- /**
2845
- * Tiny non-cryptographic 6-char hex hash. Used only to disambiguate
2846
- * Lambda function names that would otherwise overflow the 64-char
2847
- * limit; collision risk is acceptable because the truncated prefix
2848
- * already encodes most of the route path.
2849
- */
2850
- shortHash(input) {
2851
- let h = 5381;
2852
- for (let i = 0; i < input.length; i++) {
2853
- h = ((h << 5) + h + input.charCodeAt(i)) | 0;
2854
- }
2855
- return (h >>> 0).toString(16).padStart(6, '0').slice(0, 6);
2856
- }
2857
- /**
2858
- * Create a standalone Lambda function.
2859
- * Naming convention: {project}-{stage}-fn-{name}
2860
- *
2861
- * Well-Architected: Performance — memory/timeout from preset config
2862
- * Well-Architected: Sustainability — ARM64 Graviton by default
2863
- * Well-Architected: Operational Excellence — structured env vars, log retention from preset
2864
- * Well-Architected: Security — X-Ray tracing from preset config
2865
- */
2866
- createStandaloneFunction(name, handlerFile, projectName, stage, projectDir, intent, role, securityGroup, vpc) {
2867
- const functionName = `${projectName}-${stage}-fn-${name}`;
2868
- const resourceId = `fn-${name}`;
2869
- const lambdaConfig = this.envConfig.lambda;
2870
- // Auto-DLQ: every async failure goes somewhere you can inspect later.
2871
- // Lambda retries async invocations twice, then drops the event silently
2872
- // unless a DLQ is wired. We wire one by default (SQS, 14-day retention,
2873
- // SQS-managed encryption, same stable logical ID so preset changes don't
2874
- // replace it). Consumers can inspect failed events via `vk logs dlq` or
2875
- // directly in the SQS console.
2876
- const dlq = new sqs.Queue(this, `${resourceId}-dlq`, {
2877
- queueName: `${projectName}-${stage}-fn-${name}-dlq`,
2878
- retentionPeriod: cdk.Duration.days(14),
2879
- encryption: sqs.QueueEncryption.SQS_MANAGED,
2880
- });
2881
- const fn = new lambda.Function(this, resourceId, {
2882
- functionName,
2883
- runtime: this.toLambdaRuntime(),
2884
- architecture: this.toLambdaArchitecture(),
2885
- handler: 'index.main',
2886
- code: this.bundleHandlerCode(handlerFile),
2887
- // Well-Architected: Performance — memory and timeout from intent or preset
2888
- memorySize: intent?.memorySize ?? lambdaConfig.memoryMb,
2889
- timeout: cdk.Duration.seconds(intent?.timeout ?? lambdaConfig.timeoutSec),
2890
- description: intent?.description ?? `Standalone function: ${name}`,
2891
- environment: {
2892
- ...this.ventureBaseEnv,
2893
- ...lambdaConfig.environmentVariables,
2894
- },
2895
- // Well-Architected: Operational Excellence — log retention from preset
2896
- logRetention: this.toLogRetention(lambdaConfig.logRetentionDays),
2897
- // Well-Architected: Security — X-Ray tracing from preset
2898
- tracing: lambdaConfig.tracingEnabled
2899
- ? lambda.Tracing.ACTIVE
2900
- : lambda.Tracing.DISABLED,
2901
- // Well-Architected: Performance — reserved concurrency from preset
2902
- reservedConcurrentExecutions: lambdaConfig.reservedConcurrency,
2903
- // Well-Architected: Reliability — async failure destination
2904
- deadLetterQueue: dlq,
2905
- deadLetterQueueEnabled: true,
2906
- role,
2907
- // Well-Architected: Reliability — VPC-attach when a VPC exists so
2908
- // standalone workers (sagas, processors, dispatchers) can reach
2909
- // RDS through the shared SG without operators wiring it up.
2910
- ...(vpc && securityGroup
2911
- ? {
2912
- vpc,
2913
- vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
2914
- securityGroups: [securityGroup],
2915
- }
2916
- : {}),
2917
- });
2918
- new cdk.CfnOutput(this, `${resourceId}-name`, {
2919
- value: fn.functionName,
2920
- description: `Lambda function name for ${name}`,
2921
- });
2922
- new cdk.CfnOutput(this, `${resourceId}-arn`, {
2923
- value: fn.functionArn,
2924
- description: `Lambda function ARN for ${name}`,
2925
- });
2926
- new cdk.CfnOutput(this, `${resourceId}-dlq-url`, {
2927
- value: dlq.queueUrl,
2928
- description: `Dead-letter queue for ${name} (async invocation failures land here)`,
2929
- });
2930
- }
2931
- /**
2932
- * Create a queue consumer Lambda function wired to an SQS queue.
2933
- * Naming convention: {project}-{stage}-queue-{name}
2934
- *
2935
- * Well-Architected: Reliability — DLQ on consumer queue, encrypted
2936
- * Well-Architected: Performance — batch size and visibility timeout
2937
- */
2938
- createQueueConsumer(name, handlerFile, projectName, stage, projectDir, intent, role, securityGroup, vpc) {
2939
- const functionName = `${projectName}-${stage}-queue-${name}`;
2940
- const resourceId = `queue-consumer-${name}`;
2941
- const lambdaConfig = this.envConfig.lambda;
2942
- // Create the SQS queue with DLQ
2943
- const dlq = new sqs.Queue(this, `${resourceId}-dlq`, {
2944
- queueName: `${projectName}-${stage}-${name}-dlq`,
2945
- retentionPeriod: cdk.Duration.days(14),
2946
- encryption: sqs.QueueEncryption.SQS_MANAGED,
2947
- });
2948
- const queue = new sqs.Queue(this, `${resourceId}-sqs`, {
2949
- queueName: `${projectName}-${stage}-${name}`,
2950
- visibilityTimeout: cdk.Duration.seconds(intent?.visibilityTimeoutSeconds ?? (intent?.timeout ?? 30) * 6),
2951
- retentionPeriod: cdk.Duration.days(intent?.retentionDays ?? 4),
2952
- encryption: sqs.QueueEncryption.SQS_MANAGED,
2953
- deadLetterQueue: {
2954
- queue: dlq,
2955
- maxReceiveCount: 3,
2956
- },
2957
- });
2958
- const fn = new lambda.Function(this, resourceId, {
2959
- functionName,
2960
- runtime: this.toLambdaRuntime(),
2961
- architecture: this.toLambdaArchitecture(),
2962
- handler: 'index.main',
2963
- code: this.bundleHandlerCode(handlerFile),
2964
- memorySize: intent?.memorySize ?? lambdaConfig.memoryMb,
2965
- timeout: cdk.Duration.seconds(intent?.timeout ?? lambdaConfig.timeoutSec),
2966
- description: intent?.description ?? `Queue consumer: ${name}`,
2967
- environment: {
2968
- ...this.ventureBaseEnv,
2969
- ...lambdaConfig.environmentVariables,
2970
- },
2971
- logRetention: this.toLogRetention(lambdaConfig.logRetentionDays),
2972
- tracing: lambdaConfig.tracingEnabled
2973
- ? lambda.Tracing.ACTIVE
2974
- : lambda.Tracing.DISABLED,
2975
- role,
2976
- // Well-Architected: Reliability — VPC-attach so consumers can
2977
- // reach RDS. SQS itself is reachable from public Internet, but a
2978
- // queue handler that needs to UPDATE its own DB on success would
2979
- // otherwise hang forever once a database is provisioned.
2980
- ...(vpc && securityGroup
2981
- ? {
2982
- vpc,
2983
- vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
2984
- securityGroups: [securityGroup],
2985
- }
2986
- : {}),
2987
- });
2988
- // Wire Lambda to SQS queue
2989
- fn.addEventSource(new lambdaEventSources.SqsEventSource(queue, {
2990
- batchSize: intent?.batchSize ?? 10,
2991
- }));
2992
- new cdk.CfnOutput(this, `${resourceId}-fn-name`, {
2993
- value: fn.functionName,
2994
- description: `Queue consumer Lambda for ${name}`,
2995
- });
2996
- new cdk.CfnOutput(this, `${resourceId}-queue-url`, {
2997
- value: queue.queueUrl,
2998
- description: `SQS queue URL for ${name}`,
2999
- });
3000
- }
3001
- /**
3002
- * Create a cron/scheduled task Lambda function wired to an EventBridge rule.
3003
- * Naming convention: {project}-{stage}-cron-{name}
3004
- *
3005
- * Well-Architected: Performance — memory/timeout from preset
3006
- * Well-Architected: Operational Excellence — log retention, tracing from preset
3007
- */
3008
- createCronHandler(name, handlerFile, projectName, stage, projectDir, intent, role, securityGroup, vpc) {
3009
- const functionName = `${projectName}-${stage}-cron-${name}`;
3010
- const resourceId = `cron-${name}`;
3011
- const lambdaConfig = this.envConfig.lambda;
3012
- // Auto-DLQ for cron handlers. EventBridge delivers events async; without
3013
- // a DLQ, a broken cron silently stops working after Lambda's 2-retry
3014
- // budget and you only notice when someone asks "did the nightly job run?"
3015
- const dlq = new sqs.Queue(this, `${resourceId}-dlq`, {
3016
- queueName: `${projectName}-${stage}-cron-${name}-dlq`,
3017
- retentionPeriod: cdk.Duration.days(14),
3018
- encryption: sqs.QueueEncryption.SQS_MANAGED,
3019
- });
3020
- const fn = new lambda.Function(this, resourceId, {
3021
- functionName,
3022
- runtime: this.toLambdaRuntime(),
3023
- architecture: this.toLambdaArchitecture(),
3024
- handler: 'index.main',
3025
- code: this.bundleHandlerCode(handlerFile),
3026
- memorySize: intent?.memorySize ?? lambdaConfig.memoryMb,
3027
- timeout: cdk.Duration.seconds(intent?.timeout ?? 60),
3028
- description: intent?.description ?? `Cron handler: ${name}`,
3029
- environment: {
3030
- ...this.ventureBaseEnv,
3031
- ...lambdaConfig.environmentVariables,
3032
- },
3033
- logRetention: this.toLogRetention(lambdaConfig.logRetentionDays),
3034
- tracing: lambdaConfig.tracingEnabled
3035
- ? lambda.Tracing.ACTIVE
3036
- : lambda.Tracing.DISABLED,
3037
- // Well-Architected: Reliability — EventBridge failures land in the DLQ
3038
- deadLetterQueue: dlq,
3039
- deadLetterQueueEnabled: true,
3040
- role,
3041
- // Well-Architected: Reliability — VPC-attach so nightly jobs that
3042
- // poll RDS for stale state (cleanup, retries, billing rollups)
3043
- // don't spin up only to discover they can't reach the database.
3044
- ...(vpc && securityGroup
3045
- ? {
3046
- vpc,
3047
- vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
3048
- securityGroups: [securityGroup],
3049
- }
3050
- : {}),
3051
- });
3052
- // Wire EventBridge rule if schedule expression is provided
3053
- if (intent?.schedule) {
3054
- const scheduleExpr = intent.schedule;
3055
- const isEnabled = intent.enabled ?? true;
3056
- let schedule;
3057
- if ('rate' in scheduleExpr) {
3058
- schedule = events.Schedule.expression(`rate(${scheduleExpr.rate})`);
3059
- }
3060
- else {
3061
- schedule = events.Schedule.expression(`cron(${scheduleExpr.cron})`);
3062
- }
3063
- new events.Rule(this, `${resourceId}-rule`, {
3064
- ruleName: `${projectName}-${stage}-cron-${name}`,
3065
- schedule,
3066
- enabled: isEnabled,
3067
- targets: [new targets.LambdaFunction(fn)],
3068
- });
3069
- }
3070
- new cdk.CfnOutput(this, `${resourceId}-fn-name`, {
3071
- value: fn.functionName,
3072
- description: `Cron handler Lambda for ${name}`,
3073
- });
3074
- new cdk.CfnOutput(this, `${resourceId}-fn-arn`, {
3075
- value: fn.functionArn,
3076
- description: `Cron handler Lambda ARN for ${name}`,
3077
- });
3078
- new cdk.CfnOutput(this, `${resourceId}-dlq-url`, {
3079
- value: dlq.queueUrl,
3080
- description: `Dead-letter queue for ${name} (async invocation failures land here)`,
3081
- });
3082
- }
3083
- }
3084
- //# sourceMappingURL=stack.js.map