@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.
- package/dist/cdk/app-stack.d.ts +330 -0
- package/dist/cdk/app-stack.d.ts.map +1 -0
- package/dist/cdk/app-stack.js +875 -0
- package/dist/cdk/app-stack.js.map +1 -0
- package/dist/cdk/config-stack.d.ts +104 -0
- package/dist/cdk/config-stack.d.ts.map +1 -0
- package/dist/cdk/config-stack.js +161 -0
- package/dist/cdk/config-stack.js.map +1 -0
- package/dist/cdk/data-stack.d.ts +198 -0
- package/dist/cdk/data-stack.d.ts.map +1 -0
- package/dist/cdk/data-stack.js +511 -0
- package/dist/cdk/data-stack.js.map +1 -0
- package/dist/cdk/edge-stack.d.ts +91 -0
- package/dist/cdk/edge-stack.d.ts.map +1 -0
- package/dist/cdk/edge-stack.js +364 -0
- package/dist/cdk/edge-stack.js.map +1 -0
- package/dist/cdk/identity-stack.d.ts +98 -0
- package/dist/cdk/identity-stack.d.ts.map +1 -0
- package/dist/cdk/identity-stack.js +254 -0
- package/dist/cdk/identity-stack.js.map +1 -0
- package/dist/cdk/index.d.ts +16 -0
- package/dist/cdk/index.d.ts.map +1 -1
- package/dist/cdk/index.js +17 -0
- package/dist/cdk/index.js.map +1 -1
- package/dist/cdk/messaging-stack.d.ts +109 -0
- package/dist/cdk/messaging-stack.d.ts.map +1 -0
- package/dist/cdk/messaging-stack.js +320 -0
- package/dist/cdk/messaging-stack.js.map +1 -0
- package/dist/cdk/network-stack.d.ts +87 -0
- package/dist/cdk/network-stack.d.ts.map +1 -0
- package/dist/cdk/network-stack.js +265 -0
- package/dist/cdk/network-stack.js.map +1 -0
- package/dist/cdk/shared/cross-stack-refs.d.ts +278 -0
- package/dist/cdk/shared/cross-stack-refs.d.ts.map +1 -0
- package/dist/cdk/shared/cross-stack-refs.js +326 -0
- package/dist/cdk/shared/cross-stack-refs.js.map +1 -0
- package/dist/cdk/shared/lambda-helpers.d.ts +82 -0
- package/dist/cdk/shared/lambda-helpers.d.ts.map +1 -0
- package/dist/cdk/shared/lambda-helpers.js +201 -0
- package/dist/cdk/shared/lambda-helpers.js.map +1 -0
- package/dist/cdk/shared/ssm-keys.d.ts +460 -0
- package/dist/cdk/shared/ssm-keys.d.ts.map +1 -0
- package/dist/cdk/shared/ssm-keys.js +291 -0
- package/dist/cdk/shared/ssm-keys.js.map +1 -0
- package/dist/cdk/stack.d.ts +3 -11
- package/dist/cdk/stack.d.ts.map +1 -1
- package/dist/cdk/stack.js +184 -41
- package/dist/cdk/stack.js.map +1 -1
- 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
|