@deployfoundation/foundation-deploy 0.2.54 → 0.2.55

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/README.md CHANGED
@@ -55,7 +55,7 @@ bun run packages/infra/bin/foundation-deploy.ts <command> --instance <path>
55
55
  | `slack-manifest` | Print the instance's Slack app manifest as JSON (the Slack CLI's `get-manifest` hook contract). |
56
56
  | `stage-customization` | Validate and stage a tenant-owned runtime config. |
57
57
  | `crm-key` | Write the company CRM's `crm/1` key, piped in with `--stdin`, into `<secretPrefix>/crm/http` (`crm.provider` kind `http`). |
58
- | `crm-check` | Run the `crm/1` conformance suite: the read cases against the instance's CRM with its stored key, or, with `--base-url --tenant --key-stdin` and no instance, any server, write cases included. |
58
+ | `crm-check` | Run the `crm/1` conformance suite: the read cases against the instance's CRM with its stored key, or, with `--base-url --tenant --key-stdin` and no instance, any server, write cases included, every case strict given the server's harness endpoint (`--harness-url`) and the deployment's inputs (docs/crm-provider-contract.md#crm-check). A CRM can also import the suite: `@deployfoundation/foundation-deploy/crm-conformance`. |
59
59
  | `crm-replay` | Replay the agent's CRM writes (JSONL) into the instance's own table after a rollback to the DynamoDB provider. |
60
60
 
61
61
  `--instance <path>` is required for every command (or `FOUNDATION_INSTANCE_FILE`
package/dist/bin/app.js CHANGED
@@ -8,16 +8,16 @@ import {
8
8
  FoundationPages,
9
9
  FoundationPipeline,
10
10
  NewsletterStack
11
- } from "../chunk-tfn9tvaa.js";
11
+ } from "../chunk-68jxyg2n.js";
12
12
  import"../chunk-pxhftba1.js";
13
- import"../chunk-dz91avrb.js";
14
13
  import {
15
14
  adminsFor,
16
15
  instanceNames,
17
16
  loadInstanceFile,
18
17
  provisionsIntegration
19
- } from "../chunk-fjdgxw8h.js";
20
- import"../chunk-96fjaysn.js";
18
+ } from "../chunk-pmjbzsq3.js";
19
+ import"../chunk-dz91avrb.js";
20
+ import"../chunk-rvx0cncy.js";
21
21
  import"../chunk-5wgwscd0.js";
22
22
 
23
23
  // bin/app.ts
@@ -1,17 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
  // @bun
3
- import {
4
- RELEASE_BUCKET_ENV,
5
- RELEASE_VERSION_RE,
6
- manifestKey,
7
- releaseCacheDir,
8
- skillsKey,
9
- verifyManifest
10
- } from "../chunk-pxhftba1.js";
11
- import {
12
- DEFAULT_RELEASE_BUCKET,
13
- expectedKeyArn
14
- } from "../chunk-dz91avrb.js";
15
3
  import {
16
4
  argv,
17
5
  aws,
@@ -32,16 +20,28 @@ import {
32
20
  secretExists,
33
21
  stackExists,
34
22
  stackOutput
35
- } from "../chunk-nvsj7bbw.js";
23
+ } from "../chunk-345e0grd.js";
24
+ import {
25
+ RELEASE_BUCKET_ENV,
26
+ RELEASE_VERSION_RE,
27
+ manifestKey,
28
+ releaseCacheDir,
29
+ skillsKey,
30
+ verifyManifest
31
+ } from "../chunk-pxhftba1.js";
36
32
  import {
37
33
  CUSTOMIZATION_ARTIFACT_NAME,
38
34
  instanceNames,
39
35
  mongoCollectionPolicyEnv,
40
36
  slackCommandPrefix,
41
37
  versionedConfigKey
42
- } from "../chunk-fjdgxw8h.js";
43
- import"../chunk-m76jt5rc.js";
44
- import"../chunk-3614v9rr.js";
38
+ } from "../chunk-pmjbzsq3.js";
39
+ import {
40
+ DEFAULT_RELEASE_BUCKET,
41
+ expectedKeyArn
42
+ } from "../chunk-dz91avrb.js";
43
+ import"../chunk-hvntk8p5.js";
44
+ import"../chunk-xt9gdsa0.js";
45
45
  import {
46
46
  BASE_SLACK_EVENTS,
47
47
  CAPABILITY_IDS,
@@ -53,7 +53,7 @@ import {
53
53
  requiredGithubPermissions,
54
54
  requiredSlackCommands,
55
55
  requiredSlackScopes
56
- } from "../chunk-96fjaysn.js";
56
+ } from "../chunk-rvx0cncy.js";
57
57
  import {
58
58
  __require
59
59
  } from "../chunk-5wgwscd0.js";
@@ -2449,6 +2449,20 @@ crm-check options
2449
2449
  --write with --base-url only: also run the write cases, which
2450
2450
  create records and spend the week's outreach allowance;
2451
2451
  point it at a sandbox tenant
2452
+ with --base-url --write, what the remaining write cases need:
2453
+ --harness-url <url> the CRM's harness endpoint (crm-harness/1), which does
2454
+ --harness-key-stdin what only a person can; its key comes on stdin
2455
+ --readonly-key-stdin a key for the same tenant without write scopes, on stdin
2456
+ --status-policy <file>
2457
+ the deployment's capabilities.crm.statusPolicy (JSON
2458
+ or YAML)
2459
+ --suppressed-statuses <file>
2460
+ its capabilities.crm.outreach.suppressedStatuses
2461
+ --spend-weekly-allowance
2462
+ drive this week's outreach caps to their limits
2463
+ (a throwaway database only)
2464
+ Keys on stdin go one per line, in this order: the crm/1 key, the read-only
2465
+ key, the harness key (each only when its flag is given).
2452
2466
 
2453
2467
  crm-replay options
2454
2468
  --from <file> REQUIRED: the writes to replay, one JSON object per line
@@ -2462,6 +2476,22 @@ function flag2(args, name) {
2462
2476
  const index = args.indexOf(name);
2463
2477
  return index === -1 ? undefined : args[index + 1];
2464
2478
  }
