@deployfoundation/foundation-deploy 0.2.1 → 0.2.3

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.
@@ -39,6 +39,8 @@ export interface FoundationAgentProps extends cdk.StackProps {
39
39
  crmTable?: dynamodb.ITable;
40
40
  /** Proxy-only proposal drafts; explicitly denied to every agent-assumable role. */
41
41
  upworkApprovalTable?: dynamodb.ITable;
42
+ /** Gateway-only E*TRADE OOB state; explicitly denied to every agent-assumable role. */
43
+ etradePendingTable?: dynamodb.ITable;
42
44
  secrets: {
43
45
  runtime: secretsmanager.ISecret;
44
46
  codex: secretsmanager.ISecret;
@@ -404,6 +406,45 @@ export class FoundationAgent extends cdk.Stack {
404
406
  );
405
407
  }
406
408
 
409
+ /**
410
+ * Both roles are shell-reachable. Deny the stable retained secret names even
411
+ * while the integration is disabled, so an opt-out cannot expose orphaned
412
+ * OAuth material through the account's broad read-only role.
413
+ */
414
+ private denyDirectEtradeAccess(role: iam.Role, props: FoundationAgentProps): void {
415
+ const retainedSecretArn = (name: string): string =>
416
+ cdk.Stack.of(this).formatArn({
417
+ service: "secretsmanager",
418
+ resource: "secret",
419
+ resourceName: `${name}-??????`,
420
+ arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME,
421
+ });
422
+ role.addToPolicy(
423
+ new iam.PolicyStatement({
424
+ sid: "DenyEtradeSecrets",
425
+ effect: iam.Effect.DENY,
426
+ actions: ["secretsmanager:*"],
427
+ resources: [
428
+ retainedSecretArn(this.names.secretEtradeClient),
429
+ retainedSecretArn(this.names.secretEtradeCredential),
430
+ ],
431
+ }),
432
+ );
433
+ if (!provisionsIntegration(props.instance, "etrade")) return;
434
+ const pending = props.etradePendingTable;
435
+ if (pending === undefined)
436
+ throw new Error(
437
+ `${this.stackName}: integrations.etrade is enabled but FoundationData supplied no E*TRADE pending-state table`,
438
+ );
439
+ role.addToPolicy(
440
+ new iam.PolicyStatement({
441
+ sid: "DenyEtradePendingState",
442
+ effect: iam.Effect.DENY,
443
+ actions: ["dynamodb:*"],
444
+ resources: [pending.tableArn, `${pending.tableArn}/index/*`],
445
+ }),
446
+ );
447
+ }
407
448
  /**
408
449
  * `<Prefix>ReadOnlyRole` — how the agent reads its OWN account.
409
450
  *
@@ -431,6 +472,7 @@ export class FoundationAgent extends cdk.Stack {
431
472
  this.denyDirectCrmTableAccess(role, props.instance, props.crmTable);
432
473
  this.denyDirectKnockSecretAccess(role, props);
433
474
  this.denyDirectUpworkApprovalAccess(role, props.upworkApprovalTable);
475
+ this.denyDirectEtradeAccess(role, props);
434
476
  role.addToPolicy(
435
477
  new iam.PolicyStatement({
436
478
  sid: "DenyAgentCoreBrowserMetadata",
@@ -480,6 +522,7 @@ export class FoundationAgent extends cdk.Stack {
480
522
  this.denyDirectCrmTableAccess(role, props.instance, props.crmTable);
481
523
  this.denyDirectKnockSecretAccess(role, props);
482
524
  this.denyDirectUpworkApprovalAccess(role, props.upworkApprovalTable);
525
+ this.denyDirectEtradeAccess(role, props);
483
526
 
484
527
  // Foundation's own repository when a release names the image, because the
485
528
  // runtime then pulls across accounts. The pull also needs Foundation's
@@ -721,6 +764,24 @@ export class FoundationAgent extends cdk.Stack {
721
764
  }),
722
765
  );
723
766
  }
767
+ if (provisionsIntegration(props.instance, "etrade")) {
768
+ // The entire financial-data surface is one fixed-operation Lambda. The
769
+ // explicit deny installed above prevents direct secret or pending-flow access.
770
+ role.addToPolicy(
771
+ new iam.PolicyStatement({
772
+ sid: "EtradeProxyInvoke",
773
+ actions: ["lambda:InvokeFunction"],
774
+ resources: [
775
+ cdk.Stack.of(this).formatArn({
776
+ service: "lambda",
777
+ resource: "function",
778
+ resourceName: this.names.etradeProxyFunctionName,
779
+ arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME,
780
+ }),
781
+ ],
782
+ }),
783
+ );
784
+ }
724
785
  if (provisionsIntegration(props.instance, "crm")) {
725
786
  // The CRM permission surface is one named proxy Lambda. Derive the ARN
726
787
  // locally so FoundationAgent never depends on FoundationApi's output and closes a
@@ -857,6 +918,13 @@ export class FoundationAgent extends cdk.Stack {
857
918
  ],
858
919
  }),
859
920
  );
921
+ role.addToPolicy(
922
+ new iam.PolicyStatement({
923
+ sid: "RoutineScheduleList",
924
+ actions: ["scheduler:ListSchedules"],
925
+ resources: ["*"],
926
+ }),
927
+ );
860
928
  role.addToPolicy(
861
929
  new iam.PolicyStatement({
862
930
  sid: "PassRoutineSchedulerRole",
@@ -1,13 +1,19 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import {
3
3
  type CrmOutreachConfig,
4
+ type ScheduledJobBinding,
5
+ type ScheduledJobConfig,
4
6
  type WebhookJobBinding,
5
7
  crmOutreachConfigured,
6
8
  crmPolicyFingerprint,
9
+ isScheduledJob,
7
10
  parseInstanceConfig,
11
+ scheduledJobBindings,
8
12
  webhookJobBindings,
9
13
  } from "@deployfoundation/foundation-core";
14
+ import { JOB_ACTOR } from "@deployfoundation/foundation-core/actor-capability";
10
15
  import { slackCommandPrefixes } from "@deployfoundation/foundation-core/instance";
16
+ import { parseSchedule } from "@deployfoundation/foundation-core/schedule";
11
17
  import * as cdk from "aws-cdk-lib";
12
18
  import * as apigateway from "aws-cdk-lib/aws-apigateway";
13
19
  import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";
@@ -50,6 +56,8 @@ export interface FoundationApiProps extends cdk.StackProps {
50
56
  crmTable?: dynamodb.ITable;
51
57
  /** Proxy-only one-hour proposal drafts; never granted to the agent runtime. */
52
58
  upworkApprovalTable?: dynamodb.ITable;
59
+ /** Gateway-only OOB E*TRADE request-token state, present with the integration. */
60
+ etradePendingTable?: dynamodb.ITable;
53
61
  secrets: {
54
62
  signing: secretsmanager.ISecret;
55
63
  slackApp: secretsmanager.ISecret;
@@ -68,6 +76,10 @@ export interface FoundationApiProps extends cdk.StackProps {
68
76
  knockCredential?: secretsmanager.ISecret;
69
77
  /** Combined Upwork OAuth client and token secret, present only for opted-in instances. */
70
78
  upwork?: secretsmanager.ISecret;
79
+ /** E*TRADE OAuth consumer; gateway reads it but the agent never can. */
80
+ etradeClient?: secretsmanager.ISecret;
81
+ /** Connected E*TRADE credential; gateway writes and fixed read proxy alone reads. */
82
+ etradeCredential?: secretsmanager.ISecret;
71
83
  };
72
84
  /**
73
85
  * The AgentCore runtime the invoker calls. On a phase-one deploy this is
@@ -113,6 +125,8 @@ export class FoundationApi extends cdk.Stack {
113
125
  public readonly knockProxyFunction?: lambda.Function;
114
126
  /** Fixed-operation Upwork proxy, present only when the instance opts in. */
115
127
  public readonly upworkProxyFunction?: lambda.Function;
128
+ /** Fixed-operation E*TRADE read proxy, present only for opted-in instances. */
129
+ public readonly etradeProxyFunction?: lambda.Function;
116
130
 
117
131
  constructor(scope: Construct, id: string, props: FoundationApiProps) {
118
132
  super(scope, id, props);
@@ -127,7 +141,28 @@ export class FoundationApi extends cdk.Stack {
127
141
  throw new Error(
128
142
  `${id}: integrations.upwork is enabled but its secret or approval table was not supplied by FoundationData`,
129
143
  );
144
+ const etrade = provisionsIntegration(props.instance, "etrade")
145
+ ? etradeConfigForProvisioning(props.configPath)
146
+ : undefined;
147
+ const etradeClientSecret = etrade === undefined ? undefined : props.secrets.etradeClient;
148
+ const etradeCredentialSecret =
149
+ etrade === undefined ? undefined : props.secrets.etradeCredential;
150
+ const etradePendingTable = etrade === undefined ? undefined : props.etradePendingTable;
151
+ if (
152
+ etrade !== undefined &&
153
+ (etradeClientSecret === undefined ||
154
+ etradeCredentialSecret === undefined ||
155
+ etradePendingTable === undefined)
156
+ )
157
+ throw new Error(
158
+ `${id}: integrations.etrade is enabled but its client secret, credential secret, or pending OAuth table was not supplied by FoundationData`,
159
+ );
130
160
  const authenticatedQueue = requiresAuthenticatedQueue(props.instance);
161
+ const configuredScheduledJobs = scheduledJobsFor(props.configPath);
162
+ const scheduledJobs = scheduledJobBindings(configuredScheduledJobs);
163
+ const scheduledJobTimezone = configTimezoneFor(props.configPath);
164
+ const etradeAllowedChannels = etrade === undefined ? "" : JSON.stringify(etrade.channels);
165
+ const etradeAllowedUsers = etrade === undefined ? "" : JSON.stringify(etrade.users);
131
166
 
132
167
  this.gatewayDlq = new sqs.Queue(this, "GatewayDlq", {
133
168
  encryption: sqs.QueueEncryption.KMS,
@@ -227,6 +262,50 @@ export class FoundationApi extends cdk.Stack {
227
262
  }),
228
263
  );
229
264
  }
265
+ if (
266
+ etradeClientSecret !== undefined &&
267
+ etradeCredentialSecret !== undefined &&
268
+ etradePendingTable !== undefined
269
+ ) {
270
+ gatewayRole.addToPolicy(
271
+ new iam.PolicyStatement({
272
+ sid: "EtradeOAuthClientRead",
273
+ actions: ["secretsmanager:GetSecretValue"],
274
+ resources: [secretArnPattern(etradeClientSecret)],
275
+ }),
276
+ );
277
+ gatewayRole.addToPolicy(
278
+ new iam.PolicyStatement({
279
+ sid: "EtradeOAuthCredentialWrite",
280
+ actions: [
281
+ "secretsmanager:DescribeSecret",
282
+ "secretsmanager:PutSecretValue",
283
+ "secretsmanager:UpdateSecretVersionStage",
284
+ ],
285
+ resources: [secretArnPattern(etradeCredentialSecret)],
286
+ }),
287
+ );
288
+ gatewayRole.addToPolicy(
289
+ new iam.PolicyStatement({
290
+ sid: "DenyEtradeOAuthCredentialRead",
291
+ effect: iam.Effect.DENY,
292
+ actions: ["secretsmanager:GetSecretValue", "secretsmanager:BatchGetSecretValue"],
293
+ resources: [secretArnPattern(etradeCredentialSecret)],
294
+ }),
295
+ );
296
+ gatewayRole.addToPolicy(
297
+ new iam.PolicyStatement({
298
+ sid: "EtradeOAuthPendingState",
299
+ actions: [
300
+ "dynamodb:GetItem",
301
+ "dynamodb:PutItem",
302
+ "dynamodb:UpdateItem",
303
+ "dynamodb:DeleteItem",
304
+ ],
305
+ resources: [etradePendingTable.tableArn],
306
+ }),
307
+ );
308
+ }
230
309
  gatewayRole.addToPolicy(
231
310
  new iam.PolicyStatement({
232
311
  sid: "DedupeAndThreadIndex",
@@ -286,6 +365,54 @@ export class FoundationApi extends cdk.Stack {
286
365
  });
287
366
  if (jobsWebhookSecret !== undefined) jobsWebhookSecret.grantRead(gatewayRole);
288
367
 
368
+ const gatewayEnvironment: Record<string, string> = {
369
+ FOUNDATION_TABLE_NAME: props.table.tableName,
370
+ FOUNDATION_INVOKE_QUEUE_URL: this.invokeQueue.queueUrl,
371
+ FOUNDATION_GATEWAY_DLQ_URL: this.gatewayDlq.queueUrl,
372
+ FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
373
+ FOUNDATION_SLACK_APP_SECRET_ID: props.secrets.slackApp.secretName,
374
+ FOUNDATION_ADMINS: props.admins,
375
+ FOUNDATION_AGENT_RUNTIME_ARN: props.agentRuntimeArn,
376
+ FOUNDATION_DISPLAY_NAME: props.instance.displayName,
377
+ FOUNDATION_ITEMS_TABLE_NAME: props.itemsTable.tableName,
378
+ FOUNDATION_GOOGLE_CALENDAR_SECRET_ID: props.secrets.googleCalendar.secretName,
379
+ FOUNDATION_GOOGLE_OAUTH_SECRET_ID: props.secrets.googleOauth.secretName,
380
+ FOUNDATION_GOOGLE_DRIVE_SECRET_ID: props.secrets.googleDrive.secretName,
381
+ FOUNDATION_GOOGLE_EMAIL_SECRET_ID: props.secrets.googleEmail.secretName,
382
+ ...(props.secrets.knockOauthClient === undefined
383
+ ? {}
384
+ : { FOUNDATION_KNOCK_OAUTH_CLIENT_SECRET_ID: props.secrets.knockOauthClient.secretName }),
385
+ ...(props.secrets.knockCredential === undefined
386
+ ? {}
387
+ : { FOUNDATION_KNOCK_CREDENTIAL_SECRET_ID: props.secrets.knockCredential.secretName }),
388
+ ...(upworkSecret === undefined
389
+ ? {}
390
+ : { FOUNDATION_UPWORK_SECRET_ID: upworkSecret.secretName }),
391
+ ...(etradeClientSecret === undefined ||
392
+ etradeCredentialSecret === undefined ||
393
+ etradePendingTable === undefined ||
394
+ etrade === undefined
395
+ ? {}
396
+ : {
397
+ FOUNDATION_ETRADE_CLIENT_SECRET_ID: etradeClientSecret.secretName,
398
+ FOUNDATION_ETRADE_CREDENTIAL_SECRET_ID: etradeCredentialSecret.secretName,
399
+ FOUNDATION_ETRADE_PENDING_TABLE_NAME: etradePendingTable.tableName,
400
+ FOUNDATION_ETRADE_ALLOWED_CHANNELS: etradeAllowedChannels,
401
+ FOUNDATION_ETRADE_ALLOWED_USERS: etradeAllowedUsers,
402
+ }),
403
+ FOUNDATION_INSTANCE: props.instance.name,
404
+ FOUNDATION_COMMAND_PREFIXES: slackCommandPrefixes(props.instance).join(","),
405
+ FOUNDATION_PROACTIVE_CHANNELS: props.instance.proactive.channels.join(","),
406
+ FOUNDATION_PROACTIVE_MAX_PER_HOUR: String(props.instance.proactive.maxPerHour),
407
+ ...(jobsWebhookSecret === undefined
408
+ ? {}
409
+ : {
410
+ FOUNDATION_WEBHOOK_JOBS: JSON.stringify(webhookJobs),
411
+ FOUNDATION_JOBS_WEBHOOK_SECRET_ID: jobsWebhookSecret.secretName,
412
+ }),
413
+ FOUNDATION_SCHEDULED_JOB_BINDINGS: JSON.stringify(scheduledJobs),
414
+ };
415
+ assertLambdaEnvironmentBudget(gatewayEnvironment);
289
416
  const gatewayFunction = new lambda.Function(this, "GatewayFunction", {
290
417
  ...(authenticatedQueue ? { functionName: names.slackGatewayFunctionName } : {}),
291
418
  runtime: lambda.Runtime.NODEJS_22_X,
@@ -295,54 +422,7 @@ export class FoundationApi extends cdk.Stack {
295
422
  timeout: cdk.Duration.seconds(10),
296
423
  memorySize: 256,
297
424
  deadLetterQueue: this.gatewayDlq,
298
- environment: {
299
- FOUNDATION_TABLE_NAME: props.table.tableName,
300
- FOUNDATION_INVOKE_QUEUE_URL: this.invokeQueue.queueUrl,
301
- FOUNDATION_GATEWAY_DLQ_URL: this.gatewayDlq.queueUrl,
302
- FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
303
- FOUNDATION_SLACK_APP_SECRET_ID: props.secrets.slackApp.secretName,
304
- FOUNDATION_ADMINS: props.admins,
305
- FOUNDATION_AGENT_RUNTIME_ARN: props.agentRuntimeArn,
306
- // What the teammate is called. The gateway renders it in `/help`, in
307
- // every "not configured" reply and in each OAuth result page, and
308
- // `triggers.ts` uses it to tell a message addressed to the teammate
309
- // from one addressed to a person. Without it the gateway falls back
310
- // to a hard-coded name, wrong for every instance but one.
311
- FOUNDATION_DISPLAY_NAME: props.instance.displayName,
312
- FOUNDATION_ITEMS_TABLE_NAME: props.itemsTable.tableName,
313
- FOUNDATION_GOOGLE_CALENDAR_SECRET_ID: props.secrets.googleCalendar.secretName,
314
- FOUNDATION_GOOGLE_OAUTH_SECRET_ID: props.secrets.googleOauth.secretName,
315
- FOUNDATION_GOOGLE_DRIVE_SECRET_ID: props.secrets.googleDrive.secretName,
316
- FOUNDATION_GOOGLE_EMAIL_SECRET_ID: props.secrets.googleEmail.secretName,
317
- ...(props.secrets.knockOauthClient === undefined
318
- ? {}
319
- : { FOUNDATION_KNOCK_OAUTH_CLIENT_SECRET_ID: props.secrets.knockOauthClient.secretName }),
320
- ...(props.secrets.knockCredential === undefined
321
- ? {}
322
- : { FOUNDATION_KNOCK_CREDENTIAL_SECRET_ID: props.secrets.knockCredential.secretName }),
323
- ...(upworkSecret === undefined
324
- ? {}
325
- : { FOUNDATION_UPWORK_SECRET_ID: upworkSecret.secretName }),
326
- // Signed into every Drive and email consent link, so one instance's
327
- // link cannot be redeemed against another's gateway.
328
- FOUNDATION_INSTANCE: props.instance.name,
329
- // Slash-command prefixes the gateway answers (current first, then legacy),
330
- // from the instance file; the manifest renders the same prefix.
331
- FOUNDATION_COMMAND_PREFIXES: slackCommandPrefixes(props.instance).join(","),
332
- // Read at SYNTH from the instance file, like FOUNDATION_ADMINS: the gateway
333
- // decides what starts a turn and never reads `config/<name>.yaml`, so
334
- // making the teammate speak unasked in a channel is a deploy, not a config sync.
335
- // Empty for an instance that watches nothing, which is every instance
336
- // that omits the block.
337
- FOUNDATION_PROACTIVE_CHANNELS: props.instance.proactive.channels.join(","),
338
- FOUNDATION_PROACTIVE_MAX_PER_HOUR: String(props.instance.proactive.maxPerHour),
339
- ...(jobsWebhookSecret === undefined
340
- ? {}
341
- : {
342
- FOUNDATION_WEBHOOK_JOBS: JSON.stringify(webhookJobs),
343
- FOUNDATION_JOBS_WEBHOOK_SECRET_ID: jobsWebhookSecret.secretName,
344
- }),
345
- },
425
+ environment: gatewayEnvironment,
346
426
  });
347
427
  // One mention must produce one reply: Lambda's default of two async
348
428
  // retries would re-run a slow turn into duplicate Slack messages.
@@ -806,6 +886,64 @@ export class FoundationApi extends cdk.Stack {
806
886
  });
807
887
  }
808
888
 
889
+ // --- E*TRADE proxy ---------------------------------------------------
890
+ // This boundary exposes only six fixed GET operations. It alone can read
891
+ // the split OAuth consumer and connected access token, verifies signed
892
+ // actor/session claims, and reactivates inactive same-day tokens in place.
893
+ if (
894
+ etradeClientSecret !== undefined &&
895
+ etradeCredentialSecret !== undefined &&
896
+ etrade !== undefined
897
+ ) {
898
+ const etradeProxyRole = new iam.Role(this, "EtradeProxyRole", {
899
+ assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
900
+ description: `${display} E*TRADE fixed read-only proxy Lambda`,
901
+ managedPolicies: [
902
+ iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
903
+ ],
904
+ });
905
+ etradeProxyRole.addToPolicy(
906
+ new iam.PolicyStatement({
907
+ sid: "EtradeProxyOAuthSecrets",
908
+ actions: ["secretsmanager:GetSecretValue"],
909
+ resources: [
910
+ secretArnPattern(etradeClientSecret),
911
+ secretArnPattern(etradeCredentialSecret),
912
+ ],
913
+ }),
914
+ );
915
+ etradeProxyRole.addToPolicy(
916
+ new iam.PolicyStatement({
917
+ sid: "EtradeProxySigningSecret",
918
+ actions: ["secretsmanager:GetSecretValue"],
919
+ resources: [secretArnPattern(props.secrets.signing)],
920
+ }),
921
+ );
922
+ const etradeProxyEnvironment: Record<string, string> = {
923
+ FOUNDATION_ETRADE_CLIENT_SECRET_ID: etradeClientSecret.secretName,
924
+ FOUNDATION_ETRADE_CREDENTIAL_SECRET_ID: etradeCredentialSecret.secretName,
925
+ FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
926
+ FOUNDATION_SLACK_TEAM_ID: props.instance.slack.teamId,
927
+ FOUNDATION_ETRADE_ALLOWED_CHANNELS: JSON.stringify(etrade.channels),
928
+ FOUNDATION_ETRADE_ALLOWED_USERS: JSON.stringify(etrade.users),
929
+ FOUNDATION_ADMINS: props.admins,
930
+ };
931
+ assertLambdaEnvironmentBudget(etradeProxyEnvironment);
932
+ this.etradeProxyFunction = new lambda.Function(this, "EtradeProxyFunction", {
933
+ functionName: names.etradeProxyFunctionName,
934
+ runtime: lambda.Runtime.NODEJS_22_X,
935
+ handler: "index.handler",
936
+ code: lambdaCode(this, "etrade-proxy"),
937
+ role: etradeProxyRole,
938
+ timeout: cdk.Duration.seconds(30),
939
+ memorySize: 256,
940
+ environment: etradeProxyEnvironment,
941
+ });
942
+ new cdk.CfnOutput(this, "EtradeProxyFunctionArn", {
943
+ value: this.etradeProxyFunction.functionArn,
944
+ });
945
+ }
946
+
809
947
  // --- CRM proxy -------------------------------------------------------
810
948
  // Fixed operations and deployment-baked policy over the instance's own
811
949
  // self-hosted CRM table.
@@ -841,6 +979,9 @@ export class FoundationApi extends cdk.Stack {
841
979
  "dynamodb:BatchGetItem",
842
980
  "dynamodb:BatchWriteItem",
843
981
  "dynamodb:TransactWriteItems",
982
+ // A transaction's ConditionCheck is its own IAM action; the
983
+ // repository guards every upsert and activity with one.
984
+ "dynamodb:ConditionCheckItem",
844
985
  ],
845
986
  resources: [crmTable.tableArn],
846
987
  }),
@@ -852,6 +993,16 @@ export class FoundationApi extends cdk.Stack {
852
993
  resources: [crmTable.tableArn, `${crmTable.tableArn}/index/RecordsIndex`],
853
994
  }),
854
995
  );
