@deployfoundation/foundation-deploy 0.2.51 → 0.2.53

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/src/deploy/cli.ts CHANGED
@@ -35,6 +35,9 @@ export const COMMANDS = [
35
35
  "slack-manifest",
36
36
  "stage-customization",
37
37
  "job-key",
38
+ "crm-key",
39
+ "crm-check",
40
+ "crm-replay",
38
41
  ] as const;
39
42
 
40
43
  export type Command = (typeof COMMANDS)[number];
@@ -56,6 +59,12 @@ commands
56
59
  slack-manifest print the instance's Slack app manifest as JSON
57
60
  stage-customization validate and stage a tenant-owned runtime config
58
61
  job-key <id> print a webhook job's URL and signing key as JSON
62
+ crm-key write the company CRM's crm/1 key, read from stdin, into
63
+ the instance's <secretPrefix>/crm/http secret
64
+ crm-check run the crm/1 conformance suite against the instance's
65
+ CRM (crm.provider kind http) with its stored key
66
+ crm-replay replay the agent's CRM writes (JSONL) into the
67
+ instance's own table after a rollback to dynamodb
59
68
 
60
69
  options
61
70
  --instance <path> REQUIRED: path to the deployment's instance YAML
@@ -91,6 +100,24 @@ job-key options
91
100
  --put-secret <id> write {url, key} into this Secrets Manager secret
92
101
  (created if absent) instead of printing the key
93
102
 