2479
+ function valueFlag(args, name, what) {
2480
+ if (!args.includes(name))
2481
+ return;
2482
+ const value = flag2(args, name);
2483
+ if (value === undefined || value.startsWith("-"))
2484
+ throw new Error(`${name} needs ${what}`);
2485
+ return value;
2486
+ }
2487
+ var CRM_CHECK_TARGET_FLAGS = [
2488
+ "--harness-url",
2489
+ "--harness-key-stdin",
2490
+ "--readonly-key-stdin",
2491
+ "--status-policy",
2492
+ "--suppressed-statuses",
2493
+ "--spend-weekly-allowance"
2494
+ ];
2465
2495
  function contextFor(args) {
2466
2496
  const paths = loadInstanceContext(resolveInstanceFilePath(args, process.env));
2467
2497
  return awsContext({
@@ -2540,12 +2570,22 @@ ${USAGE}`);
2540
2570
  const tenantId = flag2(args, "--tenant");
2541
2571
  if (baseUrl === undefined || tenantId === undefined || !args.includes("--key-stdin"))
2542
2572
  throw new Error("crm-check --base-url needs --tenant <id> and --key-stdin");
2543
- const { crmCheckTarget, defaultCrmCommandDeps } = await import("../chunk-1pww63wd.js");
2573
+ const harnessUrl = valueFlag(args, "--harness-url", "the CRM's harness endpoint URL");
2574
+ if (harnessUrl !== undefined !== args.includes("--harness-key-stdin"))
2575
+ throw new Error("crm-check --harness-url <url> and --harness-key-stdin go together: the harness key comes on stdin");
2576
+ const statusPolicyFile = valueFlag(args, "--status-policy", "a file");
2577
+ const suppressedStatusesFile = valueFlag(args, "--suppressed-statuses", "a file");
2578
+ const { crmCheckTarget, defaultCrmCommandDeps } = await import("../chunk-ztrhakgc.js");
2544
2579
  return crmCheckTarget({
2545
2580
  baseUrl,
2546
2581
  tenantId,
2547
2582
  write: args.includes("--write"),
2548
- allowSkips: args.includes("--allow-skips")
2583
+ allowSkips: args.includes("--allow-skips"),
2584
+ readOnlyKeyStdin: args.includes("--readonly-key-stdin"),
2585
+ spendWeeklyAllowance: args.includes("--spend-weekly-allowance"),
2586
+ ...harnessUrl === undefined ? {} : { harnessUrl },
2587
+ ...statusPolicyFile === undefined ? {} : { statusPolicyFile },
2588
+ ...suppressedStatusesFile === undefined ? {} : { suppressedStatusesFile }
2549
2589
  }, defaultCrmCommandDeps());
2550
2590
  }
2551
2591
  if (command === "github-app-create") {
@@ -2613,21 +2653,24 @@ ${USAGE}`);
2613
2653
  return 0;
2614
2654
  }
2615
2655
  case "crm-key": {
2616
- const { crmKey, defaultCrmCommandDeps } = await import("../chunk-1pww63wd.js");
2656
+ const { crmKey, defaultCrmCommandDeps } = await import("../chunk-ztrhakgc.js");
2617
2657
  await crmKey(ctx, { stdin: args.includes("--stdin") }, defaultCrmCommandDeps());
2618
2658
  return 0;
2619
2659
  }
2620
2660
  case "crm-check": {
2621
2661
  if (args.includes("--write"))
2622
2662
  throw new Error("crm-check --write creates records and spends the week's outreach allowance, so it never runs against the instance's own tenant: use --base-url with a sandbox tenant");
2623
- const { crmCheck, defaultCrmCommandDeps } = await import("../chunk-1pww63wd.js");
2663
+ const targetOnly = CRM_CHECK_TARGET_FLAGS.filter((name) => args.includes(name));
2664
+ if (targetOnly.length > 0)
2665
+ throw new Error(`crm-check --instance runs the read cases with the deployment's own key and policy; ${targetOnly.join(", ")} feed the write cases: use --base-url --write with a sandbox tenant`);
2666
+ const { crmCheck, defaultCrmCommandDeps } = await import("../chunk-ztrhakgc.js");
2624
2667
  return crmCheck(ctx, { allowSkips: args.includes("--allow-skips") }, defaultCrmCommandDeps());
2625
2668
  }
2626
2669
  case "crm-replay": {
2627
2670
  const from = flag2(args, "--from");
2628
2671
  if (from === undefined || from.startsWith("-"))
2629
2672
  throw new Error("crm-replay needs --from <file>: the writes to replay, as JSONL");
2630
- const { crmReplay, defaultCrmCommandDeps } = await import("../chunk-1pww63wd.js");
2673
+ const { crmReplay, defaultCrmCommandDeps } = await import("../chunk-ztrhakgc.js");
2631
2674
  const result = await crmReplay(ctx, { from }, defaultCrmCommandDeps());
2632
2675
  return result.refused.length > 0 ? 1 : 0;
2633
2676
  }
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  instanceNames,
3
3
  loadInstanceFile
4
- } from "./chunk-fjdgxw8h.js";
4
+ } from "./chunk-pmjbzsq3.js";
5
5
 
6
6
  // src/deploy/aws.ts
7
7
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
@@ -17,7 +17,7 @@ import {
17
17
  provisionsIntegration,
18
18
  requiresAuthenticatedQueue,
19
19
  slackCommandPrefixes
20
- } from "./chunk-fjdgxw8h.js";
20
+ } from "./chunk-pmjbzsq3.js";
21
21
  import {
22
22
  capabilityScheduledJobs,
23
23
  crmOutreachConfigured,
@@ -28,7 +28,7 @@ import {
28
28
  parseSchedule,
29
29
  scheduledJobBindings,
30
30
  webhookJobBindings
31
- } from "./chunk-96fjaysn.js";
31
+ } from "./chunk-rvx0cncy.js";
32
32
 
33
33
  // src/stacks/agent-stack.ts
34
34
  import * as cdk from "aws-cdk-lib";
@@ -1942,8 +1942,10 @@ function addConfigLoadAlarm(stack, args) {
1942
1942
  `${args.instance.displayName}'s runtime is not serving the config deployed for it: the`,
1943
1943
  "config failed to load, so it runs on its last good config with company-data",
1944
1944
  "capabilities off, or on the built-in fallback. The config_fallback_active or",
1945
- "config_reload_failed line in the runtime's live log group names the refused field.",
1946
- "Fix config.yaml, run foundation-deploy check, then deploy or config:sync."
1945
+ "config_reload_failed line in the runtime's live log group says why: its error_message",
1946
+ "names the refused field (record keys shown as *), the YAML error and line, or the",
1947
+ "S3/KMS error and the IAM action it cites (error_type s3_read_failed).",
1948
+ "Fix config.yaml or the grant, run foundation-deploy check, then deploy or config:sync."
1947
1949
  ].join(" "),
1948
1950
  metric: filter.metric({ statistic: "Sum", period: cdk2.Duration.hours(1) }),
1949
1951
  threshold: 1,
@@ -15,7 +15,7 @@ import {
15
15
  classifyOutreach,
16
16
  movesToDraftedStatus,
17
17
  outreachWeekStart
18
- } from "./chunk-p7zkm4pp.js";
18
+ } from "./chunk-caqstrxb.js";
19
19
  import {
20
20
  CRM_MAX_ACTIVITY_PAGE_SIZE,
21
21
  CRM_MAX_CURSOR_CHARS,
@@ -29,8 +29,8 @@ import {
29
29
  parseCrmOutreachQueueResponse,
30
30
  parseCrmSearchResponse,
31
31
  parseCrmTimestamp
32
- } from "./chunk-3614v9rr.js";
33
- import"./chunk-96fjaysn.js";
32
+ } from "./chunk-xt9gdsa0.js";
33
+ import"./chunk-rvx0cncy.js";
34
34
  import"./chunk-5wgwscd0.js";
35
35
 
36
36
  // ../capability-crm/src/proxy/repository.ts
@@ -5,7 +5,7 @@ import {
5
5
  CRM_OUTREACH_REPLY_ACTIVITY,
6
6
  CRM_OUTREACH_SENT_ACTIVITY,
7
7
  CrmInputError
8
- } from "./chunk-3614v9rr.js";
8
+ } from "./chunk-xt9gdsa0.js";
9
9
 
10
10
  // ../capability-crm/src/proxy/policy.ts
11
11
  class CrmPolicyRefusedError extends Error {
@@ -0,0 +1,101 @@
1
+ import {
2
+ CrmConformanceHarnessError
3
+ } from "./chunk-vcz9zbb3.js";
4
+ import {
5
+ CRM_HTTP_HEADERS,
6
+ crmHttpBaseUrl,
7
+ isCrmHttpKey,
8
+ readCrmHttpBody
9
+ } from "./chunk-hvntk8p5.js";
10
+ import"./chunk-caqstrxb.js";
11
+ import"./chunk-xt9gdsa0.js";
12
+ import"./chunk-rvx0cncy.js";
13
+ import"./chunk-5wgwscd0.js";
14
+
15
+ // ../capability-crm/src/contract/http-harness.ts
16
+ var CRM_HARNESS_CONTRACT = "crm-harness/1";
17
+ var USER_AGENT = "foundation-crm-conformance/1";
18
+ var TIMEOUT_MS = 1e4;
19
+ var MAX_REPLY_BYTES = 64 * 1024;
20
+ function crmHarnessUrl(value) {
21
+ try {
22
+ return crmHttpBaseUrl(value);
23
+ } catch {
24
+ throw new Error("the harness URL must be an HTTPS URL with no userinfo, query or fragment");
25
+ }
26
+ }
27
+ function createCrmHttpHarness(options) {
28
+ const url = crmHarnessUrl(options.url);
29
+ if (!isCrmHttpKey(options.apiKey)) {
30
+ throw new Error("the harness key must be 16 to 1024 visible ASCII characters");
31
+ }
32
+ const { apiKey, tenantId } = options;
33
+ const fetchImpl = options.fetchImpl ?? fetch;
34
+ async function call(request) {
35
+ const signal = AbortSignal.timeout(TIMEOUT_MS);
36
+ let response;
37
+ try {
38
+ response = await fetchImpl(url, {
39
+ method: "POST",
40
+ redirect: "error",
41
+ headers: {
42
+ authorization: `Bearer ${apiKey}`,
43
+ accept: "application/json",
44
+ "content-type": "application/json",
45
+ "user-agent": USER_AGENT,
46
+ [CRM_HTTP_HEADERS.contract]: CRM_HARNESS_CONTRACT
47
+ },
48
+ body: JSON.stringify(request),
49
+ signal
50
+ });
51
+ } catch {
52
+ throw new CrmConformanceHarnessError(`${request.action} ${signal.aborted ? "did not answer in time" : "could not reach the harness"}`);
53
+ }
54
+ let text;
55
+ try {
56
+ text = await readCrmHttpBody(response, MAX_REPLY_BYTES, signal);
57
+ } catch {
58
+ throw new CrmConformanceHarnessError(`${request.action} reply is too large or unreadable`);
59
+ }
60
+ if (response.status < 200 || response.status > 299) {
61
+ throw new CrmConformanceHarnessError(`${request.action} answered HTTP ${response.status}`);
62
+ }
63
+ return text;
64
+ }
65
+ return {
66
+ async setStatusAsPerson(recordType, recordId, status) {
67
+ await call({ action: "set_status_as_person", tenantId, recordType, recordId, status });
68
+ },
69
+ async setDoNotContact(recordType, recordId) {
70
+ await call({ action: "set_do_not_contact", tenantId, recordType, recordId });
71
+ },
72
+ async suppressEmail(email) {
73
+ await call({ action: "suppress_email", tenantId, email });
74
+ },
75
+ async addPersonNote(recordType, recordId, summary) {
76
+ const text = await call({
77
+ action: "add_person_note",
78
+ tenantId,
79
+ recordType,
80
+ recordId,
81
+ summary
82
+ });
83
+ let activityId;
84
+ try {
85
+ const reply = JSON.parse(text);
86
+ if (typeof reply === "object" && reply !== null && "activityId" in reply) {
87
+ activityId = reply.activityId;
88
+ }
89
+ } catch {}
90
+ if (typeof activityId !== "string" || !/^[\x21-\x7e]{1,256}$/.test(activityId)) {
91
+ throw new CrmConformanceHarnessError("add_person_note answered no activityId");
92
+ }
93
+ return activityId;
94
+ }
95
+ };
96
+ }
97
+ export {
98
+ CRM_HARNESS_CONTRACT,
99
+ createCrmHttpHarness,
100
+ crmHarnessUrl
101
+ };
@@ -12,7 +12,7 @@ import {
12
12
  parseCrmOutreachState,
13
13
  parseCrmProviderMeta,
14
14
  parseCrmSearchResponse
15
- } from "./chunk-3614v9rr.js";
15
+ } from "./chunk-xt9gdsa0.js";
16
16
 
