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

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.
Files changed (49) hide show
  1. package/dist/cdk/app-stack.d.ts +330 -0
  2. package/dist/cdk/app-stack.d.ts.map +1 -0
  3. package/dist/cdk/app-stack.js +875 -0
  4. package/dist/cdk/app-stack.js.map +1 -0
  5. package/dist/cdk/config-stack.d.ts +104 -0
  6. package/dist/cdk/config-stack.d.ts.map +1 -0
  7. package/dist/cdk/config-stack.js +161 -0
  8. package/dist/cdk/config-stack.js.map +1 -0
  9. package/dist/cdk/data-stack.d.ts +198 -0
  10. package/dist/cdk/data-stack.d.ts.map +1 -0
  11. package/dist/cdk/data-stack.js +511 -0
  12. package/dist/cdk/data-stack.js.map +1 -0
  13. package/dist/cdk/edge-stack.d.ts +91 -0
  14. package/dist/cdk/edge-stack.d.ts.map +1 -0
  15. package/dist/cdk/edge-stack.js +364 -0
  16. package/dist/cdk/edge-stack.js.map +1 -0
  17. package/dist/cdk/identity-stack.d.ts +98 -0
  18. package/dist/cdk/identity-stack.d.ts.map +1 -0
  19. package/dist/cdk/identity-stack.js +254 -0
  20. package/dist/cdk/identity-stack.js.map +1 -0
  21. package/dist/cdk/index.d.ts +16 -0
  22. package/dist/cdk/index.d.ts.map +1 -1
  23. package/dist/cdk/index.js +17 -0
  24. package/dist/cdk/index.js.map +1 -1
  25. package/dist/cdk/messaging-stack.d.ts +109 -0
  26. package/dist/cdk/messaging-stack.d.ts.map +1 -0
  27. package/dist/cdk/messaging-stack.js +320 -0
  28. package/dist/cdk/messaging-stack.js.map +1 -0
  29. package/dist/cdk/network-stack.d.ts +87 -0
  30. package/dist/cdk/network-stack.d.ts.map +1 -0
  31. package/dist/cdk/network-stack.js +265 -0
  32. package/dist/cdk/network-stack.js.map +1 -0
  33. package/dist/cdk/shared/cross-stack-refs.d.ts +278 -0
  34. package/dist/cdk/shared/cross-stack-refs.d.ts.map +1 -0
  35. package/dist/cdk/shared/cross-stack-refs.js +326 -0
  36. package/dist/cdk/shared/cross-stack-refs.js.map +1 -0
  37. package/dist/cdk/shared/lambda-helpers.d.ts +82 -0
  38. package/dist/cdk/shared/lambda-helpers.d.ts.map +1 -0
  39. package/dist/cdk/shared/lambda-helpers.js +201 -0
  40. package/dist/cdk/shared/lambda-helpers.js.map +1 -0
  41. package/dist/cdk/shared/ssm-keys.d.ts +460 -0
  42. package/dist/cdk/shared/ssm-keys.d.ts.map +1 -0
  43. package/dist/cdk/shared/ssm-keys.js +291 -0
  44. package/dist/cdk/shared/ssm-keys.js.map +1 -0
  45. package/dist/cdk/stack.d.ts +3 -11
  46. package/dist/cdk/stack.d.ts.map +1 -1
  47. package/dist/cdk/stack.js +184 -41
  48. package/dist/cdk/stack.js.map +1 -1
  49. package/package.json +2 -2