103
+ crm-key options
104
+ --stdin REQUIRED: read the key from a pipe, never argv, e.g.
105
+ pbpaste | foundation-deploy crm-key --instance <path> --stdin
106
+
107
+ crm-check options
108
+ --allow-skips pass despite cases only a person can set up (they need
109
+ the CRM's own harness); without it they count as failed
110
+ --base-url <url> check any crm/1 server instead of the instance's: needs
111
+ --tenant <id> the tenant its /meta names, and the key on stdin
112
+ --key-stdin (--key-stdin). No --instance in this form.
113
+ --write with --base-url only: also run the write cases, which
114
+ create records and spend the week's outreach allowance;
115
+ point it at a sandbox tenant
116
+
117
+ crm-replay options
118
+ --from <file> REQUIRED: the writes to replay, one JSON object per line
119
+ (docs/crm-provider-contract.md); --dry-run validates only
120
+
94
121
  github-app-create options
95
122
  --org <org> GitHub org (default: the instance's github.org)
96
123
  --port <n> local callback port (default 8765)
@@ -199,6 +226,27 @@ export async function main(argv: readonly string[] = process.argv.slice(2)): Pro
199
226
  return 0;
200
227
  }
201
228
 
229
+ // A CRM's own CI checks its server with the published tool and no instance
230
+ // file at all: everything the suite needs is a flag, and the key a pipe.
231
+ if (command === "crm-check" && args.includes("--base-url")) {
232
+ if (args.some((arg) => arg === "--instance" || arg.startsWith("--instance=")))
233
+ throw new Error("crm-check takes --instance or --base-url, not both");
234
+ const baseUrl = flag(args, "--base-url");
235
+ const tenantId = flag(args, "--tenant");
236
+ if (baseUrl === undefined || tenantId === undefined || !args.includes("--key-stdin"))
237
+ throw new Error("crm-check --base-url needs --tenant <id> and --key-stdin");
238
+ const { crmCheckTarget, defaultCrmCommandDeps } = await import("./crm.ts");
239
+ return crmCheckTarget(
240
+ {
241
+ baseUrl,
242
+ tenantId,
243
+ write: args.includes("--write"),
244
+ allowSkips: args.includes("--allow-skips"),
245
+ },
246
+ defaultCrmCommandDeps(),
247
+ );
248
+ }
249
+
202
250
  if (command === "github-app-create") {
203
251
  const paths = loadInstanceContext(resolveInstanceFilePath(args, process.env));
204
252
  console.log(instanceBanner(paths));
@@ -270,6 +318,29 @@ export async function main(argv: readonly string[] = process.argv.slice(2)): Pro
270
318
  console.log(` ${result.status}: ${result.runtimeConfigPath}`);
271
319
  return 0;
272
320
  }
321
+ // Loaded on demand: the CRM commands bring the conformance suite and,
322
+ // for a replay, the AWS SDK's DynamoDB client.
323
+ case "crm-key": {
324
+ const { crmKey, defaultCrmCommandDeps } = await import("./crm.ts");
325
+ await crmKey(ctx, { stdin: args.includes("--stdin") }, defaultCrmCommandDeps());
326
+ return 0;
327
+ }
328
+ case "crm-check": {
329
+ if (args.includes("--write"))
330
+ throw new Error(
331
+ "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
+ );
333
+ const { crmCheck, defaultCrmCommandDeps } = await import("./crm.ts");
334
+ return crmCheck(ctx, { allowSkips: args.includes("--allow-skips") }, defaultCrmCommandDeps());
335
+ }
336
+ case "crm-replay": {
337
+ const from = flag(args, "--from");
338
+ if (from === undefined || from.startsWith("-"))
339
+ throw new Error("crm-replay needs --from <file>: the writes to replay, as JSONL");
340
+ const { crmReplay, defaultCrmCommandDeps } = await import("./crm.ts");
341
+ const result = await crmReplay(ctx, { from }, defaultCrmCommandDeps());
342
+ return result.refused.length > 0 ? 1 : 0;
343
+ }
273
344
  default:
274
345
  console.error(`unhandled command: ${command}\n\n${USAGE}`);
275
346
  return 1;
@@ -0,0 +1,491 @@
1
+ /**
2
+ * The operator's side of a company's own CRM (`crm.provider` kind `http`,
3
+ * docs/crm-provider-contract.md):
4
+ *
5
+ * foundation-deploy crm-key --instance <path> --stdin
6
+ * foundation-deploy crm-check --instance <path> [--allow-skips]
7
+ * foundation-deploy crm-check --base-url <url> --tenant <id> --key-stdin [--write] [--allow-skips]
8
+ * foundation-deploy crm-replay --instance <path> --from <writes.jsonl> [--dry-run]
9
+ *
10
+ * - `crm-key` writes the `crm/1` key into the secret FoundationData created
11
+ * empty. The key arrives on a pipe and goes to the AWS CLI through a 0600
12
+ * temp file: never argv, never shell history, never printed.
13
+ * - `crm-check` runs the `crm/1` conformance suite. Against the instance it
14
+ * uses the deployment's base URL, tenant and stored key and runs only the
15
+ * read cases, since the write cases create records and spend the week's
16
+ * 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-replay` is the rollback half of a cutover: once `crm.provider` is
21
+ * back on `dynamodb`, it writes the agent's writes made in the company's CRM
22
+ * since the cutover (exported as JSONL) into the instance's own table,
23
+ * through the same DynamoDB store the proxy uses, under the deployment's
24
+ * policy. Drafts move each contact's touch clock but are not counted again:
25
+ * the shared ledger counted them when they happened.
26
+ *
27
+ * A CRM reply body never reaches this output: the suite reports fixed text,
28
+ * codes and statuses only, and a refused replay line is reported by its line
29
+ * number and refusal code.
30
+ */
31
+ import { readFileSync } from "node:fs";
32
+ import type {
33
+ CrmConformanceReport,
34
+ CrmConformanceTarget,
35
+ } from "@deployfoundation/foundation-capability-crm/contract/conformance";
36
+ import {
37
+ CrmIdempotencyConflictError,
38
+ CrmRecordNotFoundError,
39
+ } from "@deployfoundation/foundation-capability-crm/proxy/errors";
40
+ import {
41
+ type CrmOutreachPolicy,
42
+ CrmPolicyRefusedError,
43
+ } from "@deployfoundation/foundation-capability-crm/proxy/policy";
44
+ import type { CrmDocumentClient } from "@deployfoundation/foundation-capability-crm/proxy/repository";
45
+ import {
46
+ CRM_MAX_ID_CHARS,
47
+ CrmInputError,
48
+ type CrmLogActivityArgs,
49
+ type CrmRecordType,
50
+ type CrmUpdateStatusArgs,
51
+ type CrmUpsertRecordsArgs,
52
+ parseCrmLogActivityArgs,
53
+ parseCrmUpdateStatusArgs,
54
+ parseCrmUpsertRecordsArgs,
55
+ } from "@deployfoundation/foundation-connectors/crm";
56
+ import { crmHttpBaseUrl, isCrmHttpKey } from "@deployfoundation/foundation-connectors/crm-http";
57
+ import {
58
+ type CrmCapabilityConfig,
59
+ type InstanceConfig,
60
+ crmOutreachConfigured,
61
+ parseInstanceConfig,
62
+ } from "@deployfoundation/foundation-core";
63
+ import { type AwsContext, aws, putSecretString, readSecretString, secretExists } from "./aws.ts";
64
+
65
+ /** What these commands reach outside the process; tests replace every one. */
66
+ export interface CrmCommandDeps {
67
+ /** Everything on stdin, or a refusal when stdin is a terminal (it would echo). */
68
+ readStdin: () => Promise<string>;
69
+ secretExists: (ctx: AwsContext, secretId: string) => Promise<boolean>;
70
+ putSecretString: (ctx: AwsContext, secretId: string, value: string) => Promise<void>;
71
+ readSecretString: (ctx: AwsContext, secretId: string) => Promise<string>;
72
+ runConformance: (target: CrmConformanceTarget) => Promise<CrmConformanceReport>;
73
+ /** The physical name of the instance's CRM table. */
74
+ crmTableName: (ctx: AwsContext) => Promise<string>;
75
+ /** A DocumentClient on the operator's own credentials. */
76
+ documentClient: (ctx: AwsContext) => Promise<CrmDocumentClient>;
77
+ readFile: (path: string) => string;
78
+ log: (line: string) => void;
79
+ }
80
+
81
+ /** The deployment's `http` provider, or a refusal naming what is missing. */
82
+ function httpProvider(ctx: AwsContext, command: string) {
83
+ const { instance } = ctx;
84
+ const provider = instance.crm.provider;
85
+ if (!instance.integrations.crm || provider.kind !== "http")
86
+ throw new Error(
87
+ `${command} needs crm.provider kind http (and integrations.crm) in ${ctx.paths.path}; the DynamoDB provider has no CRM key or base URL`,
88
+ );
89
+ return provider;
90
+ }
91
+
92
+ // --- crm-key ---------------------------------------------------------------
93
+
94
+ export interface CrmKeyResult {
95
+ secret: string;
96
+ }
97
+
98
+ /**
99
+ * Write the `crm/1` key from stdin into `<secretPrefix>/crm/http`. The secret
100
+ * must exist (FoundationData creates it on the first deploy with the `http`
101
+ * provider), so a typo in the instance cannot create a stray secret.
102
+ */
103
+ export async function crmKey(
104
+ ctx: AwsContext,
105
+ options: { stdin: boolean },
106
+ deps: CrmCommandDeps,
107
+ ): Promise<CrmKeyResult> {
108
+ httpProvider(ctx, "crm-key");
109
+ const secret = ctx.names.secretCrmHttp;
110
+ if (!options.stdin)
111
+ throw new Error(
112
+ "crm-key reads the key from stdin only, so it never lands in argv or shell history: pipe it in with --stdin, e.g. `pbpaste | foundation-deploy crm-key --instance <path> --stdin`",
113
+ );
114
+ const key = (await deps.readStdin()).trim();
115
+ if (!isCrmHttpKey(key))
116
+ throw new Error(
117
+ "stdin did not hold a usable crm/1 key: 16 to 1024 visible ASCII characters, no spaces, one per run",
118
+ );
119
+ if (!(await deps.secretExists(ctx, secret)))
120
+ throw new Error(
121
+ `secret ${secret} does not exist yet: deploy with crm.provider kind http first, which creates it empty`,
122
+ );
123
+ await deps.putSecretString(ctx, secret, JSON.stringify({ api_key: key }));
124
+ deps.log(
125
+ `wrote the crm/1 key to ${secret}. The CRM proxy reads it within 5 minutes, or at once after a 401. Check it with: foundation-deploy crm-check --instance ${ctx.paths.path}`,
126
+ );
127
+ return { secret };
128
+ }
129
+
130
+ // --- crm-check -------------------------------------------------------------
131
+
132
+ export interface CrmCheckOptions {
133
+ allowSkips: boolean;
134
+ }
135
+
136
+ /**
137
+ * The suite's read cases against the deployment's CRM, with its stored key,
138
+ * tenant and baked policy. Returns the process exit code.
139
+ */
140
+ export async function crmCheck(
141
+ ctx: AwsContext,
142
+ options: CrmCheckOptions,
143
+ deps: CrmCommandDeps,
144
+ ): Promise<number> {
145
+ const provider = httpProvider(ctx, "crm-check");
146
+ const secret = ctx.names.secretCrmHttp;
147
+ let apiKey: unknown;
148
+ try {
149
+ const raw = JSON.parse(await deps.readSecretString(ctx, secret)) as unknown;
150
+ if (typeof raw === "object" && raw !== null && "api_key" in raw) apiKey = raw.api_key;
151
+ } catch {
152
+ // Unreadable or not JSON: the same as no key, reported below.
153
+ }
154
+ if (!isCrmHttpKey(apiKey))
155
+ throw new Error(`${secret} holds no crm/1 key yet: run foundation-deploy crm-key first`);
156
+ const crm = crmCapability(instanceConfig(ctx, deps));
157
+ const report = await deps.runConformance({
158
+ baseUrl: provider.baseUrl,
159
+ apiKey,
160
+ tenantId: provider.expectedTenant,
161
+ strict: !options.allowSkips,
162
+ ...(crm === undefined
163
+ ? {}
164
+ : {
165
+ statusPolicy: crm.statusPolicy,
166
+ ...(crmOutreachConfigured(crm.outreach)
167
+ ? { suppressedStatuses: crm.outreach.suppressedStatuses }
168
+ : {}),
169
+ }),
170
+ });
171
+ return reportConformance(report, provider.baseUrl, deps.log);
172
+ }
173
+
174
+ export interface CrmCheckTargetOptions extends CrmCheckOptions {
175
+ baseUrl: string;
176
+ tenantId: string;
177
+ write: boolean;
178
+ }
179
+
180
+ /**
181
+ * 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.
183
+ */
184
+ export async function crmCheckTarget(
185
+ options: CrmCheckTargetOptions,
186
+ deps: Pick<CrmCommandDeps, "readStdin" | "runConformance" | "log">,
187
+ ): Promise<number> {
188
+ let baseUrl: string;
189
+ try {
190
+ baseUrl = crmHttpBaseUrl(options.baseUrl);
191
+ } catch {
192
+ // The connector's own message says only that it must be HTTPS.
193
+ throw new Error("--base-url must be an HTTPS URL with no userinfo, query or fragment");
194
+ }
195
+ if (!/^[\x21-\x7e]{1,256}$/.test(options.tenantId))
196
+ 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)");
199
+ const report = await deps.runConformance({
200
+ baseUrl,
201
+ apiKey,
202
+ tenantId: options.tenantId,
203
+ write: options.write,
204
+ strict: !options.allowSkips,
205
+ });
206
+ return reportConformance(report, baseUrl, deps.log);
207
+ }
208
+
209
+ function reportConformance(
210
+ report: CrmConformanceReport,
211
+ baseUrl: string,
212
+ log: (line: string) => void,
213
+ ): number {
214
+ log(`crm/1 conformance against ${baseUrl}`);
215
+ for (const entry of report.cases) {
216
+ const mark = entry.outcome === "fail" ? "FAIL" : entry.outcome;
217
+ log(
218
+ ` ${mark.padEnd(4)} ${entry.id}: ${entry.title}${entry.detail === undefined ? "" : ` (${entry.detail})`}`,
219
+ );
220
+ }
221
+ log(`${report.passed} passed, ${report.failed} failed, ${report.skipped} skipped`);
222
+ return report.failed > 0 ? 1 : 0;
223
+ }
224
+
225
+ function instanceConfig(ctx: AwsContext, deps: Pick<CrmCommandDeps, "readFile">): InstanceConfig {
226
+ return parseInstanceConfig(deps.readFile(ctx.paths.configPath));
227
+ }
228
+
229
+ /** The instance's `capabilities.crm`, when it is on. */
230
+ function crmCapability(config: InstanceConfig): CrmCapabilityConfig | undefined {
231
+ const crm = config.capabilities.crm;
232
+ return crm.enabled ? crm : undefined;
233
+ }
234
+
235
+ // --- crm-replay ------------------------------------------------------------
236
+
237
+ /**
238
+ * One write in a replay file, one JSON object per line:
239
+ *
240
+ * {"op":"upsert_records","idempotencyKey":"…","companies":[…],"contacts":[…]}
241
+ * {"op":"update_status","idempotencyKey":"…","recordType":"contact","externalId":"…","status":"…"}
242
+ * {"op":"log_activity","idempotencyKey":"…","recordType":"contact","externalId":"…",
243
+ * "activityType":"…","summary":"…","occurredAt":"…","attributes":{…}}
244
+ *
245
+ * Records are named by `externalId`, which both providers share; the
246
+ * DynamoDB store derives its own record id from it.
247
+ */
248
+ export type CrmReplayWrite =
249
+ | { line: number; op: "upsert_records"; args: CrmUpsertRecordsArgs }
250
+ | { line: number; op: "update_status"; args: CrmUpdateStatusArgs }
251
+ | { line: number; op: "log_activity"; args: CrmLogActivityArgs };
252
+
253
+ export interface CrmReplayResult {
254
+ applied: number;
255
+ refused: { line: number; op: CrmReplayWrite["op"]; code: string }[];
256
+ }
257
+
258
+ const REPLAY_OPS = ["upsert_records", "update_status", "log_activity"] as const;
259
+
260
+ /**
261
+ * Parse and validate a whole replay file against the deployment's CRM
262
+ * policy. Nothing is written unless every line passes, so a file this cannot
263
+ * replay (a status the deployment does not have, an operation it does not
264
+ * take) is fixed before anything changes. Errors name the line and the rule,
265
+ * never its content.
266
+ */
267
+ export function parseCrmReplay(
268
+ text: string,
269
+ crm: CrmCapabilityConfig,
270
+ recordIdFor: (recordType: CrmRecordType, externalId: string) => string,
271
+ ): CrmReplayWrite[] {
272
+ const writes: CrmReplayWrite[] = [];
273
+ const statuses = {
274
+ company: new Set(crm.statuses.company),
275
+ contact: new Set(crm.statuses.contact),
276
+ };
277
+ const activityTypes = new Set(crm.activityTypes);
278
+ text.split("\n").forEach((raw, index) => {
279
+ const line = index + 1;
280
+ if (raw.trim() === "") return;
281
+ let value: unknown;
282
+ try {
283
+ value = JSON.parse(raw);
284
+ } catch {
285
+ // JSON.parse quotes the offending text; the line number is enough.
286
+ throw new Error(`line ${line} is not a JSON object`);
287
+ }
288
+ if (typeof value !== "object" || value === null || Array.isArray(value))
289
+ throw new Error(`line ${line} is not a JSON object`);
290
+ const { op, externalId, ...rest } = value as Record<string, unknown>;
291
+ if (typeof op !== "string" || !(REPLAY_OPS as readonly string[]).includes(op))
292
+ throw new Error(`line ${line}: op must be one of ${REPLAY_OPS.join(", ")}`);
293
+ try {
294
+ if (op === "upsert_records") {
295
+ if (externalId !== undefined) throw new Error("upsert_records names records inside");
296
+ writes.push({ line, op, args: parseCrmUpsertRecordsArgs(rest, crm.maxBatchSize) });
297
+ return;
298
+ }
299
+ if (
300
+ typeof externalId !== "string" ||
301
+ externalId.trim() === "" ||
302
+ externalId.length > CRM_MAX_ID_CHARS
303
+ )
304
+ throw new Error(`externalId must be 1 to ${CRM_MAX_ID_CHARS} characters`);
305
+ // An id is a provider's own; only the externalId means the same record in both.
306
+ if (Object.hasOwn(rest, "recordId"))
307
+ throw new Error("records are named by externalId, never by a provider's recordId");
308
+ const recordType = rest.recordType;
309
+ if (recordType !== "company" && recordType !== "contact")
310
+ throw new Error("recordType must be company or contact");
311
+ const named = { ...rest, recordId: recordIdFor(recordType, externalId) };
312
+ if (op === "update_status") {
313
+ const args = parseCrmUpdateStatusArgs(named);
314
+ if (!statuses[args.recordType].has(args.status))
315
+ throw new Error(`status is not a configured ${args.recordType} status`);
316
+ writes.push({ line, op, args });
317
+ return;
318
+ }
319
+ const args = parseCrmLogActivityArgs(named);
320
+ if (!activityTypes.has(args.activityType))
321
+ throw new Error("activityType is not a configured activity type");
322
+ writes.push({ line, op: "log_activity", args });
323
+ } catch (error) {
324
+ // Parser and rule messages name the rule, never the value.
325
+ throw new Error(`line ${line}: ${error instanceof Error ? error.message : String(error)}`);
326
+ }
327
+ });
328
+ return writes;
329
+ }
330
+
331
+ /**
332
+ * Replay a file of the agent's CRM writes into the instance's own table.
333
+ * Each line keeps its idempotency key, so running the same file twice writes
334
+ * nothing new. A write the policy refuses (a status change a later one made
335
+ * stale, a contact already drafted) is reported and the rest go on; any
336
+ * other failure stops at that line.
337
+ */
338
+ export async function crmReplay(
339
+ ctx: AwsContext,
340
+ options: { from: string },
341
+ deps: CrmCommandDeps,
342
+ ): Promise<CrmReplayResult> {
343
+ const { instance } = ctx;
344
+ if (!instance.integrations.crmStorage)
345
+ throw new Error(
346
+ `crm-replay needs the instance's CRM table (integrations.crm) in ${ctx.paths.path}`,
347
+ );
348
+ if (instance.crm.provider.kind !== "dynamodb")
349
+ throw new Error(
350
+ "crm-replay writes into the DynamoDB provider's table: set crm.provider back to kind dynamodb, deploy, then replay",
351
+ );
352
+ const config = instanceConfig(ctx, deps);
353
+ const crm = crmCapability(config);
354
+ if (crm === undefined || crm.statuses.company.length === 0 || crm.statuses.contact.length === 0)
355
+ throw new Error(`${ctx.paths.configPath}: crm-replay needs capabilities.crm with its statuses`);
356
+ // Loaded here, not at the top: it brings the AWS SDK's DynamoDB client,
357
+ // which no other command needs.
358
+ const { createDynamoCrmClient, deterministicRecordId } = await import(
359
+ "@deployfoundation/foundation-capability-crm/proxy/repository"
360
+ );
361
+ const writes = parseCrmReplay(deps.readFile(options.from), crm, deterministicRecordId);
362
+ const counts = REPLAY_OPS.map(
363
+ (op) => `${writes.filter((write) => write.op === op).length} ${op}`,
364
+ ).join(", ");
365
+ if (ctx.dryRun === true) {
366
+ deps.log(`dry run: ${writes.length} writes would be replayed (${counts})`);
367
+ return { applied: 0, refused: [] };
368
+ }
369
+ const outreach: CrmOutreachPolicy | undefined = crmOutreachConfigured(crm.outreach)
370
+ ? { ...crm.outreach, timezone: config.timezone }
371
+ : undefined;
372
+ const client = createDynamoCrmClient({
373
+ documentClient: await deps.documentClient(ctx),
374
+ tableName: await deps.crmTableName(ctx),
375
+ // The tenant the CRM proxy serves: the instance's name, as it bakes it.
376
+ tenantId: instance.name,
377
+ defaultStatuses: {
378
+ company: crm.statuses.company[0] as string,
379
+ contact: crm.statuses.contact[0] as string,
380
+ },
381
+ maxBatchSize: crm.maxBatchSize,
382
+ actor: {
383
+ teamId: instance.slack.teamId,
384
+ userId: "foundation-deploy",
385
+ channelId: "crm-replay",
386
+ sessionKey: `crm-replay:${instance.name}`,
387
+ },
388
+ statusPolicy: crm.statusPolicy,
389
+ ...(outreach === undefined ? {} : { outreach }),
390
+ countsOutreach: false,
391
+ });
392
+ deps.log(`replaying ${writes.length} writes (${counts})`);
393
+ const result: CrmReplayResult = { applied: 0, refused: [] };
394
+ for (const write of writes) {
395
+ try {
396
+ if (write.op === "upsert_records") await client.upsertRecords(write.args);
397
+ else if (write.op === "update_status") await client.updateStatus(write.args);
398
+ else await client.logActivity(write.args);
399
+ result.applied += 1;
400
+ } catch (error) {
401
+ const code = replayRefusal(error);
402
+ if (code === undefined)
403
+ throw new Error(
404
+ `line ${write.line} (${write.op}) failed: ${error instanceof Error ? error.name : "error"}; lines before it were replayed, and rerunning the file skips them`,
405
+ );
406
+ result.refused.push({ line: write.line, op: write.op, code });
407
+ }
408
+ }
409
+ deps.log(`${result.applied} applied, ${result.refused.length} refused`);
410
+ for (const refused of result.refused)
411
+ deps.log(` line ${refused.line} (${refused.op}): ${refused.code}`);
412
+ return result;
413
+ }
414
+
415
+ /** The fixed code for a write the store refused, or undefined for a failure. */
416
+ function replayRefusal(error: unknown): string | undefined {
417
+ if (error instanceof CrmPolicyRefusedError) return error.code;
418
+ if (error instanceof CrmRecordNotFoundError) return "not_found";
419
+ if (error instanceof CrmIdempotencyConflictError) return "conflict";
420
+ if (error instanceof CrmInputError) return "invalid_input";
421
+ return undefined;
422
+ }
423
+
424
+ // --- production dependencies ------------------------------------------------
425
+
426
+ /** Every byte on stdin; refuses a terminal, where the key would be echoed. */
427
+ async function readPipedStdin(): Promise<string> {
428
+ if (process.stdin.isTTY === true)
429
+ throw new Error(
430
+ "stdin is a terminal, where the key would be echoed: pipe it in instead, e.g. `pbpaste | foundation-deploy …`",
431
+ );
432
+ const chunks: Buffer[] = [];
433
+ for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk as Uint8Array));
434
+ return Buffer.concat(chunks).toString("utf8");
435
+ }
436
+
437
+ /** The CRM table FoundationData created; its name is CloudFormation's. */
438
+ async function crmTableNameFromStack(ctx: AwsContext): Promise<string> {
439
+ const names = (
440
+ await aws(ctx, [
441
+ "cloudformation",
442
+ "describe-stack-resources",
443
+ "--stack-name",
444
+ ctx.names.data,
445
+ "--query",
446
+ "StackResources[?ResourceType=='AWS::DynamoDB::Table' && starts_with(LogicalResourceId, 'CrmTable')].PhysicalResourceId",
447
+ "--output",
448
+ "text",
449
+ ])
450
+ )
451
+ .split(/\s+/)
452
+ .filter((name) => name !== "" && name !== "None");
453
+ if (names.length !== 1)
454
+ throw new Error(
455
+ `expected one CRM table in ${ctx.names.data}, found ${names.length}: is integrations.crm deployed?`,
456
+ );
457
+ return names[0] as string;
458
+ }
459
+
460
+ async function operatorDocumentClient(ctx: AwsContext): Promise<CrmDocumentClient> {
461
+ const [{ DynamoDBClient }, { DynamoDBDocumentClient }] = await Promise.all([
462
+ import("@aws-sdk/client-dynamodb"),
463
+ import("@aws-sdk/lib-dynamodb"),
464
+ ]);
465
+ return DynamoDBDocumentClient.from(
466
+ new DynamoDBClient({
467
+ region: ctx.region,
468
+ ...(ctx.profile === "" ? {} : { profile: ctx.profile }),
469
+ }),
470
+ { marshallOptions: { removeUndefinedValues: true } },
471
+ );
472
+ }
473
+
474
+ export function defaultCrmCommandDeps(): CrmCommandDeps {
475
+ return {
476
+ readStdin: readPipedStdin,
477
+ secretExists,
478
+ putSecretString,
479
+ readSecretString,
480
+ runConformance: async (target) => {
481
+ const { runCrmConformance } = await import(
482
+ "@deployfoundation/foundation-capability-crm/contract/conformance"
483
+ );
484
+ return runCrmConformance(target);
485
+ },
486
+ crmTableName: crmTableNameFromStack,
487
+ documentClient: operatorDocumentClient,
488
+ readFile: (path) => readFileSync(path, "utf8"),
489
+ log: (line) => console.log(line),
490
+ };
491
+ }
@@ -12,6 +12,7 @@ import {
12
12
  instanceNames as namesFor,
13
13
  slackCommandPrefix,
14
14
  } from "@deployfoundation/foundation-core/instance";