17
17
  // ../connectors/src/crm-http.ts
18
18
  import { randomUUID } from "node:crypto";
@@ -0,0 +1,13 @@
1
+ import {
2
+ CrmConformanceHarnessError,
3
+ runCrmConformance
4
+ } from "./chunk-vcz9zbb3.js";
5
+ import"./chunk-hvntk8p5.js";
6
+ import"./chunk-caqstrxb.js";
7
+ import"./chunk-xt9gdsa0.js";
8
+ import"./chunk-rvx0cncy.js";
9
+ import"./chunk-5wgwscd0.js";
10
+ export {
11
+ CrmConformanceHarnessError,
12
+ runCrmConformance
13
+ };
@@ -2,7 +2,7 @@ import {
2
2
  INTEGRATION_CAPABILITY_IDS,
3
3
  PROACTIVE_CHANNEL_MODES,
4
4
  SLACK_COMMAND_PREFIX_RE
5
- } from "./chunk-96fjaysn.js";
5
+ } from "./chunk-rvx0cncy.js";
6
6
 
7
7
  // ../core/src/instance.ts
8
8
  import { parse as parseYaml } from "yaml";
@@ -1,8 +1,3 @@
1
- // ../core/src/config.ts
2
- import { createHash as createHash2 } from "node:crypto";
3
- import { parse as parseYaml } from "yaml";
4
- import { z as z4 } from "zod";
5
-
6
1
  // ../core/src/capabilities.ts
7
2
  import { z } from "zod";
8
3
 
@@ -936,6 +931,11 @@ function requiredSlackCommandSuffixes(enabled) {
936
931
  return [...suffixes].sort();
937
932
  }
938
933
 
934
+ // ../core/src/config.ts
935
+ import { createHash as createHash2 } from "node:crypto";
936
+ import { parse as parseYaml } from "yaml";
937
+ import { z as z4 } from "zod";
938
+
939
939
  // ../core/src/jobs.ts
940
940
  import { createHash, createHmac, timingSafeEqual } from "node:crypto";
941
941
  import { z as z3 } from "zod";
@@ -1234,6 +1234,33 @@ function parseInstanceConfig(yamlText) {
1234
1234
  throw new Error(`job ${job.id} owner ${job.owner} must be an instance admin`);
1235
1235
  return cfg;
1236
1236
  }
1237
+ var ZOD_ISSUE_CODES = new Set(Object.values(z4.ZodIssueCode));
1238
+ var ZOD_PARSED_TYPES = new Set(Object.values(z4.ZodParsedType));
1239
+ var YAML_ERROR_CODES = new Set([
1240
+ "ALIAS_PROPS",
1241
+ "BAD_ALIAS",
1242
+ "BAD_COLLECTION_TYPE",
1243
+ "BAD_DIRECTIVE",
1244
+ "BAD_DQ_ESCAPE",
1245
+ "BAD_INDENT",
1246
+ "BAD_PROP_ORDER",
1247
+ "BAD_SCALAR_START",
1248
+ "BLOCK_AS_IMPLICIT_KEY",
1249
+ "BLOCK_IN_FLOW",
1250
+ "DUPLICATE_KEY",
1251
+ "IMPOSSIBLE",
1252
+ "KEY_OVER_1024_CHARS",
1253
+ "MISSING_CHAR",
1254
+ "MULTILINE_IMPLICIT_KEY",
1255
+ "MULTIPLE_ANCHORS",
1256
+ "MULTIPLE_DOCS",
1257
+ "MULTIPLE_TAGS",
1258
+ "NON_STRING_KEY",
1259
+ "RESOURCE_EXHAUSTION",
1260
+ "TAB_AS_INDENT",
1261
+ "TAG_RESOLVE_FAILED",
1262
+ "UNEXPECTED_TOKEN"
1263
+ ]);
1237
1264
 
1238
1265
  // ../core/src/logger.ts
1239
1266
  import { AsyncLocalStorage } from "node:async_hooks";
