@deployfoundation/foundation-deploy 0.2.51 → 0.2.52

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.
@@ -1,6 +1,8 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import {
3
3
  type CrmOutreachConfig,
4
+ type CrmStatusPolicy,
5
+ type CrmStatusesByType,
4
6
  type EmailIdentity,
5
7
  type GoogleDocsIdentity,
6
8
  type ScheduledJobBinding,
@@ -16,7 +18,10 @@ import {
16
18
  webhookJobBindings,
17
19
  } from "@deployfoundation/foundation-core";
18
20
  import { JOB_ACTOR } from "@deployfoundation/foundation-core/actor-capability";
19
- import { slackCommandPrefixes } from "@deployfoundation/foundation-core/instance";
21
+ import {
22
+ FOUNDATION_VERSION_RE,
23
+ slackCommandPrefixes,
24
+ } from "@deployfoundation/foundation-core/instance";
20
25
  import { parseSchedule } from "@deployfoundation/foundation-core/schedule";
21
26
  import * as cdk from "aws-cdk-lib";
22
27
  import * as apigateway from "aws-cdk-lib/aws-apigateway";
@@ -34,7 +39,7 @@ import * as sns from "aws-cdk-lib/aws-sns";
34
39
  import * as sqs from "aws-cdk-lib/aws-sqs";
35
40
  import type { Construct } from "constructs";
36
41
  import { parse as parseYaml } from "yaml";
37
- import { lambdaCode } from "../artifacts.ts";
42
+ import { lambdaCode, releaseSource } from "../artifacts.ts";
38
43
  import {
39
44
  type Instance,
40
45
  LIVE_ENDPOINT_NAME,
@@ -1129,17 +1134,26 @@ export class FoundationApi extends cdk.Stack {
1129
1134
 
1130
1135
  // --- CRM proxy -------------------------------------------------------
1131
1136
  // Fixed operations and deployment-baked policy over the instance's own
1132
- // self-hosted CRM table.
1137
+ // self-hosted CRM table, or over the company's own CRM through `crm/1`
1138
+ // (`crm.provider` in the instance file). Either way this role is the only
1139
+ // principal that touches the records or holds a credential for them.
1133
1140
  if (provisionsIntegration(props.instance, "crm")) {
1134
1141
  const crm = crmConfigForProvisioning(props.configPath);
1142
+ const provider = props.instance.crm.provider;
1135
1143
  const crmTable = props.crmTable;
1136
1144
  if (crmTable === undefined)
1137
1145
  throw new Error(
1138
1146
  `${id}: integrations.crm is enabled but no CRM table was supplied by FoundationData`,
1139
1147
  );
1148
+ // With `http` the table holds only the outreach cap ledger, so the
1149
+ // proxy's role gets it only when outreach is configured.
1150
+ const usesTable = provider.kind === "dynamodb" || crm.outreach !== undefined;
1140
1151
  const crmProxyRole = new iam.Role(this, "CrmProxyRole", {
1141
1152
  assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
1142
- description: `${display} CRM proxy Lambda role (self-hosted CRM table access only)`,
1153
+ description:
1154
+ provider.kind === "dynamodb"
1155
+ ? `${display} CRM proxy Lambda role (self-hosted CRM table access only)`
1156
+ : `${display} CRM proxy Lambda role (crm/1 key and outreach ledger only)`,
1143
1157
  managedPolicies: [
1144
1158
  iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
1145
1159
  ],
@@ -1151,41 +1165,128 @@ export class FoundationApi extends cdk.Stack {
1151
1165
  resources: [secretArnPattern(props.secrets.signing)],
1152
1166
  }),
1153
1167
  );
1154
- crmProxyRole.addToPolicy(
1155
- new iam.PolicyStatement({
1156
- sid: "CrmTableData",
1157
- actions: [
1158
- "dynamodb:GetItem",
1159
- "dynamodb:PutItem",
1160
- "dynamodb:UpdateItem",
1161
- "dynamodb:DeleteItem",
1162
- "dynamodb:BatchGetItem",
1163
- "dynamodb:BatchWriteItem",
1164
- "dynamodb:TransactWriteItems",
1165
- // A transaction's ConditionCheck is its own IAM action; the
1166
- // repository guards every upsert and activity with one.
1167
- "dynamodb:ConditionCheckItem",
1168
- ],
1169
- resources: [crmTable.tableArn],
1170
- }),
1171
- );
1172
- crmProxyRole.addToPolicy(
1173
- new iam.PolicyStatement({
1174
- sid: "CrmRecordsIndex",
1175
- actions: ["dynamodb:Query"],
1176
- resources: [crmTable.tableArn, `${crmTable.tableArn}/index/RecordsIndex`],
1177
- }),
1178
- );
1168
+ if (provider.kind === "dynamodb") {
1169
+ crmProxyRole.addToPolicy(
1170
+ new iam.PolicyStatement({
1171
+ sid: "CrmTableData",
1172
+ actions: [
1173
+ "dynamodb:GetItem",
1174
+ "dynamodb:PutItem",
1175
+ "dynamodb:UpdateItem",
1176
+ "dynamodb:DeleteItem",
1177
+ "dynamodb:BatchGetItem",
1178
+ "dynamodb:BatchWriteItem",
1179
+ "dynamodb:TransactWriteItems",
1180
+ // A transaction's ConditionCheck is its own IAM action; the
1181
+ // repository guards every upsert and activity with one.
1182
+ "dynamodb:ConditionCheckItem",
1183
+ ],
1184
+ resources: [crmTable.tableArn],
1185
+ }),
1186
+ );
1187
+ crmProxyRole.addToPolicy(
1188
+ new iam.PolicyStatement({
1189
+ sid: "CrmRecordsIndex",
1190
+ actions: ["dynamodb:Query"],
1191
+ resources: [crmTable.tableArn, `${crmTable.tableArn}/index/RecordsIndex`],
1192
+ }),
1193
+ );
1194
+ } else {
1195
+ // Read only. The key is written by an operator (`crm-key`), never by
1196
+ // the proxy; the name is the fixed one FoundationData created it
1197
+ // under, so this stack holds no cross-stack reference to it.
1198
+ crmProxyRole.addToPolicy(
1199
+ new iam.PolicyStatement({
1200
+ sid: "CrmHttpSecret",
1201
+ actions: ["secretsmanager:GetSecretValue"],
1202
+ resources: [
1203
+ secretArnPattern(
1204
+ secretsmanager.Secret.fromSecretNameV2(this, "CrmHttpSecret", names.secretCrmHttp),
1205
+ ),
1206
+ ],
1207
+ }),
1208
+ );
1209
+ // The ledger's items only: its weekly counters and reservations under
1210
+ // one partition (`TENANT#<instance>#OUTREACH`). The profile and
1211
+ // activity items a DynamoDB-provider proxy wrote stay in the table,
1212
+ // unreadable to this role, and so does the records index. A reserve
1213
+ // is one transaction (Put plus Update); a release deletes the
1214
+ // reservation in another.
1215
+ if (usesTable)
1216
+ crmProxyRole.addToPolicy(
1217
+ new iam.PolicyStatement({
1218
+ sid: "CrmOutreachLedger",
1219
+ actions: [
1220
+ "dynamodb:GetItem",
1221
+ "dynamodb:PutItem",
1222
+ "dynamodb:UpdateItem",
1223
+ "dynamodb:DeleteItem",
1224
+ "dynamodb:TransactWriteItems",
1225
+ ],
1226
+ resources: [crmTable.tableArn],
1227
+ conditions: {
1228
+ "ForAllValues:StringEquals": {
1229
+ "dynamodb:LeadingKeys": [crmOutreachLedgerPartition(props.instance.name)],
1230
+ },
1231
+ },
1232
+ }),
1233
+ );
1234
+ }
1179
1235
  // The table is encrypted with the instance's data key, so every read and
1180
1236
  // write needs it. Without this grant each call failed as `proxy_error`
1181
1237
  // (KMS AccessDenied) the first time a deployment actually used its CRM.
1182
- crmProxyRole.addToPolicy(
1183
- new iam.PolicyStatement({
1184
- sid: "CrmTableKey",
1185
- actions: ["kms:Decrypt", "kms:Encrypt", "kms:GenerateDataKey", "kms:DescribeKey"],
1186
- resources: [(crmTable.encryptionKey ?? props.dataKey).keyArn],
1187
- }),
1188
- );
1238
+ if (usesTable)
1239
+ crmProxyRole.addToPolicy(
1240
+ new iam.PolicyStatement({
1241
+ sid: "CrmTableKey",
1242
+ actions: ["kms:Decrypt", "kms:Encrypt", "kms:GenerateDataKey", "kms:DescribeKey"],
1243
+ resources: [(crmTable.encryptionKey ?? props.dataKey).keyArn],
1244
+ }),
1245
+ );
1246
+ const release = releaseVersionContext(this);
1247
+ const crmProxyEnvironment: Record<string, string> = {
1248
+ // Baked even when `http` has no ledger to keep (the proxy then ignores
1249
+ // it, and the role above has no grant on the table): dropping it would
1250
+ // drop FoundationData's export of the name while the API stack still
1251
+ // deployed imports it, and the data stack, deployed first, would fail.
1252
+ FOUNDATION_CRM_TABLE_NAME: crmTable.tableName,
1253
+ FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
1254
+ FOUNDATION_SLACK_TEAM_ID: props.instance.slack.teamId,
1255
+ FOUNDATION_CRM_TENANT_ID: props.instance.name,
1256
+ FOUNDATION_CRM_ALLOWED_CHANNELS: JSON.stringify(crm.channels),
1257
+ FOUNDATION_CRM_ALLOWED_STATUSES: JSON.stringify(crm.statuses),
1258
+ FOUNDATION_CRM_ACTIVITY_TYPES: JSON.stringify(crm.activityTypes),
1259
+ FOUNDATION_CRM_MAX_BATCH_SIZE: String(crm.maxBatchSize),
1260
+ // Which status changes the agent may make, and whether it may
1261
+ // delete: refused here, so a config sync alone cannot widen them.
1262
+ FOUNDATION_CRM_STATUS_POLICY: JSON.stringify(crm.statusPolicy),
1263
+ FOUNDATION_CRM_DELETES: String(crm.deletes),
1264
+ // The outreach caps are enforced here, so they are baked like the
1265
+ // rest of the policy: a config sync alone cannot lift them.
1266
+ ...(crm.outreach === undefined
1267
+ ? {}
1268
+ : {
1269
+ FOUNDATION_CRM_OUTREACH: JSON.stringify(crm.outreach),
1270
+ FOUNDATION_CRM_TIMEZONE: crm.timezone,
1271
+ }),
1272
+ // Where the records live. Unset means `dynamodb`, so a DynamoDB
1273
+ // deployment's environment is what it was before `http` existed.
1274
+ ...(provider.kind === "http"
1275
+ ? {
1276
+ FOUNDATION_CRM_PROVIDER: "http",
1277
+ FOUNDATION_CRM_HTTP_BASE_URL: provider.baseUrl,
1278
+ // The id, never the key: the proxy reads it at request time.
1279
+ FOUNDATION_CRM_HTTP_SECRET_ID: names.secretCrmHttp,
1280
+ FOUNDATION_CRM_HTTP_EXPECTED_TENANT: provider.expectedTenant,
1281
+ // Only the User-Agent the CRM sees, for its logs.
1282
+ ...(release === undefined ? {} : { FOUNDATION_RELEASE: release }),
1283
+ }
1284
+ : {}),
1285
+ };
1286
+ // The config parser caps the policy JSON (CRM_MAX_POLICY_ENV_BYTES);
1287
+ // this checks the whole environment, names and table included, at synth
1288
+ // rather than as a failed Lambda update halfway through a deploy.
1289
+ assertLambdaEnvironmentBudget(crmProxyEnvironment);
1189
1290
  this.crmProxyFunction = new lambda.Function(this, "CrmProxyFunction", {
1190
1291
  functionName: names.crmProxyFunctionName,
1191
1292
  runtime: lambda.Runtime.NODEJS_22_X,
@@ -1194,24 +1295,7 @@ export class FoundationApi extends cdk.Stack {
1194
1295
  role: crmProxyRole,
1195
1296
  timeout: cdk.Duration.seconds(30),
1196
1297
  memorySize: 256,
1197
- environment: {
1198
- FOUNDATION_CRM_TABLE_NAME: crmTable.tableName,
1199
- FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
1200
- FOUNDATION_SLACK_TEAM_ID: props.instance.slack.teamId,
1201
- FOUNDATION_CRM_TENANT_ID: props.instance.name,
1202
- FOUNDATION_CRM_ALLOWED_CHANNELS: JSON.stringify(crm.channels),
1203
- FOUNDATION_CRM_ALLOWED_STATUSES: JSON.stringify(crm.statuses),
1204
- FOUNDATION_CRM_ACTIVITY_TYPES: JSON.stringify(crm.activityTypes),
1205
- FOUNDATION_CRM_MAX_BATCH_SIZE: String(crm.maxBatchSize),
1206
- // The outreach caps are enforced here, so they are baked like the
1207
- // rest of the policy: a config sync alone cannot lift them.
1208
- ...(crm.outreach === undefined
1209
- ? {}
1210
- : {
1211
- FOUNDATION_CRM_OUTREACH: JSON.stringify(crm.outreach),
1212
- FOUNDATION_CRM_TIMEZONE: crm.timezone,
1213
- }),
1214
- },
1298
+ environment: crmProxyEnvironment,
1215
1299
  });
1216
1300
  new cdk.CfnOutput(this, "CrmProxyFunctionArn", {
1217
1301
  value: this.crmProxyFunction.functionArn,
@@ -1277,6 +1361,13 @@ export class FoundationApi extends cdk.Stack {
1277
1361
  agentRuntimeArn: props.agentRuntimeArn,
1278
1362
  alarmAction,
1279
1363
  });
1364
+ // A company's own CRM refusing, or answering as someone else.
1365
+ if (this.crmProxyFunction !== undefined && props.instance.crm.provider.kind === "http")
1366
+ addCrmProviderAlarms(this, {
1367
+ instance: props.instance,
1368
+ functionName: names.crmProxyFunctionName,
1369
+ alarmAction,
1370
+ });
1280
1371
 
1281
1372
  // --- deployment-owned scheduled jobs --------------------------------
1282
1373
  // Config jobs never share the agent-managed routine group or pass role.
@@ -1451,37 +1542,13 @@ export function addConfigLoadAlarm(
1451
1542
  if (args.agentRuntimeArn.endsWith("-pending")) return undefined;
1452
1543
  const runtimeId = cdk.Fn.select(1, cdk.Fn.split("/", args.agentRuntimeArn));
1453
1544
  const logGroupName = `/aws/bedrock-agentcore/runtimes/${runtimeId}-${LIVE_ENDPOINT_NAME}`;
1454
- const role = new iam.Role(stack, "AgentLiveLogGroupRole", {
1455
- assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
1456
- description: `${args.instance.displayName} creates the runtime's live log group when missing`,
1457
- managedPolicies: [
1458
- iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
1459
- ],
1460
- });
1461
- role.addToPolicy(
1462
- new iam.PolicyStatement({
1463
- sid: "CreateRuntimeLiveLogGroup",
1464
- actions: ["logs:CreateLogGroup"],
1465
- resources: [
1466
- `arn:${stack.partition}:logs:${stack.region}:${stack.account}:log-group:${logGroupName}`,
1467
- ],
1468
- }),
1469
- );
1470
- const ensureFunction = new lambda.Function(stack, "AgentLiveLogGroupFunction", {
1471
- runtime: lambda.Runtime.NODEJS_22_X,
1472
- handler: "index.runtimeLogGroupHandler",
1473
- code: lambdaCode(stack, "gateway"),
1474
- role,
1475
- timeout: cdk.Duration.seconds(30),
1476
- memorySize: 128,
1477
- });
1478
- const ensureLogGroup = new cdk.CustomResource(stack, "AgentLiveLogGroup", {
1479
- serviceToken: ensureFunction.functionArn,
1545
+ const ensureLogGroup = ensureLogGroupExists(stack, {
1546
+ id: "AgentLiveLogGroup",
1547
+ sid: "CreateRuntimeLiveLogGroup",
1480
1548
  resourceType: "Custom::FoundationRuntimeLogGroup",
1481
- properties: { LogGroupName: logGroupName },
1549
+ description: `${args.instance.displayName} creates the runtime's live log group when missing`,
1550
+ logGroupName,
1482
1551
  });
1483
- // The function needs its permission before CloudFormation invokes it.
1484
- ensureLogGroup.node.addDependency(role);
1485
1552
  const filter = new logs.MetricFilter(stack, "ConfigLoadFailureMetricFilter", {
1486
1553
  logGroup: logs.LogGroup.fromLogGroupName(stack, "AgentLiveLogs", logGroupName),
1487
1554
  filterPattern: logs.FilterPattern.any(
@@ -1512,11 +1579,193 @@ export function addConfigLoadAlarm(
1512
1579
  return alarm;
1513
1580
  }
1514
1581
 
1582
+ /**
1583
+ * A custom resource that creates `logGroupName` when it is missing and leaves
1584
+ * it alone when it exists (or when the stack is deleted), so a metric filter
1585
+ * can attach to a group that AWS, not CloudFormation, creates on first use.
1586
+ * The handler (`runtimeLogGroupHandler`, in the release-signed gateway bundle)
1587
+ * accepts only an AgentCore runtime's or a Lambda's group, and the role may
1588
+ * create exactly this one.
1589
+ */
1590
+ function ensureLogGroupExists(
1591
+ stack: cdk.Stack,
1592
+ args: {
1593
+ /** Construct id prefix: `<id>Role`, `<id>Function`, and `<id>` for the resource. */
1594
+ id: string;
1595
+ sid: string;
1596
+ resourceType: string;
1597
+ description: string;
1598
+ logGroupName: string;
1599
+ },
1600
+ ): cdk.CustomResource {
1601
+ const role = new iam.Role(stack, `${args.id}Role`, {
1602
+ assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
1603
+ description: args.description,
1604
+ managedPolicies: [
1605
+ iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
1606
+ ],
1607
+ });
1608
+ role.addToPolicy(
1609
+ new iam.PolicyStatement({
1610
+ sid: args.sid,
1611
+ actions: ["logs:CreateLogGroup"],
1612
+ resources: [
1613
+ `arn:${stack.partition}:logs:${stack.region}:${stack.account}:log-group:${args.logGroupName}`,
1614
+ ],
1615
+ }),
1616
+ );
1617
+ const ensureFunction = new lambda.Function(stack, `${args.id}Function`, {
1618
+ runtime: lambda.Runtime.NODEJS_22_X,
1619
+ handler: "index.runtimeLogGroupHandler",
1620
+ code: lambdaCode(stack, "gateway"),
1621
+ role,
1622
+ timeout: cdk.Duration.seconds(30),
1623
+ memorySize: 128,
1624
+ });
1625
+ const ensureLogGroup = new cdk.CustomResource(stack, args.id, {
1626
+ serviceToken: ensureFunction.functionArn,
1627
+ resourceType: args.resourceType,
1628
+ properties: { LogGroupName: args.logGroupName },
1629
+ });
1630
+ // The function needs its permission before CloudFormation invokes it.
1631
+ ensureLogGroup.node.addDependency(role);
1632
+ return ensureLogGroup;
1633
+ }
1634
+
1635
+ /**
1636
+ * The CRM proxy's log codes that mean a company's own CRM (`crm.provider`
1637
+ * kind `http`) needs a person. Each is one metric filter on the proxy's log
1638
+ * group and one alarm on the instance's alarm topic; the log line itself
1639
+ * carries only fixed words (the op, the status class, which `/meta` check
1640
+ * failed), never a record or a reply body.
1641
+ */
1642
+ export const CRM_PROVIDER_ALARMS = [
1643
+ {
1644
+ event: "crm_proxy",
1645
+ code: "tenant_mismatch",
1646
+ name: "TenantMismatch",
1647
+ description:
1648
+ "The CRM answered as a different tenant from crm.provider.expectedTenant, so the CRM proxy refused the reply and the CRM tools fail closed. Check that the key written with crm-key was issued for that tenant, and that expectedTenant is right.",
1649
+ },
1650
+ {
1651
+ event: "crm_proxy",
1652
+ code: "upstream_unauthorized",
1653
+ name: "UpstreamUnauthorized",
1654
+ description:
1655
+ "The CRM refused the CRM proxy's key (401), even after the proxy re-read it: revoked, expired or wrong. Issue a new key in the CRM and run foundation-deploy crm-key; the proxy picks it up on its next call.",
1656
+ },
1657
+ {
1658
+ event: "crm_proxy",
1659
+ code: "upstream_forbidden",
1660
+ name: "UpstreamForbidden",
1661
+ description:
1662
+ "The CRM answered 403: a scope the key lacks, the CRM's kill switch, or a WAF in front of it blocking the proxy. Check the key's scopes, the switch and the WAF's logs.",
1663
+ },
1664
+ {
1665
+ event: "crm_proxy",
1666
+ code: "unconfigured",
1667
+ name: "Unconfigured",
1668
+ description:
1669
+ "The CRM proxy is unconfigured, so every CRM tool fails closed: no key written yet (run foundation-deploy crm-key), or the CRM's /meta does not match the deployment. The log line's check field names which: contract, statuses, activity_types, batch_size or operations.",
1670
+ },
1671
+ {
1672
+ event: "crm_proxy_warning",
1673
+ code: "ledger_kept",
1674
+ name: "LedgerKept",
1675
+ description:
1676
+ "A draft's outreach reservation was kept because the CRM's answer was unknown (a timeout, a 5xx, a 429 or a reply without the contract header). The weekly cap counts it, failing closed. Check in the CRM whether that draft was logged.",
1677
+ },
1678
+ {
1679
+ event: "crm_proxy_warning",
1680
+ code: "policy_mismatch",
1681
+ name: "PolicyMismatch",
1682
+ description:
1683
+ "The CRM's own policy in /meta differs from the deployment's capabilities.crm (the log line's fields name which). Each side enforces its own, so the stricter wins; align the two.",
1684
+ },
1685
+ ] as const;
1686
+
1687
+ /**
1688
+ * Alarms on the CRM proxy's log group for a company's own CRM: one per
1689
+ * {@link CRM_PROVIDER_ALARMS} entry, on the instance's alarm topic.
1690
+ *
1691
+ * Lambda creates `/aws/lambda/<function>` on the first invocation, and a
1692
+ * proxy that ran before (on the DynamoDB provider) already has one that
1693
+ * CloudFormation does not own, so the group is ensured by a custom resource
1694
+ * exactly as the runtime's live group is.
1695
+ *
1696
+ * Five minutes, except `policy_mismatch`: it is logged at every `/meta` check
1697
+ * for as long as the two policies differ, so an hour keeps it from
1698
+ * re-notifying after every quiet gap.
1699
+ */
1700
+ export function addCrmProviderAlarms(
1701
+ stack: cdk.Stack,
1702
+ args: { instance: Instance; functionName: string; alarmAction: cloudwatch.IAlarmAction },
1703
+ ): cloudwatch.Alarm[] {
1704
+ const logGroupName = `/aws/lambda/${args.functionName}`;
1705
+ const ensureLogGroup = ensureLogGroupExists(stack, {
1706
+ id: "CrmProxyLogGroup",
1707
+ sid: "CreateCrmProxyLogGroup",
1708
+ resourceType: "Custom::FoundationLambdaLogGroup",
1709
+ description: `${args.instance.displayName} creates the CRM proxy's log group when missing`,
1710
+ logGroupName,
1711
+ });
1712
+ const logGroup = logs.LogGroup.fromLogGroupName(stack, "CrmProxyLogs", logGroupName);
1713
+ return CRM_PROVIDER_ALARMS.map((entry) => {
1714
+ const filter = new logs.MetricFilter(stack, `CrmProxy${entry.name}MetricFilter`, {
1715
+ logGroup,
1716
+ filterPattern: logs.FilterPattern.all(
1717
+ logs.FilterPattern.stringValue("$.event", "=", entry.event),
1718
+ logs.FilterPattern.stringValue("$.code", "=", entry.code),
1719
+ ),
1720
+ metricNamespace: `Foundation/${args.instance.naming.prefix}`,
1721
+ metricName: `CrmProxy${entry.name}`,
1722
+ metricValue: "1",
1723
+ });
1724
+ filter.node.addDependency(ensureLogGroup);
1725
+ const alarm = new cloudwatch.Alarm(stack, `CrmProxy${entry.name}Alarm`, {
1726
+ alarmDescription: `${args.instance.displayName} CRM proxy logged ${entry.code}. ${entry.description}`,
1727
+ metric: filter.metric({
1728
+ statistic: "Sum",
1729
+ period: cdk.Duration.minutes(entry.code === "policy_mismatch" ? 60 : 5),
1730
+ }),
1731
+ threshold: 1,
1732
+ evaluationPeriods: 1,
1733
+ comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
1734
+ treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
1735
+ });
1736
+ alarm.addAlarmAction(args.alarmAction);
1737
+ return alarm;
1738
+ });
1739
+ }
1740
+
1741
+ /**
1742
+ * The partition holding the outreach cap ledger, the only items the CRM
1743
+ * proxy's role may touch with the `http` provider. Kept equal to
1744
+ * `outreachLedgerPartition` in the CRM capability's `proxy/ledger.ts` (a test
1745
+ * pins it); not imported, so the CDK app does not load the proxy's AWS SDK.
1746
+ */
1747
+ export function crmOutreachLedgerPartition(tenantId: string): string {
1748
+ return `TENANT#${tenantId}#OUTREACH`;
1749
+ }
1750
+
1751
+ /**
1752
+ * The release a synth deploys, for the CRM proxy's User-Agent:
1753
+ * `-c foundationVersion`, which the deploy tool passes, else the release
1754
+ * being deployed. Absent on a bare synth, which the proxy tolerates.
1755
+ */
1756
+ function releaseVersionContext(scope: Construct): string | undefined {
1757
+ const explicit = String(scope.node.tryGetContext("foundationVersion") ?? "");
1758
+ const version = explicit !== "" ? explicit : releaseSource(scope)?.version;
1759
+ return version !== undefined && FOUNDATION_VERSION_RE.test(version) ? version : undefined;
1760
+ }
1761
+
1515
1762
  interface ProvisionedCrmConfig {
1516
1763
  channels: string[];
1517
- statuses: string[];
1764
+ statuses: CrmStatusesByType;
1518
1765
  activityTypes: string[];
1519
1766
  maxBatchSize: number;
1767
+ statusPolicy: CrmStatusPolicy;
1768
+ deletes: boolean;
1520
1769
  policyFingerprint: string;
1521
1770
  /** Absent when no status is eligible for outreach. */
1522
1771
  outreach?: CrmOutreachConfig;
@@ -1526,10 +1775,12 @@ interface ProvisionedCrmConfig {
1526
1775
  function crmConfigForProvisioning(configPath: string): ProvisionedCrmConfig {
1527
1776
  const config = parseInstanceConfig(readFileSync(configPath, "utf8"));
1528
1777
  const crm = config.capabilities.crm;
1778
+ // Each record type needs a status for the records an upsert creates.
1529
1779
  const missing = [
1530
1780
  ...(crm.enabled ? [] : ["capabilities.crm.enabled"]),
1531
1781
  ...(crm.channels.length > 0 ? [] : ["capabilities.crm.channels"]),
1532
- ...(crm.statuses.length > 0 ? [] : ["capabilities.crm.statuses"]),
1782
+ ...(crm.statuses.company.length > 0 ? [] : ["capabilities.crm.statuses.company"]),
1783
+ ...(crm.statuses.contact.length > 0 ? [] : ["capabilities.crm.statuses.contact"]),
1533
1784
  ...(crm.activityTypes.length > 0 ? [] : ["capabilities.crm.activityTypes"]),
1534
1785
  ];
1535
1786
  if (missing.length > 0)
@@ -1538,9 +1789,11 @@ function crmConfigForProvisioning(configPath: string): ProvisionedCrmConfig {
1538
1789
  );
1539
1790
  return {
1540
1791
  channels: [...crm.channels],
1541
- statuses: [...crm.statuses],
1792
+ statuses: { company: [...crm.statuses.company], contact: [...crm.statuses.contact] },
1542
1793
  activityTypes: [...crm.activityTypes],
1543
1794
  maxBatchSize: crm.maxBatchSize,
1795
+ statusPolicy: crm.statusPolicy,
1796
+ deletes: crm.deletes,
1544
1797
  policyFingerprint: crmPolicyFingerprint(crm),
1545
1798
  ...(crmOutreachConfigured(crm.outreach) ? { outreach: crm.outreach } : {}),
1546
1799
  timezone: config.timezone,
@@ -1672,7 +1925,7 @@ export function assertLambdaEnvironmentBudget(values: Readonly<Record<string, st
1672
1925
  }
1673
1926
  if (bytes > LAMBDA_ENVIRONMENT_MAX_BYTES) {
1674
1927
  throw new Error(
1675
- `Gateway Lambda environment needs an estimated ${bytes} bytes; the AWS limit is ${LAMBDA_ENVIRONMENT_MAX_BYTES}. Shorten job prompts or reduce configured values.`,
1928
+ `A Lambda environment needs an estimated ${bytes} bytes; the AWS limit is ${LAMBDA_ENVIRONMENT_MAX_BYTES}. Shorten job prompts or reduce configured values.`,
1676
1929
  );
1677
1930
  }
1678
1931
  }
@@ -16,7 +16,8 @@ export interface FoundationDataProps extends cdk.StackProps {
16
16
  * FoundationData — the durable layer: one CMK, two S3 buckets (config +
17
17
  * skills, and channel documents), two always-present DynamoDB tables, an
18
18
  * optional CRM table, an optional proxy-only Upwork approval table, an optional
19
- * retained Upwork OAuth secret, and the Secrets Manager entries.
19
+ * retained Upwork OAuth secret, the optional key for a company's own CRM, and
20
+ * the Secrets Manager entries.
20
21
  * The two always-present DynamoDB tables use
21
22
  * different access patterns: `FoundationTable` is `pk` (S) only:
22
23
  * dedupe rows, the Codex credential lease and the thread→session index, every
@@ -53,6 +54,12 @@ export class FoundationData extends cdk.Stack {
53
54
  public readonly upworkApprovalTable?: dynamodb.Table;
54
55
  /** Short-lived OOB request-token state; gateway-only and destroyed with the opt-in. */
55
56
  public readonly etradePendingTable?: dynamodb.Table;
57
+ /**
58
+ * The `crm/1` key, present only with `crm.provider` kind `http`. Other stacks
59
+ * reach it by its fixed name, never through this object, so switching back to
60
+ * `dynamodb` can delete it without a cross-stack export holding it in place.
61
+ */
62
+ public readonly crmHttpSecret?: secretsmanager.Secret;
56
63
  public readonly secrets: {
57
64
  signing: secretsmanager.ISecret;
58
65
  slackApp: secretsmanager.ISecret;
@@ -230,6 +237,25 @@ export class FoundationData extends cdk.Stack {
230
237
  })
231
238
  : undefined;
232
239
 
240
+ // The key for a company's own CRM (`crm.provider` kind `http`): empty
241
+ // until `foundation-deploy crm-key` writes `{ api_key }`, and until then
242
+ // the CRM proxy answers `unconfigured`. Like the Otter key it is destroyed
243
+ // with the opt-in: a rollback to `dynamodb` must not strand a fixed-name
244
+ // secret that a later switch back to `http` could not create again, and
245
+ // the CRM issues a fresh key in seconds.
246
+ this.crmHttpSecret =
247
+ provisionsIntegration(props.instance, "crm") && props.instance.crm.provider.kind === "http"
248
+ ? new secretsmanager.Secret(this, "CrmHttpSecret", {
249
+ secretName: names.secretCrmHttp,
250
+ description: `crm/1 key for ${display}'s CRM ({ api_key }); written by foundation-deploy crm-key, read only by the CRM proxy`,
251
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
252
+ generateSecretString: {
253
+ secretStringTemplate: "{}",
254
+ generateStringKey: "placeholder",
255
+ },
256
+ })
257
+ : undefined;
258
+
233
259
  // Unlike the disposable Otter API key, the Upwork OAuth secret carries
234
260
  // client credentials plus refresh state. Retaining it makes a deploy safe
235
261
  // and leaves manually entered client credentials intact.