@deployfoundation/foundation-deploy 0.2.23 → 0.2.25

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.
@@ -250,6 +250,7 @@ COPY packages/capability-otter/ ./packages/capability-otter/
250
250
  COPY packages/capability-knock/ ./packages/capability-knock/
251
251
  COPY packages/capability-browser/ ./packages/capability-browser/
252
252
  COPY packages/capability-email/ ./packages/capability-email/
253
+ COPY packages/capability-google-docs/ ./packages/capability-google-docs/
253
254
  COPY packages/capability-crm/ ./packages/capability-crm/
254
255
  COPY packages/capability-upwork/ ./packages/capability-upwork/
255
256
  COPY packages/capability-etrade/ ./packages/capability-etrade/
package/dist/bin/app.js CHANGED
@@ -8,13 +8,13 @@ import {
8
8
  FoundationPages,
9
9
  FoundationPipeline,
10
10
  NewsletterStack
11
- } from "../chunk-hzrrx2pq.js";
11
+ } from "../chunk-7krjky1x.js";
12
12
  import {
13
13
  adminsFor,
14
14
  instanceNames,
15
15
  loadInstanceFile,
16
16
  provisionsIntegration
17
- } from "../chunk-bp4yddcp.js";
17
+ } from "../chunk-f4s4c4bg.js";
18
18
 
19
19
  // bin/app.ts
20
20
  import { execFileSync } from "node:child_process";
@@ -29,7 +29,7 @@ import {
29
29
  stackExists,
30
30
  stackOutput,
31
31
  toolVersion
32
- } from "../chunk-j3etxk5j.js";
32
+ } from "../chunk-165c7rv9.js";
33
33
  import {
34
34
  BASE_SLACK_EVENTS,
35
35
  CAPABILITY_IDS,
@@ -45,7 +45,7 @@ import {
45
45
  requiredSlackScopes,
46
46
  skillsKey,
47
47
  slackCommandPrefix
48
- } from "../chunk-bp4yddcp.js";
48
+ } from "../chunk-f4s4c4bg.js";
49
49
 
50
50
  // src/deploy/config-sync.ts
51
51
  import { existsSync, mkdirSync, mkdtempSync, readdirSync } from "node:fs";
@@ -932,6 +932,7 @@ function composeRuntimeSecret(inputs) {
932
932
  FOUNDATION_ROUTINE_GROUP: names.routineGroup,
933
933
  FOUNDATION_AGENT_ENDPOINT: inputs.agentEndpoint !== undefined && inputs.agentEndpoint !== "" ? inputs.agentEndpoint : LIVE_ENDPOINT,
934
934
  ...inputs.readOnlyRoleArn !== undefined && inputs.readOnlyRoleArn !== "" ? { FOUNDATION_AWS_READONLY_ROLE_ARN: inputs.readOnlyRoleArn } : {},
935
+ ...inputs.googleDocsProxyFunctionArn !== undefined && inputs.googleDocsProxyFunctionArn !== "" ? { FOUNDATION_GOOGLE_DOCS_PROXY_ARN: inputs.googleDocsProxyFunctionArn } : {},
935
936
  ...inputs.emailProxyFunctionArn !== undefined && inputs.emailProxyFunctionArn !== "" ? { FOUNDATION_EMAIL_PROXY_FUNCTION_ARN: inputs.emailProxyFunctionArn } : {},
936
937
  ...inputs.browserProxyFunctionArn !== undefined && inputs.browserProxyFunctionArn !== "" ? { FOUNDATION_BROWSER_PROXY_FUNCTION_ARN: inputs.browserProxyFunctionArn } : {},
937
938
  ...inputs.crmProxyFunctionArn !== undefined && inputs.crmProxyFunctionArn !== "" ? { FOUNDATION_CRM_PROXY_FUNCTION_ARN: inputs.crmProxyFunctionArn } : {},
@@ -1260,6 +1261,7 @@ async function syncRuntimeSecret(ctx) {
1260
1261
  const agentRuntimeArn = dry ? `<${agent}.AgentRuntimeArn>` : await optionalOutput(ctx, agent, "AgentRuntimeArn");
1261
1262
  const readOnlyRoleArn = dry ? `<${agent}.ReadOnlyRoleArn>` : await optionalOutput(ctx, agent, "ReadOnlyRoleArn");
1262
1263
  const emailProxyFunctionArn = dry ? `<${api}.EmailProxyFunctionArn>` : await optionalOutput(ctx, api, "EmailProxyFunctionArn");
1264
+ const googleDocsProxyFunctionArn = dry ? `<${api}.GoogleDocsProxyFunctionArn>` : await optionalOutput(ctx, api, "GoogleDocsProxyFunctionArn");
1263
1265
  const browserProxyFunctionArn = dry ? `<${api}.BrowserProxyFunctionArn>` : await optionalOutput(ctx, api, "BrowserProxyFunctionArn");
1264
1266
  const crmProxyFunctionArn = ctx.instance.integrations.crm ? dry ? `<${api}.CrmProxyFunctionArn>` : await optionalOutput(ctx, api, "CrmProxyFunctionArn") : undefined;
1265
1267
  const crmPolicyFingerprint = ctx.instance.integrations.crm ? dry ? `<${api}.CrmPolicyFingerprint>` : await optionalOutput(ctx, api, "CrmPolicyFingerprint") : undefined;
@@ -1286,6 +1288,7 @@ async function syncRuntimeSecret(ctx) {
1286
1288
  ...agentRuntimeArn === undefined ? {} : { agentRuntimeArn },
1287
1289
  ...readOnlyRoleArn === undefined ? {} : { readOnlyRoleArn },
1288
1290
  ...emailProxyFunctionArn === undefined ? {} : { emailProxyFunctionArn },
1291
+ ...googleDocsProxyFunctionArn === undefined ? {} : { googleDocsProxyFunctionArn },
1289
1292
  ...browserProxyFunctionArn === undefined ? {} : { browserProxyFunctionArn },
1290
1293
  ...crmProxyFunctionArn === undefined ? {} : { crmProxyFunctionArn },
1291
1294
  ...crmPolicyFingerprint === undefined ? {} : { crmPolicyFingerprint },
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env bun
2
2
  import {
3
3
  DEFAULT_RELEASE_BUCKET
4
- } from "../chunk-j3etxk5j.js";
5
- import"../chunk-bp4yddcp.js";
4
+ } from "../chunk-165c7rv9.js";
5
+ import"../chunk-f4s4c4bg.js";
6
6
 
7
7
  // bin/release-account.ts
8
8
  import * as cdk2 from "aws-cdk-lib";
@@ -6,7 +6,7 @@ import {
6
6
  manifestKey,
7
7
  releaseCacheDir,
8
8
  verifyManifest
9
- } from "./chunk-bp4yddcp.js";
9
+ } from "./chunk-f4s4c4bg.js";
10
10
 
11
11
  // src/deploy/release.ts
12
12
  import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2 } from "node:fs";
