diff --git a/.env.example b/.env.example index dc66093..e2c7c1e 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,10 @@ LETS_TALK_WIPRO_TEMPLATE_ID= LETS_TALK_SALES_REPLY_TEMPLATE_ID= LETS_TALK_SUPPORT_TEMPLATE_ID= # LETS_TALK_SUPPORT_RECIPIENTS=support@topcoder.com -# First address is the sales reply's reply-to; the rest are copied. +# The sales reply's reply-to addresses: a prospect's reply goes to all of them. # LETS_TALK_SALES_REPLY_TO=support@topcoder.com,test1@topcoder.com # LETS_TALK_REPLY_CAP_PER_DAY=1 + +# SpaceTech challenge notification (docs/emails/README.md). Empty values defer the flow. +SPACE_TECH_CHALLENGE_RECIPIENTS= +SPACE_TECH_CHALLENGE_TEMPLATE_ID= diff --git a/README.md b/README.md index b5b486f..83b7688 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ SecureStrings (`src/lets-talk-flows.ts`), not from the database. | `lets-talk-community-skills-reply` | `COMMUNITY_SKILLS` | Reply to the submitter (`LETS_TALK_SKILLS_TEMPLATE_ID`) | | `lets-talk-community-general-reply` | `COMMUNITY_GENERAL` | Reply to the submitter (`LETS_TALK_GENERAL_TEMPLATE_ID`) | | `lets-talk-wipro-queries-reply` | `WIPRO_QUERIES` | Reply to the submitter (`LETS_TALK_WIPRO_TEMPLATE_ID`) | -| `lets-talk-sales-reply` | `SALES_LEAD` | Reply to the submitter (`LETS_TALK_SALES_REPLY_TEMPLATE_ID`); the first `LETS_TALK_SALES_REPLY_TO` address is reply-to, the others are copied | +| `lets-talk-sales-reply` | `SALES_LEAD` | Reply to the submitter (`LETS_TALK_SALES_REPLY_TEMPLATE_ID`); every `LETS_TALK_SALES_REPLY_TO` address is a reply-to (SendGrid `reply_to_list`); none is copied | | `lets-talk-support-forward` | `COMMUNITY_SUPPORT` | Full submission to `LETS_TALK_SUPPORT_RECIPIENTS`, reply-to the submitter (`LETS_TALK_SUPPORT_TEMPLATE_ID`) | | `lets-talk-sales-email` (existing) | after cut-over: `UNKNOWN` only | The original internal email, now the fallback when classification fails | @@ -198,6 +198,15 @@ and register it in `src/main.ts` for a new action type. Rule validation lives in only validates and persists events. No browser redirects occur in this async worker; a future member signup redirect belongs in the website's form response flow. +## SpaceTech challenge notification + +Flow `space-tech-challenge-email` (forms-api-v6 migration `20261102010000_space_tech_challenge_email`, +seeded disabled) emails every `space_tech_challenge` submission to `SPACE_TECH_CHALLENGE_RECIPIENTS` +(comma-separated, same rules as `LETS_TALK_RECIPIENTS`) using `SPACE_TECH_CHALLENGE_TEMPLATE_ID`, +with the submitter's `email` answer as reply-to. Both come from processor SSM SecureStrings +(`src/space-tech-flows.ts`) and replace the flow's database settings; unset values defer delivery. +Setup: [docs/emails/README.md](docs/emails/README.md#spacetech-challenge-notification). + ## Reliability and operations * Kafka commits only after PostgreSQL persists the event. Duplicate submission IDs diff --git a/deploy/README.md b/deploy/README.md index 5b0720e..9dfea9f 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -33,6 +33,7 @@ parameters and `kms:Decrypt` when a customer-managed key is used. | `AI_API_URL`, `AI_AUTH0_AUDIENCE` | `/config/forms-processor-v6/appvar/` (optional SecureStrings; tc-ai-api base ending in `/v6/ai`, e.g. `https://api.topcoder.com/v6/ai`; the audience defaults to `AUTH0_AUDIENCE`) | | `LETS_TALK_OPPORTUNITY_TEMPLATE_ID`, `LETS_TALK_SKILLS_TEMPLATE_ID`, `LETS_TALK_GENERAL_TEMPLATE_ID`, `LETS_TALK_WIPRO_TEMPLATE_ID`, `LETS_TALK_SALES_REPLY_TEMPLATE_ID`, `LETS_TALK_SUPPORT_TEMPLATE_ID` | `/config/forms-processor-v6/appvar/` (optional SecureStrings; one SendGrid template per label flow, see `docs/emails/README.md`) | | `LETS_TALK_SUPPORT_RECIPIENTS`, `LETS_TALK_SALES_REPLY_TO`, `LETS_TALK_REPLY_CAP_PER_DAY`, `LETS_TALK_CLASSIFY_MAX_ATTEMPTS`, `LETS_TALK_CLASSIFY_MAX_AGE_MINUTES` | `/config/forms-processor-v6/appvar/` (optional SecureStrings; defaults `support@topcoder.com`, `support@topcoder.com,test1@topcoder.com`, `1`, `5`, `30`) | +| `SPACE_TECH_CHALLENGE_RECIPIENTS`, `SPACE_TECH_CHALLENGE_TEMPLATE_ID` | `/config/forms-processor-v6/appvar/` (optional SecureStrings; comma-separated addresses and the active `d-` template ID for the SpaceTech challenge notification; unset defers it) | | `DATABASE_URL`, `AUTH0_CLIENT_ID`, `AUTH0_CLIENT_SECRET` | `/config/forms-api-v6/appvar/` | | `AUTH0_URL`, `AUTH0_AUDIENCE`, optional `AUTH0_PROXY_SERVER_URL` | Forms API appvars when present, otherwise `/config/common/global-appvar/` | | `KAFKA_URL`, optional `KAFKA_SSL_ENABLED` | `/config/common/global-appvar/` | diff --git a/deploy/release.py b/deploy/release.py index 5788e29..ade1a8d 100644 --- a/deploy/release.py +++ b/deploy/release.py @@ -34,8 +34,9 @@ def read_service(ecs): return service -# Optional processor-owned settings for AI classification and label actions (docs/adr/0001). -# Absent values defer the matching flows; the code defaults apply where documented. +# Optional processor-owned settings for AI classification and label actions (docs/adr/0001), +# and for the SpaceTech challenge notification. Absent values defer the matching flows; +# the code defaults apply where documented. LABEL_ACTION_SETTINGS = [ 'AI_API_URL', 'AI_AUTH0_AUDIENCE', 'LETS_TALK_CLASSIFY_MAX_ATTEMPTS', 'LETS_TALK_CLASSIFY_MAX_AGE_MINUTES', @@ -43,6 +44,7 @@ def read_service(ecs): 'LETS_TALK_GENERAL_TEMPLATE_ID', 'LETS_TALK_WIPRO_TEMPLATE_ID', 'LETS_TALK_SALES_REPLY_TEMPLATE_ID', 'LETS_TALK_SUPPORT_TEMPLATE_ID', 'LETS_TALK_SUPPORT_RECIPIENTS', 'LETS_TALK_SALES_REPLY_TO', 'LETS_TALK_REPLY_CAP_PER_DAY', + 'SPACE_TECH_CHALLENGE_RECIPIENTS', 'SPACE_TECH_CHALLENGE_TEMPLATE_ID', ] diff --git a/docs/adr/0001-lets-talk-ai-classification-and-label-actions.md b/docs/adr/0001-lets-talk-ai-classification-and-label-actions.md index b4efc9a..248fb0c 100644 --- a/docs/adr/0001-lets-talk-ai-classification-and-label-actions.md +++ b/docs/adr/0001-lets-talk-ai-classification-and-label-actions.md @@ -36,7 +36,7 @@ Constraints from the existing system: - forms-processor-v6 routes a submission to **all** matching flows at once, using rules on answers only. It holds a row lock for the length of each action, and the pool's idle-in-transaction limit is 45 s. - The processor's email action deliberately takes recipients **only from trusted configuration**. -- email-service-v6 accepts **one** `replyTo`. +- email-service-v6 accepted **one** `replyTo` (it now also takes a list, sent as SendGrid `reply_to_list`; Decision 7). - The AI workflow's access policy is scopes-only, so administrator member tokens can't read its runs. - The Let's talk form allows 500-character names and company. The workflow's input schema caps them at 100/100/200 and expects the selector's display labels, while the form sends stable keys. @@ -86,7 +86,7 @@ Flows (seeded disabled by a forms-api-v6 migration): | `lets-talk-community-general-reply` | `sendgrid-email`, to the submitter | `COMMUNITY_GENERAL` | | `lets-talk-wipro-queries-reply` | `sendgrid-email`, to the submitter | `WIPRO_QUERIES` | | `lets-talk-support-forward` | `sendgrid-email`, to `support@topcoder.com`, reply-to the submitter | `COMMUNITY_SUPPORT` | -| `lets-talk-sales-reply` | `sendgrid-email`, to the submitter, reply-to and copies from `LETS_TALK_SALES_REPLY_TO` | `SALES_LEAD` | +| `lets-talk-sales-reply` | `sendgrid-email`, to the submitter, reply-to every `LETS_TALK_SALES_REPLY_TO` address | `SALES_LEAD` | | `lets-talk-sales-email` (existing) | `sendgrid-email`, to `LETS_TALK_RECIPIENTS` | `UNKNOWN` (fallback, Decision 6) | As for the existing flow, SSM values are authoritative for every Let's talk flow: template ids, the support recipients, the reply-to pool, and the AI API URL and audience. Changing them needs a redeploy. @@ -129,7 +129,9 @@ A submission whose classification ends `FAILED` keeps the label `UNKNOWN`. It is `needsReview` doesn't change the action; it is shown in the UI. Holding auto-replies for review would affect at least the 45% of submissions with an empty message (Q1). -### 7. Sales reply: the first listed address gets the replies, the rest are copied +### 7. Sales reply: every listed address is a reply-to + +**Revised 2026-10-29.** Copying the other addresses (`cc`) sent each of them a copy of the reply to the prospect, which was not wanted. email-service-v6 now accepts `replyTo` as a list and sends two or more addresses as SendGrid `reply_to_list`, so **every** `LETS_TALK_SALES_REPLY_TO` address is a reply-to and none is copied. A prospect who presses Reply writes to all of them. This needs email-service-v6 deployed first: an older email-service-v6 skips a message whose `replyTo` is a list as malformed. The original decision follows. Decided 2026-10-22, revising the earlier "one address per submission from a pool". The sales reply should reach a configurable list of people (`LETS_TALK_SALES_REPLY_TO`). email-service-v6 accepts a single `replyTo`, so: diff --git a/docs/emails/README.md b/docs/emails/README.md index 500c808..1548bf1 100644 --- a/docs/emails/README.md +++ b/docs/emails/README.md @@ -91,4 +91,23 @@ The Figma designs don't include these links, so check them first: - "sign up here" (general reply, developer getting started): `https://accounts-auth0.topcoder.com/?mode=signUp`. The practice email's "create your free account" links to `https://www.topcoder.com/`, as in its design. - The social links: Facebook, YouTube, LinkedIn, Twitter and Instagram (`https://discord.com/invite/topcoder` comes from the designs). -- The `SALES_LEAD` template has no Figma design; it follows the shared shell. Its signature and `mailto:` are Kiril Kartunov's (hard-coded), while replies go to the first `LETS_TALK_SALES_REPLY_TO` address (default `support@topcoder.com`; the rest, default `test1@topcoder.com`, are copied). Set that list for production so the first address is the signer's. +- The `SALES_LEAD` template has no Figma design; it follows the shared shell. Its signature and `mailto:` are Kiril Kartunov's (hard-coded), while replies go to every `LETS_TALK_SALES_REPLY_TO` address (default `support@topcoder.com`, `test1@topcoder.com`). Set that list for production so it includes the signer. + +## SpaceTech challenge notification + +[`space-tech-challenge-email.html`](space-tech-challenge-email.html) is the internal email for the SpaceTech landing page form (`space_tech_challenge`). Flow `space-tech-challenge-email` (forms-api-v6 migration `20261102010000_space_tech_challenge_email`) sends every submission to the addresses in `SPACE_TECH_CHALLENGE_RECIPIENTS`, with the submitter's `email` answer as reply-to. Suggested subject: **New space-data challenge** (keep it fixed). + +It uses `answers.full_name`, `answers.email`, `answers.company`, `answers.challenge`, `submittedAt`, `sourcePage` and `submissionId`. All the answers are submitter-controlled: double braces only. For test data, use `{"answers":{"full_name":"Alex Doe","email":"alex@example.com","company":"Orbital Labs","challenge":"Detect debris in\nlow-orbit imagery."},"submittedAt":"2026-11-02T10:00:00.000Z","sourcePage":"/space-tech/","submissionId":"00000000-0000-0000-0000-000000000000"}`. + +To go live in an environment: + +1. Create the template (steps 1–6 above). +2. Create the SecureStrings `/config/forms-processor-v6/appvar/SPACE_TECH_CHALLENGE_RECIPIENTS` (comma-separated addresses) and `/config/forms-processor-v6/appvar/SPACE_TECH_CHALLENGE_TEMPLATE_ID`, then **redeploy the processor**. +3. Enable the flow: + + ```sql + UPDATE forms."ProcessorFlow" SET enabled = true, "updatedAt" = now() + WHERE id = 'space-tech-challenge-email' AND "formKey" = 'space_tech_challenge'; + ``` + +Submissions made while the flow is disabled are queued and are sent once it is enabled. Changing the list means updating the parameter and redeploying. diff --git a/src/actions.ts b/src/actions.ts index 7adc4bc..28f75bb 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -76,8 +76,11 @@ export const emailSettingsSchema = z templateId: z.string().regex(/^d-[a-f0-9]{32}$/), fromEmail: z.email().nullish(), fromName: z.string().max(200).optional(), - /** A trusted reply-to address, or the address in an answer (e.g. support forwards). */ - replyTo: z.email().optional(), + /** Trusted reply-to address(es), or the address in an answer (e.g. support forwards). + * Several addresses become SendGrid's reply_to_list in email-service-v6. */ + replyTo: z + .union([z.email(), z.array(z.email()).min(1).max(100)]) + .optional(), replyToFromAnswer: z.string().min(1).optional(), /** Trusted copies, e.g. the sales team on a sales reply. */ cc: z.array(z.email()).max(100).optional(), @@ -139,11 +142,16 @@ export class BusEmail implements Action { ...new Set(s.recipients!.map((email) => email.toLowerCase())), ]; } + const replyTos = s.replyTo + ? [...new Set([s.replyTo].flat().map((email) => email.toLowerCase()))] + : []; const replyTo = - s.replyTo?.toLowerCase() ?? - (s.replyToFromAnswer - ? answerAddress(event, s.replyToFromAnswer) - : undefined); + replyTos.length > 1 + ? replyTos + : (replyTos[0] ?? + (s.replyToFromAnswer + ? answerAddress(event, s.replyToFromAnswer) + : undefined)); const cc = [ ...new Set((s.cc ?? []).map((email) => email.toLowerCase())), ].filter((email) => !recipients.includes(email)); diff --git a/src/config.ts b/src/config.ts index 7eec909..02b60a2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -63,7 +63,7 @@ const schema = z.object({ LETS_TALK_SALES_REPLY_TEMPLATE_ID: templateId, LETS_TALK_SUPPORT_TEMPLATE_ID: templateId, LETS_TALK_SUPPORT_RECIPIENTS: emailList("support@topcoder.com"), - /** First address: the sales reply's reply-to; the rest are copied (email-service-v6 takes one reply-to). */ + /** The sales reply's reply-to addresses; all of them receive a prospect's reply. */ LETS_TALK_SALES_REPLY_TO: emailList( "support@topcoder.com,test1@topcoder.com", ), @@ -73,6 +73,9 @@ const schema = z.object({ .min(0) .max(100) .default(1), + // SpaceTech landing page challenges: internal notification to a fixed list. + SPACE_TECH_CHALLENGE_RECIPIENTS: emailList(""), + SPACE_TECH_CHALLENGE_TEMPLATE_ID: templateId, PORT: z.coerce.number().int().min(1).max(65535).default(3000), }); export type Config = z.infer & { diff --git a/src/lets-talk-flows.ts b/src/lets-talk-flows.ts index 4faab6d..3f49c28 100644 --- a/src/lets-talk-flows.ts +++ b/src/lets-talk-flows.ts @@ -37,7 +37,6 @@ export function letsTalkFlowSettings(config: Config): Map { templateId, replyCapPerDay: config.LETS_TALK_REPLY_CAP_PER_DAY, }); - const [salesReplyTo, ...salesCopies] = config.LETS_TALK_SALES_REPLY_TO; return new Map([ [ LETS_TALK_FLOWS.legacySalesEmail, @@ -65,10 +64,11 @@ export function letsTalkFlowSettings(config: Config): Map { LETS_TALK_FLOWS.salesReply, { ...reply(config.LETS_TALK_SALES_REPLY_TEMPLATE_ID), - // email-service-v6 accepts a single reply-to: the first address gets the - // replies, the rest of the list is copied on every sales reply. - ...(salesReplyTo ? { replyTo: salesReplyTo } : {}), - ...(salesCopies.length ? { cc: salesCopies } : {}), + // Every address is a reply-to: a prospect's reply goes to all of them, + // and none of them gets a copy of the reply itself. + ...(config.LETS_TALK_SALES_REPLY_TO.length + ? { replyTo: config.LETS_TALK_SALES_REPLY_TO } + : {}), }, ], [ diff --git a/src/main.ts b/src/main.ts index 5881ae7..0fa0fc5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,6 +12,7 @@ import { letsTalkFlowSettings, } from "./lets-talk-flows.js"; import { LETS_TALK_ANSWERS } from "./lets-talk.js"; +import { spaceTechFlowSettings } from "./space-tech-flows.js"; import { Store, log } from "./store.js"; /** Start the database inbox worker, Kafka consumer, and private health endpoint with environment-owned Let's talk settings. @@ -43,7 +44,10 @@ async function main(): Promise { ), ], ]), - letsTalkFlowSettings(settings), + new Map([ + ...letsTalkFlowSettings(settings), + ...spaceTechFlowSettings(settings), + ]), { submitterFlowIds: LETS_TALK_SUBMITTER_FLOWS, submitterAddressAnswer: LETS_TALK_ANSWERS.email, diff --git a/src/space-tech-flows.ts b/src/space-tech-flows.ts new file mode 100644 index 0000000..15cbfbd --- /dev/null +++ b/src/space-tech-flows.ts @@ -0,0 +1,24 @@ +import type { Config } from "./config.js"; + +/** SpaceTech landing page flow id, as seeded by forms-api-v6 migration 20261102010000_space_tech_challenge_email. */ +export const SPACE_TECH_CHALLENGE_FLOW = "space-tech-challenge-email"; + +/** + * Settings for the SpaceTech challenge notification, from SSM-backed configuration. They replace + * the flow's database settings: every submission goes to SPACE_TECH_CHALLENGE_RECIPIENTS with the + * submitter's email as reply-to. An empty recipient list or template id leaves its deliveries + * deferred with invalid_email_settings until set. Never throws. + */ +export function spaceTechFlowSettings(config: Config): Map { + return new Map([ + [ + SPACE_TECH_CHALLENGE_FLOW, + { + recipients: config.SPACE_TECH_CHALLENGE_RECIPIENTS, + templateId: config.SPACE_TECH_CHALLENGE_TEMPLATE_ID, + // Replies go straight to the submitter. + replyToFromAnswer: "email", + }, + ], + ]); +} diff --git a/test/actions.test.ts b/test/actions.test.ts index b0fac50..cb4a4eb 100644 --- a/test/actions.test.ts +++ b/test/actions.test.ts @@ -145,7 +145,38 @@ test("a submitter reply goes to the submitted address with only the first name a }); }); -test("the sales reply sets the trusted reply-to and copies the rest, never the recipient twice", async () => { +test("several trusted reply-to addresses are published as a deduplicated list", async () => { + const { published, action } = capture(); + await action.execute( + event, + { + ...replySettings, + replyTo: [ + "Support@topcoder.com", + "test1@topcoder.com", + "support@topcoder.com", + ], + }, + context(), + ); + assert.deepEqual(published[0].payload.replyTo, [ + "support@topcoder.com", + "test1@topcoder.com", + ]); + assert.equal(published[0].payload.cc, undefined); +}); + +test("a one-address reply-to list is published as a single reply-to", async () => { + const { published, action } = capture(); + await action.execute( + event, + { ...replySettings, replyTo: ["Support@topcoder.com"] }, + context(), + ); + assert.equal(published[0].payload.replyTo, "support@topcoder.com"); +}); + +test("a trusted reply-to with copies never copies the recipient twice", async () => { const { published, action } = capture(); await action.execute( event, @@ -247,6 +278,8 @@ test("recipient settings must name exactly one source, and one reply-to", async { ...replySettings, replyTo: "a@example.com", replyToFromAnswer: "email" }, { ...replySettings, templateId: "" }, { ...replySettings, cc: ["not-an-email"] }, + { ...replySettings, replyTo: [] }, + { ...replySettings, replyTo: ["a@example.com", "not-an-email"] }, ]) await assert.rejects( action.execute(event, bad, context()), diff --git a/test/lets-talk.test.ts b/test/lets-talk.test.ts index cf27901..cc9f4a3 100644 --- a/test/lets-talk.test.ts +++ b/test/lets-talk.test.ts @@ -101,7 +101,7 @@ const base = { KAFKA_URL: "localhost:9092", }; -test("flow settings: the sales reply's first address is reply-to and the rest are copied", () => { +test("flow settings: every sales reply address is a reply-to and none is copied", () => { const settings = letsTalkFlowSettings( config({ ...base, @@ -114,8 +114,11 @@ test("flow settings: the sales reply's first address is reply-to and the rest ar recipientsFromAnswer: "email", templateId: "d-" + "b".repeat(32), replyCapPerDay: 1, - replyTo: "support@topcoder.com", - cc: ["test1@topcoder.com", "sales@topcoder.com"], + replyTo: [ + "support@topcoder.com", + "test1@topcoder.com", + "sales@topcoder.com", + ], }); }); @@ -125,8 +128,7 @@ test("flow settings: defaults, support forward and classification", () => { recipientsFromAnswer: "email", templateId: "", replyCapPerDay: 1, - replyTo: "support@topcoder.com", - cc: ["test1@topcoder.com"], + replyTo: ["support@topcoder.com", "test1@topcoder.com"], }); assert.deepEqual(settings.get(LETS_TALK_FLOWS.supportForward), { recipients: ["support@topcoder.com"], diff --git a/test/space-tech-flows.test.ts b/test/space-tech-flows.test.ts new file mode 100644 index 0000000..efdde90 --- /dev/null +++ b/test/space-tech-flows.test.ts @@ -0,0 +1,50 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { emailSettingsSchema } from "../src/actions.js"; +import { config } from "../src/config.js"; +import { + SPACE_TECH_CHALLENGE_FLOW, + spaceTechFlowSettings, +} from "../src/space-tech-flows.js"; + +const base = { + DATABASE_URL: "postgresql://localhost/test", + KAFKA_URL: "localhost:9092", +}; + +test("space tech settings: recipients come from the environment, replies go to the submitter", () => { + const settings = spaceTechFlowSettings( + config({ + ...base, + SPACE_TECH_CHALLENGE_RECIPIENTS: + " Test-Support@topcoder.com, test2-support@topcoder.com,test-support@topcoder.com", + SPACE_TECH_CHALLENGE_TEMPLATE_ID: "d-" + "c".repeat(32), + }), + ).get(SPACE_TECH_CHALLENGE_FLOW); + assert.deepEqual(settings, { + recipients: ["test-support@topcoder.com", "test2-support@topcoder.com"], + templateId: "d-" + "c".repeat(32), + replyToFromAnswer: "email", + }); + assert.equal(emailSettingsSchema.safeParse(settings).success, true); +}); + +test("space tech settings: unset variables defer delivery and malformed ones fail startup", () => { + const settings = spaceTechFlowSettings(config(base)).get( + SPACE_TECH_CHALLENGE_FLOW, + ); + assert.deepEqual(settings, { + recipients: [], + templateId: "", + replyToFromAnswer: "email", + }); + assert.equal(emailSettingsSchema.safeParse(settings).success, false); + for (const [key, value] of [ + ["SPACE_TECH_CHALLENGE_RECIPIENTS", "test-support@topcoder.com,"], + ["SPACE_TECH_CHALLENGE_TEMPLATE_ID", "d-123"], + ]) + assert.throws( + () => config({ ...base, [key!]: value }), + new RegExp(`^Error: Invalid configuration: ${key}(?:\\.\\d+)?$`), + ); +});