@deployfoundation/foundation-deploy 0.2.47 → 0.2.48

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.
package/dist/bin/app.js CHANGED
@@ -8,13 +8,13 @@ import {
8
8
  FoundationPages,
9
9
  FoundationPipeline,
10
10
  NewsletterStack
11
- } from "../chunk-yn8x77x6.js";
11
+ } from "../chunk-z4gsy5kh.js";
12
12
  import {
13
13
  adminsFor,
14
14
  instanceNames,
15
15
  loadInstanceFile,
16
16
  provisionsIntegration
17
- } from "../chunk-k6mcv6dk.js";
17
+ } from "../chunk-2tachxjf.js";
18
18
  import"../chunk-dz91avrb.js";
19
19
 
20
20
  // bin/app.ts
@@ -22,7 +22,7 @@ import {
22
22
  slackCommandPrefix,
23
23
  verifyManifest,
24
24
  versionedConfigKey
25
- } from "../chunk-k6mcv6dk.js";
25
+ } from "../chunk-2tachxjf.js";
26
26
  import {
27
27
  DEFAULT_RELEASE_BUCKET,
28
28
  expectedKeyArn
@@ -282,7 +282,7 @@ async function callerAccountId(ctx) {
282
282
  }
283
283
 
284
284
  // src/deploy/config-sync.ts
285
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, mkdtempSync as mkdtempSync2, readdirSync } from "node:fs";
285
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, mkdtempSync as mkdtempSync2, readFileSync as readFileSync3, readdirSync } from "node:fs";
286
286
  import { tmpdir as tmpdir2 } from "node:os";
287
287
  import { join as join3, resolve as resolve3 } from "node:path";
288
288
 
