@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.
@@ -8,15 +8,18 @@ import {
8
8
  crmOutreachConfigured,
9
9
  crmPolicyFingerprint,
10
10
  instanceNames,
11
+ isScheduledJob,
11
12
  lambdaCode,
12
13
  newsletterManifestFor,
13
14
  parseInstanceConfig,
15
+ parseSchedule,
14
16
  provisionsIntegration,
15
17
  releaseImageRepositoryArn,
16
18
  requiresAuthenticatedQueue,
19
+ scheduledJobBindings,
17
20
  slackCommandPrefixes,
18
21
  webhookJobBindings
19
- } from "./chunk-gcg67ck3.js";
22
+ } from "./chunk-kf1yvx1w.js";
20
23
 
21
24
  // src/stacks/agent-stack.ts
22
25
  import * as cdk from "aws-cdk-lib";
@@ -243,6 +246,34 @@ class FoundationAgent extends cdk.Stack {
243
246
  resources: [upworkApprovalTable.tableArn, `${upworkApprovalTable.tableArn}/index/*`]
244
247
  }));
245
248
  }
249
+ denyDirectEtradeAccess(role, props) {
250
+ const retainedSecretArn = (name) => cdk.Stack.of(this).formatArn({
251
+ service: "secretsmanager",
252
+ resource: "secret",
253
+ resourceName: `${name}-??????`,
254
+ arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME
255
+ });
256
+ role.addToPolicy(new iam.PolicyStatement({
257
+ sid: "DenyEtradeSecrets",
258
+ effect: iam.Effect.DENY,
259
+ actions: ["secretsmanager:*"],
260
+ resources: [
261
+ retainedSecretArn(this.names.secretEtradeClient),
262
+ retainedSecretArn(this.names.secretEtradeCredential)
263
+ ]
264
+ }));
265
+ if (!provisionsIntegration(props.instance, "etrade"))
266
+ return;
267
+ const pending = props.etradePendingTable;
268
+ if (pending === undefined)
269
+ throw new Error(`${this.stackName}: integrations.etrade is enabled but FoundationData supplied no E*TRADE pending-state table`);
270
+ role.addToPolicy(new iam.PolicyStatement({
271
+ sid: "DenyEtradePendingState",
272
+ effect: iam.Effect.DENY,
273
+ actions: ["dynamodb:*"],
274
+ resources: [pending.tableArn, `${pending.tableArn}/index/*`]
275
+ }));
276
+ }
246
277
  buildReadOnlyRole(props) {
247
278
  const role = new iam.Role(this, "AgentReadOnlyRole", {
248
279
  roleName: `${props.instance.naming.prefix}ReadOnlyRole`,
@@ -254,6 +285,7 @@ class FoundationAgent extends cdk.Stack {
254
285
  this.denyDirectCrmTableAccess(role, props.instance, props.crmTable);
255
286
  this.denyDirectKnockSecretAccess(role, props);
256
287
  this.denyDirectUpworkApprovalAccess(role, props.upworkApprovalTable);
288
+ this.denyDirectEtradeAccess(role, props);
257
289
  role.addToPolicy(new iam.PolicyStatement({
258
290
  sid: "DenyAgentCoreBrowserMetadata",
259
291
  effect: iam.Effect.DENY,
@@ -292,6 +324,7 @@ class FoundationAgent extends cdk.Stack {
292
324
  this.denyDirectCrmTableAccess(role, props.instance, props.crmTable);
293
325
  this.denyDirectKnockSecretAccess(role, props);
294
326
  this.denyDirectUpworkApprovalAccess(role, props.upworkApprovalTable);
327
+ this.denyDirectEtradeAccess(role, props);
295
328
  const releaseRepositoryArn = releaseImageRepositoryArn(this);
296
329
  role.addToPolicy(new iam.PolicyStatement({
297
330
  sid: "EcrPull",
@@ -456,6 +489,20 @@ class FoundationAgent extends cdk.Stack {
456
489
  ]
457
490
  }));
458
491
  }
492
+ if (provisionsIntegration(props.instance, "etrade")) {
493
+ role.addToPolicy(new iam.PolicyStatement({
494
+ sid: "EtradeProxyInvoke",
495
+ actions: ["lambda:InvokeFunction"],
496
+ resources: [
497
+ cdk.Stack.of(this).formatArn({
498
+ service: "lambda",
499
+ resource: "function",
500
+ resourceName: this.names.etradeProxyFunctionName,
501
+ arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME
502
+ })
503
+ ]
504
+ }));
505
+ }
459
506
  if (provisionsIntegration(props.instance, "crm")) {
460
507
  role.addToPolicy(new iam.PolicyStatement({
461
508
  sid: "CrmProxyInvoke",
@@ -550,6 +597,11 @@ class FoundationAgent extends cdk.Stack {
550
597
  `arn:${this.partition}:scheduler:${this.region}:${this.account}:schedule/${this.names.routineGroup}/*`
551
598
  ]
552
599
  }));
600
+ role.addToPolicy(new iam.PolicyStatement({
601
+ sid: "RoutineScheduleList",
602
+ actions: ["scheduler:ListSchedules"],
603
+ resources: ["*"]
604
+ }));
553
605
  role.addToPolicy(new iam.PolicyStatement({
554
606
  sid: "PassRoutineSchedulerRole",
555
607
  actions: ["iam:PassRole"],
@@ -612,6 +664,13 @@ function secretArnPattern(secret) {
612
664
 
613
665
  // src/stacks/api-stack.ts
614
666
  import { readFileSync } from "node:fs";
667
+
668
+ // ../core/src/actor-capability.ts
669
+ var ACTOR_CAPABILITY_TTL_SECONDS = 15 * 60;
670
+ var JOB_ACTOR = "config";
671
+ var JOB_ACTOR_CAPABILITY_TTL_SECONDS = 60 * 60;
672
+
673
+ // src/stacks/api-stack.ts
615
674
  import * as cdk2 from "aws-cdk-lib";
616
675
  import * as apigateway from "aws-cdk-lib/aws-apigateway";
617
676
  import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";
@@ -635,6 +694,7 @@ class FoundationApi extends cdk2.Stack {
635
694
  crmProxyFunction;
636
695
  knockProxyFunction;
637
696
  upworkProxyFunction;
697
+ etradeProxyFunction;
638
698
  constructor(scope, id, props) {
639
699
  super(scope, id, props);
640
700
  const names = instanceNames(props.instance);
@@ -644,7 +704,18 @@ class FoundationApi extends cdk2.Stack {
644
704
  const upworkApprovalTable = upwork === undefined ? undefined : props.upworkApprovalTable;
645
705
  if (upwork !== undefined && (upworkSecret === undefined || upworkApprovalTable === undefined))
646
706
  throw new Error(`${id}: integrations.upwork is enabled but its secret or approval table was not supplied by FoundationData`);
707
+ const etrade = provisionsIntegration(props.instance, "etrade") ? etradeConfigForProvisioning(props.configPath) : undefined;
708
+ const etradeClientSecret = etrade === undefined ? undefined : props.secrets.etradeClient;
709
+ const etradeCredentialSecret = etrade === undefined ? undefined : props.secrets.etradeCredential;
710
+ const etradePendingTable = etrade === undefined ? undefined : props.etradePendingTable;
711
+ if (etrade !== undefined && (etradeClientSecret === undefined || etradeCredentialSecret === undefined || etradePendingTable === undefined))
712
+ throw new Error(`${id}: integrations.etrade is enabled but its client secret, credential secret, or pending OAuth table was not supplied by FoundationData`);
647
713
  const authenticatedQueue = requiresAuthenticatedQueue(props.instance);
714
+ const configuredScheduledJobs = scheduledJobsFor(props.configPath);
715
+ const scheduledJobs = scheduledJobBindings(configuredScheduledJobs);
716
+ const scheduledJobTimezone = configTimezoneFor(props.configPath);
717
+ const etradeAllowedChannels = etrade === undefined ? "" : JSON.stringify(etrade.channels);
718
+ const etradeAllowedUsers = etrade === undefined ? "" : JSON.stringify(etrade.users);
648
719
  this.gatewayDlq = new sqs.Queue(this, "GatewayDlq", {
649
720
  encryption: sqs.QueueEncryption.KMS,
650
721
  encryptionMasterKey: props.dataKey,
@@ -711,6 +782,38 @@ class FoundationApi extends cdk2.Stack {
711
782
  resources: [secretArnPattern2(upworkSecret)]
712
783
  }));
713
784
  }
785
+ if (etradeClientSecret !== undefined && etradeCredentialSecret !== undefined && etradePendingTable !== undefined) {
786
+ gatewayRole.addToPolicy(new iam2.PolicyStatement({
787
+ sid: "EtradeOAuthClientRead",
788
+ actions: ["secretsmanager:GetSecretValue"],
789
+ resources: [secretArnPattern2(etradeClientSecret)]
790
+ }));
791
+ gatewayRole.addToPolicy(new iam2.PolicyStatement({
792
+ sid: "EtradeOAuthCredentialWrite",
793
+ actions: [
794
+ "secretsmanager:DescribeSecret",
795
+ "secretsmanager:PutSecretValue",
796
+ "secretsmanager:UpdateSecretVersionStage"
797
+ ],
798
+ resources: [secretArnPattern2(etradeCredentialSecret)]
799
+ }));
800
+ gatewayRole.addToPolicy(new iam2.PolicyStatement({
801
+ sid: "DenyEtradeOAuthCredentialRead",
802
+ effect: iam2.Effect.DENY,
803
+ actions: ["secretsmanager:GetSecretValue", "secretsmanager:BatchGetSecretValue"],
804
+ resources: [secretArnPattern2(etradeCredentialSecret)]
805
+ }));
806
+ gatewayRole.addToPolicy(new iam2.PolicyStatement({
807
+ sid: "EtradeOAuthPendingState",
808
+ actions: [
809
+ "dynamodb:GetItem",
810
+ "dynamodb:PutItem",
811
+ "dynamodb:UpdateItem",
812
+ "dynamodb:DeleteItem"
813
+ ],
814
+ resources: [etradePendingTable.tableArn]
815
+ }));
816
+ }
714
817
  gatewayRole.addToPolicy(new iam2.PolicyStatement({
715
818
  sid: "DedupeAndThreadIndex",
716
819
  actions: ["dynamodb:PutItem", "dynamodb:GetItem", "dynamodb:UpdateItem"],
@@ -745,6 +848,41 @@ class FoundationApi extends cdk2.Stack {
745
848
  });
746
849
  if (jobsWebhookSecret !== undefined)
747
850
  jobsWebhookSecret.grantRead(gatewayRole);
851
+ const gatewayEnvironment = {
852
+ FOUNDATION_TABLE_NAME: props.table.tableName,
853
+ FOUNDATION_INVOKE_QUEUE_URL: this.invokeQueue.queueUrl,
854
+ FOUNDATION_GATEWAY_DLQ_URL: this.gatewayDlq.queueUrl,
855
+ FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
856
+ FOUNDATION_SLACK_APP_SECRET_ID: props.secrets.slackApp.secretName,
857
+ FOUNDATION_ADMINS: props.admins,
858
+ FOUNDATION_AGENT_RUNTIME_ARN: props.agentRuntimeArn,
859
+ FOUNDATION_DISPLAY_NAME: props.instance.displayName,
860
+ FOUNDATION_ITEMS_TABLE_NAME: props.itemsTable.tableName,
861
+ FOUNDATION_GOOGLE_CALENDAR_SECRET_ID: props.secrets.googleCalendar.secretName,
862
+ FOUNDATION_GOOGLE_OAUTH_SECRET_ID: props.secrets.googleOauth.secretName,
863
+ FOUNDATION_GOOGLE_DRIVE_SECRET_ID: props.secrets.googleDrive.secretName,
864
+ FOUNDATION_GOOGLE_EMAIL_SECRET_ID: props.secrets.googleEmail.secretName,
865
+ ...props.secrets.knockOauthClient === undefined ? {} : { FOUNDATION_KNOCK_OAUTH_CLIENT_SECRET_ID: props.secrets.knockOauthClient.secretName },
866
+ ...props.secrets.knockCredential === undefined ? {} : { FOUNDATION_KNOCK_CREDENTIAL_SECRET_ID: props.secrets.knockCredential.secretName },
867
+ ...upworkSecret === undefined ? {} : { FOUNDATION_UPWORK_SECRET_ID: upworkSecret.secretName },
868
+ ...etradeClientSecret === undefined || etradeCredentialSecret === undefined || etradePendingTable === undefined || etrade === undefined ? {} : {
869
+ FOUNDATION_ETRADE_CLIENT_SECRET_ID: etradeClientSecret.secretName,
870
+ FOUNDATION_ETRADE_CREDENTIAL_SECRET_ID: etradeCredentialSecret.secretName,
871
+ FOUNDATION_ETRADE_PENDING_TABLE_NAME: etradePendingTable.tableName,
872
+ FOUNDATION_ETRADE_ALLOWED_CHANNELS: etradeAllowedChannels,
873
+ FOUNDATION_ETRADE_ALLOWED_USERS: etradeAllowedUsers
874
+ },
875
+ FOUNDATION_INSTANCE: props.instance.name,
876
+ FOUNDATION_COMMAND_PREFIXES: slackCommandPrefixes(props.instance).join(","),
877
+ FOUNDATION_PROACTIVE_CHANNELS: props.instance.proactive.channels.join(","),
878
+ FOUNDATION_PROACTIVE_MAX_PER_HOUR: String(props.instance.proactive.maxPerHour),
879
+ ...jobsWebhookSecret === undefined ? {} : {
880
+ FOUNDATION_WEBHOOK_JOBS: JSON.stringify(webhookJobs),
881
+ FOUNDATION_JOBS_WEBHOOK_SECRET_ID: jobsWebhookSecret.secretName
882
+ },
883
+ FOUNDATION_SCHEDULED_JOB_BINDINGS: JSON.stringify(scheduledJobs)
884
+ };
885
+ assertLambdaEnvironmentBudget(gatewayEnvironment);
748
886
  const gatewayFunction = new lambda.Function(this, "GatewayFunction", {
749
887
  ...authenticatedQueue ? { functionName: names.slackGatewayFunctionName } : {},
750
888
  runtime: lambda.Runtime.NODEJS_22_X,
@@ -754,32 +892,7 @@ class FoundationApi extends cdk2.Stack {
754
892
  timeout: cdk2.Duration.seconds(10),
755
893
  memorySize: 256,
756
894
  deadLetterQueue: this.gatewayDlq,
757
- environment: {
758
- FOUNDATION_TABLE_NAME: props.table.tableName,
759
- FOUNDATION_INVOKE_QUEUE_URL: this.invokeQueue.queueUrl,
760
- FOUNDATION_GATEWAY_DLQ_URL: this.gatewayDlq.queueUrl,
761
- FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
762
- FOUNDATION_SLACK_APP_SECRET_ID: props.secrets.slackApp.secretName,
763
- FOUNDATION_ADMINS: props.admins,
764
- FOUNDATION_AGENT_RUNTIME_ARN: props.agentRuntimeArn,
765
- FOUNDATION_DISPLAY_NAME: props.instance.displayName,
766
- FOUNDATION_ITEMS_TABLE_NAME: props.itemsTable.tableName,
767
- FOUNDATION_GOOGLE_CALENDAR_SECRET_ID: props.secrets.googleCalendar.secretName,
768
- FOUNDATION_GOOGLE_OAUTH_SECRET_ID: props.secrets.googleOauth.secretName,
769
- FOUNDATION_GOOGLE_DRIVE_SECRET_ID: props.secrets.googleDrive.secretName,
770
- FOUNDATION_GOOGLE_EMAIL_SECRET_ID: props.secrets.googleEmail.secretName,
771
- ...props.secrets.knockOauthClient === undefined ? {} : { FOUNDATION_KNOCK_OAUTH_CLIENT_SECRET_ID: props.secrets.knockOauthClient.secretName },
772
- ...props.secrets.knockCredential === undefined ? {} : { FOUNDATION_KNOCK_CREDENTIAL_SECRET_ID: props.secrets.knockCredential.secretName },
773
- ...upworkSecret === undefined ? {} : { FOUNDATION_UPWORK_SECRET_ID: upworkSecret.secretName },
774
- FOUNDATION_INSTANCE: props.instance.name,
775
- FOUNDATION_COMMAND_PREFIXES: slackCommandPrefixes(props.instance).join(","),
776
- FOUNDATION_PROACTIVE_CHANNELS: props.instance.proactive.channels.join(","),
777
- FOUNDATION_PROACTIVE_MAX_PER_HOUR: String(props.instance.proactive.maxPerHour),
778
- ...jobsWebhookSecret === undefined ? {} : {
779
- FOUNDATION_WEBHOOK_JOBS: JSON.stringify(webhookJobs),
780
- FOUNDATION_JOBS_WEBHOOK_SECRET_ID: jobsWebhookSecret.secretName
781
- }
782
- }
895
+ environment: gatewayEnvironment
783
896
  });
784
897
  gatewayFunction.configureAsyncInvoke({
785
898
  retryAttempts: 0,
@@ -1103,6 +1216,51 @@ class FoundationApi extends cdk2.Stack {
1103
1216
  value: this.upworkProxyFunction.functionArn
1104
1217
  });
1105
1218
  }
1219
+ if (etradeClientSecret !== undefined && etradeCredentialSecret !== undefined && etrade !== undefined) {
1220
+ const etradeProxyRole = new iam2.Role(this, "EtradeProxyRole", {
1221
+ assumedBy: new iam2.ServicePrincipal("lambda.amazonaws.com"),
1222
+ description: `${display} E*TRADE fixed read-only proxy Lambda`,
1223
+ managedPolicies: [
1224
+ iam2.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole")
1225
+ ]
1226
+ });
1227
+ etradeProxyRole.addToPolicy(new iam2.PolicyStatement({
1228
+ sid: "EtradeProxyOAuthSecrets",
1229
+ actions: ["secretsmanager:GetSecretValue"],
1230
+ resources: [
1231
+ secretArnPattern2(etradeClientSecret),
1232
+ secretArnPattern2(etradeCredentialSecret)
1233
+ ]
1234
+ }));
1235
+ etradeProxyRole.addToPolicy(new iam2.PolicyStatement({
1236
+ sid: "EtradeProxySigningSecret",
1237
+ actions: ["secretsmanager:GetSecretValue"],
1238
+ resources: [secretArnPattern2(props.secrets.signing)]
1239
+ }));
1240
+ const etradeProxyEnvironment = {
1241
+ FOUNDATION_ETRADE_CLIENT_SECRET_ID: etradeClientSecret.secretName,
1242
+ FOUNDATION_ETRADE_CREDENTIAL_SECRET_ID: etradeCredentialSecret.secretName,
1243
+ FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
1244
+ FOUNDATION_SLACK_TEAM_ID: props.instance.slack.teamId,
1245
+ FOUNDATION_ETRADE_ALLOWED_CHANNELS: JSON.stringify(etrade.channels),
1246
+ FOUNDATION_ETRADE_ALLOWED_USERS: JSON.stringify(etrade.users),
1247
+ FOUNDATION_ADMINS: props.admins
1248
+ };
1249
+ assertLambdaEnvironmentBudget(etradeProxyEnvironment);
1250
+ this.etradeProxyFunction = new lambda.Function(this, "EtradeProxyFunction", {
1251
+ functionName: names.etradeProxyFunctionName,
1252
+ runtime: lambda.Runtime.NODEJS_22_X,
1253
+ handler: "index.handler",
1254
+ code: lambdaCode(this, "etrade-proxy"),
1255
+ role: etradeProxyRole,
1256
+ timeout: cdk2.Duration.seconds(30),
1257
+ memorySize: 256,
1258
+ environment: etradeProxyEnvironment
1259
+ });
1260
+ new cdk2.CfnOutput(this, "EtradeProxyFunctionArn", {
1261
+ value: this.etradeProxyFunction.functionArn
1262
+ });
1263
+ }
1106
1264
  if (provisionsIntegration(props.instance, "crm")) {
1107
1265
  const crm = crmConfigForProvisioning(props.configPath);
1108
1266
  const crmTable = props.crmTable;
@@ -1129,7 +1287,8 @@ class FoundationApi extends cdk2.Stack {
1129
1287
  "dynamodb:DeleteItem",
1130
1288
  "dynamodb:BatchGetItem",
1131
1289
  "dynamodb:BatchWriteItem",
1132
- "dynamodb:TransactWriteItems"
1290
+ "dynamodb:TransactWriteItems",
1291
+ "dynamodb:ConditionCheckItem"
1133
1292
  ],
1134
1293
  resources: [crmTable.tableArn]
1135
1294
  }));
@@ -1138,6 +1297,11 @@ class FoundationApi extends cdk2.Stack {
1138
1297
  actions: ["dynamodb:Query"],
1139
1298
  resources: [crmTable.tableArn, `${crmTable.tableArn}/index/RecordsIndex`]
1140
1299
  }));
1300
+ crmProxyRole.addToPolicy(new iam2.PolicyStatement({
1301
+ sid: "CrmTableKey",
1302
+ actions: ["kms:Decrypt", "kms:Encrypt", "kms:GenerateDataKey", "kms:DescribeKey"],
1303
+ resources: [(crmTable.encryptionKey ?? props.dataKey).keyArn]
1304
+ }));
1141
1305
  this.crmProxyFunction = new lambda.Function(this, "CrmProxyFunction", {
1142
1306
  functionName: names.crmProxyFunctionName,
1143
1307
  runtime: lambda.Runtime.NODEJS_22_X,
@@ -1196,6 +1360,69 @@ class FoundationApi extends cdk2.Stack {
1196
1360
  });
1197
1361
  alarm.addAlarmAction(alarmAction);
1198
1362
  }
1363
+ if (configuredScheduledJobs.length > 0) {
1364
+ const dispatchRole = new iam2.Role(this, "ScheduledJobDispatcherRole", {
1365
+ assumedBy: new iam2.ServicePrincipal("lambda.amazonaws.com"),
1366
+ description: `${display} deployment-owned scheduled job dispatcher`,
1367
+ managedPolicies: [
1368
+ iam2.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole")
1369
+ ]
1370
+ });
1371
+ props.secrets.signing.grantRead(dispatchRole);
1372
+ gatewayFunction.grantInvoke(dispatchRole);
1373
+ const dispatchFunction = new lambda.Function(this, "ScheduledJobDispatcherFunction", {
1374
+ runtime: lambda.Runtime.NODEJS_22_X,
1375
+ handler: "index.scheduledJobDispatcherHandler",
1376
+ code: lambdaCode(this, "gateway"),
1377
+ role: dispatchRole,
1378
+ timeout: cdk2.Duration.seconds(30),
1379
+ memorySize: 128,
1380
+ deadLetterQueue: this.gatewayDlq,
1381
+ environment: {
1382
+ FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
1383
+ FOUNDATION_GATEWAY_FUNCTION_NAME: gatewayFunction.functionName
1384
+ }
1385
+ });
1386
+ dispatchFunction.configureAsyncInvoke({
1387
+ retryAttempts: 0,
1388
+ maxEventAge: cdk2.Duration.minutes(15)
1389
+ });
1390
+ const jobGroup = new scheduler.CfnScheduleGroup(this, "ScheduledJobGroup");
1391
+ const jobSchedulerRole = new iam2.Role(this, "ScheduledJobSchedulerRole", {
1392
+ assumedBy: new iam2.ServicePrincipal("scheduler.amazonaws.com", {
1393
+ conditions: { StringEquals: { "aws:SourceAccount": this.account } }
1394
+ }),
1395
+ description: `${display} deployment-owned scheduled job invoker`
1396
+ });
1397
+ dispatchFunction.grantInvoke(jobSchedulerRole);
1398
+ for (const job of configuredScheduledJobs) {
1399
+ const parsed = parseSchedule(job.on.schedule);
1400
+ if (!parsed.ok)
1401
+ throw new Error(`Scheduled job ${job.id} has an invalid schedule`);
1402
+ const schedule = new scheduler.CfnSchedule(this, `ScheduledJob-${job.id}`, {
1403
+ groupName: jobGroup.ref,
1404
+ scheduleExpression: job.on.schedule,
1405
+ scheduleExpressionTimezone: scheduledJobTimezone,
1406
+ flexibleTimeWindow: { mode: "OFF" },
1407
+ ...parsed.kind === "at" ? { actionAfterCompletion: "DELETE" } : {},
1408
+ target: {
1409
+ arn: dispatchFunction.functionArn,
1410
+ roleArn: jobSchedulerRole.roleArn,
1411
+ input: JSON.stringify({
1412
+ internal: "scheduled_job",
1413
+ routineId: `job-${job.id}`,
1414
+ name: job.name,
1415
+ channel: job.channel,
1416
+ prompt: job.prompt,
1417
+ createdBy: JOB_ACTOR,
1418
+ scheduledTime: "<aws.scheduler.scheduled-time>"
1419
+ }),
1420
+ retryPolicy: { maximumEventAgeInSeconds: 900, maximumRetryAttempts: 0 }
1421
+ }
1422
+ });
1423
+ schedule.addResourceDependency(jobGroup);
1424
+ }
1425
+ }
1199
1426
  new scheduler.CfnScheduleGroup(this, "RoutineGroup", { name: names.routineGroup });
1200
1427
  const schedulerRole = new iam2.Role(this, "RoutineSchedulerRole", {
1201
1428
  roleName: names.routineSchedulerRole,
@@ -1278,6 +1505,33 @@ function upworkConfigForProvisioning(configPath) {
1278
1505
  throw new Error(`${configPath}: integrations.upwork requires a configured Upwork capability (${missing.join(", ")})`);
1279
1506
  return { channels: [...upwork.channels] };
1280
1507
  }
1508
+ function etradeConfigForProvisioning(configPath) {
1509
+ const config = parseInstanceConfig(readFileSync(configPath, "utf8"));
1510
+ const etrade = config.capabilities.etrade;
1511
+ const missing = [
1512
+ ...etrade.enabled ? [] : ["capabilities.etrade.enabled"],
1513
+ ...etrade.channels.length > 0 ? [] : ["capabilities.etrade.channels"],
1514
+ ...etrade.users.length > 0 ? [] : ["capabilities.etrade.users"]
1515
+ ];
1516
+ if (missing.length > 0)
1517
+ throw new Error(`${configPath}: integrations.etrade requires a configured E*TRADE capability (${missing.join(", ")})`);
1518
+ const seenAdmins = new Set;
1519
+ const invalidAdmins = config.admins.filter((admin) => {
1520
+ const invalid = !/^[UW][A-Z0-9]{8,}$/.test(admin) || admin.length > 128 || seenAdmins.has(admin);
1521
+ seenAdmins.add(admin);
1522
+ return invalid;
1523
+ });
1524
+ if (invalidAdmins.length > 0) {
1525
+ throw new Error(`${configPath}: an E*TRADE deployment requires unique valid Slack user ids in admins`);
1526
+ }
1527
+ const nonAdmins = etrade.users.filter((user) => !config.admins.includes(user));
1528
+ if (nonAdmins.length > 0)
1529
+ throw new Error(`${configPath}: capabilities.etrade.users must be instance admins (${nonAdmins.join(", ")})`);
1530
+ return { channels: [...etrade.channels], users: [...etrade.users] };
1531
+ }
1532
+ function configTimezoneFor(configPath) {
1533
+ return parseInstanceConfig(readFileSync(configPath, "utf8")).timezone;
1534
+ }
1281
1535
  function emailIdentitiesFor(configPath) {
1282
1536
  const doc = parseYaml(readFileSync(configPath, "utf8"));
1283
1537
  const email = doc.capabilities?.email;
@@ -1294,6 +1548,27 @@ function webhookJobsFor(configPath) {
1294
1548
  }
1295
1549
  return jobs;
1296
1550
  }
1551
+ var LAMBDA_ENVIRONMENT_MAX_BYTES = 4096;
1552
+ var LAMBDA_ENVIRONMENT_TOKEN_BYTES = 128;
1553
+ function assertLambdaEnvironmentBudget(values) {
1554
+ let bytes = 2;
1555
+ for (const [key, value] of Object.entries(values)) {
1556
+ bytes += Buffer.byteLength(key) + 3;
1557
+ bytes += cdk2.Token.isUnresolved(value) ? LAMBDA_ENVIRONMENT_TOKEN_BYTES : Buffer.byteLength(value);
1558
+ }
1559
+ if (bytes > LAMBDA_ENVIRONMENT_MAX_BYTES) {
1560
+ throw new Error(`Gateway Lambda environment needs an estimated ${bytes} bytes; the AWS limit is ${LAMBDA_ENVIRONMENT_MAX_BYTES}. Shorten job prompts or reduce configured values.`);
1561
+ }
1562
+ }
1563
+ function scheduledJobsFor(configPath) {
1564
+ const jobs = parseInstanceConfig(readFileSync(configPath, "utf8")).jobs.filter(isScheduledJob);
1565
+ for (const job of jobs) {
1566
+ const parsed = parseSchedule(job.on.schedule);
1567
+ if (!parsed.ok)
1568
+ throw new Error(`Scheduled job ${job.id} has an invalid schedule: ${parsed.reason}`);
1569
+ }
1570
+ return jobs;
1571
+ }
1297
1572
  function browserDomainsFor(configPath) {
1298
1573
  const browser = parseInstanceConfig(readFileSync(configPath, "utf8")).capabilities.browser;
1299
1574
  return browser.enabled && browser.mode === "read" ? [...browser.allowedDomains] : [];
@@ -1457,6 +1732,7 @@ class FoundationData extends cdk4.Stack {
1457
1732
  itemsTable;
1458
1733
  crmTable;
1459
1734
  upworkApprovalTable;
1735
+ etradePendingTable;
1460
1736
  secrets;
1461
1737
  constructor(scope, id, props) {
1462
1738
  super(scope, id, props);
@@ -1531,6 +1807,14 @@ class FoundationData extends cdk4.Stack {
1531
1807
  pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
1532
1808
  removalPolicy: cdk4.RemovalPolicy.DESTROY
1533
1809
  }) : undefined;
1810
+ this.etradePendingTable = provisionsIntegration(props.instance, "etrade") ? new dynamodb.Table(this, "EtradeOAuthPendingTable", {
1811
+ partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
1812
+ timeToLiveAttribute: "ttl",
1813
+ billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
1814
+ encryption: dynamodb.TableEncryption.CUSTOMER_MANAGED,
1815
+ encryptionKey: this.dataKey,
1816
+ removalPolicy: cdk4.RemovalPolicy.DESTROY
1817
+ }) : undefined;
1534
1818
  const secretShell = (constructId, name, description) => new secretsmanager2.Secret(this, constructId, {
1535
1819
  secretName: name,
1536
1820
  description,
@@ -1568,6 +1852,24 @@ class FoundationData extends cdk4.Stack {
1568
1852
  }
1569
1853
  }) : undefined;
1570
1854
  const upwork = provisionsIntegration(props.instance, "upwork") ? secretShell("UpworkSecret", names.secretUpwork, `Upwork OAuth client and token store for ${display} ({ client_id, client_secret, access_token?, refresh_token?, expires_at?, tenant_id? })`) : undefined;
1855
+ const etradeClient = provisionsIntegration(props.instance, "etrade") ? new secretsmanager2.Secret(this, "EtradeClientSecret", {
1856
+ secretName: names.secretEtradeClient,
1857
+ description: `E*TRADE OAuth consumer for ${display} ({ consumer_key, consumer_secret, environment })`,
1858
+ removalPolicy: cdk4.RemovalPolicy.DESTROY,
1859
+ generateSecretString: {
1860
+ secretStringTemplate: "{}",
1861
+ generateStringKey: "placeholder"
1862
+ }
1863
+ }) : undefined;
1864
+ const etradeCredential = provisionsIntegration(props.instance, "etrade") ? new secretsmanager2.Secret(this, "EtradeCredentialSecret", {
1865
+ secretName: names.secretEtradeCredential,
1866
+ description: `E*TRADE access token for ${display} ({ oauth_token, oauth_token_secret }); gateway writes and fixed read proxy alone reads`,
1867
+ removalPolicy: cdk4.RemovalPolicy.DESTROY,
1868
+ generateSecretString: {
1869
+ secretStringTemplate: "{}",
1870
+ generateStringKey: "placeholder"
1871
+ }
1872
+ }) : undefined;
1571
1873
  this.secrets = {
1572
1874
  signing: secretsmanager2.Secret.fromSecretNameV2(this, "SlackSigningSecret", names.secretSlackSigning),
1573
1875
  slackApp: secretsmanager2.Secret.fromSecretNameV2(this, "SlackAppSecret", names.secretSlackApp),
@@ -1609,6 +1911,8 @@ class FoundationData extends cdk4.Stack {
1609
1911
  ...knockOauthClient === undefined ? {} : { knockOauthClient },
1610
1912
  ...knockCredential === undefined ? {} : { knockCredential },
1611
1913
  ...upwork === undefined ? {} : { upwork },
1914
+ ...etradeClient === undefined ? {} : { etradeClient },
1915
+ ...etradeCredential === undefined ? {} : { etradeCredential },
1612
1916
  googleOauth: secretShell("GoogleOauthSecret", names.secretGoogleOauth, `Google OAuth client shared by ${display}'s Google capabilities ({ client_id, client_secret, redirect_uri })`),
1613
1917
  runtime: secretShell("RuntimeSecret", names.secretRuntime, "Agent runtime env map, written by scripts/setup.ts after deploy")
1614
1918
  };
package/dist/src/index.js CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  FoundationPipeline,
10
10
  NewsletterStack,
11
11
  deployStatements
12
- } from "../chunk-paf33z2h.js";
12
+ } from "../chunk-my0awr1g.js";
13
13
  import {
14
14
  BUN_VERSION,
15
15
  LAMBDA_ENTRY_POINTS,
@@ -42,7 +42,7 @@ import {
42
42
  skillsKey,
43
43
  skipBundle,
44
44
  verifyManifest
45
- } from "../chunk-gcg67ck3.js";
45
+ } from "../chunk-kf1yvx1w.js";
46
46
  export {
47
47
  BUILD_TIMEOUT,
48
48
  BUN_VERSION,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deployfoundation/foundation-deploy",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Deploys one Foundation instance from a published, signed release: the CDK app and the deploy tool.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
package/src/artifacts.ts CHANGED
@@ -65,6 +65,7 @@ export const LAMBDA_ENTRY_POINTS = {
65
65
  "knock-proxy": { entry: "packages/capability-knock/src/proxy/handler.ts" },
66
66
  "crm-proxy": { entry: "packages/capability-crm/src/proxy/handler.ts" },
67
67
  "upwork-proxy": { entry: "packages/capability-upwork/src/proxy/handler.ts" },
68
+ "etrade-proxy": { entry: "packages/capability-etrade/src/proxy/handler.ts" },
68
69
  "browser-proxy": { entry: "packages/gateway/src/browser-proxy.ts", playwright: true },
69
70
  "newsletter-public": { entry: "packages/newsletter/src/lambda-public.ts" },
70
71
  "newsletter-campaign": { entry: "packages/newsletter/src/lambda-campaign.ts" },
@@ -78,6 +78,12 @@ features:
78
78
  url: ${COMMANDS_REQUEST_URL}
79
79
  description: Connect the Upwork account ${DISPLAY_NAME} uses (admins)
80
80
  should_escape: false
81
+ # Admins only: E*TRADE OAuth completes with an out-of-band verifier in
82
+ # the configured private channel; financial credentials never enter Slack.
83
+ - command: /${COMMAND_PREFIX}-etrade-connect
84
+ url: ${COMMANDS_REQUEST_URL}
85
+ description: Connect ${DISPLAY_NAME} to E*TRADE read-only tools (admins)
86
+ should_escape: false
81
87
 
82
88
  # oauth_config.scopes.bot and settings.event_subscriptions.bot_events are
83
89
  # GENERATED from the capability registry by `slack-manifest.ts`
@@ -369,6 +369,11 @@ export async function syncRuntimeSecret(ctx: AwsContext): Promise<string[]> {
369
369
  ? `<${api}.UpworkProxyFunctionArn>`
370
370
  : await optionalOutput(ctx, api, "UpworkProxyFunctionArn")
371
371
  : undefined;
372
+ const etradeProxyFunctionArn = ctx.instance.integrations.etrade
373
+ ? dry
374
+ ? `<${api}.EtradeProxyFunctionArn>`
375
+ : await optionalOutput(ctx, api, "EtradeProxyFunctionArn")
376
+ : undefined;
372
377
  const routineIngressFunctionArn = dry
373
378
  ? `<${api}.RoutineIngressFunctionArn>`
374
379
  : await optionalOutput(ctx, api, "RoutineIngressFunctionArn");
@@ -398,6 +403,7 @@ export async function syncRuntimeSecret(ctx: AwsContext): Promise<string[]> {
398
403
  ...(otterProxyFunctionArn === undefined ? {} : { otterProxyFunctionArn }),
399
404
  ...(knockProxyFunctionArn === undefined ? {} : { knockProxyFunctionArn }),
400
405
  ...(upworkProxyFunctionArn === undefined ? {} : { upworkProxyFunctionArn }),
406
+ ...(etradeProxyFunctionArn === undefined ? {} : { etradeProxyFunctionArn }),
401
407
  ...(routineIngressFunctionArn === undefined ? {} : { routineIngressFunctionArn }),
402
408
  });
403
409
  await putSecretJson(ctx, secretRuntime, value);
@@ -92,6 +92,12 @@ export interface RuntimeSecretInputs {
92
92
  * shell-capable container. OAuth client and token state stay in the proxy.
93
93
  */
94
94
  upworkProxyFunctionArn?: string;
95
+ /**
96
+ * `FoundationApi.EtradeProxyFunctionArn` — the agent's sole E*TRADE binding.
97
+ * Consumer/access credentials and OOB pending-state identifiers never enter
98
+ * this runtime secret.
99
+ */
100
+ etradeProxyFunctionArn?: string;
95
101
  /** Trusted gateway ingress used instead of granting the agent normal-queue writes. */
96
102
  routineIngressFunctionArn?: string;
97
103
  defaultRepo?: string;
@@ -220,6 +226,9 @@ export function composeRuntimeSecret(inputs: RuntimeSecretInputs): Record<string
220
226
  ...(inputs.upworkProxyFunctionArn !== undefined && inputs.upworkProxyFunctionArn !== ""
221
227
  ? { FOUNDATION_UPWORK_PROXY_ARN: inputs.upworkProxyFunctionArn }
222
228
  : {}),
229
+ ...(inputs.etradeProxyFunctionArn !== undefined && inputs.etradeProxyFunctionArn !== ""
230
+ ? { FOUNDATION_ETRADE_PROXY_ARN: inputs.etradeProxyFunctionArn }
231
+ : {}),
223
232
  ...(inputs.routineIngressFunctionArn !== undefined && inputs.routineIngressFunctionArn !== ""
224
233
  ? { FOUNDATION_ROUTINE_INGRESS_FUNCTION_ARN: inputs.routineIngressFunctionArn }
225
234
  : {}),
@@ -227,9 +236,9 @@ export function composeRuntimeSecret(inputs: RuntimeSecretInputs): Record<string
227
236
  // LAZILY, so a secret still holding the CDK placeholder costs nothing
228
237
  // until someone asks a MongoDB question.
229
238
  FOUNDATION_MONGODB_SECRET_ID: names.secretMongodbReadonly,
230
- // CRM storage, the Otter credential, the Knock OAuth secrets, and the Upwork
231
- // OAuth secret are intentionally absent here: the shell-capable agent receives
232
- // only their fixed proxy ARNs.
239
+ // CRM storage, the Otter credential, the Knock OAuth secrets, and the
240
+ // Upwork/E*TRADE OAuth secrets are intentionally absent here: the
241
+ // shell-capable agent receives only their fixed proxy ARNs.
233
242
  // A routine fire builds its own session key, so the container needs the
234
243
  // team id the gateway takes from the event. The app secret carries it
235
244
  // when setup wrote one; the instance file is the fallback.