@@ -1295,4 +1322,4 @@ function wrapUntrusted(source, content) {
1295
1322
  ${safe}
1296
1323
  </${UNTRUSTED_TAG}>`;
1297
1324
  }
1298
- export { parseSchedule, BASE_SLACK_EVENTS, SLACK_COMMAND_PREFIX_RE, CAPABILITY_IDS, INTEGRATION_CAPABILITY_IDS, requiredGithubPermissions, requiredSlackScopes, requiredSlackCommands, isScheduledJob, isWebhookJob, webhookJobBindings, scheduledJobBindings, jobWebhookKey, capabilityScheduledJobs, crmOutreachConfigured, crmPolicyFingerprint, parseInstanceConfig, PROACTIVE_CHANNEL_MODES, encodeProactiveChannels, wrapUntrusted };
1325
+ export { parseSchedule, RawCrmStatusPolicy, RawCrmStatusesByType, BASE_SLACK_EVENTS, SLACK_COMMAND_PREFIX_RE, CAPABILITY_IDS, INTEGRATION_CAPABILITY_IDS, requiredGithubPermissions, requiredSlackScopes, requiredSlackCommands, isScheduledJob, isWebhookJob, webhookJobBindings, scheduledJobBindings, jobWebhookKey, capabilityScheduledJobs, crmOutreachConfigured, crmPolicyFingerprint, parseInstanceConfig, PROACTIVE_CHANNEL_MODES, encodeProactiveChannels, wrapUntrusted };
@@ -6,21 +6,26 @@ import {
6
6
  createCrmHttpClient,
7
7
  encodeCrmActor,
8
8
  readCrmHttpBody
9
- } from "./chunk-m76jt5rc.js";
9
+ } from "./chunk-hvntk8p5.js";
10
10
  import {
11
11
  outreachWeekStart,
12
12
  personOwnedStatuses
13
- } from "./chunk-p7zkm4pp.js";
13
+ } from "./chunk-caqstrxb.js";
14
14
  import {
15
15
  CRM_NOTE_ACTIVITY,
16
16
  CRM_OUTREACH_DRAFTED_ACTIVITY,
17
17
  CRM_REQUIRED_PROVIDER_OPERATIONS
18
- } from "./chunk-3614v9rr.js";
19
- import"./chunk-96fjaysn.js";
20
- import"./chunk-5wgwscd0.js";
18
+ } from "./chunk-xt9gdsa0.js";
21
19
 
22
20
  // ../capability-crm/src/contract/conformance.ts
23
21
  import { randomUUID } from "node:crypto";
22
+ class CrmConformanceHarnessError extends Error {
23
+ constructor(message) {
24
+ super(message);
25
+ this.name = "CrmConformanceHarnessError";
26
+ }
27
+ }
28
+
24
29
  class Skip extends Error {
25
30
  reason;
26
31
  needs;
@@ -386,9 +391,11 @@ class Suite {
386
391
  return;
387
392
  }
388
393
  async transitionCase() {
389
- const transition = this.target.disallowedTransition;
390
- if (transition === undefined)
391
- throw new Skip("no disallowedTransition given");
394
+ const given = this.target.disallowedTransition;
395
+ const transition = given ?? this.policyDisallowedTransition();
396
+ if (transition === undefined) {
397
+ throw new Skip(this.target.statusPolicy === undefined ? "no disallowedTransition or statusPolicy given" : "the statusPolicy allows every move from a new record's status");
398
+ }
392
399
  const fixture = await this.fixture("transition");
393
400
  const recordId = transition.recordType === "company" ? fixture.companyId : fixture.contactId;
394
401
  const current = await this.profile(transition.recordType, recordId);
@@ -398,6 +405,23 @@ class Suite {
398
405
  status: transition.to,
399
406
  idempotencyKey: this.key()
400
407
  }, { status: current.status, statusVersion: current.statusVersion ?? 0 }), "refused", "transition_not_allowed");
408
+ return given === undefined ? `${transition.recordType} → ${transition.to}, from the statusPolicy` : undefined;
409
+ }
410
+ policyDisallowedTransition() {
411
+ const policy = this.target.statusPolicy;
412
+ if (policy === undefined)
413
+ return;
414
+ const meta = this.requireMeta();
415
+ for (const recordType of ["company", "contact"]) {
416
+ const rule = policy[recordType];
417
+ const [first, ...others] = meta.statuses[recordType];
418
+ if (rule === undefined || first === undefined)
419
+ continue;
420
+ const exits = Object.hasOwn(rule.agentTransitions, first) ? rule.agentTransitions[first] ?? [] : [];
421
+ const to = others.find((status) => !exits.includes(status));
422
+ if (to !== undefined)
423
+ return { recordType, to };
424
+ }
401
425
  return;
402
426
  }
403
427
  async statusLockedCase() {
@@ -1102,6 +1126,8 @@ async function expectError(call, code, reason) {
1102
1126
  function describe(error) {
1103
1127
  if (error instanceof Failure)
1104
1128
  return error.message;
1129
+ if (error instanceof CrmConformanceHarnessError)
1130
+ return `harness: ${error.message}`;
1105
1131
  if (error instanceof CrmHttpError) {
1106
1132
  return [
1107
1133
  error.code,
@@ -1112,6 +1138,5 @@ function describe(error) {
1112
1138
  }
1113
1139
  return `unexpected ${error instanceof Error ? error.name : typeof error}`;
1114
1140
  }
1115
- export {
1116
- runCrmConformance
1117
- };
1141
+
1142
+ export { CrmConformanceHarnessError, runCrmConformance };
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  wrapUntrusted
3
- } from "./chunk-96fjaysn.js";
3
+ } from "./chunk-rvx0cncy.js";
4
4
 
5
5
  // ../connectors/src/crm.ts
6
6
  var CRM_MAX_BATCH_SIZE = 100;
@@ -3,8 +3,8 @@ import {
3
3
  putSecretString,
4
4
  readSecretString,
5
5
  secretExists
6
- } from "./chunk-nvsj7bbw.js";
7
- import"./chunk-fjdgxw8h.js";
6
+ } from "./chunk-345e0grd.js";
7
+ import"./chunk-pmjbzsq3.js";
8
8
  import {
9
9
  CrmIdempotencyConflictError,
10
10
  CrmRecordNotFoundError
@@ -12,27 +12,30 @@ import {
12
12
  import {
13
13
  crmHttpBaseUrl,
14
14
  isCrmHttpKey
15
- } from "./chunk-m76jt5rc.js";
15
+ } from "./chunk-hvntk8p5.js";
16
16
  import {
17
17
  CrmPolicyRefusedError
18
- } from "./chunk-p7zkm4pp.js";
18
+ } from "./chunk-caqstrxb.js";
19
19
  import {
20
20
  CRM_MAX_ID_CHARS,
21
21
  CrmInputError,
22
22
  parseCrmLogActivityArgs,
23
23
  parseCrmUpdateStatusArgs,
24
24
  parseCrmUpsertRecordsArgs
25
- } from "./chunk-3614v9rr.js";
25
+ } from "./chunk-xt9gdsa0.js";
26
26
  import {
27
+ RawCrmStatusPolicy,
28
+ RawCrmStatusesByType,
27
29
  crmOutreachConfigured,
28
30
  parseInstanceConfig
29
- } from "./chunk-96fjaysn.js";
31
+ } from "./chunk-rvx0cncy.js";
30
32
  import {
31
33
  __require
32
34
  } from "./chunk-5wgwscd0.js";
33
35
 
34
36
  // src/deploy/crm.ts
35
37
  import { readFileSync } from "node:fs";
38
+ import { parse as parseYaml } from "yaml";
36
39
  function httpProvider(ctx, command) {
37
40
  const { instance } = ctx;
38
41
  const provider = instance.crm.provider;
@@ -78,6 +81,11 @@ async function crmCheck(ctx, options, deps) {
78
81
  });
79
82
  return reportConformance(report, provider.baseUrl, deps.log);
80
83
  }
84
+ var STDIN_KEYS = [
85
+ { flag: "--key-stdin", name: "the crm/1 key" },
86
+ { flag: "--readonly-key-stdin", name: "the read-only key" },
87
+ { flag: "--harness-key-stdin", name: "the harness key" }
88
+ ];
81
89
  async function crmCheckTarget(options, deps) {
82
90
  let baseUrl;
83
91
  try {
@@ -87,20 +95,86 @@ async function crmCheckTarget(options, deps) {
87
95
  }
88
96
  if (!/^[\x21-\x7e]{1,256}$/.test(options.tenantId))
89
97
  throw new Error("--tenant must be the tenant id the server's /meta names");
90
- const apiKey = (await deps.readStdin()).trim();
91
- if (!isCrmHttpKey(apiKey))
92
- throw new Error("stdin did not hold a usable crm/1 key (--key-stdin)");
98
+ const writeInputs = [
99
+ ["--harness-url", options.harnessUrl !== undefined],
100
+ ["--readonly-key-stdin", options.readOnlyKeyStdin === true],
101
+ ["--status-policy", options.statusPolicyFile !== undefined],
102
+ ["--suppressed-statuses", options.suppressedStatusesFile !== undefined],
103
+ ["--spend-weekly-allowance", options.spendWeeklyAllowance === true]
104
+ ].filter(([, given]) => given).map(([flag]) => flag);
105
+ if (!options.write && writeInputs.length > 0)
106
+ throw new Error(`${writeInputs.join(", ")} only feed the write cases: add --write, against a sandbox tenant`);
107
+ const { createCrmHttpHarness, crmHarnessUrl } = await import("./chunk-gpq7e1kq.js");
108
+ let harnessUrl;
109
+ try {
110
+ harnessUrl = options.harnessUrl === undefined ? undefined : crmHarnessUrl(options.harnessUrl);
111
+ } catch {
112
+ throw new Error("--harness-url must be an HTTPS URL with no userinfo, query or fragment");
113
+ }
114
+ const statusPolicy = options.statusPolicyFile === undefined ? undefined : deploymentInput(deps, "--status-policy", options.statusPolicyFile, RawCrmStatusPolicy);
115
+ const suppressedStatuses = options.suppressedStatusesFile === undefined ? undefined : deploymentInput(deps, "--suppressed-statuses", options.suppressedStatusesFile, RawCrmStatusesByType);
116
+ const wanted = STDIN_KEYS.filter(({ flag }) => flag === "--key-stdin" || flag === "--readonly-key-stdin" && options.readOnlyKeyStdin === true || flag === "--harness-key-stdin" && harnessUrl !== undefined);
117
+ const keys = stdinKeys(await deps.readStdin(), wanted);
118
+ const key = (flag) => keys[wanted.findIndex((entry) => entry.flag === flag)];
119
+ const apiKey = key("--key-stdin");
120
+ const readOnlyApiKey = key("--readonly-key-stdin");
121
+ const harnessKey = key("--harness-key-stdin");
122
+ const harness = harnessUrl === undefined || harnessKey === undefined ? undefined : createCrmHttpHarness({
123
+ url: harnessUrl,
124
+ apiKey: harnessKey,
125
+ tenantId: options.tenantId,
126
+ fetchImpl: deps.fetch
127
+ });
93
128
  const report = await deps.runConformance({
94
129
  baseUrl,
95
130
  apiKey,
96
131
  tenantId: options.tenantId,
97
132
  write: options.write,
98
- strict: !options.allowSkips
133
+ strict: !options.allowSkips,
134
+ ...readOnlyApiKey === undefined ? {} : { readOnlyApiKey },
135
+ ...statusPolicy === undefined ? {} : { statusPolicy },
136
+ ...suppressedStatuses === undefined ? {} : { suppressedStatuses },
137
+ ...options.spendWeeklyAllowance === true ? { spendWeeklyAllowance: true } : {},
138
+ ...harness === undefined ? {} : { harness }
99
139
  });
100
- return reportConformance(report, baseUrl, deps.log);
140
+ return reportConformance(report, baseUrl, deps.log, harnessUrl);
141
+ }
142
+ function stdinKeys(text, wanted) {
143
+ const lines = text.split(/\r?\n/).map((line) => line.trim()).filter((line) => line !== "");
144
+ if (wanted.length === 1) {
145
+ if (lines.length !== 1 || !isCrmHttpKey(lines[0]))
146
+ throw new Error("stdin did not hold a usable crm/1 key (--key-stdin)");
147
+ return lines;
148
+ }
149
+ if (lines.length !== wanted.length)
150
+ throw new Error(`stdin must hold ${wanted.length} keys, one per line: ${wanted.map(({ name, flag }) => `${name} (${flag})`).join(", then ")}; it held ${lines.length}`);
151
+ lines.forEach((line, index) => {
152
+ const entry = wanted[index];
153
+ if (!isCrmHttpKey(line))
154
+ throw new Error(`stdin line ${index + 1}, ${entry.name} (${entry.flag}), is not a usable key: 16 to 1024 visible ASCII characters, no spaces`);
155
+ });
156
+ return lines;
157
+ }
158
+ function deploymentInput(deps, flag, path, schema) {
159
+ let text;
160
+ try {
161
+ text = deps.readFile(path);
162
+ } catch {
163
+ throw new Error(`${flag} ${path} could not be read`);
164
+ }
165
+ let value;
166
+ try {
167
+ value = parseYaml(text);
168
+ } catch {
169
+ throw new Error(`${flag} ${path} is not JSON or YAML`);
170
+ }
171
+ const parsed = schema.safeParse(value);
172
+ if (!parsed.success)
173
+ throw new Error(`${flag} ${path}: ${parsed.error.issues.map((issue) => `${issue.path.join(".") || "(top level)"}: ${issue.message}`).join("; ")}`);
174
+ return parsed.data;
101
175
  }
102
- function reportConformance(report, baseUrl, log) {
103
- log(`crm/1 conformance against ${baseUrl}`);
176
+ function reportConformance(report, baseUrl, log, harnessUrl) {
177
+ log(`crm/1 conformance against ${baseUrl}${harnessUrl === undefined ? "" : `, harness ${harnessUrl}`}`);
104
178
  for (const entry of report.cases) {
105
179
  const mark = entry.outcome === "fail" ? "FAIL" : entry.outcome;
106
180
  log(` ${mark.padEnd(4)} ${entry.id}: ${entry.title}${entry.detail === undefined ? "" : ` (${entry.detail})`}`);
@@ -181,7 +255,7 @@ async function crmReplay(ctx, options, deps) {
181
255
  const crm = crmCapability(config);
182
256
  if (crm === undefined || crm.statuses.company.length === 0 || crm.statuses.contact.length === 0)
183
257
  throw new Error(`${ctx.paths.configPath}: crm-replay needs capabilities.crm with its statuses`);
184
- const { createDynamoCrmClient, deterministicRecordId } = await import("./chunk-mh1206ne.js");
258
+ const { createDynamoCrmClient, deterministicRecordId } = await import("./chunk-7k4e3d7n.js");
185
259
  const writes = parseCrmReplay(deps.readFile(options.from), crm, deterministicRecordId);
186
260
  const counts = REPLAY_OPS.map((op) => `${writes.filter((write) => write.op === op).length} ${op}`).join(", ");
187
261
  if (ctx.dryRun === true) {
@@ -282,12 +356,13 @@ function defaultCrmCommandDeps() {
282
356
  putSecretString,
283
357
  readSecretString,
284
358
  runConformance: async (target) => {
285
- const { runCrmConformance } = await import("./chunk-jf5rw6h4.js");
359
+ const { runCrmConformance } = await import("./chunk-p6yjbtqz.js");
286
360
  return runCrmConformance(target);
287
361
  },
288
362
  crmTableName: crmTableNameFromStack,
289
363
  documentClient: operatorDocumentClient,
290
364
  readFile: (path) => readFileSync(path, "utf8"),
365
+ fetch: (input, init) => fetch(input, init),
291
366
  log: (line) => console.log(line)
292
367
  };
293
368
  }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Declarations for `@deployfoundation/foundation-deploy/crm-conformance`
3
+ * (src/crm-conformance.ts): the `crm/1` conformance suite. Self-contained on
4
+ * purpose: the source behind them is bundled from workspace packages that are
5
+ * never published. test/crm-conformance-export.test.ts fails the typecheck
6
+ * when these and the source drift apart.
7
+ *
8
+ * docs/crm-provider-contract.md#conformance describes each option.
9
+ */
10
+
11
+ /** A fetch the suite sends every request through; default the global `fetch`. */
12
+ export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
13
+
14
+ export type CrmRecordType = "company" | "contact";
15
+
16
+ /** The agent's allowed status changes on one record type. */
17
+ export interface CrmStatusRule {
18
+ /** From each status, the statuses the agent may move a record to. No entry, no exit. */
19
+ agentTransitions: Readonly<Record<string, readonly string[]>>;
20
+ /** Apply `personSet` to a status a person set; otherwise who set it does not matter. */
21
+ respectHumanChanges: boolean;
22
+ /** `advance`: only forward along `ladder`. `lock`: never. */
23
+ personSet: "lock" | "advance";
24
+ /** The evidence ladder, earliest status first. */
25
+ ladder: readonly string[];
26
+ }
27
+
28
+ /** Absent for a record type, any configured status of that type may be set. */
29
+ export type CrmStatusPolicy = Readonly<Partial<Record<CrmRecordType, CrmStatusRule>>>;
30
+
31
+ /** What only a person can do in a CRM, done directly by the server's own test code. */
32
+ export interface CrmConformanceHarness {
33
+ /** Set a record's status as a person would: stamps `person`, bumps statusVersion. */
34
+ setStatusAsPerson(recordType: CrmRecordType, recordId: string, status: string): Promise<void>;
35
+ /** Set the do-not-contact flag on a contact or a company record. */
36
+ setDoNotContact(recordType: CrmRecordType, recordId: string): Promise<void>;
37
+ /** Suppress an email address for the tenant. */
38
+ suppressEmail(email: string): Promise<void>;
39
+ /**
40
+ * Log a note as a person (`attributes.by = person`); returns its activity
41
+ * id. The summary may exceed 4000 characters; the server must truncate it
42
+ * on read.
43
+ */
44
+ addPersonNote(recordType: CrmRecordType, recordId: string, summary: string): Promise<string>;
45
+ }
46
+
47
+ export interface CrmConformanceTarget {
48
+ baseUrl: string;
49
+ apiKey: string;
50
+ tenantId: string;
51
+ fetchImpl?: FetchLike;
52
+ /** Run cases that create records and spend outreach allowance. */
53
+ write?: boolean;
54
+ /** Also drive the weekly first-touch cap to its limit (CI against a throwaway database only). */
55
+ spendWeeklyAllowance?: boolean;
56
+ /** A key for the same tenant without write scopes: writes must be 403 forbidden. */
57
+ readOnlyApiKey?: string;
58
+ /**
59
+ * A status the agent may not move a fresh record of this type to (from the
60
+ * type's first status). Absent, it is read off `statusPolicy`.
61
+ */
62
+ disallowedTransition?: { recordType: CrmRecordType; to: string };
63
+ /** The deployment's status policy, as the proxy bakes it. */
64
+ statusPolicy?: CrmStatusPolicy;
65
+ /** The deployment's suppressed statuses. */
66
+ suppressedStatuses?: { company: readonly string[]; contact: readonly string[] };
67
+ harness?: CrmConformanceHarness;
68
+ /** Count a case skipped for want of a harness as a failure. */
69
+ strict?: boolean;
70
+ /** Prefix for created externalIds and idempotency keys; default a random id. */
71
+ runId?: string;
72
+ }
73
+
74
+ export type CrmConformanceOutcome = "pass" | "fail" | "skip";
75
+
76
+ export interface CrmConformanceCase {
77
+ id: string;
78
+ title: string;
79
+ outcome: CrmConformanceOutcome;
80
+ /** Why it failed or was skipped: fixed text, codes and statuses, never a reply body. */
81
+ detail?: string;
82
+ }
83
+
84
+ export interface CrmConformanceReport {
85
+ contract: "crm/1";
86
+ passed: number;
87
+ failed: number;
88
+ skipped: number;
89
+ cases: CrmConformanceCase[];
90
+ }
91
+
92
+ /** Run every case against one server and report each outcome. Never throws for a server fault. */
93
+ export declare function runCrmConformance(
94
+ target: CrmConformanceTarget,
95
+ ): Promise<CrmConformanceReport>;
@@ -0,0 +1,11 @@
1
+ import {
2
+ runCrmConformance
3
+ } from "../chunk-vcz9zbb3.js";
4
+ import"../chunk-hvntk8p5.js";
5
+ import"../chunk-caqstrxb.js";
6
+ import"../chunk-xt9gdsa0.js";
7
+ import"../chunk-rvx0cncy.js";
8
+ import"../chunk-5wgwscd0.js";
9
+ export {
10
+ runCrmConformance
11
+ };
package/dist/src/index.js CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  FoundationPipeline,
11
11
  NewsletterStack,
12
12
  deployStatements
13
- } from "../chunk-tfn9tvaa.js";
13
+ } from "../chunk-68jxyg2n.js";
14
14
  import {
15
15
  BUN_VERSION,
16
16
  LAMBDA_ENTRY_POINTS,
@@ -34,7 +34,6 @@ import {
34
34
  skipBundle,
35
35
  verifyManifest
36
36
  } from "../chunk-pxhftba1.js";
37
- import"../chunk-dz91avrb.js";
38
37
  import {
39
38
  LIVE_ENDPOINT_NAME,
40
39
  WEB_SEARCH_CONNECTOR_VERSION,
@@ -47,8 +46,9 @@ import {
47
46
  loadInstanceFile,
48
47
  provisionsIntegration,
49
48
  requiresAuthenticatedQueue
50
- } from "../chunk-fjdgxw8h.js";
51
- import"../chunk-96fjaysn.js";
49
+ } from "../chunk-pmjbzsq3.js";
50
+ import"../chunk-dz91avrb.js";
51
+ import"../chunk-rvx0cncy.js";
52
52
  import"../chunk-5wgwscd0.js";
53
53
  export {
54
54
  BUILD_TIMEOUT,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deployfoundation/foundation-deploy",
3
- "version": "0.2.54",
3
+ "version": "0.2.55",
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,
@@ -9,7 +9,16 @@
9
9
  "foundation-deploy": "./dist/bin/foundation-deploy.js"
10
10
  },
11
11
  "exports": {
12
- ".": "./dist/src/index.js"
12
+ ".": "./dist/src/index.js",
13
+ "./crm-conformance": {
14
+ "types": "./dist/src/crm-conformance.d.ts",
15
+ "default": "./dist/src/crm-conformance.js"
16
+ }
17
+ },
18
+ "typesVersions": {
19
+ "*": {
20
+ "crm-conformance": ["./dist/src/crm-conformance.d.ts"]
21
+ }
13
22
  },
14
23
  "files": [
15
24
  "dist",
@@ -0,0 +1,27 @@
1
+ /**
2
+ * `@deployfoundation/foundation-deploy/crm-conformance`: the `crm/1`
3
+ * conformance suite, for a CRM's own CI to run in-process with its own
4
+ * harness (docs/crm-provider-contract.md#conformance).
5
+ *
6
+ * import { runCrmConformance } from "@deployfoundation/foundation-deploy/crm-conformance";
7
+ *
8
+ * This is the published package's stable surface for the suite: the function,
9
+ * its target and report, and the harness interface. Its declarations are
10
+ * hand-written in `types/crm-conformance.d.ts`, because the source behind it
11
+ * lives in workspace packages that are bundled, never published;
12
+ * `test/crm-conformance-export.test.ts` holds the two to the same types.
13
+ */
14
+ export type { CrmRecordType } from "@deployfoundation/foundation-connectors/crm";
15
+ export type { FetchLike } from "@deployfoundation/foundation-connectors/github";
16
+ export {
17
+ type CrmConformanceCase,
18
+ type CrmConformanceHarness,
19
+ type CrmConformanceOutcome,
20
+ type CrmConformanceReport,
21
+ type CrmConformanceTarget,
22
+ runCrmConformance,
23
+ } from "@deployfoundation/foundation-capability-crm/contract/conformance";
24
+ export type {
25
+ CrmStatusPolicy,
26
+ CrmStatusRule,
27
+ } from "@deployfoundation/foundation-capability-crm/proxy/policy";
package/src/deploy/cli.ts CHANGED
@@ -113,6 +113,20 @@ crm-check options
113
113
  --write with --base-url only: also run the write cases, which
114
114
  create records and spend the week's outreach allowance;
115
115
  point it at a sandbox tenant
116
+ with --base-url --write, what the remaining write cases need:
117
+ --harness-url <url> the CRM's harness endpoint (crm-harness/1), which does
118
+ --harness-key-stdin what only a person can; its key comes on stdin
119
+ --readonly-key-stdin a key for the same tenant without write scopes, on stdin
120
+ --status-policy <file>
121
+ the deployment's capabilities.crm.statusPolicy (JSON
122
+ or YAML)
123
+ --suppressed-statuses <file>
124
+ its capabilities.crm.outreach.suppressedStatuses
125
+ --spend-weekly-allowance
126
+ drive this week's outreach caps to their limits
127
+ (a throwaway database only)
128
+ Keys on stdin go one per line, in this order: the crm/1 key, the read-only
129
+ key, the harness key (each only when its flag is given).
116
130
 
117
131
  crm-replay options
118
132
  --from <file> REQUIRED: the writes to replay, one JSON object per line
@@ -128,6 +142,24 @@ function flag(args: readonly string[], name: string): string | undefined {
128
142
  return index === -1 ? undefined : args[index + 1];
129
143
  }
130
144
 
145
+ /** A flag that must carry a value when given: refuses `--x` alone or followed by another flag. */
146
+ function valueFlag(args: readonly string[], name: string, what: string): string | undefined {
147
+ if (!args.includes(name)) return undefined;
148
+ const value = flag(args, name);
149
+ if (value === undefined || value.startsWith("-")) throw new Error(`${name} needs ${what}`);
150
+ return value;
151
+ }
152
+
153
+ /** `crm-check` inputs only the `--base-url` form takes: they feed the write cases. */
154
+ const CRM_CHECK_TARGET_FLAGS = [
155
+ "--harness-url",
156
+ "--harness-key-stdin",
157
+ "--readonly-key-stdin",
158
+ "--status-policy",
159
+ "--suppressed-statuses",
160
+ "--spend-weekly-allowance",
161
+ ] as const;
162
+
131
163
  /** The AWS context for a command, with the instance file already resolved. */
132
164
  function contextFor(args: readonly string[]): AwsContext {
133
165
  const paths = loadInstanceContext(resolveInstanceFilePath(args, process.env));
@@ -235,6 +267,15 @@ export async function main(argv: readonly string[] = process.argv.slice(2)): Pro
235
267
  const tenantId = flag(args, "--tenant");
236
268
  if (baseUrl === undefined || tenantId === undefined || !args.includes("--key-stdin"))
237
269
  throw new Error("crm-check --base-url needs --tenant <id> and --key-stdin");
270
+ const harnessUrl = valueFlag(args, "--harness-url", "the CRM's harness endpoint URL");
271
+ // The harness acts as a person, so it is never called without its key,
272
+ // and the key, like every key here, comes on stdin.
273
+ if ((harnessUrl !== undefined) !== args.includes("--harness-key-stdin"))
274
+ throw new Error(
275
+ "crm-check --harness-url <url> and --harness-key-stdin go together: the harness key comes on stdin",
276
+ );
277
+ const statusPolicyFile = valueFlag(args, "--status-policy", "a file");
278
+ const suppressedStatusesFile = valueFlag(args, "--suppressed-statuses", "a file");
238
279
  const { crmCheckTarget, defaultCrmCommandDeps } = await import("./crm.ts");
239
280
  return crmCheckTarget(
240
281
  {
@@ -242,6 +283,11 @@ export async function main(argv: readonly string[] = process.argv.slice(2)): Pro
242
283
  tenantId,
243
284
  write: args.includes("--write"),
244
285
  allowSkips: args.includes("--allow-skips"),
286
+ readOnlyKeyStdin: args.includes("--readonly-key-stdin"),
287
+ spendWeeklyAllowance: args.includes("--spend-weekly-allowance"),
288
+ ...(harnessUrl === undefined ? {} : { harnessUrl }),
289
+ ...(statusPolicyFile === undefined ? {} : { statusPolicyFile }),
290
+ ...(suppressedStatusesFile === undefined ? {} : { suppressedStatusesFile }),
245
291
  },
246
292
  defaultCrmCommandDeps(),
247
293
  );
@@ -330,6 +376,11 @@ export async function main(argv: readonly string[] = process.argv.slice(2)): Pro
330
376
  throw new Error(
331
377
  "crm-check --write creates records and spends the week's outreach allowance, so it never runs against the instance's own tenant: use --base-url with a sandbox tenant",
332
378
  );
379
+ const targetOnly = CRM_CHECK_TARGET_FLAGS.filter((name) => args.includes(name));
380
+ if (targetOnly.length > 0)
381
+ throw new Error(
382
+ `crm-check --instance runs the read cases with the deployment's own key and policy; ${targetOnly.join(", ")} feed the write cases: use --base-url --write with a sandbox tenant`,
383
+ );
333
384
  const { crmCheck, defaultCrmCommandDeps } = await import("./crm.ts");
