@deployfoundation/foundation-deploy 0.1.5 → 0.2.1

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
@@ -7,12 +7,12 @@ import {
7
7
  FoundationNetwork,
8
8
  FoundationPipeline,
9
9
  NewsletterStack
10
- } from "../chunk-m4pn6wc2.js";
10
+ } from "../chunk-paf33z2h.js";
11
11
  import {
12
12
  adminsFor,
13
13
  instanceNames,
14
14
  loadInstanceFile
15
- } from "../chunk-t86rw0f2.js";
15
+ } from "../chunk-gcg67ck3.js";
16
16
 
17
17
  // bin/app.ts
18
18
  import { execFileSync } from "node:child_process";
@@ -29,12 +29,15 @@ import {
29
29
  stackExists,
30
30
  stackOutput,
31
31
  toolVersion
32
- } from "../chunk-qc5d46ky.js";
32
+ } from "../chunk-an8qhf3t.js";
33
33
  import {
34
34
  BASE_SLACK_EVENTS,
35
35
  CAPABILITY_IDS,
36
36
  CUSTOMIZATION_ARTIFACT_NAME,
37
37
  instanceNames,
38
+ isScheduledJob,
39
+ isWebhookJob,
40
+ jobWebhookKey,
38
41
  parseInstanceConfig,
39
42
  parseSchedule,
40
43
  requiredGithubPermissions,
@@ -42,7 +45,7 @@ import {
42
45
  requiredSlackScopes,
43
46
  skillsKey,
44
47
  slackCommandPrefix
45
- } from "../chunk-t86rw0f2.js";
48
+ } from "../chunk-gcg67ck3.js";
46
49
 
47
50
  // src/deploy/config-sync.ts
48
51
  import { existsSync, mkdirSync, mkdtempSync, readdirSync } from "node:fs";
@@ -832,26 +835,25 @@ function assertUnchanged(path, platform, tenant) {
832
835
  }
833
836
  function protectedConfig(config) {
834
837
  const { digestHour: _digestHour, ...todos } = config.capabilities.todos;
835
- const { defined: _defined, ...routines } = config.capabilities.routines;
836
838
  const { outreach: _outreach, ...crm2 } = config.capabilities.crm;
837
839
  return {
838
840
  ...config,
839
841
  capabilities: {
840
842
  ...config.capabilities,
841
843
  todos,
842
- routines,
843
844
  crm: crm2
844
- }
845
+ },
846
+ jobs: config.jobs.filter(isWebhookJob)
845
847
  };
846
848
  }
847
849
  function assertProtectedPolicy(platform, tenant) {
848
850
  assertUnchanged("config", protectedConfig(platform), protectedConfig(tenant));
849
851
  }
850
- function assertValidTenantRoutines(tenant) {
851
- for (const routine of tenant.capabilities.routines.defined) {
852
- const parsed = parseSchedule(routine.schedule);
852
+ function assertValidTenantJobs(tenant) {
853
+ for (const job of tenant.jobs.filter(isScheduledJob)) {
854
+ const parsed = parseSchedule(job.on.schedule);
853
855
  if (!parsed.ok)
854
- throw new Error(`Invalid defined routine "${routine.id}": ${parsed.reason}`);
856
+ throw new Error(`Invalid job schedule "${job.id}": ${parsed.reason}`);
855
857
  }
856
858
  }
857
859
  function migrationFallback(required, log, platformConfigPath) {
@@ -881,7 +883,7 @@ function stageCustomization(options) {
881
883
  const platformConfig = parseInstanceConfig(readFileSync2(platformConfigPath, "utf8"));
882
884
  if (tenantConfig.name !== instance.displayName)
883
885
  throw new Error("Customization config name must match the selected instance.");
884
- assertValidTenantRoutines(tenantConfig);
886
+ assertValidTenantJobs(tenantConfig);
885
887
  assertProtectedPolicy(platformConfig, tenantConfig);
886
888
  if (options.dryRun !== true) {
887
889
  mkdirSync2(dirname(stagedPath), { recursive: true });
@@ -1295,6 +1297,37 @@ async function githubAppCreate(options) {
1295
1297
  }, 10 * 60000);
1296
1298
  }
1297
1299
 
1300
+ // src/deploy/job-key.ts
1301
+ import { readFileSync as readFileSync4 } from "node:fs";
1302
+ function jobWebhookOutputKey(jobId) {
1303
+ return `JobWebhookUrl${jobId.replace(/[^A-Za-z0-9]/g, "")}`;
1304
+ }
1305
+ function jobsWebhookSecretName(secretPrefix) {
1306
+ return `${secretPrefix}/jobs/webhook`;
1307
+ }
1308
+ async function jobKey(ctx, jobId, options = {}) {
1309
+ const config = parseInstanceConfig(readFileSync4(ctx.paths.configPath, "utf8"));
1310
+ const job = config.jobs.find((entry) => entry.id === jobId);
1311
+ if (job === undefined)
1312
+ throw new Error(`no job "${jobId}" in ${ctx.paths.configPath}`);
1313
+ if (!isWebhookJob(job))
1314
+ throw new Error(`job "${jobId}" is not triggered by a webhook`);
1315
+ const url = await stackOutput(ctx, ctx.names.api, jobWebhookOutputKey(jobId));
1316
+ const instanceSecret = await readSecretJson(ctx, jobsWebhookSecretName(ctx.paths.instance.naming.secretPrefix));
1317
+ if (instanceSecret.secret === undefined || instanceSecret.secret.length < 32)
1318
+ throw new Error("the jobs webhook secret has no value — has the API stack been deployed?");
1319
+ const key = jobWebhookKey(instanceSecret.secret, jobId);
1320
+ if (options.putSecret === undefined)
1321
+ return { url, key };
1322
+ const value = JSON.stringify({ url, key });
1323
+ if (await secretExists(ctx, options.putSecret)) {
1324
+ await putSecretString(ctx, options.putSecret, value);
1325
+ } else {
1326
+ await createSecretString(ctx, options.putSecret, value, `Webhook URL and signing key for Foundation job ${jobId} ({ url, key })`);
1327
+ }
1328
+ return { url, secret: options.putSecret };
1329
+ }
1330
+
1298
1331
  // src/deploy/post-deploy.ts
1299
1332
  var LIVE = "live";
1300
1333
  var FS_PROBE_PAYLOAD = '{"_fs_probe":true}';
@@ -1339,11 +1372,11 @@ async function postDeploy(ctx, opts = {}) {
1339
1372
  }
1340
1373
 
1341
1374
  // src/deploy/setup.ts
1342
- import { existsSync as existsSync3, readFileSync as readFileSync5 } from "node:fs";
1375
+ import { existsSync as existsSync3, readFileSync as readFileSync6 } from "node:fs";
1343
1376
  import { resolve as resolve3 } from "node:path";
1344
1377
 
1345
1378
  // src/deploy/slack-manifest.ts
1346
- import { readFileSync as readFileSync4 } from "node:fs";
1379
+ import { readFileSync as readFileSync5 } from "node:fs";
1347
1380
  import { join as join5 } from "node:path";
1348
1381
  import { parse as parse2 } from "yaml";
1349
1382
  var SLACK_APP_MANIFEST_PATH = join5(PACKAGE_ASSETS, "slack-app-manifest.yml");
@@ -1374,7 +1407,7 @@ function buildSlackManifest(template, instance, env) {
1374
1407
  return manifest;
1375
1408
  }
1376
1409
  function slackManifestFor(instance, env) {
1377
- return buildSlackManifest(readFileSync4(SLACK_APP_MANIFEST_PATH, "utf8"), { displayName: instance.displayName, commandPrefix: slackCommandPrefix(instance) }, env);
1410
+ return buildSlackManifest(readFileSync5(SLACK_APP_MANIFEST_PATH, "utf8"), { displayName: instance.displayName, commandPrefix: slackCommandPrefix(instance) }, env);
1378
1411
  }
1379
1412
 
1380
1413
  // src/deploy/tracing.ts
@@ -1594,7 +1627,7 @@ async function writeRuntimeSecret(ctx) {
1594
1627
  async function seedCodex(ctx, codexFile) {
1595
1628
  if (!existsSync3(codexFile))
1596
1629
  throw new Error(`${codexFile} not found — sign in locally first, or pass --codex-file`);
1597
- const document = readFileSync5(codexFile, "utf8");
1630
+ const document = readFileSync6(codexFile, "utf8");
1598
1631
  JSON.parse(document);
1599
1632
  await putSecretString(ctx, ctx.names.secretCodex, document);
1600
1633
  console.log(` ${ctx.names.secretCodex} seeded from ${codexFile}`);
@@ -1762,7 +1795,8 @@ var COMMANDS = [
1762
1795
  "setup",
1763
1796
  "github-app-create",
1764
1797
  "slack-manifest",
1765
- "stage-customization"
1798
+ "stage-customization",
1799
+ "job-key"
1766
1800
  ];
1767
1801
  var USAGE = `usage: foundation-deploy <command> --instance <path> [options]
1768
1802
 
@@ -1775,6 +1809,7 @@ commands
1775
1809
  github-app-create create the instance's GitHub App from the shipped manifest
1776
1810
  slack-manifest print the instance's Slack app manifest as JSON
1777
1811
  stage-customization validate and stage a tenant-owned runtime config
1812
+ job-key <id> print a webhook job's URL and signing key as JSON
1778
1813
 
1779
1814
  options
1780
1815
  --instance <path> REQUIRED: path to the deployment's instance YAML
@@ -1806,6 +1841,10 @@ setup options
1806
1841
  --codex-file <path> where that credential is (default: beside the instance
1807
1842
  file, .foundation-local/codex.json)
1808
1843
 
1844
+ job-key options
1845
+ --put-secret <id> write {url, key} into this Secrets Manager secret
1846
+ (created if absent) instead of printing the key
1847
+
1809
1848
  github-app-create options
1810
1849
  --org <org> GitHub org (default: the instance's github.org)
1811
1850
  --port <n> local callback port (default 8765)
@@ -1857,6 +1896,21 @@ ${USAGE}`);
1857
1896
  })));
