@deployfoundation/foundation-deploy 0.1.0

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 (54) hide show
  1. package/README.md +174 -0
  2. package/agent-image/Dockerfile +254 -0
  3. package/agent-image/bin/aws +36 -0
  4. package/agent-image/bin/gh +193 -0
  5. package/agent-image/bin/git-credential-sky +89 -0
  6. package/agent-image/security-overlay.yml +176 -0
  7. package/cdk.json +6 -0
  8. package/dist/bin/app.js +112 -0
  9. package/dist/bin/foundation-deploy.js +1906 -0
  10. package/dist/bin/release-account.js +154 -0
  11. package/dist/chunk-4aye5cee.js +2416 -0
  12. package/dist/chunk-9ddxyvq2.js +1455 -0
  13. package/dist/chunk-v7tz8g50.js +428 -0
  14. package/dist/src/index.js +88 -0
  15. package/package.json +38 -0
  16. package/pipeline/buildspec.yml +34 -0
  17. package/src/artifacts.ts +318 -0
  18. package/src/deploy/assets/github-app-manifest.yml +29 -0
  19. package/src/deploy/assets/slack-app-manifest.yml +95 -0
  20. package/src/deploy/aws.ts +265 -0
  21. package/src/deploy/cli.ts +212 -0
  22. package/src/deploy/config-sync.ts +93 -0
  23. package/src/deploy/config.ts +29 -0
  24. package/src/deploy/deploy.ts +566 -0
  25. package/src/deploy/endpoint.ts +242 -0
  26. package/src/deploy/github-app-create.ts +154 -0
  27. package/src/deploy/github-app-manifest.ts +53 -0
  28. package/src/deploy/image.ts +80 -0
  29. package/src/deploy/instance.ts +87 -0
  30. package/src/deploy/license-cache.ts +47 -0
  31. package/src/deploy/license.ts +272 -0
  32. package/src/deploy/paths.ts +65 -0
  33. package/src/deploy/post-deploy.ts +97 -0
  34. package/src/deploy/release.ts +282 -0
  35. package/src/deploy/runtime-secret.ts +241 -0
  36. package/src/deploy/setup.ts +393 -0
  37. package/src/deploy/sh.ts +74 -0
  38. package/src/deploy/slack-manifest.ts +112 -0
  39. package/src/deploy/stage-customization.ts +224 -0
  40. package/src/deploy/tracing.ts +243 -0
  41. package/src/deploy-permissions.ts +165 -0
  42. package/src/index.ts +60 -0
  43. package/src/lambda-bundle-context.ts +64 -0
  44. package/src/names.ts +170 -0
  45. package/src/release/kms.ts +86 -0
  46. package/src/release/manifest.ts +265 -0
  47. package/src/stacks/agent-stack.ts +938 -0
  48. package/src/stacks/api-stack.ts +1005 -0
  49. package/src/stacks/ci-stack.ts +96 -0
  50. package/src/stacks/data-stack.ts +446 -0
  51. package/src/stacks/network-stack.ts +282 -0
  52. package/src/stacks/newsletter-stack.ts +572 -0
  53. package/src/stacks/pipeline-stack.ts +242 -0
  54. package/src/stacks/release-account-stack.ts +229 -0