15
+ import { mongoCollectionPolicyEnv } from "@deployfoundation/foundation-core/mongodb-collections";
15
16
 
16
17
  /** The pinned AgentCore endpoint a deploy promotes and the invoker calls. */
17
18
  export const LIVE_ENDPOINT = "live";
@@ -258,6 +259,14 @@ export function composeRuntimeSecret(inputs: RuntimeSecretInputs): Record<string
258
259
  // LAZILY, so a secret still holding the CDK placeholder costs nothing
259
260
  // until someone asks a MongoDB question.
260
261
  FOUNDATION_MONGODB_SECRET_ID: names.secretMongodbReadonly,
262
+ // Collections the MongoDB tools refuse beyond the compiled-in defaults,
263
+ // from the instance file and never from config.yaml, so only a reviewed
264
+ // deploy changes it. Absent when the instance file adds nothing: the
265
+ // defaults hold without it.
266
+ ...(instance.mongodbReadonly.deniedCollections.length > 0 ||
267
+ instance.mongodbReadonly.unsafeReadDefaultDeniedCollections.length > 0
268
+ ? { FOUNDATION_MONGODB_COLLECTION_POLICY: mongoCollectionPolicyEnv(instance.mongodbReadonly) }
269
+ : {}),
261
270
  // CRM storage, the Otter credential, the Knock OAuth secrets, and the
262
271
  // Upwork/E*TRADE OAuth secrets are intentionally absent here: the
263
272
  // shell-capable agent receives only their fixed proxy ARNs.