1858
1897
  return 0;
1859
1898
  }
1899
+ if (command === "job-key") {
1900
+ const ctx2 = contextFor(args);
1901
+ const jobId = args[0];
1902
+ if (jobId === undefined || jobId.startsWith("-")) {
1903
+ console.error(`job-key needs a job id
1904
+
1905
+ ${USAGE}`);
1906
+ return 1;
1907
+ }
1908
+ console.error(instanceBanner(ctx2.paths));
1909
+ const putSecret = flag(args, "--put-secret");
1910
+ const result = await jobKey(ctx2, jobId, putSecret === undefined ? {} : { putSecret });
1911
+ console.log(JSON.stringify(result));
1912
+ return 0;
1913
+ }
1860
1914
  if (command === "github-app-create") {
1861
1915
  const paths = loadInstanceContext(resolveInstanceFilePath(args, process.env));
1862
1916
  console.log(instanceBanner(paths));
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env bun
2
2
  import {
3
3
  DEFAULT_RELEASE_BUCKET
4
- } from "../chunk-qc5d46ky.js";
5
- import"../chunk-t86rw0f2.js";
4
+ } from "../chunk-an8qhf3t.js";
5
+ import"../chunk-gcg67ck3.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-t86rw0f2.js";
9
+ } from "./chunk-gcg67ck3.js";
10
10
 
11
11
  // src/deploy/release.ts
12
12
  import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2 } from "node:fs";