334
385
  return crmCheck(ctx, { allowSkips: args.includes("--allow-skips") }, defaultCrmCommandDeps());
335
386
  }
package/src/deploy/crm.ts CHANGED
@@ -5,6 +5,9 @@
5
5
  * foundation-deploy crm-key --instance <path> --stdin
6
6
  * foundation-deploy crm-check --instance <path> [--allow-skips]
7
7
  * foundation-deploy crm-check --base-url <url> --tenant <id> --key-stdin [--write] [--allow-skips]
8
+ * [--harness-url <url> --harness-key-stdin] [--readonly-key-stdin]
9
+ * [--status-policy <file>] [--suppressed-statuses <file>]
10
+ * [--spend-weekly-allowance]
8
11
  * foundation-deploy crm-replay --instance <path> --from <writes.jsonl> [--dry-run]
9
12
  *
10
13
  * - `crm-key` writes the `crm/1` key into the secret FoundationData created
@@ -14,9 +17,13 @@
14
17
  * uses the deployment's base URL, tenant and stored key and runs only the
15
18
  * read cases, since the write cases create records and spend the week's
16
19
  * outreach allowance. With `--base-url` (no instance at all) it is what a
17
- * CRM's own CI runs against a sandbox tenant, `--write` included. Strict
18
- * unless `--allow-skips`: a case only a person can set up counts as failed
19
- * when it could not run.
20
+ * CRM's own CI runs against a sandbox tenant, `--write` included, with the
21
+ * deployment inputs the remaining cases need: the CRM's harness endpoint
22
+ * (`crm-harness/1`) for what only a person can do, a read-only key, the
23
+ * deployment's status policy and suppressed statuses, and leave to spend
24
+ * the week's caps. Every key comes on stdin, one per line. Strict unless
25
+ * `--allow-skips`: a case only a person can set up counts as failed when it
26
+ * could not run.
20
27
  * - `crm-replay` is the rollback half of a cutover: once `crm.provider` is