@@ -19,7 +19,7 @@ import {
19
19
  scheduledJobBindings,
20
20
  slackCommandPrefixes,
21
21
  webhookJobBindings
22
- } from "./chunk-bp4yddcp.js";
22
+ } from "./chunk-f4s4c4bg.js";
23
23
 
24
24
  // src/stacks/agent-stack.ts
25
25
  import * as cdk from "aws-cdk-lib";
@@ -240,6 +240,21 @@ class FoundationAgent extends cdk.Stack {
240
240
  resources: [secretArnPattern(knockOauthClient), secretArnPattern(knockCredential)]
241
241
  }));
242
242
  }
243
+ denyDirectDocsSecretAccess(role, docsSecretPrefix) {
244
+ role.addToPolicy(new iam.PolicyStatement({
245
+ sid: "DenyGoogleDocsSecrets",
246
+ effect: iam.Effect.DENY,
247
+ actions: ["secretsmanager:*"],
248
+ resources: [
249
+ cdk.Stack.of(this).formatArn({
250
+ service: "secretsmanager",
251
+ resource: "secret",
252
+ resourceName: `${docsSecretPrefix}/personal/*-??????`,
253
+ arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME
254
+ })
255
+ ]
256
+ }));
257
+ }
243
258
  denyDirectEmailSecretAccess(role, sharedMailboxSecret, personalSecretPrefix) {
244
259
  const stack = cdk.Stack.of(this);
245
260
  const personalSecrets = stack.formatArn({
@@ -358,6 +373,7 @@ class FoundationAgent extends cdk.Stack {
358
373
  this.denyDirectCrmTableAccess(role, props.instance, props.crmTable);
359
374
  this.denyDirectKnockSecretAccess(role, props);
360
375
  this.denyDirectEmailSecretAccess(role, props.secrets.googleEmail, instanceNames(props.instance).secretGoogleEmail);
376
+ this.denyDirectDocsSecretAccess(role, instanceNames(props.instance).secretGoogleDocs);
361
377
  this.denyDirectUpworkApprovalAccess(role, props.upworkApprovalTable);
362
378
  this.denyDirectEtradeAccess(role, props);
363
379
  this.pagesPolicy("AgentReadOnlyPagesPolicy", role, props.instance, { invoke: false });
@@ -421,6 +437,7 @@ class FoundationAgent extends cdk.Stack {
421
437
  this.denyDirectKnockSecretAccess(role, props);
422
438
  this.denyDirectUpworkApprovalAccess(role, props.upworkApprovalTable);
423
439
  this.denyDirectEmailSecretAccess(role, props.secrets.googleEmail, instanceNames(props.instance).secretGoogleEmail);
440
+ this.denyDirectDocsSecretAccess(role, instanceNames(props.instance).secretGoogleDocs);
424
441
  this.denyDirectEtradeAccess(role, props);
425
442
  this.pagesPolicy("AgentExecutionPagesPolicy", role, props.instance, { invoke: true });
426
443
  const releaseRepositoryArn = releaseImageRepositoryArn(this);
@@ -489,6 +506,18 @@ class FoundationAgent extends cdk.Stack {
489
506
  actions: ["secretsmanager:GetSecretValue"],
490
507
  resources: [secretArnPattern(props.secrets.signing)]
491
508
  }));
509
+ role.addToPolicy(new iam.PolicyStatement({
510
+ sid: "GoogleDocsProxyInvoke",
511
+ actions: ["lambda:InvokeFunction"],
512
+ resources: [
513
+ cdk.Stack.of(this).formatArn({
514
+ service: "lambda",
515
+ resource: "function",
516
+ resourceName: instanceNames(props.instance).googleDocsProxyFunctionName,
517
+ arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME
518
+ })
519
+ ]
520
+ }));
492
521
  role.addToPolicy(new iam.PolicyStatement({
493
522
  sid: "EmailProxyInvoke",
494
523
  actions: ["lambda:InvokeFunction"],
@@ -788,6 +817,7 @@ class FoundationApi extends cdk2.Stack {
788
817
  invokeQueue;
789
818
  invokeDlq;
790
819
  emailProxyFunction;
820
+ googleDocsProxyFunction;
791
821
  browserProxyFunction;
792
822
  otterProxyFunction;
793
823
  crmProxyFunction;
@@ -799,6 +829,7 @@ class FoundationApi extends cdk2.Stack {
799
829
  const names = instanceNames(props.instance);
800
830
  const display = props.instance.displayName;
801
831
  const emailIdentities = emailIdentitiesFor(props.configPath);
832
+ const docsIdentities = googleDocsIdentitiesFor(props.configPath);
802
833
  const personalEmailIdentities = emailIdentities.filter((identity) => identity.owner !== undefined);
803
834
  const upwork = provisionsIntegration(props.instance, "upwork") ? upworkConfigForProvisioning(props.configPath) : undefined;
804
835
  const upworkSecret = upwork === undefined ? undefined : props.secrets.upwork;
@@ -846,6 +877,15 @@ class FoundationApi extends cdk2.Stack {
846
877
  generateStringKey: "placeholder"
847
878
  }
848
879
  }));
880
+ const docsSecrets = docsIdentities.map((identity) => new secretsmanager.Secret(this, `GoogleDocsSecret${identity.owner}`, {
881
+ secretName: `${instanceNames(props.instance).secretGoogleDocs}/personal/${identity.owner}`,
882
+ description: `${display} Google Docs drafting grant for Slack ${identity.owner}; gateway writes and the docs proxy reads`,
883
+ removalPolicy: cdk2.RemovalPolicy.DESTROY,
884
+ generateSecretString: {
885
+ secretStringTemplate: "{}",
886
+ generateStringKey: "placeholder"
887
+ }
888
+ }));
849
889
  const gatewayRole = new iam2.Role(this, "GatewayRole", {
850
890
  assumedBy: new iam2.ServicePrincipal("lambda.amazonaws.com"),
851
891
  description: `${display} gateway Lambda role (least privilege)`,
@@ -877,6 +917,13 @@ class FoundationApi extends cdk2.Stack {
877
917
  ...personalEmailSecrets.map(secretArnPattern2)
878
918
  ]
879
919
  }));
920
+ if (docsSecrets.length > 0) {
921
+ gatewayRole.addToPolicy(new iam2.PolicyStatement({
922
+ sid: "GoogleDocsIdentityConnect",
923
+ actions: ["secretsmanager:PutSecretValue"],
924
+ resources: docsSecrets.map(secretArnPattern2)
925
+ }));
926
+ }
880
927
  if (props.secrets.knockCredential !== undefined) {
881
928
  gatewayRole.addToPolicy(new iam2.PolicyStatement({
882
929
  sid: "KnockCredentialConnect",
@@ -982,6 +1029,10 @@ class FoundationApi extends cdk2.Stack {
982
1029
  owner
983
1030
  })))
984
1031
  },
1032
+ ...docsIdentities.length === 0 ? {} : {
1033
+ FOUNDATION_GOOGLE_DOCS_SECRET_ID: instanceNames(props.instance).secretGoogleDocs,
1034
+ FOUNDATION_GOOGLE_DOCS_IDENTITIES: JSON.stringify(docsIdentities)
1035
+ },
985
1036
  ...props.secrets.knockOauthClient === undefined ? {} : { FOUNDATION_KNOCK_OAUTH_CLIENT_SECRET_ID: props.secrets.knockOauthClient.secretName },
986
1037
  ...props.secrets.knockCredential === undefined ? {} : { FOUNDATION_KNOCK_CREDENTIAL_SECRET_ID: props.secrets.knockCredential.secretName },
987
1038
  ...upworkSecret === undefined ? {} : { FOUNDATION_UPWORK_SECRET_ID: upworkSecret.secretName },
@@ -1152,6 +1203,50 @@ class FoundationApi extends cdk2.Stack {
1152
1203
  FOUNDATION_EMAIL_IDENTITIES: JSON.stringify(emailIdentities)
1153
1204
  }
1154
1205
  });
1206
+ if (docsIdentities.length > 0) {
1207
+ const docsProxyRole = new iam2.Role(this, "GoogleDocsProxyRole", {
1208
+ assumedBy: new iam2.ServicePrincipal("lambda.amazonaws.com"),
1209
+ description: `${display} Google Docs drafts proxy role (the only reader of a docs grant)`,
1210
+ managedPolicies: [
1211
+ iam2.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole")
1212
+ ]
1213
+ });
1214
+ docsProxyRole.addToPolicy(new iam2.PolicyStatement({
1215
+ sid: "GoogleDocsProxyCredentials",
1216
+ actions: ["secretsmanager:GetSecretValue"],
1217
+ resources: [
1218
+ ...docsSecrets.map(secretArnPattern2),
1219
+ secretArnPattern2(props.secrets.googleOauth),
1220
+ secretArnPattern2(props.secrets.signing)
1221
+ ]
1222
+ }));
1223
+ docsProxyRole.addToPolicy(new iam2.PolicyStatement({
1224
+ sid: "GoogleDocsProxyDataKey",
1225
+ actions: ["kms:Decrypt"],
1226
+ resources: [props.dataKey.keyArn]
1227
+ }));
1228
+ this.googleDocsProxyFunction = new lambda.Function(this, "GoogleDocsProxyFunction", {
1229
+ functionName: instanceNames(props.instance).googleDocsProxyFunctionName,
1230
+ runtime: lambda.Runtime.NODEJS_22_X,
1231
+ handler: "index.handler",
1232
+ code: lambdaCode(this, "google-docs-proxy"),
1233
+ role: docsProxyRole,
1234
+ timeout: cdk2.Duration.seconds(30),
1235
+ memorySize: 256,
1236
+ environment: {
1237
+ FOUNDATION_GOOGLE_DOCS_SECRET_ID: instanceNames(props.instance).secretGoogleDocs,
1238
+ FOUNDATION_GOOGLE_OAUTH_SECRET_ID: instanceNames(props.instance).secretGoogleOauth,
1239
+ FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
1240
+ FOUNDATION_SLACK_TEAM_ID: props.instance.slack.teamId,
1241
+ FOUNDATION_GOOGLE_DOCS_IDENTITIES: JSON.stringify(docsIdentities),
1242
+ FOUNDATION_GOOGLE_DOCS_FOLDER: `${props.instance.displayName} drafts`
1243
+ }
1244
+ });
1245
+ new cdk2.CfnOutput(this, "GoogleDocsProxyFunctionArn", {
1246
+ value: this.googleDocsProxyFunction.functionArn,
1247
+ description: "The Google Docs drafts proxy the agent invokes"
1248
+ });
1249
+ }
1155
1250
  const browserDomains = provisionsIntegration(props.instance, "browser") ? browserDomainsFor(props.configPath) : [];
1156
1251
  if (browserDomains.length > 0) {
1157
1252
  const browserProxyRole = new iam2.Role(this, "BrowserProxyRole", {
@@ -1656,6 +1751,10 @@ function etradeConfigForProvisioning(configPath) {
1656
1751
  function configTimezoneFor(configPath) {
1657
1752
  return parseInstanceConfig(readFileSync(configPath, "utf8")).timezone;
1658
1753
  }
1754
+ function googleDocsIdentitiesFor(configPath) {
1755
+ const docs = parseInstanceConfig(readFileSync(configPath, "utf8")).capabilities.googleDocs;
1756
+ return docs.enabled ? docs.identities.map(({ address, channel, owner }) => ({ address, channel, owner })) : [];
1757
+ }
1659
1758
  function emailIdentitiesFor(configPath) {
1660
1759
  const email = parseInstanceConfig(readFileSync(configPath, "utf8")).capabilities.email;
1661
1760
  return email.enabled ? email.identities : [];
@@ -110,6 +110,34 @@ var RawGoogleDriveCapability = z.object({
110
110
  enabled: z.boolean().default(true),
111
111
  channels: z.array(RawSlackChannel).max(100).default([])
112
112
  }).strict().default({});
113
+ var RawGoogleDocsIdentity = z.object({
114
+ address: z.string().regex(/^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i),
115
+ channel: z.string().regex(/^[CGD][A-Z0-9]{8,}$/),
116
+ owner: z.string().regex(/^U[A-Z0-9]{8,}$/, "owner must be a Slack U id")
117
+ }).strict();
118
+ var RawGoogleDocsCapability = z.object({
119
+ enabled: z.boolean().default(false),
120
+ identities: z.array(RawGoogleDocsIdentity).max(50).default([])
121
+ }).strict().superRefine((value, ctx) => {
122
+ if (value.enabled && value.identities.length === 0)
123
+ ctx.addIssue({
124
+ code: z.ZodIssueCode.custom,
125
+ path: ["identities"],
126
+ message: "an enabled Google Docs capability needs at least one identity"
127
+ });
128
+ for (const field of ["owner", "channel"]) {
129
+ const seen = new Set;
130
+ for (const [index, identity] of value.identities.entries()) {
131
+ if (seen.has(identity[field]))
132
+ ctx.addIssue({
133
+ code: z.ZodIssueCode.custom,
134
+ path: ["identities", index, field],
135
+ message: `duplicate Google Docs identity ${field}: ${identity[field]}`
136
+ });
137
+ seen.add(identity[field]);
138
+ }
139
+ }
140
+ }).default({});
113
141
  var RawCrmOutreach = z.object({
114
142
  weeklyLimit: z.number().int().min(1).max(20).default(5),
115
143
  eligibleStatuses: z.array(RawCrmValue).max(100).default([]),
@@ -373,6 +401,16 @@ var CAPABILITIES = [
373
401
  slackCommands: ["drive-connect"],
374
402
  googleScopes: ["https://www.googleapis.com/auth/drive.readonly"]
375
403
  },
404
+ {
405
+ id: "googleDocs",
406
+ summary: "Draft Google Docs in a person's own Drive, by personal consent; it reaches only the Docs it created.",
407
+ config: RawGoogleDocsCapability,
408
+ ...NONE,
409
+ secrets: ["google/docs", GOOGLE_OAUTH_SECRET],
410
+ resources: ["googleDocsProxyFunctionName"],
411
+ slackCommands: ["docs-connect"],
412
+ googleScopes: ["https://www.googleapis.com/auth/drive.file", ...GOOGLE_IDENTITY_SCOPES]
413
+ },
376
414
  {
377
415
  id: "googleCalendar",
378
416
  summary: "Each person's own calendar plus team free/busy, by personal consent.",
@@ -863,6 +901,7 @@ function instanceNames(instance) {
863
901
  secretGoogleCalendar: secret("google/calendar"),
864
902
  secretGoogleOauth: secret("google/oauth"),
865
903
  secretGoogleEmail: secret("google/email"),
904
+ secretGoogleDocs: secret("google/docs"),
866
905
  secretMongodbReadonly: secret("mongodb/readonly"),
867
906
  secretOtterApi: secret("otter/api"),
868
907
  secretKnockOauthClient: secret("knock/oauth-client"),
@@ -886,6 +925,7 @@ function instanceNames(instance) {
886
925
  deployRole: stack("DeployRole"),
887
926
  configKey: instance.config,
888
927
  emailProxyFunctionName: stack("EmailProxy"),
928
+ googleDocsProxyFunctionName: stack("GoogleDocsProxy"),
889
929
  otterProxyFunctionName: stack("OtterProxy"),
890
930
  crmProxyFunctionName: stack("CrmProxy"),
891
931
  pagesApiFunctionName: stack("PagesApi"),
@@ -1556,6 +1596,7 @@ var LAMBDA_ENTRY_POINTS = {
1556
1596
  gateway: { entry: "packages/gateway/src/lambda.ts" },
1557
1597
  invoker: { entry: "packages/gateway/src/invoker.ts" },
1558
1598
  "email-proxy": { entry: "packages/capability-email/src/proxy/handler.ts" },
1599
+ "google-docs-proxy": { entry: "packages/capability-google-docs/src/proxy/handler.ts" },
1559
1600
  "otter-proxy": { entry: "packages/capability-otter/src/proxy/handler.ts" },
1560
1601
  "knock-proxy": { entry: "packages/capability-knock/src/proxy/handler.ts" },
1561
1602
  "crm-proxy": { entry: "packages/capability-crm/src/proxy/handler.ts" },
package/dist/src/index.js CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  FoundationPipeline,
11
11
  NewsletterStack,
12
12
  deployStatements
13
- } from "../chunk-hzrrx2pq.js";
13
+ } from "../chunk-7krjky1x.js";
14
14
  import {
15
15
  BUN_VERSION,
16
16
  LAMBDA_ENTRY_POINTS,
@@ -43,7 +43,7 @@ import {
43
43
  skillsKey,
44
44
  skipBundle,
45
45
  verifyManifest
46
- } from "../chunk-bp4yddcp.js";
46
+ } from "../chunk-f4s4c4bg.js";
47
47
  export {
48
48
  BUILD_TIMEOUT,
49
49
  BUN_VERSION,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deployfoundation/foundation-deploy",
3
- "version": "0.2.23",
3
+ "version": "0.2.25",
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
@@ -61,6 +61,7 @@ export const LAMBDA_ENTRY_POINTS = {
61
61
  gateway: { entry: "packages/gateway/src/lambda.ts" },
62
62
  invoker: { entry: "packages/gateway/src/invoker.ts" },
63
63
  "email-proxy": { entry: "packages/capability-email/src/proxy/handler.ts" },
64
+ "google-docs-proxy": { entry: "packages/capability-google-docs/src/proxy/handler.ts" },
64
65
  "otter-proxy": { entry: "packages/capability-otter/src/proxy/handler.ts" },
65
66
  "knock-proxy": { entry: "packages/capability-knock/src/proxy/handler.ts" },
66
67
  "crm-proxy": { entry: "packages/capability-crm/src/proxy/handler.ts" },
@@ -68,6 +68,11 @@ features:
68
68
  url: ${COMMANDS_REQUEST_URL}
69
69
  description: Connect the mailbox ${DISPLAY_NAME} drafts from (admins)
70
70
  should_escape: false
71
+ # Personal: only a configured owner, only in their configured DM or channel.
72
+ - command: /${COMMAND_PREFIX}-docs-connect
73
+ url: ${COMMANDS_REQUEST_URL}
74
+ description: Let ${DISPLAY_NAME} draft Google Docs in your own Drive
75
+ should_escape: false
71
76
  - command: /${COMMAND_PREFIX}-knock-connect
72
77
  url: ${COMMANDS_REQUEST_URL}
73
78
  description: Connect ${DISPLAY_NAME} to Knock read-only tools (admins)
@@ -396,6 +396,9 @@ export async function syncRuntimeSecret(ctx: AwsContext): Promise<string[]> {
396
396
  const emailProxyFunctionArn = dry
397
397
  ? `<${api}.EmailProxyFunctionArn>`
398
398
  : await optionalOutput(ctx, api, "EmailProxyFunctionArn");
399
+ const googleDocsProxyFunctionArn = dry
400
+ ? `<${api}.GoogleDocsProxyFunctionArn>`
401
+ : await optionalOutput(ctx, api, "GoogleDocsProxyFunctionArn");
399
402
  const browserProxyFunctionArn = dry
400
403
  ? `<${api}.BrowserProxyFunctionArn>`
401
404
  : await optionalOutput(ctx, api, "BrowserProxyFunctionArn");
@@ -458,6 +461,7 @@ export async function syncRuntimeSecret(ctx: AwsContext): Promise<string[]> {
458
461
  ...(agentRuntimeArn === undefined ? {} : { agentRuntimeArn }),
459
462
  ...(readOnlyRoleArn === undefined ? {} : { readOnlyRoleArn }),
460
463
  ...(emailProxyFunctionArn === undefined ? {} : { emailProxyFunctionArn }),
464
+ ...(googleDocsProxyFunctionArn === undefined ? {} : { googleDocsProxyFunctionArn }),
461
465
  ...(browserProxyFunctionArn === undefined ? {} : { browserProxyFunctionArn }),
462
466
  ...(crmProxyFunctionArn === undefined ? {} : { crmProxyFunctionArn }),
463
467
  ...(crmPolicyFingerprint === undefined ? {} : { crmPolicyFingerprint }),
@@ -64,6 +64,8 @@ export interface RuntimeSecretInputs {
64
64
  * offers no email tools.
65
65
  */
66
66
  emailProxyFunctionArn?: string;
67
+ /** Present only when a Google Docs drafting identity is configured. */
68
+ googleDocsProxyFunctionArn?: string;
67
69
  /**
68
70
  * `FoundationApi.BrowserProxyFunctionArn`; absent when managed browser is disabled.
69
71
  * The runtime receives no Browser API grant or signing secret.
@@ -210,6 +212,9 @@ export function composeRuntimeSecret(inputs: RuntimeSecretInputs): Record<string
210
212
  ...(inputs.readOnlyRoleArn !== undefined && inputs.readOnlyRoleArn !== ""
211
213
  ? { FOUNDATION_AWS_READONLY_ROLE_ARN: inputs.readOnlyRoleArn }
212
214
  : {}),
215
+ ...(inputs.googleDocsProxyFunctionArn !== undefined && inputs.googleDocsProxyFunctionArn !== ""
216
+ ? { FOUNDATION_GOOGLE_DOCS_PROXY_ARN: inputs.googleDocsProxyFunctionArn }
217
+ : {}),
213
218
  ...(inputs.emailProxyFunctionArn !== undefined && inputs.emailProxyFunctionArn !== ""
214
219
  ? { FOUNDATION_EMAIL_PROXY_FUNCTION_ARN: inputs.emailProxyFunctionArn }
215
220
  : {}),
@@ -400,6 +400,29 @@ export class FoundationAgent extends cdk.Stack {
400
400
  }),
401
401
  );
402
402
  }
403
+ /**
404
+ * Google Docs drafting grants, like personal Gmail grants, are readable only
405
+ * by their proxy. Both shell-reachable roles explicitly deny every
406
+ * owner-derived name, so a future broad grant cannot reach one.
407
+ */
408
+ private denyDirectDocsSecretAccess(role: iam.Role, docsSecretPrefix: string): void {
409
+ role.addToPolicy(
410
+ new iam.PolicyStatement({
411
+ sid: "DenyGoogleDocsSecrets",
412
+ effect: iam.Effect.DENY,
413
+ actions: ["secretsmanager:*"],
414
+ resources: [
415
+ cdk.Stack.of(this).formatArn({
416
+ service: "secretsmanager",
417
+ resource: "secret",
418
+ resourceName: `${docsSecretPrefix}/personal/*-??????`,
419
+ arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME,
420
+ }),
421
+ ],
422
+ }),
423
+ );
424
+ }
425
+
403
426
  /**
404
427
  * The execution role and its ReadOnlyAccess escape hatch are both
405
428
  * shell-reachable. Keep Gmail credentials behind the proxy even if a future
@@ -590,6 +613,7 @@ export class FoundationAgent extends cdk.Stack {
590
613
  props.secrets.googleEmail,
591
614
  namesFor(props.instance).secretGoogleEmail,
592
615
  );
616
+ this.denyDirectDocsSecretAccess(role, namesFor(props.instance).secretGoogleDocs);
593
617
  this.denyDirectUpworkApprovalAccess(role, props.upworkApprovalTable);
594
618
  this.denyDirectEtradeAccess(role, props);
595
619
  this.pagesPolicy("AgentReadOnlyPagesPolicy", role, props.instance, { invoke: false });
@@ -689,6 +713,7 @@ export class FoundationAgent extends cdk.Stack {
689
713
  props.secrets.googleEmail,
690
714
  namesFor(props.instance).secretGoogleEmail,
691
715
  );
716
+ this.denyDirectDocsSecretAccess(role, namesFor(props.instance).secretGoogleDocs);
692
717
  this.denyDirectEtradeAccess(role, props);
693
718
  this.pagesPolicy("AgentExecutionPagesPolicy", role, props.instance, { invoke: true });
694
719
 
@@ -792,6 +817,22 @@ export class FoundationAgent extends cdk.Stack {
792
817
  // already depends on this stack for the runtime ARN, so referring to its
793
818
  // output here would close a cycle. The function name is derived from the
794
819
  // instance file, so both stacks compute the same string.
820
+ // The Docs drafts proxy is the agent's only Google Docs grant; named, not
821
+ // referenced, for the same cycle reason as the email proxy below.
822
+ role.addToPolicy(
823
+ new iam.PolicyStatement({
824
+ sid: "GoogleDocsProxyInvoke",
825
+ actions: ["lambda:InvokeFunction"],
826
+ resources: [
827
+ cdk.Stack.of(this).formatArn({
828
+ service: "lambda",
829
+ resource: "function",
830
+ resourceName: namesFor(props.instance).googleDocsProxyFunctionName,
831
+ arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME,
832
+ }),
833
+ ],
834
+ }),
835
+ );
795
836
  role.addToPolicy(
796
837
  new iam.PolicyStatement({
797
838
  sid: "EmailProxyInvoke",
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
2
2
  import {
3
3
  type CrmOutreachConfig,
4
4
  type EmailIdentity,
5
+ type GoogleDocsIdentity,
5
6
  type ScheduledJobBinding,
6
7
  type ScheduledJobConfig,
7
8
  type WebhookJobBinding,
@@ -116,6 +117,8 @@ export class FoundationApi extends cdk.Stack {
116
117
  public readonly invokeDlq: sqs.Queue;
117
118
  /** The email proxy: the agent role's only email grant is to invoke this. */
118
119
  public readonly emailProxyFunction: lambda.Function;
120
+ /** The Google Docs drafts proxy, present only when an identity is configured. */
121
+ public readonly googleDocsProxyFunction?: lambda.Function;
119
122
  /** Isolated policy boundary for AgentCore Browser; absent when the capability is disabled. */
120
123
  public readonly browserProxyFunction?: lambda.Function;
121
124
  /** Read-only Otter proxy, present only when the instance opts in. */
@@ -134,6 +137,7 @@ export class FoundationApi extends cdk.Stack {
134
137
  const names = namesFor(props.instance);
135
138
  const display = props.instance.displayName;
136
139
  const emailIdentities = emailIdentitiesFor(props.configPath);
140
+ const docsIdentities = googleDocsIdentitiesFor(props.configPath);
137
141
  const personalEmailIdentities = emailIdentities.filter(
138
142
  (identity): identity is EmailIdentity & { owner: string } => identity.owner !== undefined,
139
143
  );
@@ -212,6 +216,22 @@ export class FoundationApi extends cdk.Stack {
212
216
  }),
213
217
  );
214
218
 
219
+ // Google Docs drafting grants: one deployment-owned shell per configured
220
+ // owner, written by the gateway's connect callback and read only by the
221
+ // docs proxy. Removing an identity deletes its grant.
222
+ const docsSecrets = docsIdentities.map(
223
+ (identity) =>
224
+ new secretsmanager.Secret(this, `GoogleDocsSecret${identity.owner}`, {
225
+ secretName: `${namesFor(props.instance).secretGoogleDocs}/personal/${identity.owner}`,
226
+ description: `${display} Google Docs drafting grant for Slack ${identity.owner}; gateway writes and the docs proxy reads`,
227
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
228
+ generateSecretString: {
229
+ secretStringTemplate: "{}",
230
+ generateStringKey: "placeholder",
231
+ },
232
+ }),
233
+ );
234
+
215
235
  const gatewayRole = new iam.Role(this, "GatewayRole", {
216
236
  assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
217
237
  description: `${display} gateway Lambda role (least privilege)`,
@@ -262,6 +282,17 @@ export class FoundationApi extends cdk.Stack {
262
282
  ],
263
283
  }),
264
284
  );
285
+ if (docsSecrets.length > 0) {
286
+ gatewayRole.addToPolicy(
287
+ new iam.PolicyStatement({
288
+ sid: "GoogleDocsIdentityConnect",
289
+ // Write-only, on these exact owner shells: the gateway completes a
290
+ // connection but can never read a grant back.
291
+ actions: ["secretsmanager:PutSecretValue"],
292
+ resources: docsSecrets.map(secretArnPattern),
293
+ }),
294
+ );
295
+ }
265
296
  if (props.secrets.knockCredential !== undefined) {
266
297
  gatewayRole.addToPolicy(
267
298
  new iam.PolicyStatement({
@@ -418,6 +449,12 @@ export class FoundationApi extends cdk.Stack {
418
449
  })),
419
450
  ),
420
451
  }),
452
+ ...(docsIdentities.length === 0
453
+ ? {}
454
+ : {
455
+ FOUNDATION_GOOGLE_DOCS_SECRET_ID: namesFor(props.instance).secretGoogleDocs,
456
+ FOUNDATION_GOOGLE_DOCS_IDENTITIES: JSON.stringify(docsIdentities),
457
+ }),
421
458
  ...(props.secrets.knockOauthClient === undefined
422
459
  ? {}
423
460
  : { FOUNDATION_KNOCK_OAUTH_CLIENT_SECRET_ID: props.secrets.knockOauthClient.secretName }),
@@ -689,6 +726,58 @@ export class FoundationApi extends cdk.Stack {
689
726
  },
690
727
  });
691
728
 
729
+ // --- Google Docs drafts proxy ---------------------------------------
730
+ // The only reader of a Docs grant. Provisioned only when an identity is
731
+ // configured; the identities it serves are baked in here at synth.
732
+ if (docsIdentities.length > 0) {
733
+ const docsProxyRole = new iam.Role(this, "GoogleDocsProxyRole", {
734
+ assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
735
+ description: `${display} Google Docs drafts proxy role (the only reader of a docs grant)`,
736
+ managedPolicies: [
737
+ iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
738
+ ],
739
+ });
740
+ docsProxyRole.addToPolicy(
741
+ new iam.PolicyStatement({
742
+ sid: "GoogleDocsProxyCredentials",
743
+ actions: ["secretsmanager:GetSecretValue"],
744
+ resources: [
745
+ ...docsSecrets.map(secretArnPattern),
746
+ secretArnPattern(props.secrets.googleOauth),
747
+ secretArnPattern(props.secrets.signing),
748
+ ],
749
+ }),
750
+ );
751
+ docsProxyRole.addToPolicy(
752
+ new iam.PolicyStatement({
753
+ sid: "GoogleDocsProxyDataKey",
754
+ actions: ["kms:Decrypt"],
755
+ resources: [props.dataKey.keyArn],
756
+ }),
757
+ );
758
+ this.googleDocsProxyFunction = new lambda.Function(this, "GoogleDocsProxyFunction", {
759
+ functionName: namesFor(props.instance).googleDocsProxyFunctionName,
760
+ runtime: lambda.Runtime.NODEJS_22_X,
761
+ handler: "index.handler",
762
+ code: lambdaCode(this, "google-docs-proxy"),
763
+ role: docsProxyRole,
764
+ timeout: cdk.Duration.seconds(30),
765
+ memorySize: 256,
766
+ environment: {
767
+ FOUNDATION_GOOGLE_DOCS_SECRET_ID: namesFor(props.instance).secretGoogleDocs,
768
+ FOUNDATION_GOOGLE_OAUTH_SECRET_ID: namesFor(props.instance).secretGoogleOauth,
769
+ FOUNDATION_SLACK_SIGNING_SECRET_ID: props.secrets.signing.secretName,
770
+ FOUNDATION_SLACK_TEAM_ID: props.instance.slack.teamId,
771
+ FOUNDATION_GOOGLE_DOCS_IDENTITIES: JSON.stringify(docsIdentities),
772
+ FOUNDATION_GOOGLE_DOCS_FOLDER: `${props.instance.displayName} drafts`,
773
+ },
774
+ });
775
+ new cdk.CfnOutput(this, "GoogleDocsProxyFunctionArn", {
776
+ value: this.googleDocsProxyFunction.functionArn,
777
+ description: "The Google Docs drafts proxy the agent invokes",
778
+ });
779
+ }
780
+
692
781
  // --- AgentCore Browser proxy ----------------------------------------
693
782
  // The model-facing runtime can invoke this Lambda but has no Browser API grants. The proxy
694
783
  // owns those grants and re-validates a gateway-signed DM/user scope plus this deploy-time
@@ -1346,6 +1435,14 @@ function configTimezoneFor(configPath: string): string {
1346
1435
  * malformed owner/channel bindings a deployment failure rather than allowing a
1347
1436
  * looser raw YAML shape into an IAM policy or Lambda environment.
1348
1437
  */
1438
+ /** The Google Docs drafting identities to provision, read from the instance config at synth. */
1439
+ export function googleDocsIdentitiesFor(configPath: string): GoogleDocsIdentity[] {
1440
+ const docs = parseInstanceConfig(readFileSync(configPath, "utf8")).capabilities.googleDocs;
1441
+ return docs.enabled
1442
+ ? docs.identities.map(({ address, channel, owner }) => ({ address, channel, owner }))
1443
+ : [];
1444
+ }
1445
+
1349
1446
  export function emailIdentitiesFor(configPath: string): EmailIdentity[] {
1350
1447
  const email = parseInstanceConfig(readFileSync(configPath, "utf8")).capabilities.email;
1351
1448
  return email.enabled ? email.identities : [];