@@ -494,6 +494,11 @@ async function uploadRuntimeConfig(ctx, opts) {
494
494
  if (opts.runtimeConfigPath === undefined && ctx.instance.customization !== undefined)
495
495
  throw new Error("customized instances require a freshly validated runtimeConfigPath; run through `deploy` or `config:sync`");
496
496
  const source = opts.runtimeConfigPath ?? ctx.paths.configPath;
497
+ try {
498
+ parseInstanceConfig(readFileSync3(source, "utf8"));
499
+ } catch (error) {
500
+ throw new Error(`config for ${opts.version} does not parse with this release's schema, so nothing was uploaded: ${error instanceof Error ? error.message : String(error)}`);
501
+ }
497
502
  const key = versionedConfigKey(ctx.names.configKey, opts.version);
498
503
  await awsMutate(ctx, ["s3", "cp", source, `s3://${opts.bucket}/${key}`]);
499
504
  return key;
@@ -908,7 +913,7 @@ var UPWORK_GRAPHQL_DOCUMENTS = Object.freeze({
908
913
  var ETRADE_RESPONSE_MAX_BYTES = 1024 * 1024;
909
914
  var MAX_SECRET_JSON_LENGTH = 16 * 1024;
910
915
  // src/deploy/config.ts
911
- import { readFileSync as readFileSync3 } from "node:fs";
916
+ import { readFileSync as readFileSync4 } from "node:fs";
912
917
  import { parse } from "yaml";
913
918
  function adminsCsv(yamlText, source = "runtime config") {
914
919
  const doc = parse(yamlText);
@@ -922,7 +927,7 @@ function adminsCsv(yamlText, source = "runtime config") {
922
927
  }).join(",");
923
928
  }
924
929
  function adminsCsvFromFile(path) {
925
- return adminsCsv(readFileSync3(path, "utf8"), path);
930
+ return adminsCsv(readFileSync4(path, "utf8"), path);
926
931
  }
927
932
 
928
933
  // src/deploy/endpoint.ts
@@ -1416,7 +1421,7 @@ function composeRuntimeSecret(inputs) {
1416
1421
  import {
1417
1422
  lstatSync,
1418
1423
  mkdirSync as mkdirSync3,
1419
- readFileSync as readFileSync4,
1424
+ readFileSync as readFileSync5,
1420
1425
  realpathSync,
1421
1426
  rmSync as rmSync2,
1422
1427
  statSync,
@@ -1536,9 +1541,9 @@ function stageCustomization(options) {
1536
1541
  const sourceFile = resolvedCustomizationFile(sourceRoot, customization.path);
1537
1542
  if (sourceFile === undefined)
1538
1543
  return migrationFallback(customization.required, log, platformConfigPath);
1539
- const yaml = readFileSync4(sourceFile);
1544
+ const yaml = readFileSync5(sourceFile);
1540
1545
  const tenantConfig = parseInstanceConfig(yaml.toString("utf8"));
1541
- const platformConfig = parseInstanceConfig(readFileSync4(platformConfigPath, "utf8"));
1546
+ const platformConfig = parseInstanceConfig(readFileSync5(platformConfigPath, "utf8"));
1542
1547
  if (tenantConfig.name !== instance.displayName)
1543
1548
  throw new Error("Customization config name must match the selected instance.");
1544
1549
  assertValidTenantJobs(tenantConfig);
@@ -1895,7 +1900,7 @@ async function deploy(ctx, opts) {
1895
1900
  import { createServer } from "node:http";
1896
1901
 
1897
1902
  // src/deploy/github-app-manifest.ts
1898
- import { readFileSync as readFileSync5 } from "node:fs";
1903
+ import { readFileSync as readFileSync6 } from "node:fs";
1899
1904
  import { join as join6 } from "node:path";
1900
1905
  import { parse as parseYaml } from "yaml";
1901
1906
  var GITHUB_APP_MANIFEST_PATH = join6(PACKAGE_ASSETS, "github-app-manifest.yml");
@@ -1916,7 +1921,7 @@ function renderAppManifest(template, instance) {
1916
1921
  });
1917
1922
  }
1918
1923
  function loadAppManifest(instance, template) {
1919
- const text = template ?? readFileSync5(GITHUB_APP_MANIFEST_PATH, "utf8");
1924
+ const text = template ?? readFileSync6(GITHUB_APP_MANIFEST_PATH, "utf8");
1920
1925
  const manifest = parseYaml(renderAppManifest(text, instance));
1921
1926
  if ("default_permissions" in manifest)
1922
1927
  throw new Error("app-manifest.yml: default_permissions are generated from the capability registry; remove them from the template");
@@ -2034,7 +2039,7 @@ async function githubAppCreate(options) {
2034
2039
  }
2035
2040
 
2036
2041
  // src/deploy/job-key.ts
2037
- import { readFileSync as readFileSync6 } from "node:fs";
2042
+ import { readFileSync as readFileSync7 } from "node:fs";
2038
2043
  function jobWebhookOutputKey(jobId) {
2039
2044
  return `JobWebhookUrl${jobId.replace(/[^A-Za-z0-9]/g, "")}`;
2040
2045
  }
@@ -2042,7 +2047,7 @@ function jobsWebhookSecretName(secretPrefix) {
2042
2047
  return `${secretPrefix}/jobs/webhook`;
2043
2048
  }
2044
2049
  async function jobKey(ctx, jobId, options = {}) {
2045
- const config = parseInstanceConfig(readFileSync6(ctx.paths.configPath, "utf8"));
2050
+ const config = parseInstanceConfig(readFileSync7(ctx.paths.configPath, "utf8"));
2046
2051
  const job = config.jobs.find((entry) => entry.id === jobId);
2047
2052
  if (job === undefined)
2048
2053
  throw new Error(`no job "${jobId}" in ${ctx.paths.configPath}`);
@@ -2108,11 +2113,11 @@ async function postDeploy(ctx, opts = {}) {
2108
2113
  }
2109
2114
 
2110
2115
  // src/deploy/setup.ts
2111
- import { existsSync as existsSync5, readFileSync as readFileSync8 } from "node:fs";
2116
+ import { existsSync as existsSync5, readFileSync as readFileSync9 } from "node:fs";
2112
2117
  import { resolve as resolve5 } from "node:path";
2113
2118
 
2114
2119
  // src/deploy/slack-manifest.ts
2115
- import { readFileSync as readFileSync7 } from "node:fs";
2120
+ import { readFileSync as readFileSync8 } from "node:fs";
2116
2121
  import { join as join7 } from "node:path";
2117
2122
  import { parse as parse2 } from "yaml";
2118
2123
  var SLACK_APP_MANIFEST_PATH = join7(PACKAGE_ASSETS, "slack-app-manifest.yml");
@@ -2151,7 +2156,7 @@ function buildSlackManifest(template, instance, env) {
2151
2156
  return manifest;
2152
2157
  }
2153
2158
  function slackManifestFor(instance, env) {
2154
- return buildSlackManifest(readFileSync7(SLACK_APP_MANIFEST_PATH, "utf8"), {
2159
+ return buildSlackManifest(readFileSync8(SLACK_APP_MANIFEST_PATH, "utf8"), {
2155
2160
  displayName: instance.displayName,
2156
2161
  commandPrefix: slackCommandPrefix(instance),
2157
2162
  pages: instance.integrations.pages
@@ -2377,7 +2382,7 @@ async function writeRuntimeSecret(ctx) {
2377
2382
  async function seedCodex(ctx, codexFile) {
2378
2383
  if (!existsSync5(codexFile))
2379
2384
  throw new Error(`${codexFile} not found — sign in locally first, or pass --codex-file`);
2380
- const document = readFileSync8(codexFile, "utf8");
2385
+ const document = readFileSync9(codexFile, "utf8");
2381
2386
  JSON.parse(document);
2382
2387
  await putSecretString(ctx, ctx.names.secretCodex, document);
2383
2388
  console.log(` ${ctx.names.secretCodex} seeded from ${codexFile}`);
@@ -369,7 +369,8 @@ var RawGoogleDriveCapability = z.object({
369
369
  var RawGoogleDocsIdentity = z.object({
370
370
  address: z.string().regex(/^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i),
371
371
  channel: z.string().regex(/^[CGD][A-Z0-9]{8,}$/),
372
- owner: z.string().regex(/^U[A-Z0-9]{8,}$/, "owner must be a Slack U id")
372
+ owner: z.string().regex(/^U[A-Z0-9]{8,}$/, "owner must be a Slack U id"),
373
+ readPersonalDrive: z.boolean().optional()
373
374
  }).strict();
374
375
  var RawGoogleDocsCapability = z.object({
375
376
  enabled: z.boolean().default(false),
@@ -384,6 +385,12 @@ var RawGoogleDocsCapability = z.object({
384
385
  for (const field of ["owner", "channel"]) {
385
386
  const seen = new Set;
386
387
  for (const [index, identity] of value.identities.entries()) {
388
+ if (field === "owner" && identity.readPersonalDrive && !identity.channel.startsWith("D"))
389
+ ctx.addIssue({
390
+ code: z.ZodIssueCode.custom,
391
+ path: ["identities", index, "channel"],
392
+ message: "personal Drive reading requires an owner-bound DM"
393
+ });
387
394
  if (seen.has(identity[field]))
388
395
  ctx.addIssue({
389
396
  code: z.ZodIssueCode.custom,
@@ -675,13 +682,17 @@ var CAPABILITIES = [
675
682
  },
676
683
  {
677
684
  id: "googleDocs",
678
- summary: "Draft Google Docs in a person's own Drive, by personal consent; it reaches only the Docs it created.",
685
+ summary: "Draft Google Docs by personal consent; optionally connect a separate read-only personal Drive grant in the owner's DM.",
679
686
  config: RawGoogleDocsCapability,
680
687
  ...NONE,
681
688
  secrets: ["google/docs", GOOGLE_OAUTH_SECRET],
682
689
  resources: ["googleDocsProxyFunctionName"],
683
- slackCommands: ["docs-connect"],
684
- googleScopes: ["https://www.googleapis.com/auth/drive.file", ...GOOGLE_IDENTITY_SCOPES]
690
+ slackCommands: ["docs-connect", "personal-drive"],
691
+ googleScopes: [
692
+ "https://www.googleapis.com/auth/drive.file",
693
+ "https://www.googleapis.com/auth/drive.readonly",
694
+ ...GOOGLE_IDENTITY_SCOPES
695
+ ]
685
696
  },
686
697
  {
687
698
  id: "googleCalendar",
@@ -24,7 +24,7 @@ import {
24
24
  scheduledJobBindings,
25
25
  slackCommandPrefixes,
26
26
  webhookJobBindings
27
- } from "./chunk-k6mcv6dk.js";
27
+ } from "./chunk-2tachxjf.js";
28
28
 
29
29
  // src/stacks/agent-stack.ts
30
30
  import * as cdk from "aws-cdk-lib";
@@ -259,6 +259,12 @@ class FoundationAgent extends cdk.Stack {
259
259
  resource: "secret",
260
260
  resourceName: `${docsSecretPrefix}/personal/*-??????`,
261
261
  arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME
262
+ }),
263
+ cdk.Stack.of(this).formatArn({
264
+ service: "secretsmanager",
265
+ resource: "secret",
266
+ resourceName: `${docsSecretPrefix}/personal-drive/*-??????`,
267
+ arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME
262
268
  })
263
269
  ]
264
270
  }));
@@ -382,6 +388,12 @@ class FoundationAgent extends cdk.Stack {
382
388
  this.denyDirectKnockSecretAccess(role, props);
383
389
  this.denyDirectEmailSecretAccess(role, props.secrets.googleEmail, instanceNames(props.instance).secretGoogleEmail);
384
390
  this.denyDirectDocsSecretAccess(role, instanceNames(props.instance).secretGoogleDocs);
391
+ role.addToPolicy(new iam.PolicyStatement({
392
+ sid: "DenySlackSigningSecret",
393
+ effect: iam.Effect.DENY,
394
+ actions: ["secretsmanager:*"],
395
+ resources: [secretArnPattern(props.secrets.signing)]
396
+ }));
385
397
  this.denyDirectUpworkApprovalAccess(role, props.upworkApprovalTable);
386
398
  this.denyDirectEtradeAccess(role, props);
387
399
  this.pagesPolicy("AgentReadOnlyPagesPolicy", role, props.instance, { invoke: false });
@@ -906,6 +918,14 @@ class FoundationApi extends cdk2.Stack {
906
918
  generateStringKey: "placeholder"
907
919
  }
908
920
  }));
921
+ for (const identity of docsIdentities.filter((entry) => entry.readPersonalDrive === true)) {
922
+ docsSecrets.push(new secretsmanager.Secret(this, `PersonalDriveSecret${identity.owner}`, {
923
+ secretName: `${instanceNames(props.instance).secretGoogleDocs}/personal-drive/${identity.owner}`,
924
+ description: `${display} separate read-only personal Drive grant for Slack ${identity.owner}`,
925
+ removalPolicy: cdk2.RemovalPolicy.DESTROY,
926
+ generateSecretString: { secretStringTemplate: "{}", generateStringKey: "placeholder" }
927
+ }));
928
+ }
909
929
  const gatewayRole = new iam2.Role(this, "GatewayRole", {
910
930
  assumedBy: new iam2.ServicePrincipal("lambda.amazonaws.com"),
911
931
  description: `${display} gateway Lambda role (least privilege)`,
@@ -1807,7 +1827,7 @@ function configTimezoneFor(configPath) {
1807
1827
  }
1808
1828
  function googleDocsIdentitiesFor(configPath) {
1809
1829
  const docs = parseInstanceConfig(readFileSync(configPath, "utf8")).capabilities.googleDocs;
1810
- return docs.enabled ? docs.identities.map(({ address, channel, owner }) => ({ address, channel, owner })) : [];
1830
+ return docs.enabled ? docs.identities.map((identity) => ({ ...identity })) : [];
1811
1831
  }
1812
1832
  function emailIdentitiesFor(configPath) {
1813
1833
  const email = parseInstanceConfig(readFileSync(configPath, "utf8")).capabilities.email;
package/dist/src/index.js CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  FoundationPipeline,
11
11
  NewsletterStack,
12
12
  deployStatements
13
- } from "../chunk-yn8x77x6.js";
13
+ } from "../chunk-z4gsy5kh.js";
14
14
  import {
15
15
  BUN_VERSION,
16
16
  LAMBDA_ENTRY_POINTS,
@@ -44,7 +44,7 @@ import {
44
44
  skillsKey,
45
45
  skipBundle,
46
46
  verifyManifest
47
- } from "../chunk-k6mcv6dk.js";
47
+ } from "../chunk-2tachxjf.js";
48
48
  import"../chunk-dz91avrb.js";
49
49
  export {
50
50
  BUILD_TIMEOUT,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deployfoundation/foundation-deploy",
3
- "version": "0.2.47",
3
+ "version": "0.2.48",
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,
@@ -73,6 +73,10 @@ features:
73
73
  url: ${COMMANDS_REQUEST_URL}
74
74
  description: Let ${DISPLAY_NAME} draft Google Docs in your own Drive
75
75
  should_escape: false
76
+ - command: /${COMMAND_PREFIX}-personal-drive
77
+ url: ${COMMANDS_REQUEST_URL}
78
+ description: Connect your personal Drive read-only in your configured DM
79
+ should_escape: false
76
80
  - command: /${COMMAND_PREFIX}-knock-connect
77
81
  url: ${COMMANDS_REQUEST_URL}
78
82
  description: Connect ${DISPLAY_NAME} to Knock read-only tools (admins)
@@ -17,9 +17,10 @@
17
17
  * checkout's `skills/` otherwise. A company's own skills are a source in its
18
18
  * runtime config (a GitHub repo), never a directory in this package.
19
19
  */
20
- import { existsSync, mkdirSync, mkdtempSync, readdirSync } from "node:fs";
20
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync } from "node:fs";
21
21
  import { tmpdir } from "node:os";
22
22
  import { join, resolve } from "node:path";
23
+ import { parseInstanceConfig } from "@deployfoundation/foundation-core";
23
24
  import { versionedConfigKey } from "@deployfoundation/foundation-core/instance";
24
25
  import { skillsKey } from "../release/manifest.ts";
25
26
  import { type AwsContext, awsMutate, stackOutput } from "./aws.ts";
@@ -130,6 +131,19 @@ export async function uploadRuntimeConfig(
130
131
  "customized instances require a freshly validated runtimeConfigPath; run through `deploy` or `config:sync`",
131
132
  );
132
133
  const source = opts.runtimeConfigPath ?? ctx.paths.configPath;
134
+ // Parse with THIS release's schema before anything is written. Redeploying
135
+ // the live version writes the very object the live runtime reads, so an
136
+ // unparseable upload puts every session on the fallback config even though
137
+ // the deploy then fails at synth (Sky, 2026-10-19: no Upwork for a day).
138
+ try {
139
+ parseInstanceConfig(readFileSync(source, "utf8"));
140
+ } catch (error) {
141
+ throw new Error(
142
+ `config for ${opts.version} does not parse with this release's schema, so nothing was uploaded: ${
143
+ error instanceof Error ? error.message : String(error)
144
+ }`,
145
+ );
146
+ }
133
147
  const key = versionedConfigKey(ctx.names.configKey, opts.version);
134
148
  await awsMutate(ctx, ["s3", "cp", source, `s3://${opts.bucket}/${key}`]);
135
149
  return key;
@@ -672,9 +672,11 @@ export async function deploy(ctx: AwsContext, opts: DeployOptions): Promise<void
672
672
 
673
673
  // The config of the version going in, written BEFORE `cdk deploy` creates
674
674
  // that version, so its pre-warmed containers boot with it rather than with
675
- // the fallback. Nothing else reads this object: the version serving now
676
- // reads its own, which this deploy never touches. A brand-new instance has
677
- // no bucket yet; the config sync after `cdk deploy` covers it.
675
+ // the fallback. When the version going in is the one serving now (a config
676
+ // change on the same release), the live runtime reads this very object, so
677
+ // uploadRuntimeConfig parses it with this release's schema first and refuses
678
+ // rather than write a config the runtime would reject. A brand-new instance
679
+ // has no bucket yet; the config sync after `cdk deploy` covers it.
678
680
  const version = foundationVersion(release);
679
681
  const bucket = phase1
680
682
  ? undefined
@@ -433,6 +433,12 @@ export class FoundationAgent extends cdk.Stack {
433
433
  resourceName: `${docsSecretPrefix}/personal/*-??????`,
434
434
  arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME,
435
435
  }),
436
+ cdk.Stack.of(this).formatArn({
437
+ service: "secretsmanager",
438
+ resource: "secret",
439
+ resourceName: `${docsSecretPrefix}/personal-drive/*-??????`,
440
+ arnFormat: cdk.ArnFormat.COLON_RESOURCE_NAME,
441
+ }),
436
442
  ],
437
443
  }),
438
444
  );
@@ -629,6 +635,14 @@ export class FoundationAgent extends cdk.Stack {
629
635
  namesFor(props.instance).secretGoogleEmail,
630
636
  );
631
637
  this.denyDirectDocsSecretAccess(role, namesFor(props.instance).secretGoogleDocs);
638
+ role.addToPolicy(
639
+ new iam.PolicyStatement({
640
+ sid: "DenySlackSigningSecret",
641
+ effect: iam.Effect.DENY,
642
+ actions: ["secretsmanager:*"],
643
+ resources: [secretArnPattern(props.secrets.signing)],
644
+ }),
645
+ );
632
646
  this.denyDirectUpworkApprovalAccess(role, props.upworkApprovalTable);
633
647
  this.denyDirectEtradeAccess(role, props);
634
648
  this.pagesPolicy("AgentReadOnlyPagesPolicy", role, props.instance, { invoke: false });
@@ -233,6 +233,16 @@ export class FoundationApi extends cdk.Stack {
233
233
  },
234
234
  }),
235
235
  );
236
+ for (const identity of docsIdentities.filter((entry) => entry.readPersonalDrive === true)) {
237
+ docsSecrets.push(
238
+ new secretsmanager.Secret(this, `PersonalDriveSecret${identity.owner}`, {
239
+ secretName: `${namesFor(props.instance).secretGoogleDocs}/personal-drive/${identity.owner}`,
240
+ description: `${display} separate read-only personal Drive grant for Slack ${identity.owner}`,
241
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
242
+ generateSecretString: { secretStringTemplate: "{}", generateStringKey: "placeholder" },
243
+ }),
244
+ );
245
+ }
236
246
 
237
247
  const gatewayRole = new iam.Role(this, "GatewayRole", {
238
248
  assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
@@ -1495,9 +1505,7 @@ function configTimezoneFor(configPath: string): string {
1495
1505
  /** The Google Docs drafting identities to provision, read from the instance config at synth. */
1496
1506
  export function googleDocsIdentitiesFor(configPath: string): GoogleDocsIdentity[] {
1497
1507
  const docs = parseInstanceConfig(readFileSync(configPath, "utf8")).capabilities.googleDocs;
1498
- return docs.enabled
1499
- ? docs.identities.map(({ address, channel, owner }) => ({ address, channel, owner }))
1500
- : [];
1508
+ return docs.enabled ? docs.identities.map((identity) => ({ ...identity })) : [];
1501
1509
  }
1502
1510
 
1503
1511
  export function emailIdentitiesFor(configPath: string): EmailIdentity[] {