21
28
  * back on `dynamodb`, it writes the agent's writes made in the company's CRM
22
29
  * since the cutover (exported as JSONL) into the instance's own table,
@@ -54,12 +61,19 @@ import {
54
61
  parseCrmUpsertRecordsArgs,
55
62
  } from "@deployfoundation/foundation-connectors/crm";
56
63
  import { crmHttpBaseUrl, isCrmHttpKey } from "@deployfoundation/foundation-connectors/crm-http";
64
+ import type { FetchLike } from "@deployfoundation/foundation-connectors/github";
57
65
  import {
58
66
  type CrmCapabilityConfig,
59
67
  type InstanceConfig,
60
68
  crmOutreachConfigured,
61
69
  parseInstanceConfig,
62
70
  } from "@deployfoundation/foundation-core";
71
+ import {
72
+ RawCrmStatusPolicy,
73
+ RawCrmStatusesByType,
74
+ } from "@deployfoundation/foundation-core/capabilities";
75
+ import { parse as parseYaml } from "yaml";
76
+ import type { z } from "zod";
63
77
  import { type AwsContext, aws, putSecretString, readSecretString, secretExists } from "./aws.ts";
64
78
 
65
79
  /** What these commands reach outside the process; tests replace every one. */
@@ -75,6 +89,8 @@ export interface CrmCommandDeps {
75
89
  /** A DocumentClient on the operator's own credentials. */
76
90
  documentClient: (ctx: AwsContext) => Promise<CrmDocumentClient>;
77
91
  readFile: (path: string) => string;
92
+ /** What the HTTP harness calls the CRM's harness endpoint with. */
93
+ fetch: FetchLike;
78
94
  log: (line: string) => void;
79
95
  }
