@deployfoundation/foundation-deploy 0.1.5 → 0.2.0
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 +2 -2
- package/dist/bin/foundation-deploy.js +69 -15
- package/dist/bin/release-account.js +2 -2
- package/dist/{chunk-qc5d46ky.js → chunk-an8qhf3t.js} +1 -1
- package/dist/{chunk-t86rw0f2.js → chunk-gcg67ck3.js} +138 -79
- package/dist/{chunk-m4pn6wc2.js → chunk-paf33z2h.js} +64 -18
- package/dist/src/index.js +2 -2
- package/package.json +1 -1
- package/src/deploy/cli.ts +23 -0
- package/src/deploy/job-key.ts +76 -0
- package/src/deploy/stage-customization.ts +13 -8
- package/src/stacks/api-stack.ts +84 -2
package/dist/bin/app.js
CHANGED
|
@@ -7,12 +7,12 @@ import {
|
|
|
7
7
|
FoundationNetwork,
|
|
8
8
|
FoundationPipeline,
|
|
9
9
|
NewsletterStack
|
|
10
|
-
} from "../chunk-
|
|
10
|
+
} from "../chunk-paf33z2h.js";
|
|
11
11
|
import {
|
|
12
12
|
adminsFor,
|
|
13
13
|
instanceNames,
|
|
14
14
|
loadInstanceFile
|
|
15
|
-
} from "../chunk-
|
|
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-
|
|
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-
|
|
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
|
|
851
|
-
for (const
|
|
852
|
-
const parsed = parseSchedule(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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 =
|
|
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));
|
|
@@ -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
|
|
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
|
|
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 =
|
|
773
|
-
var RawImages =
|
|
774
|
-
backend:
|
|
775
|
-
model:
|
|
776
|
-
dailyCap:
|
|
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 =
|
|
790
|
-
var RawChannelModels =
|
|
791
|
-
var RawSubagents =
|
|
792
|
-
enabled:
|
|
793
|
-
maxConcurrency:
|
|
794
|
-
maxRecursionDepth:
|
|
795
|
-
effort:
|
|
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 =
|
|
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:
|
|
801
|
-
scope:
|
|
802
|
-
requires:
|
|
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 =
|
|
805
|
-
|
|
806
|
-
github:
|
|
807
|
-
ref:
|
|
808
|
-
path:
|
|
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
|
-
|
|
865
|
+
z4.object({ s3: z4.string().min(1), ...RawSkillSourceMetadata }).strict()
|
|
812
866
|
]);
|
|
813
|
-
var RawSkills =
|
|
814
|
-
sources:
|
|
815
|
-
promoteTo:
|
|
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 =
|
|
818
|
-
name:
|
|
819
|
-
model:
|
|
820
|
-
admins:
|
|
821
|
-
instructions:
|
|
822
|
-
timezone:
|
|
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
|
|
1199
|
+
import { z as z5 } from "zod";
|
|
1141
1200
|
var RELEASE_VERSION_RE = /^v\d+\.\d+\.\d+$/;
|
|
1142
|
-
var Sha256 =
|
|
1143
|
-
var ReleaseArtifactSchema =
|
|
1144
|
-
key:
|
|
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:
|
|
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 =
|
|
1160
|
-
version:
|
|
1161
|
-
gitCommit:
|
|
1162
|
-
createdAt:
|
|
1163
|
-
agentImage:
|
|
1164
|
-
repository:
|
|
1165
|
-
tag:
|
|
1166
|
-
digest:
|
|
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:
|
|
1169
|
-
skills:
|
|
1170
|
-
signature:
|
|
1171
|
-
kmsKeyArn:
|
|
1172
|
-
algorithm:
|
|
1173
|
-
value:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
1527
|
-
slackApp:
|
|
1528
|
-
githubApp:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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-
|
|
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-
|
|
45
|
+
} from "../chunk-gcg67ck3.js";
|
|
46
46
|
export {
|
|
47
47
|
BUILD_TIMEOUT,
|
|
48
48
|
BUN_VERSION,
|
package/package.json
CHANGED
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
|
-
/**
|
|
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
|
|
165
|
-
for (const
|
|
166
|
-
const parsed = parseSchedule(
|
|
167
|
-
if (!parsed.ok) throw new Error(`Invalid
|
|
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
|
-
|
|
218
|
+
assertValidTenantJobs(tenantConfig);
|
|
214
219
|
assertProtectedPolicy(platformConfig, tenantConfig);
|
|
215
220
|
|
|
216
221
|
if (options.dryRun !== true) {
|
package/src/stacks/api-stack.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
-
import {
|
|
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
|
|
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;
|