996
+ // The table is encrypted with the instance's data key, so every read and
997
+ // write needs it. Without this grant each call failed as `proxy_error`
998
+ // (KMS AccessDenied) the first time a deployment actually used its CRM.
999
+ crmProxyRole.addToPolicy(
1000
+ new iam.PolicyStatement({
1001
+ sid: "CrmTableKey",
1002
+ actions: ["kms:Decrypt", "kms:Encrypt", "kms:GenerateDataKey", "kms:DescribeKey"],
1003
+ resources: [(crmTable.encryptionKey ?? props.dataKey).keyArn],
1004
+ }),
1005
+ );
855
1006
  this.crmProxyFunction = new lambda.Function(this, "CrmProxyFunction", {
856
1007
  functionName: names.crmProxyFunctionName,
857
1008
  runtime: lambda.Runtime.NODEJS_22_X,
@@ -918,6 +1069,75 @@ export class FoundationApi extends cdk.Stack {
918
1069
  alarm.addAlarmAction(alarmAction);
919
1070
  }
920
1071
 
1072
+ // --- deployment-owned scheduled jobs --------------------------------
1073
+ // Config jobs never share the agent-managed routine group or pass role.
1074
+ // Only this unexposed dispatcher can mint the HMAC that ingress accepts
1075
+ // for JOB_ACTOR, so an agent shell cannot manufacture a privileged fire.
1076
+ if (configuredScheduledJobs.length > 0) {
1077
+ const dispatchRole = new iam.Role(this, "ScheduledJobDispatcherRole", {
1078
+ assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
1079
+ description: `${display} deployment-owned scheduled job dispatcher`,
1080
+ managedPolicies: [
1081
+ iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
1082
+ ],
1083
+ });
1084
+ props.secrets.signing.grantRead(dispatchRole);
1085
+ gatewayFunction.grantInvoke(dispatchRole);
1086
+ const dispatchFunction = new lambda.Function(this, "ScheduledJobDispatcherFunction", {
1087
+ runtime: lambda.Runtime.NODEJS_22_X,
1088
+ handler: "index.scheduledJobDispatcherHandler",
1089
+ code: lambdaCode(this, "gateway"),
1090
+ role: dispatchRole,
1091
+ timeout: cdk.Duration.seconds(30),
1092
+ memorySize: 128,
1093
+ deadLetterQueue: this.gatewayDlq,
1094
+ environment: {
1095
+ FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
1096
+ FOUNDATION_GATEWAY_FUNCTION_NAME: gatewayFunction.functionName,
1097
+ },
1098
+ });
1099
+ dispatchFunction.configureAsyncInvoke({
1100
+ retryAttempts: 0,
1101
+ maxEventAge: cdk.Duration.minutes(15),
1102
+ });
1103
+
1104
+ const jobGroup = new scheduler.CfnScheduleGroup(this, "ScheduledJobGroup");
1105
+ const jobSchedulerRole = new iam.Role(this, "ScheduledJobSchedulerRole", {
1106
+ assumedBy: new iam.ServicePrincipal("scheduler.amazonaws.com", {
1107
+ conditions: { StringEquals: { "aws:SourceAccount": this.account } },
1108
+ }),
1109
+ description: `${display} deployment-owned scheduled job invoker`,
1110
+ });
1111
+ dispatchFunction.grantInvoke(jobSchedulerRole);
1112
+
1113
+ for (const job of configuredScheduledJobs) {
1114
+ const parsed = parseSchedule(job.on.schedule);
1115
+ if (!parsed.ok) throw new Error(`Scheduled job ${job.id} has an invalid schedule`);
1116
+ const schedule = new scheduler.CfnSchedule(this, `ScheduledJob-${job.id}`, {
1117
+ groupName: jobGroup.ref,
1118
+ scheduleExpression: job.on.schedule,
1119
+ scheduleExpressionTimezone: scheduledJobTimezone,
1120
+ flexibleTimeWindow: { mode: "OFF" },
1121
+ ...(parsed.kind === "at" ? { actionAfterCompletion: "DELETE" } : {}),
1122
+ target: {
1123
+ arn: dispatchFunction.functionArn,
1124
+ roleArn: jobSchedulerRole.roleArn,
1125
+ input: JSON.stringify({
1126
+ internal: "scheduled_job",
1127
+ routineId: `job-${job.id}`,
1128
+ name: job.name,
1129
+ channel: job.channel,
1130
+ prompt: job.prompt,
1131
+ createdBy: JOB_ACTOR,
1132
+ scheduledTime: "<aws.scheduler.scheduled-time>",
1133
+ }),
1134
+ retryPolicy: { maximumEventAgeInSeconds: 900, maximumRetryAttempts: 0 },
1135
+ },
1136
+ });
1137
+ schedule.addResourceDependency(jobGroup);
1138
+ }
1139
+ }
1140
+
921
1141
  // --- routines --------------------------------------------------------
922
1142
  // The group and the role live here because the queue does. The agent
923
1143
  // creates schedules INSIDE the group and passes the role; it never needs
@@ -1034,6 +1254,48 @@ function upworkConfigForProvisioning(configPath: string): ProvisionedUpworkConfi
1034
1254
  return { channels: [...upwork.channels] };
1035
1255
  }
1036
1256
 
1257
+ interface ProvisionedEtradeConfig {
1258
+ channels: string[];
1259
+ users: string[];
1260
+ }
1261
+
1262
+ /** Deployment-baked E*TRADE access policy; config sync cannot widen it. */
1263
+ export function etradeConfigForProvisioning(configPath: string): ProvisionedEtradeConfig {
1264
+ const config = parseInstanceConfig(readFileSync(configPath, "utf8"));
1265
+ const etrade = config.capabilities.etrade;
1266
+ const missing = [
1267
+ ...(etrade.enabled ? [] : ["capabilities.etrade.enabled"]),
1268
+ ...(etrade.channels.length > 0 ? [] : ["capabilities.etrade.channels"]),
1269
+ ...(etrade.users.length > 0 ? [] : ["capabilities.etrade.users"]),
1270
+ ];
1271
+ if (missing.length > 0)
1272
+ throw new Error(
1273
+ `${configPath}: integrations.etrade requires a configured E*TRADE capability (${missing.join(", ")})`,
1274
+ );
1275
+ const seenAdmins = new Set<string>();
1276
+ const invalidAdmins = config.admins.filter((admin) => {
1277
+ const invalid =
1278
+ !/^[UW][A-Z0-9]{8,}$/.test(admin) || admin.length > 128 || seenAdmins.has(admin);
1279
+ seenAdmins.add(admin);
1280
+ return invalid;
1281
+ });
1282
+ if (invalidAdmins.length > 0) {
1283
+ throw new Error(
1284
+ `${configPath}: an E*TRADE deployment requires unique valid Slack user ids in admins`,
1285
+ );
1286
+ }
1287
+ const nonAdmins = etrade.users.filter((user) => !config.admins.includes(user));
1288
+ if (nonAdmins.length > 0)
1289
+ throw new Error(
1290
+ `${configPath}: capabilities.etrade.users must be instance admins (${nonAdmins.join(", ")})`,
1291
+ );
1292
+ return { channels: [...etrade.channels], users: [...etrade.users] };
1293
+ }
1294
+
1295
+ function configTimezoneFor(configPath: string): string {
1296
+ return parseInstanceConfig(readFileSync(configPath, "utf8")).timezone;
1297
+ }
1298
+
1037
1299
  /**
1038
1300
  * The mailbox identities to bake into the proxy, read from the instance's own
1039
1301
  * config file at synth.
@@ -1071,6 +1333,42 @@ export function webhookJobsFor(configPath: string): WebhookJobBinding[] {
1071
1333
  return jobs;
1072
1334
  }
1073
1335
 
1336
+ export const LAMBDA_ENVIRONMENT_MAX_BYTES = 4_096;
1337
+ /** Conservative bound for generated names/ARNs after instance naming limits are applied. */
1338
+ export const LAMBDA_ENVIRONMENT_TOKEN_BYTES = 128;
1339
+
1340
+ /** Enforce Lambda's aggregate 4 KiB environment limit over every key and value. */
1341
+ export function assertLambdaEnvironmentBudget(values: Readonly<Record<string, string>>): void {
1342
+ let bytes = 2;
1343
+ for (const [key, value] of Object.entries(values)) {
1344
+ bytes += Buffer.byteLength(key) + 3;
1345
+ bytes += cdk.Token.isUnresolved(value)
1346
+ ? LAMBDA_ENVIRONMENT_TOKEN_BYTES
1347
+ : Buffer.byteLength(value);
1348
+ }
1349
+ if (bytes > LAMBDA_ENVIRONMENT_MAX_BYTES) {
1350
+ throw new Error(
1351
+ `Gateway Lambda environment needs an estimated ${bytes} bytes; the AWS limit is ${LAMBDA_ENVIRONMENT_MAX_BYTES}. Shorten job prompts or reduce configured values.`,
1352
+ );
1353
+ }
1354
+ }
1355
+
1356
+ /** Deployment-baked hashes stop routine ingress minting arbitrary JOB_ACTOR turns. */
1357
+ export function scheduledJobBindingsFor(configPath: string): ScheduledJobBinding[] {
1358
+ return scheduledJobBindings(scheduledJobsFor(configPath));
1359
+ }
1360
+
1361
+ /** Scheduled jobs are deployment-owned infrastructure, never agent-owned routines. */
1362
+ export function scheduledJobsFor(configPath: string): ScheduledJobConfig[] {
1363
+ const jobs = parseInstanceConfig(readFileSync(configPath, "utf8")).jobs.filter(isScheduledJob);
1364
+ for (const job of jobs) {
1365
+ const parsed = parseSchedule(job.on.schedule);
1366
+ if (!parsed.ok)
1367
+ throw new Error(`Scheduled job ${job.id} has an invalid schedule: ${parsed.reason}`);
1368
+ }
1369
+ return jobs;
1370
+ }
1371
+
1074
1372
  /** Server-side Browser allowlist baked into the isolated proxy at deploy time. */
1075
1373
  export function browserDomainsFor(configPath: string): string[] {
1076
1374
  const browser = parseInstanceConfig(readFileSync(configPath, "utf8")).capabilities.browser;
@@ -51,6 +51,8 @@ export class FoundationData extends cdk.Stack {
51
51
  public readonly crmTable?: dynamodb.Table;
52
52
  /** Ephemeral proposal drafts; accessible only to the credential-isolated Upwork proxy. */
53
53
  public readonly upworkApprovalTable?: dynamodb.Table;
54
+ /** Short-lived OOB request-token state; gateway-only and destroyed with the opt-in. */
55
+ public readonly etradePendingTable?: dynamodb.Table;
54
56
  public readonly secrets: {
55
57
  signing: secretsmanager.ISecret;
56
58
  slackApp: secretsmanager.ISecret;
@@ -69,6 +71,10 @@ export class FoundationData extends cdk.Stack {
69
71
  knockCredential?: secretsmanager.ISecret;
70
72
  /** Combined Upwork OAuth client and token secret, provisioned only when opted in. */
71
73
  upwork?: secretsmanager.ISecret;
74
+ /** Retained E*TRADE OAuth consumer; the gateway reads this to connect. */
75
+ etradeClient?: secretsmanager.ISecret;
76
+ /** Retained access credential; gateway writes and fixed read proxy alone reads. */
77
+ etradeCredential?: secretsmanager.ISecret;
72
78
  runtime: secretsmanager.ISecret;
73
79
  };
74
80
 
@@ -158,6 +164,16 @@ export class FoundationData extends cdk.Stack {
158
164
  removalPolicy: cdk.RemovalPolicy.DESTROY,
159
165
  })
160
166
  : undefined;
167
+ this.etradePendingTable = provisionsIntegration(props.instance, "etrade")
168
+ ? new dynamodb.Table(this, "EtradeOAuthPendingTable", {
169
+ partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
170
+ timeToLiveAttribute: "ttl",
171
+ billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
172
+ encryption: dynamodb.TableEncryption.CUSTOMER_MANAGED,
173
+ encryptionKey: this.dataKey,
174
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
175
+ })
176
+ : undefined;
161
177
 
162
178
  // An empty shell CDK is willing to synth without a real value; the real
163
179
  // value is written out of band and survives every later deploy because
@@ -225,6 +241,32 @@ export class FoundationData extends cdk.Stack {
225
241
  )
226
242
  : undefined;
227
243
 
244
+ // E*TRADE access tokens expire daily, and disabling the integration must
245
+ // not strand fixed-name retained secrets CloudFormation cannot adopt on a
246
+ // later re-enable. Operators deliberately repopulate the client and reconnect.
247
+ const etradeClient = provisionsIntegration(props.instance, "etrade")
248
+ ? new secretsmanager.Secret(this, "EtradeClientSecret", {
249
+ secretName: names.secretEtradeClient,
250
+ description: `E*TRADE OAuth consumer for ${display} ({ consumer_key, consumer_secret, environment })`,
251
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
252
+ generateSecretString: {
253
+ secretStringTemplate: "{}",
254
+ generateStringKey: "placeholder",
255
+ },
256
+ })
257
+ : undefined;
258
+ const etradeCredential = provisionsIntegration(props.instance, "etrade")
259
+ ? new secretsmanager.Secret(this, "EtradeCredentialSecret", {
260
+ secretName: names.secretEtradeCredential,
261
+ description: `E*TRADE access token for ${display} ({ oauth_token, oauth_token_secret }); gateway writes and fixed read proxy alone reads`,
262
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
263
+ generateSecretString: {
264
+ secretStringTemplate: "{}",
265
+ generateStringKey: "placeholder",
266
+ },
267
+ })
268
+ : undefined;
269
+
228
270
  this.secrets = {
229
271
  // Already created out of band — import, never manage.
230
272
  signing: secretsmanager.Secret.fromSecretNameV2(
@@ -305,6 +347,8 @@ export class FoundationData extends cdk.Stack {
305
347
  ...(knockOauthClient === undefined ? {} : { knockOauthClient }),
306
348
  ...(knockCredential === undefined ? {} : { knockCredential }),
307
349
  ...(upwork === undefined ? {} : { upwork }),
350
+ ...(etradeClient === undefined ? {} : { etradeClient }),
351
+ ...(etradeCredential === undefined ? {} : { etradeCredential }),
308
352
  // The OAuth client every Google capability shares: Calendar builds a
309
353
  // per-person consent link with it, Drive connects the company identity
310
354
  // with it. Never a refresh token. A placeholder is fine — an unparseable