@@ -0,0 +1,875 @@
1
+ /**
2
+ * Tier 6 — App stack (the "fast" tier).
3
+ *
4
+ * Owns every resource that turns over on a normal `vk deploy`:
5
+ *
6
+ * - Lambda functions: route handlers, queue consumers, scheduled
7
+ * handlers, notify dispatcher, bounce handler, migration runner.
8
+ * - HttpApi route/integration registrations on the imported API.
9
+ * - SQS event-source mappings binding consumer Lambdas to
10
+ * queues imported from the messaging stack.
11
+ * - EventBridge rules + Lambda targets for `schedules[]` intents
12
+ * and the notify dispatcher cron.
13
+ * - Per-Lambda CloudWatch LogGroups (so retention is enforced
14
+ * even on cold-start failures, before the runtime can write
15
+ * its first line).
16
+ * - Shared Lambda execution role + shared SG (in VPC) — the
17
+ * common identity through which every grant is plumbed.
18
+ * - Internal HMAC secret for Lambda-to-Lambda `invoke()` signing.
19
+ * - All IAM grants: Cognito admin, S3 R/W, Secrets Manager
20
+ * reads, SES send, SQS consume/produce, SNS publish.
21
+ *
22
+ * # Lifecycle position
23
+ *
24
+ * Updated on every code change (~daily on active projects). Every
25
+ * resource here is DESTROY-on-delete: re-creating a Lambda or an
26
+ * ESM from a saved CFN template is cheap and safe (the durable
27
+ * state — DB rows, queue messages, S3 objects — lives in
28
+ * lower-tier stacks). Logs are an exception: under strict
29
+ * dataSafety we RETAIN log groups so post-mortem debugging
30
+ * survives a redeploy.
31
+ *
32
+ * # What the constructor does (in order)
33
+ *
34
+ * 1. Resolve cross-stack refs from imported props or via SSM.
35
+ * 2. Create shared Lambda execution role + (if VPC) shared SG.
36
+ * 3. Generate / import the internal HMAC secret.
37
+ * 4. Assemble `baseEnvVars` from cross-tier SSM-resolved values:
38
+ * DB connection, primary storage, primary notify, Cognito,
39
+ * federated provider secrets.
40
+ * 5. Wire each Lambda intent (delegated to step-9b/c/d helpers
41
+ * that follow this scaffold).
42
+ *
43
+ * # Why the role + SG live in this stack
44
+ *
45
+ * They're scoped to "the Lambdas in this app deployment" — every
46
+ * grant attached to them is therefore lifecycle-aligned with the
47
+ * Lambdas. A redeploy that adds a new managed policy or a new
48
+ * `addPermission` call only churns the app stack; the lower-tier
49
+ * resources stay untouched.
50
+ *
51
+ * # What this stack does NOT do
52
+ *
53
+ * - **No durable state.** Every resource here is replaceable.
54
+ * If you need RETAIN-class durability, the resource belongs in
55
+ * data / messaging / identity / config / edge.
56
+ *
57
+ * - **No HttpApi creation.** Imported via
58
+ * `HttpApi.fromHttpApiAttributes` from the edge stack's published
59
+ * `apiId` SSM key.
60
+ *
61
+ * - **No SSM publication.** App-stack outputs (Lambda ARNs, log
62
+ * group names) are operational details that no other VentureKit
63
+ * stack consumes. Operators inspecting them go through the AWS
64
+ * console / CLI, not SSM.
65
+ */
66
+ import * as fs from 'fs';
67
+ import * as path from 'path';
68
+ import { createRequire } from 'module';
69
+ import * as cdk from 'aws-cdk-lib';
70
+ import * as iam from 'aws-cdk-lib/aws-iam';
71
+ import * as ec2 from 'aws-cdk-lib/aws-ec2';
72
+ import * as lambda from 'aws-cdk-lib/aws-lambda';
73
+ import * as lambdaEventSources from 'aws-cdk-lib/aws-lambda-event-sources';
74
+ import * as s3 from 'aws-cdk-lib/aws-s3';
75
+ import * as sqs from 'aws-cdk-lib/aws-sqs';
76
+ import * as events from 'aws-cdk-lib/aws-events';
77
+ import * as eventsTargets from 'aws-cdk-lib/aws-events-targets';
78
+ import * as apigatewayv2 from 'aws-cdk-lib/aws-apigatewayv2';
79
+ import * as apigatewayv2Integrations from 'aws-cdk-lib/aws-apigatewayv2-integrations';
80
+ import * as sns from 'aws-cdk-lib/aws-sns';
81
+ import * as snsSubscriptions from 'aws-cdk-lib/aws-sns-subscriptions';
82
+ import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
83
+ import * as logs from 'aws-cdk-lib/aws-logs';
84
+ import { DATA_SAFETY_CONFIG, } from '@venturekit/core';
85
+ import { readSsmString } from './shared/cross-stack-refs.js';
86
+ import { networkKeys, edgeKeys } from './shared/ssm-keys.js';
87
+ import { bundleHandlerCodeWithEsbuild, inlineStubLambdaCode, toLambdaArchitecture, toLambdaRuntime, toLogRetention, clampLambdaFunctionName, discoverFunctionFiles, } from './shared/lambda-helpers.js';
88
+ import { discoverRouteFiles } from './stack.js';
89
+ /**
90
+ * Convert an intent id (e.g. `user-uploads`) to upper-snake-case
91
+ * (`USER_UPLOADS`) suitable for use in env-var names. Without this,
92
+ * a hyphenated id would produce `STORAGE_user-uploads_BUCKET`,
93
+ * which CloudFormation rejects.
94
+ */
95
+ function upperSnake(id) {
96
+ return String(id).toUpperCase().replace(/[^A-Z0-9_]/g, '_');
97
+ }
98
+ /**
99
+ * App stack — see file header for the full lifecycle rationale.
100
+ *
101
+ * **Step 9a scaffold.** This skeleton covers:
102
+ * - shared Lambda role + (if VPC) shared SG
103
+ * - imported HttpApi + VPC handles exposed for sub-step helpers
104
+ * - internal HMAC secret
105
+ * - `baseEnvVars` assembled from cross-stack imports
106
+ *
107
+ * Lambda creation lives in subsequent sub-steps (9b: routes;
108
+ * 9c: queue ESMs + schedules + migration; 9d: notify + grants).
109
+ * Calling the constructor with no Lambda-bearing intents produces
110
+ * a stack with the role / SG / secret only — no Lambdas, no
111
+ * routes — which is the correct no-op behavior for projects that
112
+ * declare only data + messaging.
113
+ */
114
+ export class VentureAppStack extends cdk.Stack {
115
+ /** Shared execution role for every Lambda in this stack. */
116
+ lambdaRole;
117
+ /** Shared SG when a VPC is imported; undefined for VPC-less projects. */
118
+ lambdaSecurityGroup;
119
+ /** Internal HMAC signing secret for Lambda-to-Lambda `invoke()`. */
120
+ internalHmacSecret;
121
+ /**
122
+ * Base environment variables applied to every Lambda this stack
123
+ * creates. Subsequent helpers (9b/c/d) read this when constructing
124
+ * each Lambda's `environment` block. Mutable during construction
125
+ * — per-intent helpers append `STORAGE_<ID>_*`, `NOTIFY_<ID>_*`,
126
+ * etc. — frozen at the end of the constructor.
127
+ */
128
+ baseEnvVars;
129
+ /** The imported HttpApi — sub-step 9b registers routes on this. */
130
+ api;
131
+ /** Imported VPC, when present. */
132
+ vpc;
133
+ /**
134
+ * Route Lambdas keyed by slug. Sub-step 9c reads this when wiring
135
+ * cross-cutting grants (e.g. Cognito admin auth) onto every
136
+ * Lambda — though the shared role makes most grants role-level
137
+ * not function-level, this map lets per-function grants happen
138
+ * when needed.
139
+ */
140
+ routeLambdas = new Map();
141
+ /** Queue consumer Lambdas keyed by handler name. */
142
+ queueLambdas = new Map();
143
+ /** Scheduled / cron Lambdas keyed by handler name. */
144
+ scheduleLambdas = new Map();
145
+ /** Notify dispatcher Lambdas keyed by notify intent id. */
146
+ notifyDispatcherLambdas = new Map();
147
+ /** Notify bounce-handler Lambdas keyed by notify intent id. */
148
+ notifyBounceLambdas = new Map();
149
+ isStrictRetain;
150
+ projectName;
151
+ stage;
152
+ envConfig;
153
+ skipBundling;
154
+ projectDir;
155
+ constructor(scope, id, props) {
156
+ super(scope, id, props);
157
+ const { projectName, stage, envConfig, infrastructure, imports } = props;
158
+ this.isStrictRetain =
159
+ DATA_SAFETY_CONFIG[envConfig.dataSafety].removalPolicy === 'retain';
160
+ this.vpc = imports.vpc;
161
+ this.api = imports.api;
162
+ this.projectName = projectName;
163
+ this.stage = stage;
164
+ this.envConfig = envConfig;
165
+ this.skipBundling = props.skipBundling ?? false;
166
+ this.projectDir = props.projectDir;
167
+ // ─── Lambda count probe ────────────────────────────────────
168
+ // Detect whether this deployment includes any Lambda-bearing
169
+ // intent. If not, we still create the role + secret + base env
170
+ // (cheap, static) so the stack is consistent across deploys —
171
+ // but we skip the SG (which only makes sense with Lambdas in a
172
+ // VPC). The probe is intentionally permissive: any of
173
+ // functions / queues / schedules / notify triggers it.
174
+ // Routes and migrations are discovered from the filesystem
175
+ // (see route discovery below and `hasMigrations(dbDir)` in
176
+ // `stack.ts`); fold them in here when this probe is wired up
177
+ // to gate SG creation.
178
+ const hasAnyLambda = Boolean(infrastructure.functions) ||
179
+ Boolean(infrastructure.queues) ||
180
+ Boolean(infrastructure.schedules) ||
181
+ Boolean(infrastructure.notify);
182
+ // ─── Shared Lambda execution role ──────────────────────────
183
+ this.lambdaRole = new iam.Role(this, 'LambdaRole', {
184
+ assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'),
185
+ description: `Shared execution role for ${projectName}-${stage} Lambdas`,
186
+ managedPolicies: [
187
+ iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole'),
188
+ ],
189
+ });
190
+ if (this.vpc) {
191
+ // VPC-attached Lambdas need ENI-creation rights on cold start.
192
+ // Bolted on conditionally so VPC-less projects don't carry the
193
+ // unused managed policy.
194
+ this.lambdaRole.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaVPCAccessExecutionRole'));
195
+ this.lambdaSecurityGroup = new ec2.SecurityGroup(this, 'LambdaSg', {
196
+ vpc: this.vpc,
197
+ description: `Shared SG for ${projectName}-${stage} Lambdas`,
198
+ // Outbound open to anywhere — Lambdas in private subnets reach
199
+ // the internet through the VPC NAT, and reach AWS services
200
+ // through interface endpoints. Restricting outbound at SG level
201
+ // breaks legitimate egress (DB, AWS APIs, third-party APIs)
202
+ // without meaningfully tightening security; the firewall is
203
+ // the network ACL on the subnet, not the SG.
204
+ allowAllOutbound: true,
205
+ });
206
+ // DB ingress rules from the shared Lambda SG to each declared
207
+ // database SG live HERE because they're scoped to "Lambdas
208
+ // talking to DBs" — pure app-tier wiring. The lower-tier
209
+ // network/data stacks own the SGs themselves; this stack
210
+ // contributes only the ingress permission.
211
+ //
212
+ // Implementation deferred to sub-step 9c (alongside ESM wiring
213
+ // — both sets of grants share the same iteration over
214
+ // `infrastructure.databases[]`).
215
+ }
216
+ // ─── Internal HMAC secret ──────────────────────────────────
217
+ // Per-project HMAC key for signing internal Lambda-to-Lambda
218
+ // calls (see `@venturekit/runtime/invoke`). CloudFormation auto-
219
+ // generates the value on first deploy and preserves it across
220
+ // stack updates — so warm Lambdas don't lose trust on every
221
+ // redeploy. The VALUE is injected as an env var (not the ARN),
222
+ // so handlers don't pay an SDK round-trip per cold start.
223
+ //
224
+ // Lifecycle: this secret IS app-tier — its lifetime is tied to
225
+ // the Lambdas that consume it, and rotating it would require a
226
+ // coordinated Lambda rollout anyway. RETAIN under strict isn't
227
+ // appropriate; on a stack delete we're tearing down every Lambda
228
+ // that uses the key, so retaining the key alone serves no purpose.
229
+ this.internalHmacSecret = new secretsmanager.Secret(this, 'internalHmacSecret', {
230
+ secretName: `venturekit/${projectName}/${stage}/internal-hmac`,
231
+ description: 'VentureKit internal Lambda-to-Lambda HMAC signing key. Auto-generated.',
232
+ generateSecretString: {
233
+ // 64 hex chars = 256 bits. SHA-256 HMAC's natural key size.
234
+ // `excludePunctuation` keeps the value free of shell
235
+ // metacharacters when operators copy it for debugging.
236
+ passwordLength: 64,
237
+ excludePunctuation: true,
238
+ // No JSON wrapper — the secret value is read as a raw
239
+ // string by `@venturekit/runtime/invoke`.
240
+ generateStringKey: 'value',
241
+ secretStringTemplate: '{}',
242
+ },
243
+ });
244
+ // ─── Base env-var assembly ─────────────────────────────────
245
+ // Every Lambda gets these set in its `environment` block. The
246
+ // record is mutable during the rest of the constructor — sub-
247
+ // step helpers append per-intent keys (`STORAGE_<ID>_BUCKET`,
248
+ // `NOTIFY_<ID>_FROM`, etc.) before the first Lambda is created.
249
+ // Frozen as the very last constructor step (sub-step 9d) so
250
+ // any post-construct mutation throws.
251
+ this.baseEnvVars = {
252
+ VENTURE_PROJECT_NAME: projectName,
253
+ VENTURE_STAGE: stage,
254
+ // Inline value, not ARN. Lambdas read it directly from
255
+ // env without hitting Secrets Manager — zero cold-start cost
256
+ // for HMAC verification.
257
+ VENTURE_INTERNAL_HMAC_SECRET: this.internalHmacSecret.secretValue.unsafeUnwrap(),
258
+ // `--enable-source-maps` makes thrown errors point to the
259
+ // .ts source instead of bundled .js. Free win in CloudWatch.
260
+ NODE_OPTIONS: '--enable-source-maps',
261
+ };
262
+ // ─── Cross-tier env injection (cheap, static keys only) ────
263
+ // Per-intent keys go through helpers in sub-steps 9b/c/d. The
264
+ // ones below are stack-wide singletons — primary DB, primary
265
+ // storage, primary auth — that can be assembled here without
266
+ // iterating intents.
267
+ if (imports.databaseEndpointsById && imports.databaseEndpointsById.size > 0) {
268
+ // Convention: the FIRST declared `database` intent is the
269
+ // primary. Operators with multiple DBs (rare) read the per-
270
+ // intent keys (`DB_<ID>_*`, populated in 9c) instead.
271
+ const [primaryId, primary] = imports.databaseEndpointsById.entries().next().value;
272
+ void primaryId;
273
+ this.baseEnvVars['DB_HOST'] = primary.host;
274
+ this.baseEnvVars['DB_PORT'] = primary.port;
275
+ this.baseEnvVars['DB_NAME'] = primary.name;
276
+ // RDS enforces TLS on every modern engine; flipping this here
277
+ // means consumers don't have to remember to set it themselves.
278
+ this.baseEnvVars['DB_SSL'] = 'true';
279
+ if (primary.secretArn) {
280
+ this.baseEnvVars['DB_SECRET_ARN'] = primary.secretArn;
281
+ }
282
+ }
283
+ if (imports.storageBucketNamesById && imports.storageBucketNamesById.size > 0) {
284
+ const [, primaryBucket] = imports.storageBucketNamesById.entries().next().value;
285
+ this.baseEnvVars['VENTURE_STORAGE_BUCKET'] = primaryBucket;
286
+ }
287
+ if (imports.userPoolArnsByAuthId && imports.userPoolArnsByAuthId.size > 0) {
288
+ // The runtime's `loadAuthServerConfig()` reads
289
+ // `COGNITO_USER_POOL_ID`. We don't have just the ID here —
290
+ // ARN format is `arn:aws:cognito-idp:<region>:<acct>:userpool/<id>`,
291
+ // so we slice it.
292
+ const [, primaryArn] = imports.userPoolArnsByAuthId.entries().next().value;
293
+ const idMatch = primaryArn.match(/userpool\/(.+)$/);
294
+ if (idMatch) {
295
+ this.baseEnvVars['COGNITO_USER_POOL_ID'] = idMatch[1];
296
+ }
297
+ }
298
+ void hasAnyLambda;
299
+ void readSsmString;
300
+ void networkKeys;
301
+ void edgeKeys;
302
+ // ─── Per-intent env-var injection (sub-step 9d) ────────────
303
+ // Sub-step 9a populated PRIMARY-only keys (DB_HOST,
304
+ // VENTURE_STORAGE_BUCKET, COGNITO_USER_POOL_ID). Per-intent
305
+ // keys land here so handlers can reference any declared
306
+ // resource by id without runtime SSM lookups.
307
+ if (imports.storageBucketNamesById) {
308
+ for (const [id, bucketName] of imports.storageBucketNamesById) {
309
+ const idKey = upperSnake(id);
310
+ this.baseEnvVars[`STORAGE_${idKey}_BUCKET`] = bucketName;
311
+ const cdnDomain = imports.storageCdnDomainsById?.get(id);
312
+ if (cdnDomain) {
313
+ this.baseEnvVars[`STORAGE_${idKey}_CDN_URL`] = `https://${cdnDomain}`;
314
+ }
315
+ }
316
+ // VENTURE_STORAGE_CDN_URL primary mirror (if the primary has CDN).
317
+ const [primaryStorageId] = imports.storageBucketNamesById.entries().next().value;
318
+ const primaryCdn = imports.storageCdnDomainsById?.get(primaryStorageId);
319
+ if (primaryCdn) {
320
+ this.baseEnvVars['VENTURE_STORAGE_CDN_URL'] = `https://${primaryCdn}`;
321
+ }
322
+ }
323
+ if (imports.notifyConfigurationSetNamesById) {
324
+ // Primary notify is the first declared intent.
325
+ const entries = [...imports.notifyConfigurationSetNamesById.entries()];
326
+ if (entries.length > 0) {
327
+ const [primaryId, primaryConfigSet] = entries[0];
328
+ this.baseEnvVars['VENTURE_NOTIFY_CONFIG_SET'] = primaryConfigSet;
329
+ const primaryTopicArn = imports.notifyEventsTopicArnsById?.get(primaryId);
330
+ if (primaryTopicArn) {
331
+ this.baseEnvVars['VENTURE_NOTIFY_EVENTS_TOPIC_ARN'] = primaryTopicArn;
332
+ }
333
+ const primaryWhatsapp = imports.whatsappTokenSecretArnsById?.get(primaryId);
334
+ if (primaryWhatsapp) {
335
+ this.baseEnvVars['VENTURE_NOTIFY_WHATSAPP_TOKEN_SECRET'] = primaryWhatsapp;
336
+ }
337
+ }
338
+ for (const [id, configSet] of imports.notifyConfigurationSetNamesById) {
339
+ const idKey = upperSnake(id);
340
+ this.baseEnvVars[`NOTIFY_${idKey}_CONFIG_SET`] = configSet;
341
+ const topicArn = imports.notifyEventsTopicArnsById?.get(id);
342
+ if (topicArn) {
343
+ this.baseEnvVars[`NOTIFY_${idKey}_EVENTS_TOPIC_ARN`] = topicArn;
344
+ }
345
+ }
346
+ }
347
+ // ─── IAM grants on the shared Lambda role ──────────────────
348
+ // Every grant attached to `this.lambdaRole` benefits every
349
+ // Lambda in the stack (routes, queue consumers, schedules,
350
+ // notify dispatcher/bounce). The trade-off documented in
351
+ // 9a's role rationale: a route Lambda technically has SQS
352
+ // receive perms even though it never uses them — accepted
353
+ // because the alternative (one role per Lambda) blows past
354
+ // CFN's 500-resource limit on large projects.
355
+ this.applyImportedGrants(imports);
356
+ // Freeze base env-vars after all per-intent injection. Any
357
+ // post-construct mutation by a third-party extender (or a
358
+ // future sub-step that forgets to run before freeze) throws
359
+ // at runtime instead of silently producing inconsistent
360
+ // Lambda environments.
361
+ Object.freeze(this.baseEnvVars);
362
+ // ─── Route Lambdas + HttpApi route registrations ───────────
363
+ // Walks `${projectDir}/${routesDir}` and creates one Lambda +
364
+ // HttpRoute per discovered handler. Skipped silently when no
365
+ // routes dir exists or no API was imported — the latter would
366
+ // produce orphan Lambdas with nothing routing to them, which
367
+ // is never the right outcome.
368
+ if (props.projectDir) {
369
+ const resolvedRoutesDir = path.resolve(props.projectDir, props.routesDir ?? 'src/routes');
370
+ const discoveredRoutes = fs.existsSync(resolvedRoutesDir)
371
+ ? discoverRouteFiles(resolvedRoutesDir)
372
+ : [];
373
+ if (discoveredRoutes.length > 0) {
374
+ if (!this.api) {
375
+ throw new Error(`[venturekit] app stack: ${discoveredRoutes.length} route handler(s) ` +
376
+ `discovered under '${resolvedRoutesDir}' but no HttpApi was imported. ` +
377
+ `The CLI generator must pass imports.api when route handlers exist. ` +
378
+ `(Edge stack publishes apiId via SSM; generator imports via ` +
379
+ `HttpApi.fromHttpApiAttributes.)`);
380
+ }
381
+ for (const route of discoveredRoutes) {
382
+ this.createRouteFunction(route);
383
+ }
384
+ }
385
+ // ─── Queue consumer Lambdas + ESMs ────────────────────────
386
+ // Each file in `queuesDir` produces one consumer Lambda
387
+ // bound to a queue imported from the messaging stack via
388
+ // `imports.queueArnsById`. The queue ID matches the file-
389
+ // derived handler name (`src/queues/jobs.ts` →
390
+ // `imports.queueArnsById.get('jobs')`).
391
+ const resolvedQueuesDir = path.resolve(props.projectDir, props.queuesDir ?? 'src/queues');
392
+ if (fs.existsSync(resolvedQueuesDir)) {
393
+ const queueHandlers = discoverFunctionFiles(resolvedQueuesDir);
394
+ const queueIntents = infrastructure.queues ?? [];
395
+ for (const qFile of queueHandlers) {
396
+ const intent = queueIntents.find((q) => q.id === qFile.name);
397
+ this.createQueueConsumer(qFile.name, qFile.handlerFile, intent, imports);
398
+ }
399
+ }
400
+ // ─── Schedule / cron Lambdas + EventBridge rules ─────────
401
+ // File-discovered from `cronsDir`; the matching
402
+ // `infrastructure.schedules[]` intent (if any) provides the
403
+ // schedule expression. Files without a matching intent
404
+ // create the Lambda but no Rule — the operator can wire
405
+ // the trigger manually or in a follow-up deploy after
406
+ // declaring the schedule.
407
+ const resolvedCronsDir = path.resolve(props.projectDir, props.cronsDir ?? 'src/crons');
408
+ if (fs.existsSync(resolvedCronsDir)) {
409
+ const cronHandlers = discoverFunctionFiles(resolvedCronsDir);
410
+ const scheduleIntents = infrastructure.schedules ?? [];
411
+ for (const cFile of cronHandlers) {
412
+ const intent = scheduleIntents.find((s) => s.id === cFile.name);
413
+ this.createScheduleHandler(cFile.name, cFile.handlerFile, intent);
414
+ }
415
+ }
416
+ // ─── Notify dispatcher + bounce-handler Lambdas (9d-2) ────
417
+ // Each declared `notify[]` intent gets two managed Lambdas:
418
+ // a cron-driven dispatcher (drains the outbox table → SES /
419
+ // WhatsApp) and an SNS-subscribed bounce handler. Both
420
+ // bundle stub modules shipped by `@venturekit/notify`; the
421
+ // stubs delegate to the operator's notify client module
422
+ // discovered via project package.json convention.
423
+ if (infrastructure.notify && infrastructure.notify.length > 0) {
424
+ this.provisionNotifyHandlers(infrastructure.notify, props.projectDir, imports);
425
+ }
426
+ }
427
+ }
428
+ /**
429
+ * Build the Lambda code asset for a handler file. Production path:
430
+ * esbuild in-process, sub-second per handler. Test path: inline
431
+ * stub that satisfies API Gateway's contract without invoking
432
+ * esbuild.
433
+ */
434
+ bundleHandlerCode(handlerFile) {
435
+ if (this.skipBundling)
436
+ return inlineStubLambdaCode();
437
+ if (!this.projectDir) {
438
+ // The constructor only triggers route discovery when
439
+ // `projectDir` is set, so reaching here implies a future
440
+ // sub-step (9c/d) that bundles a Lambda without a project
441
+ // dir — caller bug, not user error.
442
+ throw new Error(`[venturekit] bundleHandlerCode called without projectDir set. ` +
443
+ `Set VentureAppStackProps.projectDir or VentureAppStackProps.skipBundling.`);
444
+ }
445
+ return bundleHandlerCodeWithEsbuild(handlerFile, this.projectDir);
446
+ }
447
+ /**
448
+ * Materialise one route handler: a Lambda function + an
449
+ * `HttpRoute` on the imported API + an `HttpLambdaIntegration`
450
+ * binding them together. CDK's `HttpLambdaIntegration` adds the
451
+ * `lambda:InvokeFunction` resource policy automatically so the
452
+ * cross-stack pattern works the same as in-stack.
453
+ */
454
+ createRouteFunction(route) {
455
+ const lambdaConfig = this.envConfig.lambda;
456
+ const functionName = clampLambdaFunctionName(`${this.projectName}-${this.stage}-route-${route.slug}`);
457
+ const resourceId = `route-${route.slug}`;
458
+ const fn = new lambda.Function(this, resourceId, {
459
+ functionName,
460
+ runtime: toLambdaRuntime(this.envConfig),
461
+ architecture: toLambdaArchitecture(this.envConfig),
462
+ handler: 'index.main',
463
+ code: this.bundleHandlerCode(route.handlerFile),
464
+ memorySize: lambdaConfig.memoryMb,
465
+ timeout: cdk.Duration.seconds(lambdaConfig.timeoutSec),
466
+ description: `${route.method} ${route.apiPath}`,
467
+ environment: {
468
+ ...this.baseEnvVars,
469
+ ...lambdaConfig.environmentVariables,
470
+ },
471
+ // Explicit LogGroup, NOT the deprecated `logRetention` prop.
472
+ // `logRetention` provisions a stack-wide custom-resource Lambda
473
+ // (+ its own IAM role) that calls `PutRetentionPolicy` after
474
+ // every deploy — that helper Lambda counts toward the project's
475
+ // function quota AND is created lazily on first cold-start
476
+ // failure of any handler, which is exactly when you DON'T want
477
+ // a CFN custom-resource invocation. Pre-creating the LogGroup
478
+ // fixes the retention at synth time and lets us apply RETAIN
479
+ // under strict dataSafety the same way the rest of this stack
480
+ // does.
481
+ logGroup: new logs.LogGroup(this, `${resourceId}-logs`, {
482
+ logGroupName: `/aws/lambda/${functionName}`,
483
+ retention: toLogRetention(lambdaConfig.logRetentionDays),
484
+ removalPolicy: this.isStrictRetain
485
+ ? cdk.RemovalPolicy.RETAIN
486
+ : cdk.RemovalPolicy.DESTROY,
487
+ }),
488
+ tracing: lambdaConfig.tracingEnabled
489
+ ? lambda.Tracing.ACTIVE
490
+ : lambda.Tracing.DISABLED,
491
+ reservedConcurrentExecutions: lambdaConfig.reservedConcurrency,
492
+ role: this.lambdaRole,
493
+ // Attach to the VPC iff one exists. Route Lambdas without a VPC
494
+ // can't reach RDS, but the free preset and DB-less projects
495
+ // don't need one — paying ENI cold-start tax there would be
496
+ // a regression.
497
+ ...(this.vpc && this.lambdaSecurityGroup
498
+ ? {
499
+ vpc: this.vpc,
500
+ vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
501
+ securityGroups: [this.lambdaSecurityGroup],
502
+ }
503
+ : {}),
504
+ });
505
+ this.routeLambdas.set(route.slug, fn);
506
+ // Add the HttpRoute on the imported API. `HttpRouteKey.with`
507
+ // builds the canonical `${METHOD} ${path}` string AWS uses
508
+ // internally; passing path + method separately keeps us honest
509
+ // if AWS ever changes the format.
510
+ new apigatewayv2.HttpRoute(this, `${resourceId}-route`, {
511
+ httpApi: this.api,
512
+ routeKey: apigatewayv2.HttpRouteKey.with(route.apiPath, apigatewayv2.HttpMethod[route.method]),
513
+ integration: new apigatewayv2Integrations.HttpLambdaIntegration(`${resourceId}-integration`, fn),
514
+ });
515
+ }
516
+ /**
517
+ * Build a Lambda function with the standard app-stack properties
518
+ * (shared role, VPC attachment, base env vars, log retention).
519
+ * Used by `createRouteFunction` (now refactored), `createQueueConsumer`,
520
+ * and `createScheduleHandler` to keep the per-Lambda invariants in
521
+ * one place.
522
+ */
523
+ buildLambda(resourceId, functionName, handlerFile, description, overrides) {
524
+ const lambdaConfig = this.envConfig.lambda;
525
+ return new lambda.Function(this, resourceId, {
526
+ functionName,
527
+ runtime: toLambdaRuntime(this.envConfig),
528
+ architecture: toLambdaArchitecture(this.envConfig),
529
+ handler: 'index.main',
530
+ code: this.bundleHandlerCode(handlerFile),
531
+ memorySize: lambdaConfig.memoryMb,
532
+ timeout: cdk.Duration.seconds(lambdaConfig.timeoutSec),
533
+ description,
534
+ environment: {
535
+ ...this.baseEnvVars,
536
+ ...lambdaConfig.environmentVariables,
537
+ },
538
+ logGroup: new logs.LogGroup(this, `${resourceId}-logs`, {
539
+ logGroupName: `/aws/lambda/${functionName}`,
540
+ retention: toLogRetention(lambdaConfig.logRetentionDays),
541
+ removalPolicy: this.isStrictRetain
542
+ ? cdk.RemovalPolicy.RETAIN
543
+ : cdk.RemovalPolicy.DESTROY,
544
+ }),
545
+ tracing: lambdaConfig.tracingEnabled
546
+ ? lambda.Tracing.ACTIVE
547
+ : lambda.Tracing.DISABLED,
548
+ reservedConcurrentExecutions: lambdaConfig.reservedConcurrency,
549
+ role: this.lambdaRole,
550
+ ...(this.vpc && this.lambdaSecurityGroup
551
+ ? {
552
+ vpc: this.vpc,
553
+ vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
554
+ securityGroups: [this.lambdaSecurityGroup],
555
+ }
556
+ : {}),
557
+ ...overrides,
558
+ });
559
+ }
560
+ /**
561
+ * Materialise one queue consumer: a Lambda + an `SqsEventSource`
562
+ * binding it to the queue ARN imported from the messaging stack.
563
+ * The DLQ already lives in the messaging tier (RETAIN-class
564
+ * durability); we don't recreate it here.
565
+ *
566
+ * Throws if no matching queue ARN is in `imports.queueArnsById` —
567
+ * a consumer with no queue is dead infrastructure.
568
+ */
569
+ createQueueConsumer(name, handlerFile, intent, imports) {
570
+ const queueArn = imports.queueArnsById?.get(name);
571
+ if (!queueArn) {
572
+ throw new Error(`[venturekit] app stack: queue handler '${name}' (file: ${handlerFile}) ` +
573
+ `has no matching queue in imports.queueArnsById. The CLI generator ` +
574
+ `must declare a queues[] intent with id='${name}' so the messaging ` +
575
+ `stack creates the queue, then pass its ARN through ` +
576
+ `imports.queueArnsById.set('${name}', queueArn).`);
577
+ }
578
+ const resourceId = `queue-consumer-${name}`;
579
+ const functionName = clampLambdaFunctionName(`${this.projectName}-${this.stage}-queue-${name}`);
580
+ const fn = this.buildLambda(resourceId, functionName, handlerFile, intent?.description ?? `Queue consumer: ${name}`, {
581
+ memorySize: intent?.memorySize ?? this.envConfig.lambda.memoryMb,
582
+ timeout: cdk.Duration.seconds(intent?.timeout ?? this.envConfig.lambda.timeoutSec),
583
+ });
584
+ this.queueLambdas.set(name, fn);
585
+ // `Queue.fromQueueArn` produces an `IQueue` that supports
586
+ // `addEventSource` via the service principal grant CDK auto-
587
+ // attaches to the consumer Lambda's role. The role grant
588
+ // (`sqs:ReceiveMessage`, `DeleteMessage`, `GetQueueAttributes`)
589
+ // is added to `this.lambdaRole` because the SqsEventSource
590
+ // constructor calls `queue.grantConsumeMessages(fn)` internally.
591
+ const importedQueue = sqs.Queue.fromQueueArn(this, `${resourceId}-imported-queue`, queueArn);
592
+ fn.addEventSource(new lambdaEventSources.SqsEventSource(importedQueue, {
593
+ batchSize: intent?.batchSize ?? 10,
594
+ }));
595
+ }
596
+ /**
597
+ * Materialise one scheduled handler: a Lambda + (optional)
598
+ * EventBridge `Rule` that targets it. When no schedule intent is
599
+ * declared for the handler, the Lambda is created without a Rule
600
+ * — the operator can wire the trigger manually post-deploy.
601
+ *
602
+ * Each scheduled Lambda gets its own DLQ for async-invoke
603
+ * failures. Without it, a broken cron silently stops working
604
+ * after Lambda's 2-retry budget. The DLQ is app-tier (DESTROY-on-
605
+ * delete): EventBridge-delivery failures don't accumulate
606
+ * meaningfully across redeploys, and the messaging-tier DLQs are
607
+ * specific to the notify dispatcher / bounce paths.
608
+ */
609
+ createScheduleHandler(name, handlerFile, intent) {
610
+ const resourceId = `cron-${name}`;
611
+ const functionName = clampLambdaFunctionName(`${this.projectName}-${this.stage}-cron-${name}`);
612
+ const dlq = new sqs.Queue(this, `${resourceId}-dlq`, {
613
+ queueName: `${this.projectName}-${this.stage}-cron-${name}-dlq`,
614
+ retentionPeriod: cdk.Duration.days(14),
615
+ encryption: sqs.QueueEncryption.SQS_MANAGED,
616
+ // App-tier: DESTROY-on-delete. See header comment.
617
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
618
+ });
619
+ const fn = this.buildLambda(resourceId, functionName, handlerFile, intent?.description ?? `Cron handler: ${name}`, {
620
+ memorySize: intent?.memorySize ?? this.envConfig.lambda.memoryMb,
621
+ // Crons default to 60s timeout (vs 30s for sync routes) —
622
+ // background jobs are typically heavier than HTTP handlers.
623
+ timeout: cdk.Duration.seconds(intent?.timeout ?? 60),
624
+ deadLetterQueue: dlq,
625
+ deadLetterQueueEnabled: true,
626
+ });
627
+ this.scheduleLambdas.set(name, fn);
628
+ // Optional EventBridge rule. When `intent` is undefined the
629
+ // handler is created without a trigger — this is intentional
630
+ // (lets operators stage handler deploys before declaring the
631
+ // schedule).
632
+ if (intent?.schedule) {
633
+ const scheduleExpr = intent.schedule;
634
+ const isEnabled = intent.enabled ?? true;
635
+ const schedule = 'rate' in scheduleExpr
636
+ ? events.Schedule.expression(`rate(${scheduleExpr.rate})`)
637
+ : events.Schedule.expression(`cron(${scheduleExpr.cron})`);
638
+ new events.Rule(this, `${resourceId}-rule`, {
639
+ ruleName: `${this.projectName}-${this.stage}-cron-${name}`,
640
+ schedule,
641
+ enabled: isEnabled,
642
+ targets: [new eventsTargets.LambdaFunction(fn)],
643
+ });
644
+ }
645
+ }
646
+ /**
647
+ * Attach IAM grants to the shared Lambda role for every imported
648
+ * resource. Called once during construction, before any Lambda is
649
+ * created — CDK collects role policies eagerly at synth, so the
650
+ * order doesn't matter, but co-locating all grants in one helper
651
+ * makes the policy surface auditable.
652
+ *
653
+ * Grant inventory:
654
+ * - DB master credential secret read (one per database)
655
+ * - Federated provider Secret reads (one per provider per auth intent)
656
+ * - WhatsApp token Secret reads (one per notify intent with channel)
657
+ * - S3 bucket read+write (one per storage intent)
658
+ * - Cognito admin actions (one per auth intent's user pool)
659
+ * - SES send (account-level, scoped via configuration set)
660
+ *
661
+ * SQS receive grants come from `SqsEventSource` per consumer
662
+ * Lambda, NOT here, because they need the role+function pair and
663
+ * are added at consumer-creation time. Same for HttpApi invoke
664
+ * (added by `HttpLambdaIntegration`) and EventBridge invoke
665
+ * (added by `LambdaFunction` target).
666
+ */
667
+ applyImportedGrants(imports) {
668
+ // ── DB secrets ────────────────────────────────────────────
669
+ if (imports.databaseEndpointsById) {
670
+ for (const [id, db] of imports.databaseEndpointsById) {
671
+ if (!db.secretArn)
672
+ continue;
673
+ const importedSecret = secretsmanager.Secret.fromSecretCompleteArn(this, `imported-db-secret-${id}`, db.secretArn);
674
+ importedSecret.grantRead(this.lambdaRole);
675
+ }
676
+ }
677
+ // ── Federated provider secrets ────────────────────────────
678
+ if (imports.federatedSecretArnsByAuthId) {
679
+ for (const [authId, providers] of imports.federatedSecretArnsByAuthId) {
680
+ for (const [provider, arn] of Object.entries(providers)) {
681
+ const importedSecret = secretsmanager.Secret.fromSecretCompleteArn(this, `imported-fed-secret-${authId}-${provider}`, arn);
682
+ importedSecret.grantRead(this.lambdaRole);
683
+ }
684
+ }
685
+ }
686
+ // ── WhatsApp token secrets ────────────────────────────────
687
+ if (imports.whatsappTokenSecretArnsById) {
688
+ for (const [id, arn] of imports.whatsappTokenSecretArnsById) {
689
+ const importedSecret = secretsmanager.Secret.fromSecretCompleteArn(this, `imported-whatsapp-secret-${id}`, arn);
690
+ importedSecret.grantRead(this.lambdaRole);
691
+ }
692
+ }
693
+ // ── S3 buckets (read+write) ──────────────────────────────
694
+ if (imports.storageBucketNamesById) {
695
+ for (const [id, bucketName] of imports.storageBucketNamesById) {
696
+ const bucket = s3.Bucket.fromBucketName(this, `imported-bucket-${id}`, bucketName);
697
+ bucket.grantReadWrite(this.lambdaRole);
698
+ }
699
+ }
700
+ // ── Cognito admin actions per user pool ──────────────────
701
+ if (imports.userPoolArnsByAuthId) {
702
+ for (const [, poolArn] of imports.userPoolArnsByAuthId) {
703
+ // Scope to specific Cognito admin actions used by
704
+ // `@venturekit/auth/server`'s federated sign-in path.
705
+ // Avoid `cognito-idp:*` — overly broad, includes pool
706
+ // delete/update which Lambdas should never do.
707
+ this.lambdaRole.addToPrincipalPolicy(new iam.PolicyStatement({
708
+ effect: iam.Effect.ALLOW,
709
+ actions: [
710
+ 'cognito-idp:AdminCreateUser',
711
+ 'cognito-idp:AdminGetUser',
712
+ 'cognito-idp:AdminConfirmSignUp',
713
+ 'cognito-idp:AdminInitiateAuth',
714
+ 'cognito-idp:AdminSetUserPassword',
715
+ 'cognito-idp:AdminUpdateUserAttributes',
716
+ 'cognito-idp:AdminLinkProviderForUser',
717
+ 'cognito-idp:ListUsers',
718
+ ],
719
+ resources: [poolArn],
720
+ }));
721
+ }
722
+ }
723
+ // ── SES send ─────────────────────────────────────────────
724
+ // SES grants are account-level; the security boundary comes
725
+ // from `ConfigurationSetName` (validated server-side against
726
+ // the configured identity). No `Resource: *` workaround
727
+ // because SES doesn't support resource-level scoping for
728
+ // SendEmail.
729
+ if (imports.notifyConfigurationSetNamesById && imports.notifyConfigurationSetNamesById.size > 0) {
730
+ this.lambdaRole.addToPrincipalPolicy(new iam.PolicyStatement({
731
+ effect: iam.Effect.ALLOW,
732
+ actions: ['ses:SendEmail', 'ses:SendRawEmail'],
733
+ resources: ['*'],
734
+ }));
735
+ }
736
+ // ── SNS publish on notify events topics ─────────────────
737
+ // Used by the dispatcher to publish synthetic events when an
738
+ // operator triggers a manual replay. App handlers don't
739
+ // typically publish here; the grant exists for the dispatcher
740
+ // alone but lives on the shared role for the same accumulator
741
+ // reasons documented elsewhere.
742
+ if (imports.notifyEventsTopicArnsById) {
743
+ const topicArns = [...imports.notifyEventsTopicArnsById.values()];
744
+ if (topicArns.length > 0) {
745
+ this.lambdaRole.addToPrincipalPolicy(new iam.PolicyStatement({
746
+ effect: iam.Effect.ALLOW,
747
+ actions: ['sns:Publish'],
748
+ resources: topicArns,
749
+ }));
750
+ }
751
+ }
752
+ }
753
+ /**
754
+ * Provision dispatcher + bounce-handler Lambdas per declared
755
+ * `notify[]` intent. The Lambdas bundle stub modules shipped
756
+ * by `@venturekit/notify` from the consumer's `node_modules`;
757
+ * the stubs delegate to the operator's notify client module at
758
+ * runtime. Each Lambda gets its on-failure DLQ from the
759
+ * messaging tier (RETAIN-class) — losing those DLQs across
760
+ * redeploys would discard the canonical record of failed
761
+ * deliveries.
762
+ *
763
+ * # Why both Lambdas live HERE not in messaging stack
764
+ *
765
+ * The Lambdas are throwaway app-tier code; their schedule
766
+ * (EventBridge rule) and DLQs (messaging-tier RETAIN) are
767
+ * separate concerns. Co-locating the handler with the rule + DLQ
768
+ * would force the messaging stack to depend on the handler
769
+ * code's bundled hash, which churns every code change and
770
+ * defeats the lifecycle split.
771
+ */
772
+ provisionNotifyHandlers(notifyIntents, projectDir, imports) {
773
+ if (this.skipBundling) {
774
+ // In test mode we still want to verify dispatcher / bounce
775
+ // *resources* exist, but we can't resolve `@venturekit/notify`
776
+ // in the test sandbox. Use the inline stub path; bundling
777
+ // helper handles it.
778
+ }
779
+ // Resolve stub paths from the consumer's `node_modules`. Skip
780
+ // resolution entirely when bundling is off (test mode); the
781
+ // inline stub path doesn't need real handler files.
782
+ let dispatcherStubSrc = '';
783
+ let bounceStubSrc = '';
784
+ if (!this.skipBundling) {
785
+ const projectRequire = createRequire(path.join(projectDir, 'package.json'));
786
+ try {
787
+ dispatcherStubSrc = projectRequire.resolve('@venturekit/notify/runtime/dispatcher-stub');
788
+ bounceStubSrc = projectRequire.resolve('@venturekit/notify/runtime/bounce-handler-stub');
789
+ }
790
+ catch (err) {
791
+ throw new Error(`[venturekit] notify intent declared but '@venturekit/notify' is not ` +
792
+ `installed in the project. Run \`pnpm add @venturekit/notify\` ` +
793
+ `(or your equivalent) and redeploy. Underlying error: ${err.message}`);
794
+ }
795
+ }
796
+ // Materialize per-intent stub copies under `.vk/`. Per-intent
797
+ // copies (named by id) prevent collisions when the operator
798
+ // declares multiple notify intents with separate client
799
+ // modules (e.g. transactional vs marketing).
800
+ const vkDir = path.join(projectDir, '.vk');
801
+ if (!this.skipBundling) {
802
+ fs.mkdirSync(vkDir, { recursive: true });
803
+ }
804
+ for (const intent of notifyIntents) {
805
+ const id = String(intent.id);
806
+ const dispatchRateMinutes = intent.dispatchRateMinutes && intent.dispatchRateMinutes >= 1
807
+ ? intent.dispatchRateMinutes
808
+ : 1;
809
+ const batchSize = intent.dispatchBatchSize && intent.dispatchBatchSize >= 1
810
+ ? intent.dispatchBatchSize
811
+ : 50;
812
+ let dispatchStubPath = `${id}-dispatcher.ts`;
813
+ let bouncerStubPath = `${id}-bounce.ts`;
814
+ if (!this.skipBundling) {
815
+ dispatchStubPath = path.join(vkDir, `notify-${id}-dispatcher.ts`);
816
+ bouncerStubPath = path.join(vkDir, `notify-${id}-bounce.ts`);
817
+ fs.copyFileSync(dispatcherStubSrc, dispatchStubPath);
818
+ fs.copyFileSync(bounceStubSrc, bouncerStubPath);
819
+ }
820
+ // ── Dispatcher Lambda + cron rule ────────────────────────
821
+ const dispatcherFnId = `notify-${id}-dispatcher`;
822
+ const dispatcherDlqArn = imports.notifyDispatcherDlqArnsById?.get(id);
823
+ const dispatcherDlq = dispatcherDlqArn
824
+ ? sqs.Queue.fromQueueArn(this, `${dispatcherFnId}-dlq-imported`, dispatcherDlqArn)
825
+ : undefined;
826
+ const dispatcherFn = this.buildLambda(dispatcherFnId, clampLambdaFunctionName(`${this.projectName}-${this.stage}-${dispatcherFnId}`), dispatchStubPath, `Notify dispatcher for '${id}' — drains the outbox.`, {
827
+ timeout: cdk.Duration.seconds(60),
828
+ environment: {
829
+ ...this.baseEnvVars,
830
+ ...this.envConfig.lambda.environmentVariables,
831
+ VENTURE_NOTIFY_BATCH_SIZE: String(batchSize),
832
+ VENTURE_NOTIFY_INTENT_ID: id,
833
+ },
834
+ ...(dispatcherDlq
835
+ ? { deadLetterQueue: dispatcherDlq, deadLetterQueueEnabled: true }
836
+ : {}),
837
+ });
838
+ this.notifyDispatcherLambdas.set(id, dispatcherFn);
839
+ new events.Rule(this, `${dispatcherFnId}-rule`, {
840
+ ruleName: `${this.projectName}-${this.stage}-${dispatcherFnId}`,
841
+ schedule: events.Schedule.rate(cdk.Duration.minutes(dispatchRateMinutes)),
842
+ enabled: true,
843
+ targets: [new eventsTargets.LambdaFunction(dispatcherFn)],
844
+ });
845
+ // ── Bounce-handler Lambda + SNS subscription ─────────────
846
+ const bounceFnId = `notify-${id}-bounce-handler`;
847
+ const bounceDlqArn = imports.notifyBounceDlqArnsById?.get(id);
848
+ const bounceDlq = bounceDlqArn
849
+ ? sqs.Queue.fromQueueArn(this, `${bounceFnId}-dlq-imported`, bounceDlqArn)
850
+ : undefined;
851
+ const bounceFn = this.buildLambda(bounceFnId, clampLambdaFunctionName(`${this.projectName}-${this.stage}-${bounceFnId}`), bouncerStubPath, `Notify bounce/complaint handler for '${id}'.`, {
852
+ timeout: cdk.Duration.seconds(30),
853
+ environment: {
854
+ ...this.baseEnvVars,
855
+ ...this.envConfig.lambda.environmentVariables,
856
+ VENTURE_NOTIFY_INTENT_ID: id,
857
+ },
858
+ ...(bounceDlq
859
+ ? { deadLetterQueue: bounceDlq, deadLetterQueueEnabled: true }
860
+ : {}),
861
+ });
862
+ this.notifyBounceLambdas.set(id, bounceFn);
863
+ // Subscribe to the events topic. Topic lives in messaging
864
+ // stack — import via ARN. CDK's `LambdaSubscription` adds
865
+ // the `lambda:InvokeFunction` resource policy on the bounce
866
+ // Lambda granting SNS the right to invoke it.
867
+ const topicArn = imports.notifyEventsTopicArnsById?.get(id);
868
+ if (topicArn) {
869
+ const importedTopic = sns.Topic.fromTopicArn(this, `${bounceFnId}-topic-imported`, topicArn);
870
+ importedTopic.addSubscription(new snsSubscriptions.LambdaSubscription(bounceFn));
871
+ }
872
+ }
873
+ }
874
+ }
875
+ //# sourceMappingURL=app-stack.js.map