80
96
 
@@ -175,15 +191,35 @@ export interface CrmCheckTargetOptions extends CrmCheckOptions {
175
191
  baseUrl: string;
176
192
  tenantId: string;
177
193
  write: boolean;
194
+ /** The CRM's harness endpoint (`crm-harness/1`); its key is read from stdin. */
195
+ harnessUrl?: string;
196
+ /** Read a key for the same tenant without write scopes from stdin (`read_only_scope`). */
197
+ readOnlyKeyStdin?: boolean;
198
+ /** The deployment's `capabilities.crm.statusPolicy`, as a JSON or YAML file. */
199
+ statusPolicyFile?: string;
200
+ /** The deployment's `capabilities.crm.outreach.suppressedStatuses`, as a JSON or YAML file. */
201
+ suppressedStatusesFile?: string;
202
+ /** Drive this week's first-touch and follow-up caps to their limits: a throwaway database only. */
203
+ spendWeeklyAllowance?: boolean;
178
204
  }
179
205
 
206
+ /** The keys `crm-check --base-url` reads from stdin: one per line, in this order. */
207
+ const STDIN_KEYS = [
208
+ { flag: "--key-stdin", name: "the crm/1 key" },
209
+ { flag: "--readonly-key-stdin", name: "the read-only key" },
210
+ { flag: "--harness-key-stdin", name: "the harness key" },
211
+ ] as const;
212
+
213
+ type StdinKey = (typeof STDIN_KEYS)[number];
214
+
180
215
  /**
181
216
  * The suite against any `crm/1` server, no instance involved: what a CRM's
182
- * own CI runs from the published tool. The key comes from stdin.
217
+ * own CI runs from the published tool. Keys come from stdin; everything else
218
+ * is a flag or a file, all checked before stdin is read.
183
219
  */