@@ -77,7 +77,13 @@ var RawGoogleDriveCapability = z.object({
77
77
  var RawCrmOutreach = z.object({
78
78
  weeklyLimit: z.number().int().min(1).max(20).default(5),
79
79
  eligibleStatuses: z.array(RawCrmValue).max(100).default([]),
80
- blockedStatuses: z.array(RawCrmValue).max(100).default([])
80
+ blockedStatuses: z.array(RawCrmValue).max(100).default([]),
81
+ followUpAfterDays: z.number().int().min(1).max(60).default(7),
82
+ followUpLimit: z.number().int().min(0).max(20).default(5),
83
+ maxFollowUps: z.number().int().min(0).max(5).default(2),
84
+ rankAttribute: z.string().regex(/^[A-Za-z0-9_.-]{1,64}$/).default("score"),
85
+ followUpStatuses: z.array(RawCrmValue).max(100).default([]),
86
+ draftedStatus: RawCrmValue.optional()
81
87
  }).strict().default({});
82
88
  var RawCrmCapability = z.object({
83
89
  enabled: z.boolean().default(false),
@@ -138,6 +144,33 @@ var RawCrmCapability = z.object({
138
144
  }
139
145
  }
140
146
  }
147
+ const outreach = value.outreach;
148
+ const eligible = new Set(outreach.eligibleStatuses);
149
+ const followUpSeen = new Set;
150
+ for (const [index, status] of outreach.followUpStatuses.entries()) {
151
+ if (followUpSeen.has(status) || eligible.has(status) || !configuredStatuses.has(status)) {
152
+ ctx.addIssue({
153
+ code: z.ZodIssueCode.custom,
154
+ path: ["outreach", "followUpStatuses", index],
155
+ message: `CRM outreach follow-up status must be configured, unique and not eligible for a first touch: ${status}`
156
+ });
157
+ }
158
+ followUpSeen.add(status);
159
+ }
160
+ if (outreach.draftedStatus !== undefined && (!configuredStatuses.has(outreach.draftedStatus) || eligible.has(outreach.draftedStatus))) {
161
+ ctx.addIssue({
162
+ code: z.ZodIssueCode.custom,
163
+ path: ["outreach", "draftedStatus"],
164
+ message: `CRM outreach draftedStatus must be a configured status that is not eligible: ${outreach.draftedStatus}`
165
+ });
166
+ }
167
+ if (eligible.size > 0 && !value.activityTypes.includes("email_drafted")) {
168
+ ctx.addIssue({
169
+ code: z.ZodIssueCode.custom,
170
+ path: ["activityTypes"],
171
+ message: "CRM outreach requires the email_drafted activity type"
172
+ });
173
+ }
141
174
  }).default({});
142
175
  var RawObservabilityCapability = z.object({
143
176
  enabled: z.boolean().default(true),
@@ -181,31 +214,7 @@ var RawTodosCapability = z.object({
181
214
  enabled: z.boolean().default(true),
182
215
  digestHour: z.union([z.number().int().min(0).max(23), z.literal("off")]).default(8)
183
216
  }).strict().default({});
184
- var RawDefinedRoutine = z.object({
185
- id: z.string().regex(/^[a-z0-9][a-z0-9-]{0,40}$/),
186
- channel: z.string().regex(/^[CG][A-Z0-9]{8,}$/),
187
- name: z.string().min(1).max(80),
188
- kind: z.enum(["turn", "remind"]).default("turn"),
189
- schedule: z.string().min(1),
190
- prompt: z.string().min(1).max(2000),
191
- quiet: z.boolean().default(false)
192
- }).strict();
193
- var RawRoutinesCapability = z.object({
194
- enabled: z.boolean().default(true),
195
- defined: z.array(RawDefinedRoutine).default([])
196
- }).strict().superRefine((value, ctx) => {
197
- const seen = new Set;
198
- for (const [index, entry] of value.defined.entries()) {
199
- if (seen.has(entry.id)) {
200
- ctx.addIssue({
201
- code: z.ZodIssueCode.custom,
202
- path: ["defined", index, "id"],
203
- message: `duplicate defined routine id: ${entry.id}`
204
- });
205
- }
206
- seen.add(entry.id);
207
- }
208
- }).default({});
217
+ var RawRoutinesCapability = z.object({ enabled: z.boolean().default(true) }).strict().default({});
209
218
  var NONE = {
210
219
  secrets: [],
211
220
  resources: [],
@@ -262,7 +271,7 @@ var CAPABILITIES = [
262
271
  },
263
272
  {
264
273
  id: "routines",
265
- summary: "Scheduled turns and reminders, including routines the config defines.",
274
+ summary: "Scheduled turns and reminders people create in chat.",
266
275
  config: RawRoutinesCapability,
267
276
  ...NONE,
268
277
  resources: ["routineGroup", "routineSchedulerRole"]
@@ -767,13 +776,58 @@ var contextStorage = new AsyncLocalStorage;
767
776
  // ../core/src/config.ts
768
777
  import { createHash } from "node:crypto";
769
778
  import { parse as parseYaml2 } from "yaml";
779
+ import { z as z4 } from "zod";
780
+
781
+ // ../core/src/jobs.ts
782
+ import { createHmac, timingSafeEqual } from "node:crypto";
770
783
  import { z as z3 } from "zod";
784
+ var JOB_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,40}$/;
785
+ var RawScheduleTrigger = z3.object({
786
+ schedule: z3.string().min(1)
787
+ }).strict();
788
+ var RawWebhookTrigger = z3.object({
789
+ webhook: z3.object({}).strict()
790
+ }).strict();
791
+ var RawJob = z3.object({
792
+ id: z3.string().regex(JOB_ID_PATTERN),
793
+ name: z3.string().min(1).max(80),
794
+ channel: z3.string().regex(/^[CG][A-Z0-9]{8,}$/, "a job runs in a Slack channel id"),
795
+ on: z3.union([RawScheduleTrigger, RawWebhookTrigger]),
796
+ prompt: z3.string().min(1).max(4000)
797
+ }).strict();
798
+ var RawJobs = z3.array(RawJob).default([]).superRefine((jobs, ctx) => {
799
+ const seen = new Set;
800
+ for (const [index, job] of jobs.entries()) {
801
+ if (seen.has(job.id)) {
802
+ ctx.addIssue({
803
+ code: z3.ZodIssueCode.custom,
804
+ path: [index, "id"],
805
+ message: `duplicate job id: ${job.id}`
806
+ });
807
+ }
808
+ seen.add(job.id);
809
+ }
810
+ });
811
+ function isScheduledJob(job) {
812
+ return "schedule" in job.on;
813
+ }
814
+ function isWebhookJob(job) {
815
+ return "webhook" in job.on;
816
+ }
817
+ function webhookJobBindings(jobs) {
818
+ return jobs.filter(isWebhookJob).map(({ id, name, channel, prompt }) => ({ id, name, channel, prompt }));
819
+ }
820
+ function jobWebhookKey(instanceSecret, jobId) {
821
+ return createHmac("sha256", instanceSecret).update(`foundation-job:${jobId}`).digest("hex");
822
+ }
823
+
824
+ // ../core/src/config.ts
771
825
  var MODEL_RE = /^([a-z0-9-]+)\/(.+)$/;
772
- var ThinkingLevel = z3.enum(["none", "low", "medium", "high", "xhigh", "max"]);
773
- var RawImages = z3.object({
774
- backend: z3.enum(["gemini"]).default("gemini"),
775
- model: z3.string().min(1).default("gemini-2.5-flash-image"),
776
- dailyCap: z3.number().int().min(0).default(40)
826
+ var ThinkingLevel = z4.enum(["none", "low", "medium", "high", "xhigh", "max"]);
827
+ var RawImages = z4.object({
828
+ backend: z4.enum(["gemini"]).default("gemini"),
829
+ model: z4.string().min(1).default("gemini-2.5-flash-image"),
830
+ dailyCap: z4.number().int().min(0).default(40)
777
831
  }).strict().default({});
778
832
  var OMP_MODEL_ROLES = [
779
833
  "default",
@@ -786,49 +840,53 @@ var OMP_MODEL_ROLES = [
786
840
  "task",
787
841
  "advisor"
788
842
  ];
789
- var RawModelRoles = z3.record(z3.enum(OMP_MODEL_ROLES), z3.string().regex(MODEL_RE)).default({});
790
- var RawChannelModels = z3.record(z3.string().regex(/^[CG][A-Z0-9]+$/, "channelModels keys must be Slack channel ids"), z3.string().regex(MODEL_RE, "channel model must be provider/modelId")).default({});
791
- var RawSubagents = z3.object({
792
- enabled: z3.boolean().default(true),
793
- maxConcurrency: z3.number().int().min(1).max(8).default(3),
794
- maxRecursionDepth: z3.number().int().min(1).max(3).default(2),
795
- effort: z3.boolean().default(true)
843
+ var RawModelRoles = z4.record(z4.enum(OMP_MODEL_ROLES), z4.string().regex(MODEL_RE)).default({});
844
+ var RawChannelModels = z4.record(z4.string().regex(/^[CG][A-Z0-9]+$/, "channelModels keys must be Slack channel ids"), z4.string().regex(MODEL_RE, "channel model must be provider/modelId")).default({});
845
+ var RawSubagents = z4.object({
846
+ enabled: z4.boolean().default(true),
847
+ maxConcurrency: z4.number().int().min(1).max(8).default(3),
848
+ maxRecursionDepth: z4.number().int().min(1).max(3).default(2),
849
+ effort: z4.boolean().default(true)
796
850
  }).strict().default({});
797
- var RawMemory = z3.object({ ompBackend: z3.enum(["off", "local"]).default("off") }).strict().default({});
851
+ var RawMemory = z4.object({ ompBackend: z4.enum(["off", "local"]).default("off") }).strict().default({});
798
852
  var SKILL_SOURCE_SCOPES = ["shared", "instance"];
799
853
  var RawSkillSourceMetadata = {
800
- owner: z3.string().min(1).optional(),
801
- scope: z3.enum(SKILL_SOURCE_SCOPES).optional(),
802
- requires: z3.array(z3.enum(CAPABILITY_IDS)).optional()
854
+ owner: z4.string().min(1).optional(),
855
+ scope: z4.enum(SKILL_SOURCE_SCOPES).optional(),
856
+ requires: z4.array(z4.enum(CAPABILITY_IDS)).optional()
803
857
  };
804
- var RawSkillSource = z3.union([
805
- z3.object({
806
- github: z3.string().min(1),
807
- ref: z3.string().min(1).optional(),
808
- path: z3.string().optional(),
858
+ var RawSkillSource = z4.union([
859
+ z4.object({
860
+ github: z4.string().min(1),
861
+ ref: z4.string().min(1).optional(),
862
+ path: z4.string().optional(),
809
863
  ...RawSkillSourceMetadata
810
864
  }).strict(),
811
- z3.object({ s3: z3.string().min(1), ...RawSkillSourceMetadata }).strict()
865
+ z4.object({ s3: z4.string().min(1), ...RawSkillSourceMetadata }).strict()
812
866
  ]);
813
- var RawSkills = z3.object({
814
- sources: z3.array(RawSkillSource).optional(),
815
- promoteTo: z3.string().min(1).optional()
867
+ var RawSkills = z4.object({
868
+ sources: z4.array(RawSkillSource).optional(),
869
+ promoteTo: z4.string().min(1).optional()
816
870
  }).strict().default({});
817
- var RawInstanceConfig = z3.object({
818
- name: z3.string().min(1),
819
- model: z3.string().regex(MODEL_RE, "model must be provider/modelId"),
820
- admins: z3.array(z3.string().min(1)).min(1, "admins must list at least one Slack user id"),
821
- instructions: z3.string().default(""),
822
- timezone: z3.string().default("UTC").refine(isValidTimezone, (tz) => ({ message: `unknown timezone: ${tz}` })),
871
+ var RawInstanceConfig = z4.object({
872
+ name: z4.string().min(1),
873
+ model: z4.string().regex(MODEL_RE, "model must be provider/modelId"),
874
+ admins: z4.array(z4.string().min(1)).min(1, "admins must list at least one Slack user id"),
875
+ instructions: z4.string().default(""),
876
+ timezone: z4.string().default("UTC").refine(isValidTimezone, (tz) => ({ message: `unknown timezone: ${tz}` })),
823
877
  thinkingLevel: ThinkingLevel.optional(),
824
878
  skills: RawSkills,
825
879
  images: RawImages,
826
880
  modelRoles: RawModelRoles,
827
881
  channelModels: RawChannelModels,
828
882
  capabilities: RawCapabilities,
883
+ jobs: RawJobs,
829
884
  subagents: RawSubagents,
830
885
  memory: RawMemory
831
886
  }).strict();
887
+ function crmOutreachConfigured(outreach) {
888
+ return outreach.eligibleStatuses.length > 0;
889
+ }
832
890
  function crmPolicyFingerprint(config) {
833
891
  return createHash("sha256").update(JSON.stringify({
834
892
  channels: config.channels,
@@ -879,7 +937,8 @@ function parseInstanceConfig(yamlText) {
879
937
  effort: raw.subagents.effort
880
938
  },
881
939
  memory: { ompBackend: raw.memory.ompBackend },
882
- capabilities: raw.capabilities
940
+ capabilities: raw.capabilities,
941
+ jobs: raw.jobs
883
942
  };
884
943
  if (raw.thinkingLevel !== undefined)
885
944
  cfg.thinkingLevel = raw.thinkingLevel;
@@ -1137,13 +1196,13 @@ var LIVE_ENDPOINT_NAME = "live";
1137
1196
  // src/release/manifest.ts
1138
1197
  import { createHash as createHash2 } from "node:crypto";
1139
1198
  import { readFileSync as readFileSync2, statSync } from "node:fs";
1140
- import { z as z4 } from "zod";
1199
+ import { z as z5 } from "zod";
1141
1200
  var RELEASE_VERSION_RE = /^v\d+\.\d+\.\d+$/;
1142
- var Sha256 = z4.string().regex(/^[0-9a-f]{64}$/, "must be a lowercase hex sha256");
1143
- var ReleaseArtifactSchema = z4.object({
1144
- key: z4.string().min(1),
1201
+ var Sha256 = z5.string().regex(/^[0-9a-f]{64}$/, "must be a lowercase hex sha256");
1202
+ var ReleaseArtifactSchema = z5.object({
1203
+ key: z5.string().min(1),
1145
1204
  sha256: Sha256,
1146
- bytes: z4.number().int().nonnegative()
1205
+ bytes: z5.number().int().nonnegative()
1147
1206
  }).strict();
1148
1207
  var SIGNING_ALGORITHMS = [
1149
1208
  "RSASSA_PSS_SHA_256",
@@ -1156,21 +1215,21 @@ var SIGNING_ALGORITHMS = [
1156
1215
  "ECDSA_SHA_384",
1157
1216
  "ECDSA_SHA_512"
1158
1217
  ];
1159
- var ReleaseManifestSchema = z4.object({
1160
- version: z4.string().regex(RELEASE_VERSION_RE, "must be vMAJOR.MINOR.PATCH"),
1161
- gitCommit: z4.string().regex(/^[0-9a-f]{40}$/, "must be a full commit sha"),
1162
- createdAt: z4.string().datetime(),
1163
- agentImage: z4.object({
1164
- repository: z4.string().min(1),
1165
- tag: z4.string().min(1),
1166
- digest: z4.string().regex(/^sha256:[0-9a-f]{64}$/)
1218
+ var ReleaseManifestSchema = z5.object({
1219
+ version: z5.string().regex(RELEASE_VERSION_RE, "must be vMAJOR.MINOR.PATCH"),
1220
+ gitCommit: z5.string().regex(/^[0-9a-f]{40}$/, "must be a full commit sha"),
1221
+ createdAt: z5.string().datetime(),
1222
+ agentImage: z5.object({
1223
+ repository: z5.string().min(1),
1224
+ tag: z5.string().min(1),
1225
+ digest: z5.string().regex(/^sha256:[0-9a-f]{64}$/)
1167
1226
  }).strict(),
1168
- lambda: z4.record(ReleaseArtifactSchema),
1169
- skills: z4.object({ key: z4.string().min(1), sha256: Sha256 }).strict(),
1170
- signature: z4.object({
1171
- kmsKeyArn: z4.string().min(1),
1172
- algorithm: z4.enum(SIGNING_ALGORITHMS),
1173
- value: z4.string().min(1)
1227
+ lambda: z5.record(ReleaseArtifactSchema),
1228
+ skills: z5.object({ key: z5.string().min(1), sha256: Sha256 }).strict(),
1229
+ signature: z5.object({
1230
+ kmsKeyArn: z5.string().min(1),
1231
+ algorithm: z5.enum(SIGNING_ALGORITHMS),
1232
+ value: z5.string().min(1)
1174
1233
  }).strict().optional()
1175
1234
  }).strict();
1176
1235
  function parseManifest(json) {
@@ -1452,4 +1511,4 @@ function ecrRepositoryArn(repositoryUri) {
1452
1511
  return `arn:aws:ecr:${region}:${account}:repository/${name}`;
1453
1512
  }
1454
1513
 
1455
- export { BASE_SLACK_EVENTS, CAPABILITY_IDS, requiredGithubPermissions, requiredSlackScopes, requiredSlackCommands, CUSTOMIZATION_ARTIFACT_NAME, newsletterManifestFor, instanceNames, slackCommandPrefix, slackCommandPrefixes, crmPolicyFingerprint, parseInstanceConfig, parseSchedule, loadInstanceFile, configPathFor, capabilityEnabled, requiresAuthenticatedQueue, enabledIntegrations, provisionsIntegration, adminsFor, WEB_SEARCH_TARGET, WEB_SEARCH_CONNECTOR_VERSION, LIVE_ENDPOINT_NAME, RELEASE_VERSION_RE, parseManifest, manifestKey, lambdaKey, skillsKey, buildManifest, manifestSigningPayload, verifyManifest, lambdaBundleContext, BUN_VERSION, LAMBDA_ENTRY_POINTS, skipBundle, RELEASE_BUCKET_ENV, RELEASE_DIR_ENV, releaseCacheDir, releaseSource, lambdaCode, agentImage, agentImageTagRequired, releaseImageRepositoryArn, ecrRepositoryArn };
1514
+ export { BASE_SLACK_EVENTS, CAPABILITY_IDS, requiredGithubPermissions, requiredSlackScopes, requiredSlackCommands, CUSTOMIZATION_ARTIFACT_NAME, newsletterManifestFor, instanceNames, slackCommandPrefix, slackCommandPrefixes, isScheduledJob, isWebhookJob, webhookJobBindings, jobWebhookKey, crmOutreachConfigured, crmPolicyFingerprint, parseInstanceConfig, parseSchedule, loadInstanceFile, configPathFor, capabilityEnabled, requiresAuthenticatedQueue, enabledIntegrations, provisionsIntegration, adminsFor, WEB_SEARCH_TARGET, WEB_SEARCH_CONNECTOR_VERSION, LIVE_ENDPOINT_NAME, RELEASE_VERSION_RE, parseManifest, manifestKey, lambdaKey, skillsKey, buildManifest, manifestSigningPayload, verifyManifest, lambdaBundleContext, BUN_VERSION, LAMBDA_ENTRY_POINTS, skipBundle, RELEASE_BUCKET_ENV, RELEASE_DIR_ENV, releaseCacheDir, releaseSource, lambdaCode, agentImage, agentImageTagRequired, releaseImageRepositoryArn, ecrRepositoryArn };
@@ -5,6 +5,7 @@ import {
5
5
  agentImage,
6
6
  agentImageTagRequired,
7
7
  capabilityEnabled,
8
+ crmOutreachConfigured,
8
9
  crmPolicyFingerprint,
9
10
  instanceNames,
10
11
  lambdaCode,
@@ -13,8 +14,9 @@ import {
13
14
  provisionsIntegration,
14
15
  releaseImageRepositoryArn,
15
16
  requiresAuthenticatedQueue,
16
- slackCommandPrefixes
17
- } from "./chunk-t86rw0f2.js";
17
+ slackCommandPrefixes,
18
+ webhookJobBindings
19
+ } from "./chunk-gcg67ck3.js";
18
20
 
19
21
  // src/stacks/agent-stack.ts
20
22
  import * as cdk from "aws-cdk-lib";
@@ -618,6 +620,7 @@ import * as iam2 from "aws-cdk-lib/aws-iam";
618
620
  import * as lambda from "aws-cdk-lib/aws-lambda";
619
621
  import * as lambdaEventSources from "aws-cdk-lib/aws-lambda-event-sources";
620
622
  import * as scheduler from "aws-cdk-lib/aws-scheduler";
623
+ import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
621
624
  import * as sns from "aws-cdk-lib/aws-sns";
622
625
  import * as sqs from "aws-cdk-lib/aws-sqs";
623
626
  import { parse as parseYaml } from "yaml";
@@ -728,6 +731,20 @@ class FoundationApi extends cdk2.Stack {
728
731
  actions: ["kms:Decrypt", "kms:Encrypt", "kms:GenerateDataKey", "kms:DescribeKey"],
729
732
  resources: [props.dataKey.keyArn]
730
733
  }));
734
+ const webhookJobs = webhookJobsFor(props.configPath);
735
+ const jobsWebhookSecret = webhookJobs.length === 0 ? undefined : new secretsmanager.Secret(this, "JobsWebhookSecret", {
736
+ secretName: `${props.instance.naming.secretPrefix}/jobs/webhook`,
737
+ description: `${display} webhook job signing secret ({ secret }); sender keys derive from it`,
738
+ removalPolicy: cdk2.RemovalPolicy.DESTROY,
739
+ generateSecretString: {
740
+ secretStringTemplate: "{}",
741
+ generateStringKey: "secret",
742
+ passwordLength: 64,
743
+ excludePunctuation: true
744
+ }
745
+ });
746
+ if (jobsWebhookSecret !== undefined)
747
+ jobsWebhookSecret.grantRead(gatewayRole);
731
748
  const gatewayFunction = new lambda.Function(this, "GatewayFunction", {
732
749
  ...authenticatedQueue ? { functionName: names.slackGatewayFunctionName } : {},
733
750
  runtime: lambda.Runtime.NODEJS_22_X,
@@ -757,7 +774,11 @@ class FoundationApi extends cdk2.Stack {
757
774
  FOUNDATION_INSTANCE: props.instance.name,
758
775
  FOUNDATION_COMMAND_PREFIXES: slackCommandPrefixes(props.instance).join(","),
759
776
  FOUNDATION_PROACTIVE_CHANNELS: props.instance.proactive.channels.join(","),
760
- FOUNDATION_PROACTIVE_MAX_PER_HOUR: String(props.instance.proactive.maxPerHour)
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
+ }
761
782
  }
762
783
  });
763
784
  gatewayFunction.configureAsyncInvoke({
@@ -851,6 +872,15 @@ class FoundationApi extends cdk2.Stack {
851
872
  this.api.root.addResource("upwork").addResource("oauth").addResource("callback").addMethod("GET", integration);
852
873
  new cdk2.CfnOutput(this, "UpworkOAuthRedirectUrl", { value: upworkRedirectUri });
853
874
  }
875
+ if (webhookJobs.length > 0) {
876
+ this.api.root.addResource("jobs").addResource("{id}").addMethod("POST", integration);
877
+ for (const job of webhookJobs) {
878
+ new cdk2.CfnOutput(this, `JobWebhookUrl${job.id.replace(/[^A-Za-z0-9]/g, "")}`, {
879
+ description: `Webhook URL for job ${job.id}`,
880
+ value: this.api.urlForPath(`/jobs/${job.id}`)
881
+ });
882
+ }
883
+ }
854
884
  const emailProxyRole = new iam2.Role(this, "EmailProxyRole", {
855
885
  assumedBy: new iam2.ServicePrincipal("lambda.amazonaws.com"),
856
886
  description: `${display} email proxy Lambda role (the only reader of the mailbox secret)`,
@@ -1097,6 +1127,7 @@ class FoundationApi extends cdk2.Stack {
1097
1127
  "dynamodb:PutItem",
1098
1128
  "dynamodb:UpdateItem",
1099
1129
  "dynamodb:DeleteItem",
1130
+ "dynamodb:BatchGetItem",
1100
1131
  "dynamodb:BatchWriteItem",
1101
1132
  "dynamodb:TransactWriteItems"
1102
1133
  ],
@@ -1123,7 +1154,11 @@ class FoundationApi extends cdk2.Stack {
1123
1154
  FOUNDATION_CRM_ALLOWED_CHANNELS: JSON.stringify(crm.channels),
1124
1155
  FOUNDATION_CRM_ALLOWED_STATUSES: JSON.stringify(crm.statuses),
1125
1156
  FOUNDATION_CRM_ACTIVITY_TYPES: JSON.stringify(crm.activityTypes),
1126
- FOUNDATION_CRM_MAX_BATCH_SIZE: String(crm.maxBatchSize)
1157
+ FOUNDATION_CRM_MAX_BATCH_SIZE: String(crm.maxBatchSize),
1158
+ ...crm.outreach === undefined ? {} : {
1159
+ FOUNDATION_CRM_OUTREACH: JSON.stringify(crm.outreach),
1160
+ FOUNDATION_CRM_TIMEZONE: crm.timezone
1161
+ }
1127
1162
  }
1128
1163
  });
1129
1164
  new cdk2.CfnOutput(this, "CrmProxyFunctionArn", {
@@ -1221,7 +1256,9 @@ function crmConfigForProvisioning(configPath) {
1221
1256
  statuses: [...crm.statuses],
1222
1257
  activityTypes: [...crm.activityTypes],
1223
1258
  maxBatchSize: crm.maxBatchSize,
1224
- policyFingerprint: crmPolicyFingerprint(crm)
1259
+ policyFingerprint: crmPolicyFingerprint(crm),
1260
+ ...crmOutreachConfigured(crm.outreach) ? { outreach: crm.outreach } : {},
1261
+ timezone: config.timezone
1225
1262
  };
1226
1263
  }
1227
1264
  function knockConfigForProvisioning(configPath) {
@@ -1248,6 +1285,15 @@ function emailIdentitiesFor(configPath) {
1248
1285
  return [];
1249
1286
  return email.identities;
1250
1287
  }
1288
+ var WEBHOOK_JOBS_MAX_ENV_BYTES = 2048;
1289
+ function webhookJobsFor(configPath) {
1290
+ const jobs = webhookJobBindings(parseInstanceConfig(readFileSync(configPath, "utf8")).jobs);
1291
+ const bytes = Buffer.byteLength(JSON.stringify(jobs));
1292
+ if (bytes > WEBHOOK_JOBS_MAX_ENV_BYTES) {
1293
+ throw new Error(`Webhook jobs take ${bytes} bytes of the gateway's environment; the limit is ${WEBHOOK_JOBS_MAX_ENV_BYTES}. Shorten their prompts and put the procedure in a skill.`);
1294
+ }
1295
+ return jobs;
1296
+ }
1251
1297
  function browserDomainsFor(configPath) {
1252
1298
  const browser = parseInstanceConfig(readFileSync(configPath, "utf8")).capabilities.browser;
1253
1299
  return browser.enabled && browser.mode === "read" ? [...browser.allowedDomains] : [];
@@ -1401,7 +1447,7 @@ import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
1401
1447
  import * as iam5 from "aws-cdk-lib/aws-iam";
1402
1448
  import * as kms from "aws-cdk-lib/aws-kms";
1403
1449
  import * as s3 from "aws-cdk-lib/aws-s3";
1404
- import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
1450
+ import * as secretsmanager2 from "aws-cdk-lib/aws-secretsmanager";
1405
1451
  class FoundationData extends cdk4.Stack {
1406
1452
  dataKey;
1407
1453
  bucket;
@@ -1485,7 +1531,7 @@ class FoundationData extends cdk4.Stack {
1485
1531
  pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
1486
1532
  removalPolicy: cdk4.RemovalPolicy.DESTROY
1487
1533
  }) : undefined;
1488
- const secretShell = (constructId, name, description) => new secretsmanager.Secret(this, constructId, {
1534
+ const secretShell = (constructId, name, description) => new secretsmanager2.Secret(this, constructId, {
1489
1535
  secretName: name,
1490
1536
  description,
1491
1537
  removalPolicy: cdk4.RemovalPolicy.RETAIN,
@@ -1494,7 +1540,7 @@ class FoundationData extends cdk4.Stack {
1494
1540
  generateStringKey: "placeholder"
1495
1541
  }
1496
1542
  });
1497
- const otterApi = provisionsIntegration(props.instance, "otter") ? new secretsmanager.Secret(this, "OtterApiSecret", {
1543
+ const otterApi = provisionsIntegration(props.instance, "otter") ? new secretsmanager2.Secret(this, "OtterApiSecret", {
1498
1544
  secretName: names.secretOtterApi,
1499
1545
  description: `Otter Enterprise Public API key for ${display} ({ api_key })`,
1500
1546
  removalPolicy: cdk4.RemovalPolicy.DESTROY,
@@ -1503,7 +1549,7 @@ class FoundationData extends cdk4.Stack {
1503
1549
  generateStringKey: "placeholder"
1504
1550
  }
1505
1551
  }) : undefined;
1506
- const knockOauthClient = provisionsIntegration(props.instance, "knock") ? new secretsmanager.Secret(this, "KnockOauthClientSecret", {
1552
+ const knockOauthClient = provisionsIntegration(props.instance, "knock") ? new secretsmanager2.Secret(this, "KnockOauthClientSecret", {
1507
1553
  secretName: names.secretKnockOauthClient,
1508
1554
  description: `Knock public OAuth client for ${display} ({ client_id, redirect_uri })`,
1509
1555
  removalPolicy: cdk4.RemovalPolicy.DESTROY,
@@ -1512,7 +1558,7 @@ class FoundationData extends cdk4.Stack {
1512
1558
  generateStringKey: "placeholder"
1513
1559
  }
1514
1560
  }) : undefined;
1515
- const knockCredential = provisionsIntegration(props.instance, "knock") ? new secretsmanager.Secret(this, "KnockCredentialSecret", {
1561
+ const knockCredential = provisionsIntegration(props.instance, "knock") ? new secretsmanager2.Secret(this, "KnockCredentialSecret", {
1516
1562
  secretName: names.secretKnockCredential,
1517
1563
  description: `Knock OAuth credential for ${display}; callback writes and proxy alone reads`,
1518
1564
  removalPolicy: cdk4.RemovalPolicy.DESTROY,
@@ -1523,11 +1569,11 @@ class FoundationData extends cdk4.Stack {
1523
1569
  }) : undefined;
1524
1570
  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;
1525
1571
  this.secrets = {
1526
- signing: secretsmanager.Secret.fromSecretNameV2(this, "SlackSigningSecret", names.secretSlackSigning),
1527
- slackApp: secretsmanager.Secret.fromSecretNameV2(this, "SlackAppSecret", names.secretSlackApp),
1528
- githubApp: secretsmanager.Secret.fromSecretNameV2(this, "GithubAppSecret", names.secretGithubApp),
1572
+ signing: secretsmanager2.Secret.fromSecretNameV2(this, "SlackSigningSecret", names.secretSlackSigning),
1573
+ slackApp: secretsmanager2.Secret.fromSecretNameV2(this, "SlackAppSecret", names.secretSlackApp),
1574
+ githubApp: secretsmanager2.Secret.fromSecretNameV2(this, "GithubAppSecret", names.secretGithubApp),
1529
1575
  codex: secretShell("CodexSecret", names.secretCodex, "OpenAI Codex credential store (rotated tokens are written back here)"),
1530
- googleAiStudio: new secretsmanager.Secret(this, "GoogleAiStudioSecret", {
1576
+ googleAiStudio: new secretsmanager2.Secret(this, "GoogleAiStudioSecret", {
1531
1577
  secretName: names.secretGoogleAiStudio,
1532
1578
  encryptionKey: this.dataKey,
1533
1579
  description: "Google AI Studio API key for image generation ({ api_key })",
@@ -1537,7 +1583,7 @@ class FoundationData extends cdk4.Stack {
1537
1583
  generateStringKey: "placeholder"
1538
1584
  }
1539
1585
  }),
1540
- googleDrive: new secretsmanager.Secret(this, "GoogleDriveSecret", {
1586
+ googleDrive: new secretsmanager2.Secret(this, "GoogleDriveSecret", {
1541
1587
  secretName: names.secretGoogleDrive,
1542
1588
  encryptionKey: this.dataKey,
1543
1589
  description: `Google Drive service identity: the company-owned Drive account ${display} reads as. JSON { client_email, private_key, token_uri? }, optionally nested under identities.default. Access is granted by sharing files/folders with client_email; scope is read-only.`,
@@ -1549,7 +1595,7 @@ class FoundationData extends cdk4.Stack {
1549
1595
  }),
1550
1596
  googleCalendar: secretShell("GoogleCalendarSecret", names.secretGoogleCalendar, `Google Calendar OAuth client for ${display}'s per-person calendar connections`),
1551
1597
  googleEmail: secretShell("GoogleEmailSecret", names.secretGoogleEmail, `Gmail identity for ${display}, connected from Slack — written by the gateway, read only by the email proxy`),
1552
- mongodbReadonly: new secretsmanager.Secret(this, "MongodbReadonlySecret", {
1598
+ mongodbReadonly: new secretsmanager2.Secret(this, "MongodbReadonlySecret", {
1553
1599
  secretName: names.secretMongodbReadonly,
1554
1600
  encryptionKey: this.dataKey,
1555
1601
  description: `Read-only MongoDB Atlas credential for ${display}. JSON { uri, databases? } where uri authenticates an Atlas user provisioned with the \`read\` role, and databases (optional) narrows which databases the agent may name.`,
@@ -1799,7 +1845,7 @@ import * as lambda2 from "aws-cdk-lib/aws-lambda";
1799
1845
  import * as lambdaEventSources2 from "aws-cdk-lib/aws-lambda-event-sources";
1800
1846
  import * as route53 from "aws-cdk-lib/aws-route53";
1801
1847
  import * as route53Targets from "aws-cdk-lib/aws-route53-targets";
1802
- import * as secretsmanager2 from "aws-cdk-lib/aws-secretsmanager";
1848
+ import * as secretsmanager3 from "aws-cdk-lib/aws-secretsmanager";
1803
1849
  import * as sns2 from "aws-cdk-lib/aws-sns";
1804
1850
  import * as snsSubscriptions from "aws-cdk-lib/aws-sns-subscriptions";
1805
1851
  import * as sqs2 from "aws-cdk-lib/aws-sqs";
@@ -1866,7 +1912,7 @@ class NewsletterStack extends cdk6.Stack {
1866
1912
  pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true },
1867
1913
  removalPolicy: cdk6.RemovalPolicy.RETAIN
1868
1914
  });
1869
- this.tokenSigningSecret = new secretsmanager2.Secret(this, "TokenSigningSecret", {
1915
+ this.tokenSigningSecret = new secretsmanager3.Secret(this, "TokenSigningSecret", {
1870
1916
  secretName: names.newsletterTokenSigningSecret,
1871
1917
  description: "Newsletter confirmation and unsubscribe token HMAC secret",
1872
1918
  encryptionKey: newsletterKey,
package/dist/src/index.js CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  FoundationPipeline,
10
10
  NewsletterStack,
11
11
  deployStatements
12
- } from "../chunk-m4pn6wc2.js";
12
+ } from "../chunk-paf33z2h.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-t86rw0f2.js";
45
+ } from "../chunk-gcg67ck3.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.1.5",
3
+ "version": "0.2.1",
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/deploy/cli.ts CHANGED
@@ -16,6 +16,7 @@ import { configSync } from "./config-sync.ts";
16
16
  import { deploy } from "./deploy.ts";
17
17
  import { githubAppCreate } from "./github-app-create.ts";
18
18
  import { instanceBanner, loadInstanceContext, resolveInstanceFilePath } from "./instance.ts";
19
+ import { jobKey } from "./job-key.ts";
19
20
  import { postDeploy } from "./post-deploy.ts";
20
21
  import { releaseRequest, resolveRelease } from "./release.ts";
21
22
  import { setup } from "./setup.ts";
@@ -30,6 +31,7 @@ export const COMMANDS = [
30
31
  "github-app-create",
31
32
  "slack-manifest",
32
33
  "stage-customization",
34
+ "job-key",
33
35
  ] as const;
34
36
 
35
37
  export type Command = (typeof COMMANDS)[number];
@@ -45,6 +47,7 @@ commands
45
47
  github-app-create create the instance's GitHub App from the shipped manifest
46
48
  slack-manifest print the instance's Slack app manifest as JSON
47
49
  stage-customization validate and stage a tenant-owned runtime config
50
+ job-key <id> print a webhook job's URL and signing key as JSON
48
51
 
49
52
  options
50
53
  --instance <path> REQUIRED: path to the deployment's instance YAML
@@ -76,6 +79,10 @@ setup options
76
79
  --codex-file <path> where that credential is (default: beside the instance
77
80
  file, .foundation-local/codex.json)
78
81
 
82
+ job-key options
83
+ --put-secret <id> write {url, key} into this Secrets Manager secret
84
+ (created if absent) instead of printing the key
85
+
79
86
  github-app-create options
80
87
  --org <org> GitHub org (default: the instance's github.org)
81
88
  --port <n> local callback port (default 8765)
@@ -148,6 +155,22 @@ export async function main(argv: readonly string[] = process.argv.slice(2)): Pro
148
155
  return 0;
149
156
  }
150
157
 
158
+ // Like `slack-manifest`, stdout is a machine-readable document, so the
159
+ // banner goes to stderr.
160
+ if (command === "job-key") {
161
+ const ctx = contextFor(args);
162
+ const jobId = args[0];
163
+ if (jobId === undefined || jobId.startsWith("-")) {
164
+ console.error(`job-key needs a job id\n\n${USAGE}`);
165
+ return 1;
166
+ }
167
+ console.error(instanceBanner(ctx.paths));
168
+ const putSecret = flag(args, "--put-secret");
169
+ const result = await jobKey(ctx, jobId, putSecret === undefined ? {} : { putSecret });
170
+ console.log(JSON.stringify(result));
171
+ return 0;
172
+ }
173
+
151
174
  if (command === "github-app-create") {
152
175
  const paths = loadInstanceContext(resolveInstanceFilePath(args, process.env));
153
176
  console.log(instanceBanner(paths));
@@ -0,0 +1,76 @@
1
+ import { readFileSync } from "node:fs";
2
+ /**
3
+ * `foundation-deploy job-key <id>`: the URL and signing key one webhook job's
4
+ * sender needs.
5
+ *
6
+ * The instance holds one webhook secret; each job's key is derived from it
7
+ * (`jobWebhookKey`), so a sender can open only its own job and the operator
8
+ * never hands out the instance secret. With `--put-secret <id>` the pair is
9
+ * written into that secret (created if absent) and never printed, which is
10
+ * the way to wire a sender that already reads its settings from Secrets
11
+ * Manager.
12
+ */
13
+ import {
14
+ type InstanceConfig,
15
+ isWebhookJob,
16
+ jobWebhookKey,
17
+ parseInstanceConfig,
18
+ } from "@deployfoundation/foundation-core";
19
+ import {
20
+ type AwsContext,
21
+ createSecretString,
22
+ putSecretString,
23
+ readSecretJson,
24
+ secretExists,
25
+ stackOutput,
26
+ } from "./aws.ts";
27
+
28
+ /** The CloudFormation output the API stack writes for one job's URL. */
29
+ export function jobWebhookOutputKey(jobId: string): string {
30
+ return `JobWebhookUrl${jobId.replace(/[^A-Za-z0-9]/g, "")}`;
31
+ }
32
+
33
+ export function jobsWebhookSecretName(secretPrefix: string): string {
34
+ return `${secretPrefix}/jobs/webhook`;
35
+ }
36
+
37
+ export interface JobKeyResult {
38
+ url: string;
39
+ /** Absent when it went into `--put-secret` instead. */
40
+ key?: string;
41
+ secret?: string;
42
+ }
43
+
44
+ export async function jobKey(
45
+ ctx: AwsContext,
46
+ jobId: string,
47
+ options: { putSecret?: string } = {},
48
+ ): Promise<JobKeyResult> {
49
+ const config: InstanceConfig = parseInstanceConfig(readFileSync(ctx.paths.configPath, "utf8"));
50
+ const job = config.jobs.find((entry) => entry.id === jobId);
51
+ if (job === undefined) throw new Error(`no job "${jobId}" in ${ctx.paths.configPath}`);
52
+ if (!isWebhookJob(job)) throw new Error(`job "${jobId}" is not triggered by a webhook`);
53
+
54
+ const url = await stackOutput(ctx, ctx.names.api, jobWebhookOutputKey(jobId));
55
+ const instanceSecret = await readSecretJson<{ secret?: string }>(
56
+ ctx,
57
+ jobsWebhookSecretName(ctx.paths.instance.naming.secretPrefix),
58
+ );
59
+ if (instanceSecret.secret === undefined || instanceSecret.secret.length < 32)
60
+ throw new Error("the jobs webhook secret has no value — has the API stack been deployed?");
61
+ const key = jobWebhookKey(instanceSecret.secret, jobId);
62
+
63
+ if (options.putSecret === undefined) return { url, key };
64
+ const value = JSON.stringify({ url, key });
65
+ if (await secretExists(ctx, options.putSecret)) {
66
+ await putSecretString(ctx, options.putSecret, value);
67
+ } else {
68
+ await createSecretString(
69
+ ctx,
70
+ options.putSecret,
71
+ value,
72
+ `Webhook URL and signing key for Foundation job ${jobId} ({ url, key })`,
73
+ );
74
+ }
75
+ return { url, secret: options.putSecret };
76
+ }
@@ -19,6 +19,8 @@ import { dirname, isAbsolute, posix, relative, resolve, sep } from "node:path";
19
19
  import { isDeepStrictEqual } from "node:util";
20
20
  import {
21
21
  type InstanceConfig,
22
+ isScheduledJob,
23
+ isWebhookJob,
22
24
  parseInstanceConfig,
23
25
  parseSchedule,
24
26
  } from "@deployfoundation/foundation-core";
@@ -141,19 +143,22 @@ function assertUnchanged(path: string, platform: unknown, tenant: unknown): void
141
143
  );
142
144
  }
143
145
 
144
- /** Remove only the three tenant-editable leaves from a complete config. */
146
+ /**
147
+ * Remove only the tenant-editable leaves from a complete config: the digest
148
+ * hour, CRM outreach policy and scheduled jobs. Webhook jobs stay protected:
149
+ * each is a public endpoint baked into the gateway at deploy.
150
+ */
145
151
  function protectedConfig(config: InstanceConfig): unknown {
146
152
  const { digestHour: _digestHour, ...todos } = config.capabilities.todos;
147
- const { defined: _defined, ...routines } = config.capabilities.routines;
148
153
  const { outreach: _outreach, ...crm } = config.capabilities.crm;
149
154
  return {
150
155
  ...config,
151
156
  capabilities: {
152
157
  ...config.capabilities,
153
158
  todos,
154
- routines,
155
159
  crm,
156
160
  },
161
+ jobs: config.jobs.filter(isWebhookJob),
157
162
  };
158
163
  }
159
164
 
@@ -161,10 +166,10 @@ function assertProtectedPolicy(platform: InstanceConfig, tenant: InstanceConfig)
161
166
  assertUnchanged("config", protectedConfig(platform), protectedConfig(tenant));
162
167
  }
163
168
 
164
- function assertValidTenantRoutines(tenant: InstanceConfig): void {
165
- for (const routine of tenant.capabilities.routines.defined) {
166
- const parsed = parseSchedule(routine.schedule);
167
- if (!parsed.ok) throw new Error(`Invalid defined routine "${routine.id}": ${parsed.reason}`);
169
+ function assertValidTenantJobs(tenant: InstanceConfig): void {
170
+ for (const job of tenant.jobs.filter(isScheduledJob)) {
171
+ const parsed = parseSchedule(job.on.schedule);
172
+ if (!parsed.ok) throw new Error(`Invalid job schedule "${job.id}": ${parsed.reason}`);
168
173
  }
169
174
  }
170
175
 
@@ -210,7 +215,7 @@ export function stageCustomization(options: StageCustomizationOptions): Customiz
210
215
  const platformConfig = parseInstanceConfig(readFileSync(platformConfigPath, "utf8"));
211
216
  if (tenantConfig.name !== instance.displayName)
212
217
  throw new Error("Customization config name must match the selected instance.");
213
- assertValidTenantRoutines(tenantConfig);
218
+ assertValidTenantJobs(tenantConfig);
214
219
  assertProtectedPolicy(platformConfig, tenantConfig);
215
220
 
216
221
  if (options.dryRun !== true) {
@@ -1,5 +1,12 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { crmPolicyFingerprint, parseInstanceConfig } from "@deployfoundation/foundation-core";
2
+ import {
3
+ type CrmOutreachConfig,
4
+ type WebhookJobBinding,
5
+ crmOutreachConfigured,
6
+ crmPolicyFingerprint,
7
+ parseInstanceConfig,
8
+ webhookJobBindings,
9
+ } from "@deployfoundation/foundation-core";
3
10
  import { slackCommandPrefixes } from "@deployfoundation/foundation-core/instance";
4
11
  import * as cdk from "aws-cdk-lib";
5
12
  import * as apigateway from "aws-cdk-lib/aws-apigateway";
@@ -11,7 +18,7 @@ import type * as kms from "aws-cdk-lib/aws-kms";
11
18
  import * as lambda from "aws-cdk-lib/aws-lambda";
12
19
  import * as lambdaEventSources from "aws-cdk-lib/aws-lambda-event-sources";
13
20
  import * as scheduler from "aws-cdk-lib/aws-scheduler";
14
- import type * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
21
+ import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
15
22
  import * as sns from "aws-cdk-lib/aws-sns";
16
23
  import * as sqs from "aws-cdk-lib/aws-sqs";
17
24
  import type { Construct } from "constructs";
@@ -255,6 +262,30 @@ export class FoundationApi extends cdk.Stack {
255
262
  }),
256
263
  );
257
264
 
265
+ // Webhook jobs are read at SYNTH, like the proactive channels: each one is a
266
+ // public endpoint, so declaring one is a deploy, not a config sync. The one
267
+ // instance secret exists only while some job needs it; each sender gets a
268
+ // key derived from it for its own job (`foundation-deploy job-key`).
269
+ const webhookJobs = webhookJobsFor(props.configPath);
270
+ const jobsWebhookSecret =
271
+ webhookJobs.length === 0
272
+ ? undefined
273
+ : new secretsmanager.Secret(this, "JobsWebhookSecret", {
274
+ secretName: `${props.instance.naming.secretPrefix}/jobs/webhook`,
275
+ description: `${display} webhook job signing secret ({ secret }); sender keys derive from it`,
276
+ // Destroyed with the last webhook job, like the opt-in integration
277
+ // secrets: a retained fixed-name secret could not be adopted when a
278
+ // job is added again. Senders get new keys then.
279
+ removalPolicy: cdk.RemovalPolicy.DESTROY,
280
+ generateSecretString: {
281
+ secretStringTemplate: "{}",
282
+ generateStringKey: "secret",
283
+ passwordLength: 64,
284
+ excludePunctuation: true,
285
+ },
286
+ });
287
+ if (jobsWebhookSecret !== undefined) jobsWebhookSecret.grantRead(gatewayRole);
288
+
258
289
  const gatewayFunction = new lambda.Function(this, "GatewayFunction", {
259
290
  ...(authenticatedQueue ? { functionName: names.slackGatewayFunctionName } : {}),
260
291
  runtime: lambda.Runtime.NODEJS_22_X,
@@ -305,6 +336,12 @@ export class FoundationApi extends cdk.Stack {
305
336
  // that omits the block.
306
337
  FOUNDATION_PROACTIVE_CHANNELS: props.instance.proactive.channels.join(","),
307
338
  FOUNDATION_PROACTIVE_MAX_PER_HOUR: String(props.instance.proactive.maxPerHour),
339
+ ...(jobsWebhookSecret === undefined
340
+ ? {}
341
+ : {
342
+ FOUNDATION_WEBHOOK_JOBS: JSON.stringify(webhookJobs),
343
+ FOUNDATION_JOBS_WEBHOOK_SECRET_ID: jobsWebhookSecret.secretName,
344
+ }),
308
345
  },
309
346
  });
310
347
  // One mention must produce one reply: Lambda's default of two async
@@ -463,6 +500,18 @@ export class FoundationApi extends cdk.Stack {
463
500
  new cdk.CfnOutput(this, "UpworkOAuthRedirectUrl", { value: upworkRedirectUri });
464
501
  }
465
502
 
503
+ if (webhookJobs.length > 0) {
504
+ // Authenticated by the job's own HMAC in the gateway, like the Slack
505
+ // routes are by Slack's signature.
506
+ this.api.root.addResource("jobs").addResource("{id}").addMethod("POST", integration);
507
+ for (const job of webhookJobs) {
508
+ new cdk.CfnOutput(this, `JobWebhookUrl${job.id.replace(/[^A-Za-z0-9]/g, "")}`, {
509
+ description: `Webhook URL for job ${job.id}`,
510
+ value: this.api.urlForPath(`/jobs/${job.id}`),
511
+ });
512
+ }
513
+ }
514
+
466
515
  // --- email proxy -----------------------------------------------------
467
516
  // The permission barrier for Gmail, and the reason the design puts it in
468
517
  // a Lambda rather than a broker inside the agent container: this role is
@@ -789,6 +838,7 @@ export class FoundationApi extends cdk.Stack {
789
838
  "dynamodb:PutItem",
790
839
  "dynamodb:UpdateItem",
791
840
  "dynamodb:DeleteItem",
841
+ "dynamodb:BatchGetItem",
792
842
  "dynamodb:BatchWriteItem",
793
843
  "dynamodb:TransactWriteItems",
794
844
  ],
@@ -819,6 +869,14 @@ export class FoundationApi extends cdk.Stack {
819
869
  FOUNDATION_CRM_ALLOWED_STATUSES: JSON.stringify(crm.statuses),
820
870
  FOUNDATION_CRM_ACTIVITY_TYPES: JSON.stringify(crm.activityTypes),
821
871
  FOUNDATION_CRM_MAX_BATCH_SIZE: String(crm.maxBatchSize),
872
+ // The outreach caps are enforced here, so they are baked like the
873
+ // rest of the policy: a config sync alone cannot lift them.
874
+ ...(crm.outreach === undefined
875
+ ? {}
876
+ : {
877
+ FOUNDATION_CRM_OUTREACH: JSON.stringify(crm.outreach),
878
+ FOUNDATION_CRM_TIMEZONE: crm.timezone,
879
+ }),
822
880
  },
823
881
  });
824
882
  new cdk.CfnOutput(this, "CrmProxyFunctionArn", {
@@ -921,6 +979,9 @@ interface ProvisionedCrmConfig {
921
979
  activityTypes: string[];
922
980
  maxBatchSize: number;
923
981
  policyFingerprint: string;
982
+ /** Absent when no status is eligible for outreach. */
983
+ outreach?: CrmOutreachConfig;
984
+ timezone: string;
924
985
  }
925
986
 
926
987
  function crmConfigForProvisioning(configPath: string): ProvisionedCrmConfig {
@@ -942,6 +1003,8 @@ function crmConfigForProvisioning(configPath: string): ProvisionedCrmConfig {
942
1003
  activityTypes: [...crm.activityTypes],
943
1004
  maxBatchSize: crm.maxBatchSize,
944
1005
  policyFingerprint: crmPolicyFingerprint(crm),
1006
+ ...(crmOutreachConfigured(crm.outreach) ? { outreach: crm.outreach } : {}),
1007
+ timezone: config.timezone,
945
1008
  };
946
1009
  }
947
1010
 
@@ -989,6 +1052,25 @@ export function emailIdentitiesFor(configPath: string): unknown[] {
989
1052
  return email.identities;
990
1053
  }
991
1054
 
1055
+ /**
1056
+ * Lambda environment variables share one 4KB budget with every other
1057
+ * setting, so the webhook jobs baked into the gateway get a fixed slice of it.
1058
+ * A long procedure belongs in a skill the prompt names.
1059
+ */
1060
+ export const WEBHOOK_JOBS_MAX_ENV_BYTES = 2048;
1061
+
1062
+ /** The config's webhook jobs, as the gateway is baked with them. */
1063
+ export function webhookJobsFor(configPath: string): WebhookJobBinding[] {
1064
+ const jobs = webhookJobBindings(parseInstanceConfig(readFileSync(configPath, "utf8")).jobs);
1065
+ const bytes = Buffer.byteLength(JSON.stringify(jobs));
1066
+ if (bytes > WEBHOOK_JOBS_MAX_ENV_BYTES) {
1067
+ throw new Error(
1068
+ `Webhook jobs take ${bytes} bytes of the gateway's environment; the limit is ${WEBHOOK_JOBS_MAX_ENV_BYTES}. Shorten their prompts and put the procedure in a skill.`,
1069
+ );
1070
+ }
1071
+ return jobs;
1072
+ }
1073
+
992
1074
  /** Server-side Browser allowlist baked into the isolated proxy at deploy time. */
993
1075
  export function browserDomainsFor(configPath: string): string[] {
994
1076
  const browser = parseInstanceConfig(readFileSync(configPath, "utf8")).capabilities.browser;