@@ -0,0 +1,1005 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { crmPolicyFingerprint, parseInstanceConfig } from "@deployfoundation/foundation-core";
3
+ import { slackCommandPrefixes } from "@deployfoundation/foundation-core/instance";
4
+ import * as cdk from "aws-cdk-lib";
5
+ import * as apigateway from "aws-cdk-lib/aws-apigateway";
6
+ import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";
7
+ import * as cwActions from "aws-cdk-lib/aws-cloudwatch-actions";
8
+ import type * as dynamodb from "aws-cdk-lib/aws-dynamodb";
9
+ import * as iam from "aws-cdk-lib/aws-iam";
10
+ import type * as kms from "aws-cdk-lib/aws-kms";
11
+ import * as lambda from "aws-cdk-lib/aws-lambda";
12
+ import * as lambdaEventSources from "aws-cdk-lib/aws-lambda-event-sources";
13
+ import * as scheduler from "aws-cdk-lib/aws-scheduler";
14
+ import type * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
15
+ import * as sns from "aws-cdk-lib/aws-sns";
16
+ import * as sqs from "aws-cdk-lib/aws-sqs";
17
+ import type { Construct } from "constructs";
18
+ import { parse as parseYaml } from "yaml";
19
+ import { lambdaCode } from "../artifacts.ts";
20
+ import {
21
+ type Instance,
22
+ LIVE_ENDPOINT_NAME,
23
+ namesFor,
24
+ provisionsIntegration,
25
+ requiresAuthenticatedQueue,
26
+ } from "../names.ts";
27
+
28
+ export interface FoundationApiProps extends cdk.StackProps {
29
+ /** The deployment this stack belongs to; every name below comes from it. */
30
+ instance: Instance;
31
+ /**
32
+ * The instance's runtime config. Several proxies bake policy from it at
33
+ * synth — the identities email may draft as, the domains the browser may
34
+ * visit, CRM's channels and statuses — so a config sync alone can never
35
+ * widen a deployed proxy.
36
+ */
37
+ configPath: string;
38
+ dataKey: kms.IKey;
39
+ table: dynamodb.ITable;
40
+ /** `pk` + `sk`: where the Google Calendar callback stores per-person tokens. */
41
+ itemsTable: dynamodb.ITable;
42
+ /** Dedicated CRM table, present only when the instance enables the integration. */
43
+ crmTable?: dynamodb.ITable;
44
+ /** Proxy-only one-hour proposal drafts; never granted to the agent runtime. */
45
+ upworkApprovalTable?: dynamodb.ITable;
46
+ secrets: {
47
+ signing: secretsmanager.ISecret;
48
+ slackApp: secretsmanager.ISecret;
49
+ googleCalendar: secretsmanager.ISecret;
50
+ /** The shared OAuth client; the gateway reads it to build a consent link. */
51
+ googleOauth: secretsmanager.ISecret;
52
+ /** The Drive identity. The gateway WRITES this one and never reads it. */
53
+ googleDrive: secretsmanager.ISecret;
54
+ /** The connected mailbox. The gateway WRITES it; only the proxy reads it. */
55
+ googleEmail: secretsmanager.ISecret;
56
+ /** Otter API key. Only the optional Otter proxy role can read it. */
57
+ otterApi?: secretsmanager.ISecret;
58
+ /** Knock's public OAuth client. Gateway reads it; deployment owns registration. */
59
+ knockOauthClient?: secretsmanager.ISecret;
60
+ /** Connected Knock credential. Gateway writes; proxy alone reads or refreshes. */
61
+ knockCredential?: secretsmanager.ISecret;
62
+ /** Combined Upwork OAuth client and token secret, present only for opted-in instances. */
63
+ upwork?: secretsmanager.ISecret;
64
+ };
65
+ /**
66
+ * The AgentCore runtime the invoker calls. On a phase-one deploy this is
67
+ * FoundationAgent's `…-pending` placeholder — a syntactically valid ARN that
68
+ * nothing can invoke, so the plumbing synthesizes before the image exists.
69
+ */
70
+ agentRuntimeArn: string;
71
+ /** CSV of Slack user ids allowed to run privileged commands. */
72
+ admins: string;
73
+ /** Optional address subscribed to the DLQ alarm topic. */
74
+ alarmEmail?: string;
75
+ }
76
+
77
+ /**
78
+ * FoundationApi — the edge: API Gateway → gateway Lambda → SQS → invoker
79
+ * Lambda → `InvokeAgentRuntime`. The gateway must answer Slack inside 3
80
+ * seconds, so it only verifies, dedupes and enqueues; the invoker holds the
81
+ * blocking agent turn for up to Lambda's 15 minutes (which is also
82
+ * AgentCore's per-invoke ceiling).
83
+ *
84
+ * Neither Lambda is VPC-attached. Everything they call — Secrets Manager,
85
+ * SQS, DynamoDB, AgentCore — is a public AWS endpoint, so the VPC would buy
86
+ * nothing but ENI cold-start latency on the 3-second path.
87
+ *
88
+ * `maxReceiveCount: 1` on the invoke queue: chat prefers dropping a turn over
89
+ * duplicating it. A failed invoke rolls straight to the DLQ for manual
90
+ * redrive, and the DLQ alarm below is what says so.
91
+ */
92
+ export class FoundationApi extends cdk.Stack {
93
+ public readonly api: apigateway.RestApi;
94
+ public readonly gatewayDlq: sqs.Queue;
95
+ public readonly invokeQueue: sqs.Queue;
96
+ public readonly invokeDlq: sqs.Queue;
97
+ /** The email proxy: the agent role's only email grant is to invoke this. */
98
+ public readonly emailProxyFunction: lambda.Function;
99
+ /** Isolated policy boundary for AgentCore Browser; absent when the capability is disabled. */
100
+ public readonly browserProxyFunction?: lambda.Function;
101
+ /** Read-only Otter proxy, present only when the instance opts in. */
102
+ public readonly otterProxyFunction?: lambda.Function;
103
+ /** CRM proxy, present only when the instance opts into its CRM integration. */
104
+ public readonly crmProxyFunction?: lambda.Function;
105
+ /** Read-only Knock remote-MCP proxy, present only for opted-in instances. */
106
+ public readonly knockProxyFunction?: lambda.Function;
107
+ /** Fixed-operation Upwork proxy, present only when the instance opts in. */
108
+ public readonly upworkProxyFunction?: lambda.Function;
109
+
110
+ constructor(scope: Construct, id: string, props: FoundationApiProps) {
111
+ super(scope, id, props);
112
+ const names = namesFor(props.instance);
113
+ const display = props.instance.displayName;
114
+ const upwork = provisionsIntegration(props.instance, "upwork")
115
+ ? upworkConfigForProvisioning(props.configPath)
116
+ : undefined;
117
+ const upworkSecret = upwork === undefined ? undefined : props.secrets.upwork;
118
+ const upworkApprovalTable = upwork === undefined ? undefined : props.upworkApprovalTable;
119
+ if (upwork !== undefined && (upworkSecret === undefined || upworkApprovalTable === undefined))
120
+ throw new Error(
121
+ `${id}: integrations.upwork is enabled but its secret or approval table was not supplied by FoundationData`,
122
+ );
123
+ const authenticatedQueue = requiresAuthenticatedQueue(props.instance);
124
+
125
+ this.gatewayDlq = new sqs.Queue(this, "GatewayDlq", {
126
+ encryption: sqs.QueueEncryption.KMS,
127
+ encryptionMasterKey: props.dataKey,
128
+ enforceSSL: true,
129
+ retentionPeriod: cdk.Duration.days(14),
130
+ });
131
+
132
+ this.invokeDlq = new sqs.Queue(this, "InvokeDlq", {
133
+ encryption: sqs.QueueEncryption.KMS,
134
+ encryptionMasterKey: props.dataKey,
135
+ enforceSSL: true,
136
+ retentionPeriod: cdk.Duration.days(14),
137
+ });
138
+
139
+ this.invokeQueue = new sqs.Queue(this, "InvokeQueue", {
140
+ encryption: sqs.QueueEncryption.KMS,
141
+ encryptionMasterKey: props.dataKey,
142
+ enforceSSL: true,
143
+ // Matches the invoker's 15-minute timeout: Lambda extends visibility
144
+ // while the turn runs, and a dead invoker's message surfaces once and
145
+ // then rolls to the DLQ.
146
+ visibilityTimeout: cdk.Duration.seconds(900),
147
+ retentionPeriod: cdk.Duration.hours(6),
148
+ deadLetterQueue: { queue: this.invokeDlq, maxReceiveCount: 1 },
149
+ });
150
+
151
+ const gatewayRole = new iam.Role(this, "GatewayRole", {
152
+ assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
153
+ description: `${display} gateway Lambda role (least privilege)`,
154
+ managedPolicies: [
155
+ iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
156
+ ],
157
+ });
158
+ gatewayRole.addToPolicy(
159
+ new iam.PolicyStatement({
160
+ sid: "GatewayCredentials",
161
+ actions: ["secretsmanager:GetSecretValue"],
162
+ // Slack request verification, plus the Google OAuth *client* used to
163
+ // build a consent link and redeem a code. No refresh token is ever
164
+ // read from a secret: they are per-person rows in `FoundationItems`.
165
+ resources: [
166
+ secretArnPattern(props.secrets.signing),
167
+ secretArnPattern(props.secrets.slackApp),
168
+ secretArnPattern(props.secrets.googleCalendar),
169
+ secretArnPattern(props.secrets.googleOauth),
170
+ ...(props.secrets.knockOauthClient === undefined
171
+ ? []
172
+ : [secretArnPattern(props.secrets.knockOauthClient)]),
173
+ ],
174
+ }),
175
+ );
176
+ gatewayRole.addToPolicy(
177
+ new iam.PolicyStatement({
178
+ sid: "DriveIdentityConnect",
179
+ // `/sky-drive-connect` stores the connected account's refresh token.
180
+ // Write-only and on this one secret: the gateway must be unable to
181
+ // read back the Drive credential it just wrote, which is what keeps
182
+ // Drive reads exclusive to the agent runtime.
183
+ actions: ["secretsmanager:PutSecretValue"],
184
+ resources: [secretArnPattern(props.secrets.googleDrive)],
185
+ }),
186
+ );
187
+ gatewayRole.addToPolicy(
188
+ new iam.PolicyStatement({
189
+ sid: "EmailIdentityConnect",
190
+ // The same shape, and here it matters more: the mailbox credential is
191
+ // the one thing in the system that could SEND mail. A read grant would
192
+ // put it one injection away from an internet-facing Lambda.
193
+ actions: ["secretsmanager:PutSecretValue"],
194
+ resources: [secretArnPattern(props.secrets.googleEmail)],
195
+ }),
196
+ );
197
+ if (props.secrets.knockCredential !== undefined) {
198
+ gatewayRole.addToPolicy(
199
+ new iam.PolicyStatement({
200
+ sid: "KnockCredentialConnect",
201
+ // The callback writes a newly connected token but never reads it;
202
+ // the fixed proxy owns every subsequent read and refresh.
203
+ actions: ["secretsmanager:PutSecretValue"],
204
+ resources: [secretArnPattern(props.secrets.knockCredential)],
205
+ }),
206
+ );
207
+ }
208
+ if (upworkSecret !== undefined) {
209
+ gatewayRole.addToPolicy(
210
+ new iam.PolicyStatement({
211
+ sid: "UpworkOAuth",
212
+ // The callback exchanges a code and conditionally promotes refreshed
213
+ // token state alongside the manually entered client credentials.
214
+ actions: [
215
+ "secretsmanager:GetSecretValue",
216
+ "secretsmanager:PutSecretValue",
217
+ "secretsmanager:UpdateSecretVersionStage",
218
+ ],
219
+ resources: [secretArnPattern(upworkSecret)],
220
+ }),
221
+ );
222
+ }
223
+ gatewayRole.addToPolicy(
224
+ new iam.PolicyStatement({
225
+ sid: "DedupeAndThreadIndex",
226
+ // UpdateItem is the proactive channels' hourly budget counter: one row
227
+ // per channel per hour, incremented under a condition so two Lambdas
228
+ // cannot both spend the last start of the hour.
229
+ actions: ["dynamodb:PutItem", "dynamodb:GetItem", "dynamodb:UpdateItem"],
230
+ resources: [props.table.tableArn],
231
+ }),
232
+ );
233
+ gatewayRole.addToPolicy(
234
+ new iam.PolicyStatement({
235
+ sid: "CalendarConnections",
236
+ // Write-only, and only on the OAuth callback: the gateway stores a
237
+ // person's refresh token but never reads one back.
238
+ actions: ["dynamodb:PutItem"],
239
+ resources: [props.itemsTable.tableArn],
240
+ }),
241
+ );
242
+ gatewayRole.addToPolicy(
243
+ new iam.PolicyStatement({
244
+ sid: "QueueSend",
245
+ actions: ["sqs:SendMessage"],
246
+ // The gateway DLQ is granted separately by `deadLetterQueue` below.
247
+ resources: [this.invokeQueue.queueArn],
248
+ }),
249
+ );
250
+ gatewayRole.addToPolicy(
251
+ new iam.PolicyStatement({
252
+ sid: "DataKeyUsage",
253
+ actions: ["kms:Decrypt", "kms:Encrypt", "kms:GenerateDataKey", "kms:DescribeKey"],
254
+ resources: [props.dataKey.keyArn],
255
+ }),
256
+ );
257
+
258
+ const gatewayFunction = new lambda.Function(this, "GatewayFunction", {
259
+ ...(authenticatedQueue ? { functionName: names.slackGatewayFunctionName } : {}),
260
+ runtime: lambda.Runtime.NODEJS_22_X,
261
+ handler: "index.handler",
262
+ code: lambdaCode(this, "gateway"),
263
+ role: gatewayRole,
264
+ timeout: cdk.Duration.seconds(10),
265
+ memorySize: 256,
266
+ deadLetterQueue: this.gatewayDlq,
267
+ environment: {
268
+ FOUNDATION_TABLE_NAME: props.table.tableName,
269
+ FOUNDATION_INVOKE_QUEUE_URL: this.invokeQueue.queueUrl,
270
+ FOUNDATION_GATEWAY_DLQ_URL: this.gatewayDlq.queueUrl,
271
+ FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
272
+ FOUNDATION_SLACK_APP_SECRET_ID: props.secrets.slackApp.secretName,
273
+ FOUNDATION_ADMINS: props.admins,
274
+ FOUNDATION_AGENT_RUNTIME_ARN: props.agentRuntimeArn,
275
+ // What the teammate is called. The gateway renders it in `/help`, in
276
+ // every "not configured" reply and in each OAuth result page, and
277
+ // `triggers.ts` uses it to tell a message addressed to the teammate
278
+ // from one addressed to a person. Without it the gateway falls back
279
+ // to a hard-coded name, wrong for every instance but one.
280
+ FOUNDATION_DISPLAY_NAME: props.instance.displayName,
281
+ FOUNDATION_ITEMS_TABLE_NAME: props.itemsTable.tableName,
282
+ FOUNDATION_GOOGLE_CALENDAR_SECRET_ID: props.secrets.googleCalendar.secretName,
283
+ FOUNDATION_GOOGLE_OAUTH_SECRET_ID: props.secrets.googleOauth.secretName,
284
+ FOUNDATION_GOOGLE_DRIVE_SECRET_ID: props.secrets.googleDrive.secretName,
285
+ FOUNDATION_GOOGLE_EMAIL_SECRET_ID: props.secrets.googleEmail.secretName,
286
+ ...(props.secrets.knockOauthClient === undefined
287
+ ? {}
288
+ : { FOUNDATION_KNOCK_OAUTH_CLIENT_SECRET_ID: props.secrets.knockOauthClient.secretName }),
289
+ ...(props.secrets.knockCredential === undefined
290
+ ? {}
291
+ : { FOUNDATION_KNOCK_CREDENTIAL_SECRET_ID: props.secrets.knockCredential.secretName }),
292
+ ...(upworkSecret === undefined
293
+ ? {}
294
+ : { FOUNDATION_UPWORK_SECRET_ID: upworkSecret.secretName }),
295
+ // Signed into every Drive and email consent link, so one instance's
296
+ // link cannot be redeemed against another's gateway.
297
+ FOUNDATION_INSTANCE: props.instance.name,
298
+ // Slash-command prefixes the gateway answers (current first, then legacy),
299
+ // from the instance file; the manifest renders the same prefix.
300
+ FOUNDATION_COMMAND_PREFIXES: slackCommandPrefixes(props.instance).join(","),
301
+ // Read at SYNTH from the instance file, like FOUNDATION_ADMINS: the gateway
302
+ // decides what starts a turn and never reads `config/<name>.yaml`, so
303
+ // making the teammate speak unasked in a channel is a deploy, not a config sync.
304
+ // Empty for an instance that watches nothing, which is every instance
305
+ // that omits the block.
306
+ FOUNDATION_PROACTIVE_CHANNELS: props.instance.proactive.channels.join(","),
307
+ FOUNDATION_PROACTIVE_MAX_PER_HOUR: String(props.instance.proactive.maxPerHour),
308
+ },
309
+ });
310
+ // One mention must produce one reply: Lambda's default of two async
311
+ // retries would re-run a slow turn into duplicate Slack messages.
312
+ gatewayFunction.configureAsyncInvoke({
313
+ retryAttempts: 0,
314
+ maxEventAge: cdk.Duration.seconds(60),
315
+ });
316
+
317
+ const invokerRole = new iam.Role(this, "InvokerRole", {
318
+ assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
319
+ description: `${display} invoker Lambda role (least privilege)`,
320
+ managedPolicies: [
321
+ iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
322
+ ],
323
+ });
324
+ invokerRole.addToPolicy(
325
+ new iam.PolicyStatement({
326
+ sid: "InvokeAgentRuntime",
327
+ actions: ["bedrock-agentcore:InvokeAgentRuntime"],
328
+ // Name prefix, not the literal ARN: on a phase-one deploy the runtime
329
+ // does not exist yet, and the placeholder ARN this stack is handed
330
+ // would otherwise bake a dead resource into the policy.
331
+ resources: [
332
+ `arn:${this.partition}:bedrock-agentcore:${this.region}:${this.account}:runtime/${names.runtimeName}*`,
333
+ `arn:${this.partition}:bedrock-agentcore:${this.region}:${this.account}:runtime/${names.runtimeName}*/runtime-endpoint/*`,
334
+ ],
335
+ }),
336
+ );
337
+ invokerRole.addToPolicy(
338
+ new iam.PolicyStatement({
339
+ sid: "SlackBotToken",
340
+ actions: ["secretsmanager:GetSecretValue"],
341
+ resources: [secretArnPattern(props.secrets.slackApp)],
342
+ }),
343
+ );
344
+ if (authenticatedQueue) {
345
+ // Hardened queue envelopes are HMAC-authenticated before AgentCore.
346
+ // This trusted invoker can verify Slack's signing secret; the agent role
347
+ // remains explicitly denied and cannot forge a normal turn.
348
+ invokerRole.addToPolicy(
349
+ new iam.PolicyStatement({
350
+ sid: "QueueEnvelopeVerification",
351
+ actions: ["secretsmanager:GetSecretValue"],
352
+ resources: [secretArnPattern(props.secrets.signing)],
353
+ }),
354
+ );
355
+ } else {
356
+ // Legacy instances do not yet require authenticated queue envelopes.
357
+ invokerRole.addToPolicy(
358
+ new iam.PolicyStatement({
359
+ sid: "DenySlackSigningSecret",
360
+ effect: iam.Effect.DENY,
361
+ actions: ["secretsmanager:GetSecretValue"],
362
+ resources: [
363
+ `arn:${this.partition}:secretsmanager:${this.region}:${this.account}:secret:${names.secretSlackSigning}-??????`,
364
+ ],
365
+ }),
366
+ );
367
+ }
368
+ invokerRole.addToPolicy(
369
+ new iam.PolicyStatement({
370
+ sid: "DataKeyUsage",
371
+ actions: ["kms:Decrypt", "kms:GenerateDataKey"],
372
+ resources: [props.dataKey.keyArn],
373
+ }),
374
+ );
375
+
376
+ const invokerFunction = new lambda.Function(this, "InvokerFunction", {
377
+ runtime: lambda.Runtime.NODEJS_22_X,
378
+ handler: "index.handler",
379
+ code: lambdaCode(this, "invoker"),
380
+ role: invokerRole,
381
+ timeout: cdk.Duration.seconds(900),
382
+ memorySize: 512,
383
+ environment: {
384
+ FOUNDATION_SLACK_APP_SECRET_ID: props.secrets.slackApp.secretName,
385
+ FOUNDATION_AWS_REGION: this.region,
386
+ // Production traffic hits the pinned `live` endpoint, promoted by the
387
+ // deploy workflow after the smoke test — never DEFAULT (newest version).
388
+ FOUNDATION_AGENT_RUNTIME_QUALIFIER: LIVE_ENDPOINT_NAME,
389
+ ...(authenticatedQueue
390
+ ? { FOUNDATION_QUEUE_SIGNING_SECRET_ID: props.secrets.signing.secretName }
391
+ : {}),
392
+ },
393
+ });
394
+ invokerFunction.addEventSource(
395
+ new lambdaEventSources.SqsEventSource(this.invokeQueue, {
396
+ batchSize: 1,
397
+ // One message per invocation, so partial-batch reporting has nothing
398
+ // to report: a failed record must fail the whole invocation and roll
399
+ // to the DLQ (maxReceiveCount 1) rather than be retried.
400
+ reportBatchItemFailures: false,
401
+ }),
402
+ );
403
+
404
+ // AuthorizationType NONE by design: Slack cannot sign AWS IAM requests.
405
+ // Authentication is the Slack v2 signature the gateway Lambda verifies.
406
+ this.api = new apigateway.RestApi(this, "FoundationApi", {
407
+ restApiName: `${props.instance.naming.secretPrefix}-events`,
408
+ description: `${display} Slack ingress (events + slash commands + interactivity)`,
409
+ endpointTypes: [apigateway.EndpointType.REGIONAL],
410
+ deployOptions: {
411
+ stageName: "prod",
412
+ throttlingRateLimit: 50,
413
+ throttlingBurstLimit: 100,
414
+ metricsEnabled: true,
415
+ },
416
+ cloudWatchRole: false,
417
+ });
418
+ const slack = this.api.root.addResource("slack");
419
+ const integration = new apigateway.LambdaIntegration(gatewayFunction);
420
+ slack.addResource("events").addMethod("POST", integration);
421
+ slack.addResource("commands").addMethod("POST", integration);
422
+ // Block Kit answers to `ask_user` questions (Slack "Interactivity").
423
+ slack.addResource("interactive").addMethod("POST", integration);
424
+ // Google's OAuth redirect. Not a Slack route and not Slack-signed: it
425
+ // authenticates the person through the signed `state` the gateway issued.
426
+ this.api.root
427
+ .addResource("calendar")
428
+ .addResource("oauth")
429
+ .addResource("callback")
430
+ .addMethod("GET", integration);
431
+ // The same for the company Drive identity an admin connects.
432
+ this.api.root
433
+ .addResource("drive")
434
+ .addResource("oauth")
435
+ .addResource("callback")
436
+ .addMethod("GET", integration);
437
+ // And for the mailbox. The gateway derives this callback's URL from the
438
+ // shared client's `redirect_uri` (`redirectUriFor`), so nothing here has
439
+ // to tell it — which also avoids depending on the deployment stage from
440
+ // the very function the stage depends on.
441
+ this.api.root
442
+ .addResource("email")
443
+ .addResource("oauth")
444
+ .addResource("callback")
445
+ .addMethod("GET", integration);
446
+ if (provisionsIntegration(props.instance, "knock")) {
447
+ // Knock redirects only for deployments that opted its infrastructure in,
448
+ // which is what creates both its public client and the write-only
449
+ // callback credential destination.
450
+ this.api.root
451
+ .addResource("knock")
452
+ .addResource("oauth")
453
+ .addResource("callback")
454
+ .addMethod("GET", integration);
455
+ }
456
+ if (upworkSecret !== undefined && upwork !== undefined) {
457
+ const upworkRedirectUri = this.api.urlForPath("/upwork/oauth/callback");
458
+ this.api.root
459
+ .addResource("upwork")
460
+ .addResource("oauth")
461
+ .addResource("callback")
462
+ .addMethod("GET", integration);
463
+ new cdk.CfnOutput(this, "UpworkOAuthRedirectUrl", { value: upworkRedirectUri });
464
+ }
465
+
466
+ // --- email proxy -----------------------------------------------------
467
+ // The permission barrier for Gmail, and the reason the design puts it in
468
+ // a Lambda rather than a broker inside the agent container: this role is
469
+ // the ONLY principal in the account that can read the mailbox secret, and
470
+ // the agent's execution role carries an explicit Deny on it. A fully
471
+ // hijacked session can invoke the four operations and nothing more.
472
+ const emailProxyRole = new iam.Role(this, "EmailProxyRole", {
473
+ assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
474
+ description: `${display} email proxy Lambda role (the only reader of the mailbox secret)`,
475
+ managedPolicies: [
476
+ iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
477
+ ],
478
+ });
479
+ emailProxyRole.addToPolicy(
480
+ new iam.PolicyStatement({
481
+ sid: "EmailProxyCredentials",
482
+ actions: ["secretsmanager:GetSecretValue"],
483
+ // Exactly two: the mailbox, and the OAuth client its token is minted
484
+ // with. Nothing else in the account is readable by this role.
485
+ resources: [
486
+ secretArnPattern(props.secrets.googleEmail),
487
+ secretArnPattern(props.secrets.googleOauth),
488
+ ],
489
+ }),
490
+ );
491
+ emailProxyRole.addToPolicy(
492
+ new iam.PolicyStatement({
493
+ sid: "EmailProxyDataKey",
494
+ actions: ["kms:Decrypt"],
495
+ resources: [props.dataKey.keyArn],
496
+ }),
497
+ );
498
+
499
+ this.emailProxyFunction = new lambda.Function(this, "EmailProxyFunction", {
500
+ functionName: namesFor(props.instance).emailProxyFunctionName,
501
+ runtime: lambda.Runtime.NODEJS_22_X,
502
+ handler: "index.handler",
503
+ code: lambdaCode(this, "email-proxy"),
504
+ role: emailProxyRole,
505
+ timeout: cdk.Duration.seconds(30),
506
+ memorySize: 256,
507
+ // No reserved concurrency: a fresh account's Lambda quota is 10 and
508
+ // reserving any of it is refused ("decreases UnreservedConcurrentExecution
509
+ // below its minimum", 2026-09-06). Gmail-side rate is bounded
510
+ // by the hourly routine and one turn per channel at a time instead.
511
+ environment: {
512
+ FOUNDATION_GOOGLE_EMAIL_SECRET_ID: props.secrets.googleEmail.secretName,
513
+ FOUNDATION_GOOGLE_OAUTH_SECRET_ID: props.secrets.googleOauth.secretName,
514
+ // Read at SYNTH from the instance's config file. The identity list a
515
+ // turn can draft as is therefore fixed at deploy time and cannot be
516
+ // widened by anything the agent sends or by a config sync alone.
517
+ FOUNDATION_EMAIL_IDENTITIES: JSON.stringify(emailIdentitiesFor(props.configPath)),
518
+ },
519
+ });
520
+
521
+ // --- AgentCore Browser proxy ----------------------------------------
522
+ // The model-facing runtime can invoke this Lambda but has no Browser API grants. The proxy
523
+ // owns those grants and re-validates a gateway-signed DM/user scope plus this deploy-time
524
+ // domain policy before every read operation.
525
+ const browserDomains = provisionsIntegration(props.instance, "browser")
526
+ ? browserDomainsFor(props.configPath)
527
+ : [];
528
+ if (browserDomains.length > 0) {
529
+ const browserProxyRole = new iam.Role(this, "BrowserProxyRole", {
530
+ assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
531
+ description: `${display} policy-enforcing AgentCore Browser proxy role`,
532
+ managedPolicies: [
533
+ iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
534
+ ],
535
+ });
536
+ browserProxyRole.addToPolicy(
537
+ new iam.PolicyStatement({
538
+ sid: "BrowserProxySigningSecret",
539
+ actions: ["secretsmanager:GetSecretValue"],
540
+ resources: [secretArnPattern(props.secrets.signing)],
541
+ }),
542
+ );
543
+ browserProxyRole.addToPolicy(
544
+ new iam.PolicyStatement({
545
+ sid: "BrowserProxyDataKey",
546
+ actions: ["kms:Decrypt", "kms:Encrypt", "kms:GenerateDataKey", "kms:DescribeKey"],
547
+ resources: [props.dataKey.keyArn],
548
+ }),
549
+ );
550
+ browserProxyRole.addToPolicy(
551
+ new iam.PolicyStatement({
552
+ sid: "BrowserProxySessions",
553
+ actions: [
554
+ "bedrock-agentcore:StartBrowserSession",
555
+ "bedrock-agentcore:StopBrowserSession",
556
+ "bedrock-agentcore:SaveBrowserSessionProfile",
557
+ "bedrock-agentcore:ConnectBrowserAutomationStream",
558
+ ],
559
+ resources: [
560
+ `arn:${this.partition}:bedrock-agentcore:${this.region}:aws:browser/aws.browser.v1`,
561
+ `arn:${this.partition}:bedrock-agentcore:${this.region}:${this.account}:browser-profile/*`,
562
+ ],
563
+ }),
564
+ );
565
+ browserProxyRole.addToPolicy(
566
+ new iam.PolicyStatement({
567
+ sid: "BrowserProxyProfiles",
568
+ actions: [
569
+ "bedrock-agentcore:CreateBrowserProfile",
570
+ "bedrock-agentcore:GetBrowserProfile",
571
+ "bedrock-agentcore:ListBrowserProfiles",
572
+ ],
573
+ // Create/List have no resource ARN. The proxy derives opaque profile names from the
574
+ // verified Slack user and a server-side allowlisted domain and exposes no delete API.
575
+ resources: ["*"],
576
+ }),
577
+ );
578
+ browserProxyRole.addToPolicy(
579
+ new iam.PolicyStatement({
580
+ sid: "BrowserProxyLease",
581
+ actions: ["dynamodb:PutItem", "dynamodb:DeleteItem"],
582
+ resources: [props.itemsTable.tableArn],
583
+ }),
584
+ );
585
+ this.browserProxyFunction = new lambda.Function(this, "BrowserProxyFunction", {
586
+ functionName: names.browserProxyFunctionName,
587
+ runtime: lambda.Runtime.NODEJS_22_X,
588
+ handler: "index.handler",
589
+ role: browserProxyRole,
590
+ timeout: cdk.Duration.seconds(240),
591
+ code: lambdaCode(this, "browser-proxy"),
592
+ environment: {
593
+ FOUNDATION_BROWSER_ALLOWED_DOMAINS: JSON.stringify(browserDomains),
594
+ FOUNDATION_INSTANCE: props.instance.name,
595
+ FOUNDATION_ITEMS_TABLE_NAME: props.itemsTable.tableName,
596
+ FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
597
+ FOUNDATION_TEAM_ID: props.instance.slack.teamId,
598
+ },
599
+ });
600
+ }
601
+
602
+ // --- Otter proxy -----------------------------------------------------
603
+ // Like email, this is a hard credential boundary: the shell-capable
604
+ // agent can invoke two fixed GET operations but cannot read the API key.
605
+ if (provisionsIntegration(props.instance, "otter")) {
606
+ const otterApi = props.secrets.otterApi;
607
+ if (otterApi === undefined)
608
+ throw new Error(
609
+ `${id}: integrations.otter is enabled but FoundationData supplied no Otter secret`,
610
+ );
611
+ const otterProxyRole = new iam.Role(this, "OtterProxyRole", {
612
+ assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
613
+ description: `${display} Otter proxy Lambda role (the only reader of the API key)`,
614
+ managedPolicies: [
615
+ iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
616
+ ],
617
+ });
618
+ otterProxyRole.addToPolicy(
619
+ new iam.PolicyStatement({
620
+ sid: "OtterProxyCredentials",
621
+ actions: ["secretsmanager:GetSecretValue"],
622
+ // Otter key for the API call; Slack signing secret to verify the
623
+ // actor capability the gateway minted and the agent cannot forge.
624
+ resources: [secretArnPattern(otterApi), secretArnPattern(props.secrets.signing)],
625
+ }),
626
+ );
627
+ this.otterProxyFunction = new lambda.Function(this, "OtterProxyFunction", {
628
+ functionName: names.otterProxyFunctionName,
629
+ runtime: lambda.Runtime.NODEJS_22_X,
630
+ handler: "index.handler",
631
+ code: lambdaCode(this, "otter-proxy"),
632
+ role: otterProxyRole,
633
+ timeout: cdk.Duration.seconds(30),
634
+ memorySize: 256,
635
+ environment: {
636
+ FOUNDATION_OTTER_API_SECRET_ID: otterApi.secretName,
637
+ FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
638
+ FOUNDATION_SLACK_TEAM_ID: props.instance.slack.teamId,
639
+ FOUNDATION_ADMINS: props.admins,
640
+ },
641
+ });
642
+ new cdk.CfnOutput(this, "OtterProxyFunctionArn", {
643
+ value: this.otterProxyFunction.functionArn,
644
+ });
645
+ }
646
+
647
+ // --- Knock proxy -----------------------------------------------------
648
+ // The proxy is the only runtime role that can read or refresh the tenant
649
+ // credential. The gateway has write-only callback access; the agent can
650
+ // invoke this Lambda and is explicitly denied both Knock secrets.
651
+ if (provisionsIntegration(props.instance, "knock")) {
652
+ const knockCredential = props.secrets.knockCredential;
653
+ if (knockCredential === undefined)
654
+ throw new Error(
655
+ `${id}: integrations.knock is enabled but FoundationData supplied no Knock secret`,
656
+ );
657
+ const knock = knockConfigForProvisioning(props.configPath);
658
+ const knockProxyRole = new iam.Role(this, "KnockProxyRole", {
659
+ assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
660
+ description: `${display} Knock read-only MCP proxy Lambda`,
661
+ managedPolicies: [
662
+ iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
663
+ ],
664
+ });
665
+ knockProxyRole.addToPolicy(
666
+ new iam.PolicyStatement({
667
+ sid: "KnockProxyCredentialRead",
668
+ actions: ["secretsmanager:GetSecretValue"],
669
+ resources: [secretArnPattern(knockCredential), secretArnPattern(props.secrets.signing)],
670
+ }),
671
+ );
672
+ knockProxyRole.addToPolicy(
673
+ new iam.PolicyStatement({
674
+ sid: "KnockProxyCredentialRefresh",
675
+ actions: ["secretsmanager:PutSecretValue", "secretsmanager:UpdateSecretVersionStage"],
676
+ resources: [secretArnPattern(knockCredential)],
677
+ }),
678
+ );
679
+ this.knockProxyFunction = new lambda.Function(this, "KnockProxyFunction", {
680
+ functionName: names.knockProxyFunctionName,
681
+ runtime: lambda.Runtime.NODEJS_22_X,
682
+ handler: "index.handler",
683
+ code: lambdaCode(this, "knock-proxy"),
684
+ role: knockProxyRole,
685
+ timeout: cdk.Duration.seconds(30),
686
+ memorySize: 256,
687
+ environment: {
688
+ FOUNDATION_KNOCK_CREDENTIAL_SECRET_ID: knockCredential.secretName,
689
+ FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
690
+ FOUNDATION_SLACK_TEAM_ID: props.instance.slack.teamId,
691
+ FOUNDATION_ADMINS: props.admins,
692
+ FOUNDATION_KNOCK_DEBUG: String(knock.debug),
693
+ },
694
+ });
695
+ new cdk.CfnOutput(this, "KnockProxyFunctionArn", {
696
+ value: this.knockProxyFunction.functionArn,
697
+ });
698
+ }
699
+
700
+ // --- Upwork proxy ----------------------------------------------------
701
+ // The shell-capable agent receives only this Lambda ARN. OAuth client and
702
+ // token state remain in one retained secret that this role alone can read
703
+ // or update; every request also carries a gateway-signed actor envelope.
704
+ if (upworkSecret !== undefined && upwork !== undefined && upworkApprovalTable !== undefined) {
705
+ const upworkProxyRole = new iam.Role(this, "UpworkProxyRole", {
706
+ assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
707
+ description: `${display} Upwork proxy Lambda role (fixed OAuth and proposal operations)`,
708
+ managedPolicies: [
709
+ iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
710
+ ],
711
+ });
712
+ upworkProxyRole.addToPolicy(
713
+ new iam.PolicyStatement({
714
+ sid: "UpworkProxySecret",
715
+ actions: [
716
+ "secretsmanager:GetSecretValue",
717
+ "secretsmanager:PutSecretValue",
718
+ "secretsmanager:UpdateSecretVersionStage",
719
+ ],
720
+ resources: [secretArnPattern(upworkSecret)],
721
+ }),
722
+ );
723
+ upworkProxyRole.addToPolicy(
724
+ new iam.PolicyStatement({
725
+ sid: "UpworkProxySigningSecret",
726
+ actions: ["secretsmanager:GetSecretValue"],
727
+ resources: [secretArnPattern(props.secrets.signing)],
728
+ }),
729
+ );
730
+ upworkProxyRole.addToPolicy(
731
+ new iam.PolicyStatement({
732
+ sid: "UpworkDraftState",
733
+ actions: ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:DeleteItem"],
734
+ resources: [upworkApprovalTable.tableArn],
735
+ }),
736
+ );
737
+ this.upworkProxyFunction = new lambda.Function(this, "UpworkProxyFunction", {
738
+ functionName: names.upworkProxyFunctionName,
739
+ runtime: lambda.Runtime.NODEJS_22_X,
740
+ handler: "index.handler",
741
+ code: lambdaCode(this, "upwork-proxy"),
742
+ role: upworkProxyRole,
743
+ timeout: cdk.Duration.seconds(30),
744
+ memorySize: 256,
745
+ environment: {
746
+ FOUNDATION_UPWORK_SECRET_ID: upworkSecret.secretName,
747
+ FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
748
+ FOUNDATION_TABLE_NAME: upworkApprovalTable.tableName,
749
+ FOUNDATION_SLACK_TEAM_ID: props.instance.slack.teamId,
750
+ FOUNDATION_UPWORK_ALLOWED_CHANNELS: JSON.stringify(upwork.channels),
751
+ FOUNDATION_ADMINS: props.admins,
752
+ FOUNDATION_INSTANCE: props.instance.name,
753
+ },
754
+ });
755
+ new cdk.CfnOutput(this, "UpworkProxyFunctionArn", {
756
+ value: this.upworkProxyFunction.functionArn,
757
+ });
758
+ }
759
+
760
+ // --- CRM proxy -------------------------------------------------------
761
+ // Fixed operations and deployment-baked policy over the instance's own
762
+ // self-hosted CRM table.
763
+ if (provisionsIntegration(props.instance, "crm")) {
764
+ const crm = crmConfigForProvisioning(props.configPath);
765
+ const crmTable = props.crmTable;
766
+ if (crmTable === undefined)
767
+ throw new Error(
768
+ `${id}: integrations.crm is enabled but no CRM table was supplied by FoundationData`,
769
+ );
770
+ const crmProxyRole = new iam.Role(this, "CrmProxyRole", {
771
+ assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
772
+ description: `${display} CRM proxy Lambda role (self-hosted CRM table access only)`,
773
+ managedPolicies: [
774
+ iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
775
+ ],
776
+ });
777
+ crmProxyRole.addToPolicy(
778
+ new iam.PolicyStatement({
779
+ sid: "CrmProxySigningSecret",
780
+ actions: ["secretsmanager:GetSecretValue"],
781
+ resources: [secretArnPattern(props.secrets.signing)],
782
+ }),
783
+ );
784
+ crmProxyRole.addToPolicy(
785
+ new iam.PolicyStatement({
786
+ sid: "CrmTableData",
787
+ actions: [
788
+ "dynamodb:GetItem",
789
+ "dynamodb:PutItem",
790
+ "dynamodb:UpdateItem",
791
+ "dynamodb:DeleteItem",
792
+ "dynamodb:BatchWriteItem",
793
+ "dynamodb:TransactWriteItems",
794
+ ],
795
+ resources: [crmTable.tableArn],
796
+ }),
797
+ );
798
+ crmProxyRole.addToPolicy(
799
+ new iam.PolicyStatement({
800
+ sid: "CrmRecordsIndex",
801
+ actions: ["dynamodb:Query"],
802
+ resources: [crmTable.tableArn, `${crmTable.tableArn}/index/RecordsIndex`],
803
+ }),
804
+ );
805
+ this.crmProxyFunction = new lambda.Function(this, "CrmProxyFunction", {
806
+ functionName: names.crmProxyFunctionName,
807
+ runtime: lambda.Runtime.NODEJS_22_X,
808
+ handler: "index.handler",
809
+ code: lambdaCode(this, "crm-proxy"),
810
+ role: crmProxyRole,
811
+ timeout: cdk.Duration.seconds(30),
812
+ memorySize: 256,
813
+ environment: {
814
+ FOUNDATION_CRM_TABLE_NAME: crmTable.tableName,
815
+ FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
816
+ FOUNDATION_SLACK_TEAM_ID: props.instance.slack.teamId,
817
+ FOUNDATION_CRM_TENANT_ID: props.instance.name,
818
+ FOUNDATION_CRM_ALLOWED_CHANNELS: JSON.stringify(crm.channels),
819
+ FOUNDATION_CRM_ALLOWED_STATUSES: JSON.stringify(crm.statuses),
820
+ FOUNDATION_CRM_ACTIVITY_TYPES: JSON.stringify(crm.activityTypes),
821
+ FOUNDATION_CRM_MAX_BATCH_SIZE: String(crm.maxBatchSize),
822
+ },
823
+ });
824
+ new cdk.CfnOutput(this, "CrmProxyFunctionArn", {
825
+ value: this.crmProxyFunction.functionArn,
826
+ });
827
+ new cdk.CfnOutput(this, "CrmPolicyFingerprint", {
828
+ value: crm.policyFingerprint,
829
+ });
830
+ }
831
+
832
+ const alarmTopic = new sns.Topic(this, "AlarmTopic", {
833
+ displayName: `${display} alarms`,
834
+ });
835
+ if (props.alarmEmail) {
836
+ new sns.Subscription(this, "AlarmEmail", {
837
+ topic: alarmTopic,
838
+ protocol: sns.SubscriptionProtocol.EMAIL,
839
+ endpoint: props.alarmEmail,
840
+ });
841
+ }
842
+ const alarmAction = new cwActions.SnsAction(alarmTopic);
843
+ // Depth ≥ 1 on either DLQ means a user's message was silently dropped —
844
+ // the only way anyone learns it needs a redrive.
845
+ for (const [name, queue] of [
846
+ ["GatewayDlqDepthAlarm", this.gatewayDlq],
847
+ ["InvokeDlqDepthAlarm", this.invokeDlq],
848
+ ] as const) {
849
+ const alarm = new cloudwatch.Alarm(this, name, {
850
+ alarmDescription: `${name}: messages are sitting in the DLQ`,
851
+ metric: queue.metricApproximateNumberOfMessagesVisible({
852
+ period: cdk.Duration.minutes(5),
853
+ statistic: "Maximum",
854
+ }),
855
+ threshold: 1,
856
+ evaluationPeriods: 1,
857
+ comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
858
+ treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
859
+ });
860
+ alarm.addAlarmAction(alarmAction);
861
+ }
862
+
863
+ // --- routines --------------------------------------------------------
864
+ // The group and the role live here because the queue does. The agent
865
+ // creates schedules INSIDE the group and passes the role; it never needs
866
+ // to create either, which is what keeps its own grants narrow.
867
+ new scheduler.CfnScheduleGroup(this, "RoutineGroup", { name: names.routineGroup });
868
+
869
+ const schedulerRole = new iam.Role(this, "RoutineSchedulerRole", {
870
+ roleName: names.routineSchedulerRole,
871
+ description: "Assumed by EventBridge Scheduler to enqueue a routine fire",
872
+ // Scoped to this account: without the condition any Scheduler schedule
873
+ // in any account could be pointed at this role.
874
+ assumedBy: new iam.ServicePrincipal("scheduler.amazonaws.com", {
875
+ conditions: { StringEquals: { "aws:SourceAccount": this.account } },
876
+ }),
877
+ });
878
+ // Grants the KMS use on the data key too, since the queue is encrypted.
879
+ this.invokeQueue.grantSendMessages(schedulerRole);
880
+
881
+ new cdk.CfnOutput(this, "GoogleCalendarOAuthRedirectUrl", {
882
+ value: this.api.urlForPath("/calendar/oauth/callback"),
883
+ });
884
+ new cdk.CfnOutput(this, "GoogleDriveOAuthRedirectUrl", {
885
+ value: this.api.urlForPath("/drive/oauth/callback"),
886
+ });
887
+ new cdk.CfnOutput(this, "GoogleEmailOAuthRedirectUrl", {
888
+ value: this.api.urlForPath("/email/oauth/callback"),
889
+ });
890
+ if (this.knockProxyFunction !== undefined) {
891
+ new cdk.CfnOutput(this, "KnockOAuthRedirectUrl", {
892
+ value: this.api.urlForPath("/knock/oauth/callback"),
893
+ });
894
+ }
895
+ new cdk.CfnOutput(this, "EmailProxyFunctionArn", {
896
+ value: this.emailProxyFunction.functionArn,
897
+ });
898
+ if (this.browserProxyFunction !== undefined) {
899
+ new cdk.CfnOutput(this, "BrowserProxyFunctionArn", {
900
+ value: this.browserProxyFunction.functionArn,
901
+ });
902
+ }
903
+ new cdk.CfnOutput(this, "InvokeQueueUrl", { value: this.invokeQueue.queueUrl });
904
+ new cdk.CfnOutput(this, "RoutineSchedulerRoleArn", { value: schedulerRole.roleArn });
905
+ if (authenticatedQueue)
906
+ new cdk.CfnOutput(this, "RoutineIngressFunctionArn", {
907
+ value: gatewayFunction.functionArn,
908
+ });
909
+
910
+ new cdk.CfnOutput(this, "EventsUrl", { value: this.api.urlForPath("/slack/events") });
911
+ new cdk.CfnOutput(this, "CommandsUrl", { value: this.api.urlForPath("/slack/commands") });
912
+ new cdk.CfnOutput(this, "InteractiveUrl", {
913
+ value: this.api.urlForPath("/slack/interactive"),
914
+ });
915
+ }
916
+ }
917
+
918
+ interface ProvisionedCrmConfig {
919
+ channels: string[];
920
+ statuses: string[];
921
+ activityTypes: string[];
922
+ maxBatchSize: number;
923
+ policyFingerprint: string;
924
+ }
925
+
926
+ function crmConfigForProvisioning(configPath: string): ProvisionedCrmConfig {
927
+ const config = parseInstanceConfig(readFileSync(configPath, "utf8"));
928
+ const crm = config.capabilities.crm;
929
+ const missing = [
930
+ ...(crm.enabled ? [] : ["capabilities.crm.enabled"]),
931
+ ...(crm.channels.length > 0 ? [] : ["capabilities.crm.channels"]),
932
+ ...(crm.statuses.length > 0 ? [] : ["capabilities.crm.statuses"]),
933
+ ...(crm.activityTypes.length > 0 ? [] : ["capabilities.crm.activityTypes"]),
934
+ ];
935
+ if (missing.length > 0)
936
+ throw new Error(
937
+ `${configPath}: integrations.crm requires a configured CRM capability (${missing.join(", ")})`,
938
+ );
939
+ return {
940
+ channels: [...crm.channels],
941
+ statuses: [...crm.statuses],
942
+ activityTypes: [...crm.activityTypes],
943
+ maxBatchSize: crm.maxBatchSize,
944
+ policyFingerprint: crmPolicyFingerprint(crm),
945
+ };
946
+ }
947
+
948
+ /** Deployment-baked debug policy: a config-only edit cannot widen the proxy. */
949
+ export function knockConfigForProvisioning(configPath: string): { debug: boolean } {
950
+ const config = parseInstanceConfig(readFileSync(configPath, "utf8"));
951
+ return {
952
+ debug: config.capabilities.knock.enabled && config.capabilities.knock.debug,
953
+ };
954
+ }
955
+
956
+ interface ProvisionedUpworkConfig {
957
+ channels: string[];
958
+ }
959
+
960
+ function upworkConfigForProvisioning(configPath: string): ProvisionedUpworkConfig {
961
+ const config = parseInstanceConfig(readFileSync(configPath, "utf8"));
962
+ const upwork = config.capabilities.upwork;
963
+ const missing = [
964
+ ...(upwork.enabled ? [] : ["capabilities.upwork.enabled"]),
965
+ ...(upwork.channels.length > 0 ? [] : ["capabilities.upwork.channels"]),
966
+ ];
967
+ if (missing.length > 0)
968
+ throw new Error(
969
+ `${configPath}: integrations.upwork requires a configured Upwork capability (${missing.join(", ")})`,
970
+ );
971
+ return { channels: [...upwork.channels] };
972
+ }
973
+
974
+ /**
975
+ * The mailbox identities to bake into the proxy, read from the instance's own
976
+ * config file at synth.
977
+ *
978
+ * A plain file read: the CDK app must synthesize with no credentials and no
979
+ * network. A disabled capability yields an empty list, and so does a config
980
+ * that does not mention email at all — the proxy then refuses every identity,
981
+ * which is the correct behaviour for a capability nobody has turned on.
982
+ */
983
+ export function emailIdentitiesFor(configPath: string): unknown[] {
984
+ const doc = parseYaml(readFileSync(configPath, "utf8")) as {
985
+ capabilities?: { email?: { enabled?: unknown; identities?: unknown } };
986
+ };
987
+ const email = doc.capabilities?.email;
988
+ if (email?.enabled !== true || !Array.isArray(email.identities)) return [];
989
+ return email.identities;
990
+ }
991
+
992
+ /** Server-side Browser allowlist baked into the isolated proxy at deploy time. */
993
+ export function browserDomainsFor(configPath: string): string[] {
994
+ const browser = parseInstanceConfig(readFileSync(configPath, "utf8")).capabilities.browser;
995
+ return browser.enabled && browser.mode === "read" ? [...browser.allowedDomains] : [];
996
+ }
997
+
998
+ /**
999
+ * Secrets imported by name carry a partial ARN (no service-generated
1000
+ * 6-character suffix), which IAM will not match — append the wildcard the
1001
+ * same way CDK's own `grantRead` does.
1002
+ */
1003
+ function secretArnPattern(secret: secretsmanager.ISecret): string {
1004
+ return secret.secretFullArn ?? `${secret.secretArn}-??????`;
1005
+ }