184
220
  export async function crmCheckTarget(
185
221
  options: CrmCheckTargetOptions,
186
- deps: Pick<CrmCommandDeps, "readStdin" | "runConformance" | "log">,
222
+ deps: Pick<CrmCommandDeps, "readStdin" | "runConformance" | "readFile" | "fetch" | "log">,
187
223
  ): Promise<number> {
188
224
  let baseUrl: string;
189
225
  try {
@@ -194,24 +230,153 @@ export async function crmCheckTarget(
194
230
  }
195
231
  if (!/^[\x21-\x7e]{1,256}$/.test(options.tenantId))
196
232
  throw new Error("--tenant must be the tenant id the server's /meta names");
197
- const apiKey = (await deps.readStdin()).trim();
198
- if (!isCrmHttpKey(apiKey)) throw new Error("stdin did not hold a usable crm/1 key (--key-stdin)");
233
+ // Each of these feeds write cases only: without --write it would do nothing.
234
+ const writeInputs = (
235
+ [
236
+ ["--harness-url", options.harnessUrl !== undefined],
237
+ ["--readonly-key-stdin", options.readOnlyKeyStdin === true],
238
+ ["--status-policy", options.statusPolicyFile !== undefined],
239
+ ["--suppressed-statuses", options.suppressedStatusesFile !== undefined],
240
+ ["--spend-weekly-allowance", options.spendWeeklyAllowance === true],
241
+ ] as const
242
+ )
243
+ .filter(([, given]) => given)
244
+ .map(([flag]) => flag);
245
+ if (!options.write && writeInputs.length > 0)
246
+ throw new Error(
247
+ `${writeInputs.join(", ")} only feed the write cases: add --write, against a sandbox tenant`,
248
+ );
249
+ // Loaded here, like the suite: no other command needs it.
250
+ const { createCrmHttpHarness, crmHarnessUrl } = await import(
251
+ "@deployfoundation/foundation-capability-crm/contract/http-harness"
252
+ );
253
+ let harnessUrl: string | undefined;
254
+ try {
255
+ harnessUrl = options.harnessUrl === undefined ? undefined : crmHarnessUrl(options.harnessUrl);
256
+ } catch {
257
+ throw new Error("--harness-url must be an HTTPS URL with no userinfo, query or fragment");
258
+ }
259
+ const statusPolicy =
260
+ options.statusPolicyFile === undefined
261
+ ? undefined
262
+ : deploymentInput(deps, "--status-policy", options.statusPolicyFile, RawCrmStatusPolicy);
263
+ const suppressedStatuses =
264
+ options.suppressedStatusesFile === undefined
265
+ ? undefined
266
+ : deploymentInput(
267
+ deps,
268
+ "--suppressed-statuses",
269
+ options.suppressedStatusesFile,
270
+ RawCrmStatusesByType,
271
+ );
272
+
273
+ const wanted = STDIN_KEYS.filter(
274
+ ({ flag }) =>
275
+ flag === "--key-stdin" ||
276
+ (flag === "--readonly-key-stdin" && options.readOnlyKeyStdin === true) ||
277
+ (flag === "--harness-key-stdin" && harnessUrl !== undefined),
278
+ );
279
+ const keys = stdinKeys(await deps.readStdin(), wanted);
280
+ const key = (flag: StdinKey["flag"]) => keys[wanted.findIndex((entry) => entry.flag === flag)];
281
+ const apiKey = key("--key-stdin") as string;
282
+ const readOnlyApiKey = key("--readonly-key-stdin");
283
+ const harnessKey = key("--harness-key-stdin");
284
+ const harness =
285
+ harnessUrl === undefined || harnessKey === undefined
286
+ ? undefined
287
+ : createCrmHttpHarness({
288
+ url: harnessUrl,
289
+ apiKey: harnessKey,
290
+ tenantId: options.tenantId,
291
+ fetchImpl: deps.fetch,
292
+ });
199
293
  const report = await deps.runConformance({
200
294
  baseUrl,
201
295
  apiKey,
202
296
  tenantId: options.tenantId,
203
297
  write: options.write,
204
298
  strict: !options.allowSkips,
299
+ ...(readOnlyApiKey === undefined ? {} : { readOnlyApiKey }),
300
+ ...(statusPolicy === undefined ? {} : { statusPolicy }),
301
+ ...(suppressedStatuses === undefined ? {} : { suppressedStatuses }),
302
+ ...(options.spendWeeklyAllowance === true ? { spendWeeklyAllowance: true } : {}),
303
+ ...(harness === undefined ? {} : { harness }),
205
304
  });
206
- return reportConformance(report, baseUrl, deps.log);
305
+ return reportConformance(report, baseUrl, deps.log, harnessUrl);
306
+ }
307
+
308
+ /**
309
+ * The keys on stdin, one per non-blank line, exactly as many as the flags
310
+ * asked for. Errors name a line and its flag, never what it held.
311
+ */
312
+ function stdinKeys(text: string, wanted: readonly StdinKey[]): string[] {
313
+ const lines = text
314
+ .split(/\r?\n/)
315
+ .map((line) => line.trim())
316
+ .filter((line) => line !== "");
317
+ if (wanted.length === 1) {
318
+ if (lines.length !== 1 || !isCrmHttpKey(lines[0]))
319
+ throw new Error("stdin did not hold a usable crm/1 key (--key-stdin)");
320
+ return lines;
321
+ }
322
+ if (lines.length !== wanted.length)
323
+ throw new Error(
324
+ `stdin must hold ${wanted.length} keys, one per line: ${wanted
325
+ .map(({ name, flag }) => `${name} (${flag})`)
326
+ .join(", then ")}; it held ${lines.length}`,
327
+ );
328
+ lines.forEach((line, index) => {
329
+ const entry = wanted[index] as StdinKey;
330
+ if (!isCrmHttpKey(line))
331
+ throw new Error(
332
+ `stdin line ${index + 1}, ${entry.name} (${entry.flag}), is not a usable key: 16 to 1024 visible ASCII characters, no spaces`,
333
+ );
334
+ });
335
+ return lines;
336
+ }
337
+
338
+ /**
339
+ * A block of the deployment's `capabilities.crm`, from a JSON or YAML file,
340
+ * validated by the same schema as that block in `config.yaml`. Errors name
341
+ * the file and the rule; these files hold policy, never a key.
342
+ */
343
+ function deploymentInput<T>(
344
+ deps: Pick<CrmCommandDeps, "readFile">,
345
+ flag: string,
346
+ path: string,
347
+ schema: z.ZodType<T, z.ZodTypeDef, unknown>,
348
+ ): T {
349
+ let text: string;
350
+ try {
351
+ text = deps.readFile(path);
352
+ } catch {
353
+ throw new Error(`${flag} ${path} could not be read`);
354
+ }
355
+ let value: unknown;
356
+ try {
357
+ value = parseYaml(text);
358
+ } catch {
359
+ throw new Error(`${flag} ${path} is not JSON or YAML`);
360
+ }
361
+ const parsed = schema.safeParse(value);
362
+ if (!parsed.success)
363
+ throw new Error(
364
+ `${flag} ${path}: ${parsed.error.issues
365
+ .map((issue) => `${issue.path.join(".") || "(top level)"}: ${issue.message}`)
366
+ .join("; ")}`,
367
+ );
368
+ return parsed.data;
207
369
  }
208
370
 
209
371
  function reportConformance(
210
372
  report: CrmConformanceReport,
211
373
  baseUrl: string,
212
374
  log: (line: string) => void,
375
+ harnessUrl?: string,
213
376
  ): number {
214
- log(`crm/1 conformance against ${baseUrl}`);
377
+ log(
378
+ `crm/1 conformance against ${baseUrl}${harnessUrl === undefined ? "" : `, harness ${harnessUrl}`}`,
379
+ );
215
380
  for (const entry of report.cases) {
216
381
  const mark = entry.outcome === "fail" ? "FAIL" : entry.outcome;
217
382
  log(
@@ -486,6 +651,7 @@ export function defaultCrmCommandDeps(): CrmCommandDeps {
486
651
  crmTableName: crmTableNameFromStack,
487
652
  documentClient: operatorDocumentClient,
488
653
  readFile: (path) => readFileSync(path, "utf8"),
654
+ fetch: (input, init) => fetch(input, init),
489
655
  log: (line) => console.log(line),
490
656
  };
491
657
  }
@@ -1566,8 +1566,10 @@ export function addConfigLoadAlarm(
1566
1566
  `${args.instance.displayName}'s runtime is not serving the config deployed for it: the`,
1567
1567
  "config failed to load, so it runs on its last good config with company-data",
1568
1568
  "capabilities off, or on the built-in fallback. The config_fallback_active or",
1569
- "config_reload_failed line in the runtime's live log group names the refused field.",
1570
- "Fix config.yaml, run foundation-deploy check, then deploy or config:sync.",
1569
+ "config_reload_failed line in the runtime's live log group says why: its error_message",
1570
+ "names the refused field (record keys shown as *), the YAML error and line, or the",
1571
+ "S3/KMS error and the IAM action it cites (error_type s3_read_failed).",
1572
+ "Fix config.yaml or the grant, run foundation-deploy check, then deploy or config:sync.",
1571
1573
  ].join(" "),
1572
1574
  metric: filter.metric({ statistic: "Sum", period: cdk.Duration.hours(1) }),
1573
1575
  threshold: 1,