@enricai/barnacle 1.9.5 → 1.9.6

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.
@@ -22,19 +22,24 @@
22
22
  */
23
23
  Object.defineProperty(exports, "__esModule", { value: true });
24
24
  exports.resolveStepPayloadField = resolveStepPayloadField;
25
+ exports.extractStepPersonaValue = extractStepPersonaValue;
26
+ exports.harvestPersonaBindings = harvestPersonaBindings;
25
27
  exports.inferZodSchemaFromSamples = inferZodSchemaFromSamples;
26
28
  exports.selectPayloadAction = selectPayloadAction;
27
29
  exports.selectReturnAction = selectReturnAction;
28
30
  exports.selectEffectiveResponseBody = selectEffectiveResponseBody;
31
+ exports.extractEntryUrlParams = extractEntryUrlParams;
29
32
  exports.resolveManifestActionSequence = resolveManifestActionSequence;
30
33
  exports.extractActionSequence = extractActionSequence;
31
34
  exports.extractGraphQLActionSequence = extractGraphQLActionSequence;
32
35
  exports.detectFormSchemaFieldNames = detectFormSchemaFieldNames;
36
+ exports.indexEnumEnumNamesSchemas = indexEnumEnumNamesSchemas;
37
+ exports.indexLabelValueOptionCodes = indexLabelValueOptionCodes;
38
+ exports.buildSelectOptionResolutions = buildSelectOptionResolutions;
33
39
  exports.walkSetCookiePairs = walkSetCookiePairs;
34
40
  exports.indexStateValues = indexStateValues;
35
41
  exports.compileActionSteps = compileActionSteps;
36
42
  exports.collectHeaderBindings = collectHeaderBindings;
37
- exports.loadQuestionPromptKeywords = loadQuestionPromptKeywords;
38
43
  exports.emitMultiStepExecuteHttp = emitMultiStepExecuteHttp;
39
44
  exports.emitContractTs = emitContractTs;
40
45
  exports.emitConfigManifest = emitConfigManifest;
@@ -112,6 +117,81 @@ function resolveStepPayloadField(instruction, explicit, forceNone, vocabulary =
112
117
  }
113
118
  return null;
114
119
  }
120
+ /**
121
+ * Extracts the concrete persona VALUE a flow step fills — the recon-supplied
122
+ * constant that appears verbatim in the captured request body — so the body
123
+ * emitter can bind it to `${payload.<field>}`. This is a stricter job than
124
+ * {@link buildStepInstructionExpr}'s browser-flow splice, which only needs *a*
125
+ * span to replace: here the extracted string must equal the wire value exactly,
126
+ * or the value-identity substitution silently misses.
127
+ *
128
+ * Two grammar facts, both verified against real ATS flows, drive the rule:
129
+ * 1. The possessive apostrophe in "the candidate's first name 'Reginald'"
130
+ * opens a false quote — a naive `/'[^']*'/` yields `s first name `, not
131
+ * `Reginald`. Neutralizing `\w's` → `\ws` before matching removes it.
132
+ * 2. A `Select`/`Choose` step names the ANSWER first, then the question
133
+ * ("Select 'No' for the 'sponsorship' question"), so the value is the
134
+ * FIRST quoted token; a `Fill`/`Enter`/`Type` step names the field label
135
+ * first and the value last, so it is the LAST quoted token.
136
+ * A `${RECON_EMAIL}` token (or the literal email `env` value) short-circuits to
137
+ * the env-resolved address, matching recon-browser's own env substitution.
138
+ *
139
+ * @param instruction the flow step's plain-English instruction
140
+ * @param env process env (or a stub) supplying `${VAR}` token values, e.g. RECON_EMAIL
141
+ * @returns the persona value, or null when the step carries no spliceable constant
142
+ */
143
+ function extractStepPersonaValue(instruction, env) {
144
+ const emailToken = `$${"{RECON_EMAIL}"}`;
145
+ const reconEmail = env.RECON_EMAIL;
146
+ if (reconEmail && (instruction.includes(emailToken) || instruction.includes(reconEmail))) {
147
+ return reconEmail;
148
+ }
149
+ // Resolve any other `${UPPER_SNAKE}` env token the same way recon-browser's
150
+ // substituteFlowEnvVars does, so an env-supplied value (e.g. RECON_PHONE) is
151
+ // matched against the wire body by its runtime form, not the literal token.
152
+ const envToken = /\$\{([A-Z_][A-Z0-9_]*)\}/.exec(instruction);
153
+ if (envToken) {
154
+ const resolved = env[envToken[1]];
155
+ if (resolved)
156
+ return resolved;
157
+ }
158
+ // Neutralize the possessive apostrophe so it isn't read as a quote delimiter.
159
+ const cleaned = instruction.replace(/(\w)'s\b/g, "$1s");
160
+ const quotes = [...cleaned.matchAll(/'([^']*)'/g)].map((m) => m[1]);
161
+ if (quotes.length === 0)
162
+ return null;
163
+ const value = /^\s*(select|choose|pick)\b/i.test(instruction)
164
+ ? quotes[0]
165
+ : quotes[quotes.length - 1];
166
+ return value.length > 0 ? value : null;
167
+ }
168
+ /**
169
+ * Builds the map from a recon persona VALUE (as it appears in the captured
170
+ * request body) to the `payload.<Field>` accessor that should replace it, by
171
+ * pairing each flow step's resolved field ({@link resolveStepPayloadField})
172
+ * with its extracted value ({@link extractStepPersonaValue}). This is the
173
+ * value→field reconciliation the browser flow and payload schema already do,
174
+ * finally applied to the request-body templates.
175
+ *
176
+ * Earliest step wins on a duplicate value. Site-agnostic: the field mapping
177
+ * lives entirely in the consumer's `--vocabulary`.
178
+ */
179
+ function harvestPersonaBindings(flowSteps, vocabulary, env) {
180
+ const bindings = new Map();
181
+ for (const step of flowSteps) {
182
+ const isObj = typeof step !== "string";
183
+ const instruction = isObj ? step.step : step;
184
+ const field = resolveStepPayloadField(instruction, isObj ? step.payloadField : undefined, isObj ? step.payloadFieldNone : undefined, vocabulary);
185
+ if (field === null)
186
+ continue;
187
+ const value = extractStepPersonaValue(instruction, env);
188
+ if (value === null)
189
+ continue;
190
+ if (!bindings.has(value))
191
+ bindings.set(value, `payload.${field}`);
192
+ }
193
+ return bindings;
194
+ }
115
195
  /**
116
196
  * How deep to infer before collapsing to z.unknown(). Deep enough to reach the
117
197
  * fields that carry meaning on real inventory APIs — a cruise sailing's price
@@ -320,6 +400,40 @@ function deriveBaseUrl(captures) {
320
400
  }
321
401
  return "https://example.com";
322
402
  }
403
+ /**
404
+ * Extracts caller-supplied job/context coordinates from the recon ENTRY URL's
405
+ * query string, mapping each param VALUE to a `payload.<param>` accessor. The
406
+ * first capture is the landing navigation, and a submission flow's job context
407
+ * (`?jobSeqNo=...`, `?jobId=...`) rides its query string — it belongs to the
408
+ * target posting, not the recon run, so it must be caller-supplied. Values
409
+ * below {@link MIN_STATE_VALUE_LENGTH} and cache-buster keys are skipped, since
410
+ * a 1–7 char value collides with arbitrary substrings elsewhere in the body.
411
+ *
412
+ * Site-agnostic: reads only the entry URL's own query keys; no site-specific
413
+ * param knowledge. Downstream, `interpolateStateValues`' length-descending pass
414
+ * composes embedded substrings (a jobId inside a longer jobSeqNo) automatically.
415
+ */
416
+ function extractEntryUrlParams(entryUrl) {
417
+ const params = new Map();
418
+ let u;
419
+ try {
420
+ u = new URL(entryUrl);
421
+ }
422
+ catch {
423
+ return params;
424
+ }
425
+ for (const [key, value] of u.searchParams) {
426
+ if (value.length < MIN_STATE_VALUE_LENGTH)
427
+ continue;
428
+ if (CACHE_BUSTER_QUERY_KEYS.has(key))
429
+ continue;
430
+ if (!isValidJsIdentifier(key))
431
+ continue;
432
+ if (!params.has(value))
433
+ params.set(value, `payload.${key}`);
434
+ }
435
+ return params;
436
+ }
323
437
  const IGNORE_REQUEST_HEADERS = new Set([
324
438
  "host",
325
439
  "content-length",
@@ -1207,6 +1321,408 @@ function applyRawOptionIdPayloadSubstitutions(rawBody, fieldNameMap, fieldOption
1207
1321
  }
1208
1322
  return result;
1209
1323
  }
1324
+ /** i18n label placeholders (e.g. `{{apply.option.label.gender.a}}`) are not
1325
+ * human-facing answers and can never be matched against a flow step's quoted
1326
+ * label, so a schema whose labels are all templated yields no usable
1327
+ * label→code mapping. */
1328
+ function isI18nLabel(label) {
1329
+ return label.includes("{{");
1330
+ }
1331
+ /**
1332
+ * Indexes the JSON-Schema `enum`/`enumNames` PARALLEL-ARRAY convention across
1333
+ * every response body: an object carrying a string `name` plus equal-length
1334
+ * `enum` (option codes) and `enumNames` (option labels) declares one dropdown's
1335
+ * label→code mapping, disambiguated per-question by `name`. This is the PRIMARY
1336
+ * label→code source for ATS demographic/eligibility dropdowns whose submitted
1337
+ * value is an opaque code, not the label.
1338
+ *
1339
+ * The same `name` can appear in several captures with progressively fuller
1340
+ * option lists (a later page reveals the "decline to answer" choice), so the
1341
+ * entry with the MOST non-i18n labels wins rather than first-seen — the flow's
1342
+ * answer might be the choice only the fuller list carries.
1343
+ *
1344
+ * Site-agnostic: `enum`/`enumNames` are generic JSON-Schema keys; the field
1345
+ * identities are discovered from the response, never hardcoded.
1346
+ */
1347
+ function indexEnumEnumNamesSchemas(captures) {
1348
+ const out = new Map();
1349
+ const walk = (value) => {
1350
+ if (Array.isArray(value)) {
1351
+ for (const item of value)
1352
+ walk(item);
1353
+ return;
1354
+ }
1355
+ if (value === null || typeof value !== "object")
1356
+ return;
1357
+ const obj = value;
1358
+ const name = obj.name;
1359
+ const en = obj.enum;
1360
+ const nm = obj.enumNames;
1361
+ if (typeof name === "string" &&
1362
+ Array.isArray(en) &&
1363
+ Array.isArray(nm) &&
1364
+ en.length === nm.length &&
1365
+ en.length > 0 &&
1366
+ en.every((c) => typeof c === "string") &&
1367
+ nm.every((l) => typeof l === "string")) {
1368
+ const codes = en;
1369
+ const labels = nm;
1370
+ const usable = labels.filter((l) => !isI18nLabel(l)).length;
1371
+ const prior = out.get(name);
1372
+ const priorUsable = prior ? prior.labels.filter((l) => !isI18nLabel(l)).length : -1;
1373
+ if (usable > priorUsable)
1374
+ out.set(name, { codes, labels });
1375
+ }
1376
+ for (const v of Object.values(obj))
1377
+ walk(v);
1378
+ };
1379
+ for (const capture of captures)
1380
+ walk(capture.responseBody);
1381
+ return out;
1382
+ }
1383
+ /**
1384
+ * Indexes the `{label,value}`-shaped option-object convention: arrays of
1385
+ * objects each carrying both a `label` and a `value` string (state/country
1386
+ * pickers ship these). Builds a GLOBAL label→code map used as the fallback
1387
+ * source for dropdowns whose flow step carries no `id=` hint (so the
1388
+ * enum/enumNames index can't be keyed) — the answer label is looked up
1389
+ * directly. First non-i18n binding wins on a duplicate label.
1390
+ *
1391
+ * Site-agnostic: `label`/`value` are generic option-object keys; no field name
1392
+ * is assumed.
1393
+ */
1394
+ function indexLabelValueOptionCodes(captures) {
1395
+ const out = new Map();
1396
+ const walk = (value) => {
1397
+ if (Array.isArray(value)) {
1398
+ for (const item of value) {
1399
+ if (item !== null && typeof item === "object" && !Array.isArray(item)) {
1400
+ const r = item;
1401
+ const label = r.label;
1402
+ const code = r.value;
1403
+ if (typeof label === "string" &&
1404
+ typeof code === "string" &&
1405
+ label.length > 0 &&
1406
+ code.length > 0 &&
1407
+ !isI18nLabel(label) &&
1408
+ !out.has(label)) {
1409
+ out.set(label, code);
1410
+ }
1411
+ }
1412
+ walk(item);
1413
+ }
1414
+ return;
1415
+ }
1416
+ if (value === null || typeof value !== "object")
1417
+ return;
1418
+ for (const v of Object.values(value))
1419
+ walk(v);
1420
+ };
1421
+ for (const capture of captures)
1422
+ walk(capture.responseBody);
1423
+ return out;
1424
+ }
1425
+ /**
1426
+ * Reconciles each flow SELECT step to a submitted option CODE so the body-slot
1427
+ * literal (`"applyHealthCareExclusion":"5395"`) can be rewritten to a
1428
+ * caller-driven `${OPT_<Name>[payload.<Name>]}` lookup. Real ATS bodies carry
1429
+ * codes, not labels; the flow carries labels — this bridges them via the two
1430
+ * generic label→code conventions ({@link indexEnumEnumNamesSchemas} primary,
1431
+ * {@link indexLabelValueOptionCodes} fallback).
1432
+ *
1433
+ * The wire KEY is discovered from the step's `id=<field>` hint when present
1434
+ * (the enum/enumNames schema is keyed by that same field name); steps without
1435
+ * an `id=` (state/country) fall back to the vocabulary-resolved persona field
1436
+ * lowercased, matched against the `{label,value}` map by label. A dropdown
1437
+ * whose labels are all i18n placeholders (e.g. gender) yields no enum and is
1438
+ * reported separately for the raw-code channel.
1439
+ *
1440
+ * Returns structured resolutions plus the set of i18n-only wire keys (raw-code
1441
+ * fallbacks). Site-agnostic: field identities come from the flow's own `id=`
1442
+ * hints and the consumer vocabulary, never a hardcoded key.
1443
+ */
1444
+ function buildSelectOptionResolutions(flowSteps, captures, vocabulary, env) {
1445
+ const enumSchemas = indexEnumEnumNamesSchemas(captures);
1446
+ const labelValue = indexLabelValueOptionCodes(captures);
1447
+ const resolutions = [];
1448
+ const rawCodeFields = new Map();
1449
+ const seenWireKeys = new Set();
1450
+ for (const step of flowSteps) {
1451
+ const instruction = typeof step === "string" ? step : step.step;
1452
+ if (!/^\s*(select|choose|pick)\b/i.test(instruction) && !/\bselect\b/i.test(instruction)) {
1453
+ continue;
1454
+ }
1455
+ const idMatch = /id=(\w+)/.exec(instruction);
1456
+ // A Select step names the ANSWER first, then the question — so the answer is
1457
+ // the FIRST quoted token (apostrophe-aware). extractStepPersonaValue only
1458
+ // returns first-quote when the sentence STARTS with select/choose/pick; a
1459
+ // dropdown step phrased "On the X step, select 'No' in the '…?' dropdown"
1460
+ // starts with "On", so read the first quote directly here for id= steps.
1461
+ const firstQuoteLabel = (() => {
1462
+ const cleaned = instruction.replace(/(\w)'s\b/g, "$1s");
1463
+ const m = /'([^']*)'/.exec(cleaned);
1464
+ return m && m[1].length > 0 ? m[1] : null;
1465
+ })();
1466
+ const label = idMatch ? firstQuoteLabel : extractStepPersonaValue(instruction, env);
1467
+ if (label === null)
1468
+ continue;
1469
+ // Primary: an id= hint names the wire key AND the enum/enumNames schema key.
1470
+ if (idMatch) {
1471
+ const wireKey = idMatch[1];
1472
+ if (seenWireKeys.has(wireKey))
1473
+ continue;
1474
+ const schema = enumSchemas.get(wireKey);
1475
+ if (!schema)
1476
+ continue;
1477
+ const semanticName = fieldNameToPascalCase(wireKey, null);
1478
+ if (semanticName === null)
1479
+ continue;
1480
+ const idx = schema.labels.indexOf(label);
1481
+ const usableOptions = schema.labels
1482
+ .map((l, i) => ({ label: l, code: schema.codes[i] }))
1483
+ .filter((o) => !isI18nLabel(o.label));
1484
+ if (idx >= 0 && !isI18nLabel(schema.labels[idx])) {
1485
+ resolutions.push({
1486
+ wireKey,
1487
+ semanticName,
1488
+ code: schema.codes[idx],
1489
+ label,
1490
+ options: usableOptions,
1491
+ });
1492
+ seenWireKeys.add(wireKey);
1493
+ continue;
1494
+ }
1495
+ // Label is i18n-only (or the answer maps to a templated label): the field
1496
+ // still must not stay frozen — surface the recon-observed code so the raw
1497
+ // channel emits a caller-supplied default.
1498
+ if (usableOptions.length === 0 && schema.codes.length > 0) {
1499
+ const fallbackIdx = idx >= 0 ? idx : schema.codes.length - 1;
1500
+ rawCodeFields.set(semanticName, { wireKey, code: schema.codes[fallbackIdx] });
1501
+ seenWireKeys.add(wireKey);
1502
+ }
1503
+ continue;
1504
+ }
1505
+ // Fallback: no id= — resolve the wire key from the vocabulary persona field
1506
+ // (lowercased) and the code from the global {label,value} map by label.
1507
+ const field = resolveStepPayloadField(instruction, typeof step === "string" ? undefined : step.payloadField, typeof step === "string" ? undefined : step.payloadFieldNone, vocabulary);
1508
+ if (field === null)
1509
+ continue;
1510
+ const code = labelValue.get(label);
1511
+ if (code === undefined)
1512
+ continue;
1513
+ const wireKey = field.toLowerCase();
1514
+ if (seenWireKeys.has(wireKey))
1515
+ continue;
1516
+ const semanticName = fieldNameToPascalCase(wireKey, null);
1517
+ if (semanticName === null)
1518
+ continue;
1519
+ // The {label,value} map only reliably yields the one answered label→code
1520
+ // pair here; emit a single-choice enum so the field still binds (the caller
1521
+ // can widen it). Co-located labels aren't safely attributable to this field.
1522
+ resolutions.push({ wireKey, semanticName, code, label, options: [{ label, code }] });
1523
+ seenWireKeys.add(wireKey);
1524
+ }
1525
+ return { resolutions, rawCodeFields };
1526
+ }
1527
+ /**
1528
+ * Rewrites plain-JSON dropdown body slots (`"<wireKey>":"<code>"`) to a
1529
+ * caller-driven `${OPT_<Name>[payload.<Name>]}` lookup, anchored on the wire
1530
+ * KEY rather than the UUID field-id marker
1531
+ * {@link applyFormSchemaOptionIdSubstitutions} uses. This is the plain-JSON
1532
+ * ATS case where the submitted body is a flat `{ "field": "code" }` map with no
1533
+ * schema envelope, so the key/value pair — both drawn from recon input — is the
1534
+ * only closed-set anchor available.
1535
+ *
1536
+ * Records each rewritten field's semanticName into `outDiscoveredOptionFields`
1537
+ * so emitContractTs lights up its OPT_<Name> const + z.enum payload entry, and
1538
+ * mutates `fieldOptionsMap` so that emit finds the option mapping. Closed-set:
1539
+ * both the key and the code come from the recon-derived resolutions.
1540
+ */
1541
+ function applyGenericOptionCodeSubstitutions(rawBody, resolutions, fieldOptionsMap, outDiscoveredOptionFields) {
1542
+ if (resolutions.length === 0)
1543
+ return rawBody;
1544
+ let result = rawBody;
1545
+ for (const res of resolutions) {
1546
+ const slot = `"${res.wireKey}":"${res.code}"`;
1547
+ if (!result.includes(slot))
1548
+ continue;
1549
+ const replacement = `"${res.wireKey}":"$${"{"}OPT_${res.semanticName}[payload.${res.semanticName}]${"}"}"`;
1550
+ result = result.split(slot).join(replacement);
1551
+ // Mutate the option map so emitContractTs emits OPT_<Name> + the z.enum.
1552
+ if (!fieldOptionsMap.has(res.wireKey)) {
1553
+ fieldOptionsMap.set(res.wireKey, {
1554
+ semanticName: res.semanticName,
1555
+ options: res.options.map((o) => ({ value: o.label, optionId: o.code })),
1556
+ });
1557
+ }
1558
+ outDiscoveredOptionFields.add(res.semanticName);
1559
+ }
1560
+ return result;
1561
+ }
1562
+ /**
1563
+ * Rewrites the body slot of an i18n-only dropdown (one whose labels are all
1564
+ * templated placeholders, so no OPT_<Name> enum is possible) from its frozen
1565
+ * recon code to a caller-supplied `${payload.<Name>Code}`. This is the
1566
+ * plain-JSON, wire-key-anchored twin of {@link applyRawOptionIdPayloadSubstitutions}
1567
+ * (which is UUID/form-schema anchored) — without it a field like gender would
1568
+ * submit the recon persona's frozen choice for every caller.
1569
+ */
1570
+ function applyGenericRawCodeSubstitutions(rawBody, rawCodeFields) {
1571
+ if (rawCodeFields.size === 0)
1572
+ return rawBody;
1573
+ let result = rawBody;
1574
+ for (const [semanticName, { wireKey, code }] of rawCodeFields) {
1575
+ const slot = `"${wireKey}":"${code}"`;
1576
+ if (!result.includes(slot))
1577
+ continue;
1578
+ const replacement = `"${wireKey}":"$${"{"}payload.${semanticName}Code${"}"}"`;
1579
+ result = result.split(slot).join(replacement);
1580
+ }
1581
+ return result;
1582
+ }
1583
+ /** Counts an object's DIRECT primitive-valued children (string/number/boolean/
1584
+ * null). The form envelope is the object with the most of these — its scalar
1585
+ * children are the fields every other binding pass individually parameterizes,
1586
+ * so it must never be swallowed wholesale. */
1587
+ function directPrimitiveChildCount(obj) {
1588
+ let n = 0;
1589
+ for (const v of Object.values(obj)) {
1590
+ if (v === null || (typeof v !== "object" && typeof v !== "function"))
1591
+ n++;
1592
+ }
1593
+ return n;
1594
+ }
1595
+ /**
1596
+ * Locates the FORM ENVELOPE inside a submit body — the nested object that
1597
+ * actually holds the scalar form fields (which every other pass binds one by
1598
+ * one) — by descending through wrapper objects and picking the object with the
1599
+ * most direct primitive children. Returns its dotted path from the body root
1600
+ * (empty when the root itself is the envelope). Site-agnostic: no key names are
1601
+ * assumed; the envelope is found by shape.
1602
+ */
1603
+ function locateFormEnvelopePath(parsedBody) {
1604
+ const candidates = [];
1605
+ const visit = (value, path) => {
1606
+ if (value === null || typeof value !== "object" || Array.isArray(value))
1607
+ return;
1608
+ const obj = value;
1609
+ candidates.push({ path, primitives: directPrimitiveChildCount(obj) });
1610
+ for (const [k, v] of Object.entries(obj))
1611
+ visit(v, [...path, k]);
1612
+ };
1613
+ visit(parsedBody, []);
1614
+ if (candidates.length === 0)
1615
+ return [];
1616
+ const maxP = Math.max(...candidates.map((c) => c.primitives));
1617
+ // The analytics blob (`eventData`) mirrors the form, so the object with the
1618
+ // MOST primitives can be a deep descendant of the true envelope. Pick the
1619
+ // SHALLOWEST primitive-rich object (≥ half the max) instead — that is the
1620
+ // form envelope itself, whose analytics mirror sits below it. Tie-break on a
1621
+ // higher primitive count. Threshold is relative, not a magic key name.
1622
+ const rich = candidates.filter((c) => c.primitives >= Math.max(1, maxP / 2));
1623
+ // No object carries a scalar field (maxP === 0): the body root is the only
1624
+ // sensible envelope — operate at the top level.
1625
+ if (rich.length === 0)
1626
+ return [];
1627
+ rich.sort((a, b) => a.path.length - b.path.length || b.primitives - a.primitives);
1628
+ return rich[0].path;
1629
+ }
1630
+ /**
1631
+ * Parameterizes whole nested caller-supplied structures sitting BESIDE the
1632
+ * scalar form fields — the array-valued work/education history
1633
+ * (`experienceData`/`educationData`/`dqData`) and the opaque `eventData`
1634
+ * analytics object — replacing each `"key":<json>` span with
1635
+ * `"key":${JSON.stringify(payload.<key>)}` and recording the key's inferred Zod
1636
+ * schema so emitContractTs adds it to the payload contract.
1637
+ *
1638
+ * These blocks are caller data the recon merely captured a frozen sample of;
1639
+ * freezing them would submit one applicant's history for every caller. Crucially
1640
+ * the FORM ENVELOPE object itself (the one carrying firstName/state/… that the
1641
+ * persona and dropdown passes bind field-by-field) is NEVER swallowed — that
1642
+ * would collapse the whole form to one opaque `${JSON.stringify(payload.formData)}`
1643
+ * and defeat every other binding. {@link locateFormEnvelopePath} finds it by
1644
+ * shape; only its non-scalar SIBLING children are parameterized. A
1645
+ * brace/bracket-depth scanner finds the exact JSON span (the captured body is
1646
+ * well-formed).
1647
+ *
1648
+ * NOTE on `eventData`: it becomes an opaque `${JSON.stringify(payload.eventData)}`
1649
+ * passthrough. Its nested volatiles (apTxnId, per-step timestamps) therefore
1650
+ * become the CALLER's responsibility to mint fresh — acceptable because the
1651
+ * whole blob is caller-supplied; the generator can't reach inside a value it
1652
+ * has delegated wholesale.
1653
+ *
1654
+ * Site-agnostic: operates only on the recon body's own shape.
1655
+ */
1656
+ function applyStructuredValuePayloadSubstitutions(template, parsedBody, outStructuredKeys) {
1657
+ if (parsedBody === null || typeof parsedBody !== "object" || Array.isArray(parsedBody)) {
1658
+ return template;
1659
+ }
1660
+ // Resolve the envelope object whose non-scalar children are caller structures.
1661
+ const envelopePath = locateFormEnvelopePath(parsedBody);
1662
+ let envelope = parsedBody;
1663
+ for (const seg of envelopePath) {
1664
+ if (envelope !== null && typeof envelope === "object" && !Array.isArray(envelope)) {
1665
+ envelope = envelope[seg];
1666
+ }
1667
+ }
1668
+ if (envelope === null || typeof envelope !== "object" || Array.isArray(envelope)) {
1669
+ return template;
1670
+ }
1671
+ let result = template;
1672
+ for (const [key, value] of Object.entries(envelope)) {
1673
+ const isNonEmptyArray = Array.isArray(value) && value.length > 0;
1674
+ const isNestedObject = value !== null &&
1675
+ typeof value === "object" &&
1676
+ !Array.isArray(value) &&
1677
+ Object.keys(value).length > 0;
1678
+ if (!isNonEmptyArray && !isNestedObject)
1679
+ continue;
1680
+ const keyMarker = `"${key}":`;
1681
+ const markerIdx = result.indexOf(keyMarker);
1682
+ if (markerIdx === -1)
1683
+ continue;
1684
+ const spanStart = markerIdx + keyMarker.length;
1685
+ const open = result[spanStart];
1686
+ if (open !== "[" && open !== "{")
1687
+ continue;
1688
+ const close = open === "[" ? "]" : "}";
1689
+ let depth = 0;
1690
+ let inString = false;
1691
+ let escaped = false;
1692
+ let spanEnd = -1;
1693
+ for (let i = spanStart; i < result.length; i++) {
1694
+ const ch = result[i];
1695
+ if (inString) {
1696
+ if (escaped)
1697
+ escaped = false;
1698
+ else if (ch === "\\")
1699
+ escaped = true;
1700
+ else if (ch === '"')
1701
+ inString = false;
1702
+ continue;
1703
+ }
1704
+ if (ch === '"')
1705
+ inString = true;
1706
+ else if (ch === open)
1707
+ depth++;
1708
+ else if (ch === close) {
1709
+ depth--;
1710
+ if (depth === 0) {
1711
+ spanEnd = i + 1;
1712
+ break;
1713
+ }
1714
+ }
1715
+ }
1716
+ if (spanEnd === -1)
1717
+ continue;
1718
+ const replacement = `$${"{"}JSON.stringify(payload.${key})${"}"}`;
1719
+ result = result.slice(0, spanStart) + replacement + result.slice(spanEnd);
1720
+ if (!outStructuredKeys.has(key)) {
1721
+ outStructuredKeys.set(key, inferZodSchema(value));
1722
+ }
1723
+ }
1724
+ return result;
1725
+ }
1210
1726
  /** Maximum length to guard against indexing massive blobs (HTML fragments,
1211
1727
  * embedded base64 images, etc.) that aren't candidates for state threading. */
1212
1728
  const MAX_STATE_VALUE_LENGTH = 256;
@@ -1695,172 +2211,91 @@ function applyPayloadKeyValueSubstitutions(template, inputBody, additionalBodies
1695
2211
  }
1696
2212
  return result;
1697
2213
  }
1698
- // ── base64 Content parameterization ──────────────────────────────────────────
1699
2214
  /**
1700
- * Maps a site's screening-question prompts to the payload field that answers
1701
- * them, as `{ payloadField: [keyword, …] }`.
2215
+ * Documented closed set of JSON-key-name fragments (matched case-insensitively)
2216
+ * that mark a value as a per-request TIMESTAMP the plugin must generate fresh at
2217
+ * call time, not replay from the capture. Closed set per the no-regex-on-open-
2218
+ * sets feedback, mirroring {@link CACHE_BUSTER_QUERY_KEYS}'s posture. A frozen
2219
+ * capture timestamp would make every submission claim the recon instant.
2220
+ */
2221
+ const VOLATILE_TIMESTAMP_KEY_FRAGMENTS = ["timestamp", "esign", "signeddate", "signedat"];
2222
+ /** JSON-key-name suffix marking a value as a per-request time the plugin must
2223
+ * regenerate (e.g. `stepStartTime`, `submissionTime`). Separate from the
2224
+ * fragment set so it anchors on the suffix and doesn't match `runtime`/`downtime`. */
2225
+ const VOLATILE_TIME_KEY_SUFFIX = "time";
2226
+ function isVolatileTimestampKey(key) {
2227
+ const k = key.toLowerCase();
2228
+ if (VOLATILE_TIMESTAMP_KEY_FRAGMENTS.some((frag) => k.includes(frag)))
2229
+ return true;
2230
+ return k.endsWith(VOLATILE_TIME_KEY_SUFFIX) && k !== "time";
2231
+ }
2232
+ /**
2233
+ * Rewrites per-request VOLATILE values in a body template so the generated
2234
+ * plugin produces them at call time instead of replaying the capture's:
2235
+ * - a UUID-valued leaf → a fresh `crypto.randomUUID()`
2236
+ * - a timestamp/eSign/-time-named leaf → a fresh `new Date().toISOString()`
1702
2237
  *
1703
- * Empty by default and supplied by the operator via `RECON_QUESTION_KEYWORDS`
1704
- * (JSON) the engine cannot know what any site asks or what a caller's payload
1705
- * calls things. It previously hardcoded one product's field names, which capped
1706
- * discovery at those questions and silently dropped every other site's.
1707
- */
1708
- function loadQuestionPromptKeywords() {
1709
- const raw = process.env.RECON_QUESTION_KEYWORDS;
1710
- if (!raw)
1711
- return {};
1712
- try {
1713
- return JSON.parse(raw);
1714
- }
1715
- catch (err) {
1716
- logger.warn(`RECON_QUESTION_KEYWORDS is not valid JSON, ignoring: ${(0, errors_1.toErrorMessage)(err)}`);
1717
- return {};
2238
+ * Walks the PARSED body (so keys are known) and rewrites JSON-key-anchored
2239
+ * (`"key":JSON.stringify(value)`), the same closed-set idiom as
2240
+ * {@link applyPayloadKeyValueSubstitutions}, recursing to ANY depth so nested
2241
+ * analytics/step blobs (`eventData`, `stepInfo[]`) are neutralized too. Values
2242
+ * in `shieldedUuids` (schema field-id/option-id anchors) or `boundValues` (a
2243
+ * value already substituted to `${payload…}`/`${txnId}`/state) are left alone
2244
+ * an already-threaded transaction id or a bound email is not volatile.
2245
+ *
2246
+ * `crypto`/`Date` are bare Node/JS globals in the generated file (which already
2247
+ * uses `Buffer` bare); the `${…}` fragments are assembled by concatenation so
2248
+ * Biome's noTemplateCurlyInString doesn't flag THIS file's source.
2249
+ */
2250
+ function applyVolatileFieldSubstitutions(template, parsedBody, shieldedUuids, boundValues) {
2251
+ const uuidGen = `$${"{"}crypto.randomUUID()${"}"}`;
2252
+ const isoGen = `$${"{"}new Date().toISOString()${"}"}`;
2253
+ let result = template;
2254
+ for (const { value, path } of walkAllPrimitiveLeaves(parsedBody)) {
2255
+ if (typeof value !== "string" || value.length === 0)
2256
+ continue;
2257
+ if (shieldedUuids.has(value) || boundValues.has(value))
2258
+ continue;
2259
+ const key = path[path.length - 1] ?? "";
2260
+ const replacement = UUID_REGEX.test(value)
2261
+ ? uuidGen
2262
+ : isVolatileTimestampKey(key)
2263
+ ? isoGen
2264
+ : null;
2265
+ if (replacement === null)
2266
+ continue;
2267
+ const target = `"${JSON.stringify(value).slice(1, -1)}"`;
2268
+ result = result.split(target).join(`"${replacement}"`);
1718
2269
  }
2270
+ return result;
1719
2271
  }
1720
- const QUESTION_PROMPT_KEYWORDS = loadQuestionPromptKeywords();
1721
2272
  /**
1722
- * Scans captures for a `recruitingCEQuestions` GET response and builds a
1723
- * mapping from question prompts to payload.Answers field names using keyword
1724
- * overlap scoring. Returns null if no questions capture is found.
2273
+ * Collects captured string leaves that survived every binding/generation pass as
2274
+ * still-literal the values a reviewer must look at because they couldn't be
2275
+ * traced to a payload field, a generator, or a schema anchor. Returns the JSON
2276
+ * key names (deduped, in first-seen order) so the emitter can prepend a single
2277
+ * `// TODO: unbound captured literal` marker; it never mutates the body, so the
2278
+ * file still compiles. Short values (< {@link MIN_STATE_VALUE_LENGTH}) are
2279
+ * skipped — they are the legitimately-constant enum-like fields.
1725
2280
  */
1726
- function buildQuestionnaireMapping(captures) {
1727
- const questionCapture = captures.find((c) => c.method === "GET" && c.url.includes("recruitingCEQuestions"));
1728
- if (!questionCapture)
1729
- return null;
1730
- const resp = typeof questionCapture.responseBody === "string"
1731
- ? JSON.parse(questionCapture.responseBody)
1732
- : questionCapture.responseBody;
1733
- if (!resp || !Array.isArray(resp.items))
1734
- return null;
1735
- const mappings = [];
1736
- const unmapped = [];
1737
- for (const item of resp.items) {
1738
- const prompt = String(item.Prompt ?? "").toLowerCase();
1739
- const qid = item.AttributeName;
1740
- const uiType = String(item.UIDisplayType ?? "");
1741
- if (!qid || uiType === "TextBox")
2281
+ function collectUnboundLiterals(finalTemplate, parsedBody, shieldedUuids) {
2282
+ const unbound = [];
2283
+ const seen = new Set();
2284
+ for (const { value, path } of walkAllPrimitiveLeaves(parsedBody)) {
2285
+ if (typeof value !== "string" || value.length < MIN_STATE_VALUE_LENGTH)
1742
2286
  continue;
1743
- let bestField = null;
1744
- let bestScore = 0;
1745
- for (const [field, keywords] of Object.entries(QUESTION_PROMPT_KEYWORDS)) {
1746
- const score = keywords.filter((kw) => prompt.includes(kw)).length;
1747
- if (score > bestScore) {
1748
- bestScore = score;
1749
- bestField = field;
1750
- }
1751
- }
1752
- // A question the keyword map cannot place is the interesting case: it is a
1753
- // question this site asks and the caller has no field for. Report it —
1754
- // dropping it silently is how a generated plugin ends up submitting nothing
1755
- // for a required question.
1756
- if (!bestField || bestScore < 2) {
1757
- unmapped.push(`${qid}: ${String(item.Prompt ?? "")}`);
2287
+ if (shieldedUuids.has(value))
1758
2288
  continue;
1759
- }
1760
- if (mappings.some((m) => m.payloadField === bestField))
2289
+ const key = path[path.length - 1] ?? "";
2290
+ if (seen.has(key))
1761
2291
  continue;
1762
- const answers = {};
1763
- for (const a of (item.answers ?? [])) {
1764
- const meaning = String(a.Meaning ?? "");
1765
- const code = a.LookupCode;
1766
- if (meaning && code)
1767
- answers[meaning] = code;
1768
- }
1769
- mappings.push({ questionId: qid, payloadField: bestField, answers });
1770
- }
1771
- if (unmapped.length > 0) {
1772
- logger.warn(`${unmapped.length} screening question(s) matched no payload field and will be unanswered — add keywords to RECON_QUESTION_KEYWORDS: ${unmapped.join(" | ")}`);
1773
- }
1774
- return mappings.length > 0 ? mappings : null;
1775
- }
1776
- /**
1777
- * Builds the TypeScript source for a `buildBase64Content` function that
1778
- * constructs the base64-encoded Content JSON from payload values and returns
1779
- * it as a base64 string. The function replaces persona-specific values
1780
- * with payload references and maps questionnaire answers via a static
1781
- * lookup table derived from the recon captures.
1782
- */
1783
- function emitBuildBase64ContentFunction(base64, personaValues, questionMapping, pascal) {
1784
- const decoded = Buffer.from(base64, "base64").toString("utf8");
1785
- const content = JSON.parse(decoded);
1786
- const candidate = content.candidate;
1787
- const basic = candidate.basicInformation;
1788
- const phone = basic.phone;
1789
- const application = content.application;
1790
- const esig = application.eSignature;
1791
- basic.firstName = "__PAYLOAD_FirstName__";
1792
- basic.lastName = "__PAYLOAD_LastName__";
1793
- basic.email = "__PAYLOAD_Email__";
1794
- if (esig)
1795
- esig.fullName = "__PAYLOAD_SignatureFullName__";
1796
- if (basic.displayName && typeof basic.displayName === "string")
1797
- basic.displayName = "__PAYLOAD_DisplayName__";
1798
- if (phone && typeof phone.number === "string" && phone.number)
1799
- phone.number = "__PAYLOAD_Phone__";
1800
- for (const [personaVal, _payloadRef] of personaValues) {
1801
- if (typeof basic.email === "string" && basic.email === personaVal)
1802
- basic.email = "__PAYLOAD_Email__";
1803
- }
1804
- const questionnaires = candidate.questionnaires;
1805
- if (questionnaires && questionMapping) {
1806
- for (const q of questionnaires) {
1807
- q.questionnaireId = -1;
1808
- for (const question of q.questions) {
1809
- const mapping = questionMapping.find((m) => m.questionId === question.questionId);
1810
- if (mapping) {
1811
- question.answer = `__QMAP_${mapping.payloadField}__`;
1812
- }
1813
- }
1814
- }
1815
- }
1816
- const attachments = candidate.attachments;
1817
- if (attachments) {
1818
- for (const att of attachments) {
1819
- if (att.id && att.id !== "draft-json-undefined") {
1820
- att.id = "__PAYLOAD_AttachmentId__";
1821
- }
1822
- }
1823
- if (attachments[0]) {
1824
- attachments[0].appDraftId = "__PAYLOAD_DraftId__";
2292
+ // Still a bare literal in the emitted template (no ${} took its place).
2293
+ if (finalTemplate.includes(JSON.stringify(value))) {
2294
+ seen.add(key);
2295
+ unbound.push(key);
1825
2296
  }
1826
2297
  }
1827
- const jsonStr = JSON.stringify(content, null, 0);
1828
- const contentObj = JSON.parse(jsonStr);
1829
- const questionMapEntries = (questionMapping ?? []).map((m) => ` ${JSON.stringify(m.payloadField)}: { answers: ${JSON.stringify(m.answers)} as Record<string, number>, questionId: ${m.questionId} },`);
1830
- const questionMapConst2 = questionMapEntries.length > 0
1831
- ? `\nconst QUESTIONNAIRE_ANSWER_MAP = {\n${questionMapEntries.join("\n")}\n};\n`
1832
- : "";
1833
- const contentTemplate = JSON.stringify(contentObj, null, 2);
1834
- const parameterized = contentTemplate
1835
- .replace(/"__PAYLOAD_FirstName__"/g, "payload.FirstName")
1836
- .replace(/"__PAYLOAD_LastName__"/g, "payload.LastName")
1837
- .replace(/"__PAYLOAD_Email__"/g, "payload.Email")
1838
- .replace(/"__PAYLOAD_Phone__"/g, "payload.Phone")
1839
- .replace(/"__PAYLOAD_SignatureFullName__"/g, "payload.Answers.SignatureFullName")
1840
- // biome-ignore lint/suspicious/noTemplateCurlyInString: emitted as generated template-literal source
1841
- .replace(/"__PAYLOAD_DisplayName__"/g, "`${payload.FirstName} ${payload.LastName}`")
1842
- .replace(/"__PAYLOAD_AttachmentId__"/g, "attachmentId")
1843
- .replace(/"__PAYLOAD_DraftId__"/g, "draftId")
1844
- .replace(/-1(?=,\n\s*"questions")/g, "questionnaireId");
1845
- for (const m of questionMapping ?? []) {
1846
- parameterized.replace(`"__QMAP_${m.payloadField}__"`, `QUESTIONNAIRE_ANSWER_MAP[${JSON.stringify(m.payloadField)}].answers[payload.Answers.${m.payloadField}] ?? "draft-json-undefined"`);
1847
- }
1848
- let finalTemplate = parameterized.replace(/"__QMAP_[^"]*__"/g, '"draft-json-undefined"');
1849
- for (const m of questionMapping ?? []) {
1850
- finalTemplate = finalTemplate.replace(`"__QMAP_${m.payloadField}__"`, `(QUESTIONNAIRE_ANSWER_MAP[${JSON.stringify(m.payloadField)}].answers[payload.Answers.${m.payloadField}] ?? "draft-json-undefined")`);
1851
- }
1852
- return `${questionMapConst2}
1853
- /** Builds the ATS Content payload as a base64-encoded JSON string. */
1854
- function buildBase64Content(
1855
- payload: ${pascal}Payload,
1856
- questionnaireId: number,
1857
- draftId: number,
1858
- attachmentId: string
1859
- ): string {
1860
- const content = ${finalTemplate};
1861
- return Buffer.from(JSON.stringify(content)).toString("base64");
1862
- }
1863
- `;
2298
+ return unbound;
1864
2299
  }
1865
2300
  /** Builds the multi-step `executeHttp` body as a single template-literal string.
1866
2301
  *
@@ -1920,7 +2355,7 @@ function emitErrorSignalGuards(varName, urlPath, signals) {
1920
2355
  }
1921
2356
  /** Exported for unit testing — lets tests drive the multipart-upload code path directly
1922
2357
  * without going through the full emitContractTs pipeline. */
1923
- function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap, outDiscoveredFields, fieldOptionsMap, outDiscoveredOptionFields, outDiscoveredRawOptionFields, outDiscoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, base64PatchOverride = new Map(), formSchema = null) {
2358
+ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap, outDiscoveredFields, fieldOptionsMap, outDiscoveredOptionFields, outDiscoveredRawOptionFields, outDiscoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, formSchema = null, personaBindings = new Map(), entryUrlParams = new Map(), shieldedUuids = new Set(), selectResolutions = [], outStructuredKeys = new Map(), rawCodeFields = new Map()) {
1924
2359
  // Walk the first action's request body to map each leaf string value to its
1925
2360
  // `payload.<accessor>` expression. The emit's second interpolation pass uses
1926
2361
  // this to substitute literal occurrences (e.g. "Reginald") with their
@@ -1958,6 +2393,67 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
1958
2393
  payloadAccessorByValue.set(baseUrl, "payload.BaseUrl");
1959
2394
  outDiscoveredFields.add("BaseUrl");
1960
2395
  }
2396
+ // Persona identity bindings (from the flow's quoted literals + RECON_EMAIL,
2397
+ // paired to a payload field by the consumer vocabulary). Merged into the same
2398
+ // value→accessor map so `interpolateStateValues`' length-descending payload
2399
+ // pass substitutes them at ANY nesting depth — the fix for nested ATS bodies
2400
+ // like `formData.firstName` that the top-level-only key pass never reached.
2401
+ //
2402
+ // Collision guard (not a blunt length floor): that pass replaces by UNANCHORED
2403
+ // `String.split(value)`, so a persona value that appears INSIDE a longer token
2404
+ // would corrupt it — e.g. a `Select 'No' …` answer a vocabulary mapped to a
2405
+ // field would rewrite the "No" inside "Nursing"/"Not". A value binds only when
2406
+ // every occurrence across the action bodies sits at a token boundary (the
2407
+ // adjacent character is a non-alphanumeric JSON delimiter like `"`, space, or
2408
+ // punctuation), never flanked by alphanumerics. This keeps legitimately-short
2409
+ // identity values that don't collide (a 5-digit zip `06103`, a first name that
2410
+ // also appears space-delimited inside a signature) while dropping genuinely
2411
+ // dangerous substrings, which then surface via the unbound-literal TODO.
2412
+ // State-threaded produced values still win — they run in Pass 1, before this.
2413
+ const actionBodies = actions
2414
+ .map((a) => a.capture.requestPostData)
2415
+ .filter((b) => typeof b === "string" && b.length > 0);
2416
+ const isAlnum = (ch) => ch !== undefined && /[A-Za-z0-9]/.test(ch);
2417
+ const bindsWithoutCollision = (value) => {
2418
+ for (const body of actionBodies) {
2419
+ let from = 0;
2420
+ while (true) {
2421
+ const at = body.indexOf(value, from);
2422
+ if (at === -1)
2423
+ break;
2424
+ // Flanked by an alphanumeric on either side → it's a substring of a
2425
+ // longer token; binding it would mangle that token. Block the value.
2426
+ if (isAlnum(body[at - 1]) || isAlnum(body[at + value.length]))
2427
+ return false;
2428
+ from = at + value.length;
2429
+ }
2430
+ }
2431
+ return true;
2432
+ };
2433
+ for (const [value, accessor] of personaBindings) {
2434
+ if (value.length === 0)
2435
+ continue;
2436
+ if (!bindsWithoutCollision(value))
2437
+ continue;
2438
+ if (!payloadAccessorByValue.has(value))
2439
+ payloadAccessorByValue.set(value, accessor);
2440
+ const field = accessor.startsWith("payload.") ? accessor.slice("payload.".length) : null;
2441
+ if (field !== null && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(field))
2442
+ outDiscoveredFields.add(field);
2443
+ }
2444
+ // Job coordinates from the recon entry URL's query string (e.g.
2445
+ // `?jobSeqNo=...`). Registered the same way as BaseUrl so every verbatim
2446
+ // occurrence — and, via length-descending order, embedded substrings like a
2447
+ // jobId inside a jobSeqNo — rewrites to the caller-supplied value.
2448
+ for (const [value, accessor] of entryUrlParams) {
2449
+ if (value.length === 0)
2450
+ continue;
2451
+ if (!payloadAccessorByValue.has(value))
2452
+ payloadAccessorByValue.set(value, accessor);
2453
+ const field = accessor.startsWith("payload.") ? accessor.slice("payload.".length) : null;
2454
+ if (field !== null && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(field))
2455
+ outDiscoveredFields.add(field);
2456
+ }
1961
2457
  // G2: register any tenant-subdomain header values as payload-supplied fields
1962
2458
  // (e.g. an `API-ShortName: "addus"` header becomes `payload.ApiShortName`).
1963
2459
  for (const [headerName, _value] of tenantSubdomainHeaders) {
@@ -1979,6 +2475,37 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
1979
2475
  // skip non-JSON bodies (e.g. multipart raw bytes)
1980
2476
  }
1981
2477
  }
2478
+ // Detect the flow's THREADED transaction id: a single UUID the site mints
2479
+ // once (on page load) and reuses across every submit body to correlate the
2480
+ // multi-step wizard — observed on real ATS flows where one such id spans
2481
+ // every step. A frozen capture UUID would collide across concurrent/real
2482
+ // submissions, so the plugin must mint ONE at call time and thread it — hence
2483
+ // it maps to a hoisted local, not a payload field. Identified generically:
2484
+ // the same non-shielded UUID present in ≥2 action bodies.
2485
+ const uuidBodyCounts = new Map();
2486
+ for (const { capture } of actions) {
2487
+ const seen = new Set();
2488
+ for (const v of jsonBodyLeafValues(capture.requestPostData) ?? []) {
2489
+ if (UUID_REGEX.test(v) && !shieldedUuids.has(v))
2490
+ seen.add(v);
2491
+ }
2492
+ for (const v of seen)
2493
+ uuidBodyCounts.set(v, (uuidBodyCounts.get(v) ?? 0) + 1);
2494
+ }
2495
+ const threadedTxnId = [...uuidBodyCounts.entries()].find(([, n]) => n >= 2)?.[0] ?? null;
2496
+ // The value→`${txnId}` binding rides the same substitution map as payload
2497
+ // accessors (Pass 2 of interpolateStateValues); the hoisted `const txnId`
2498
+ // declaration is emitted once above the step sequence below. `txnId` is a
2499
+ // generic local name — the wire key it fills is whatever the body used.
2500
+ if (threadedTxnId !== null && !payloadAccessorByValue.has(threadedTxnId)) {
2501
+ payloadAccessorByValue.set(threadedTxnId, "txnId");
2502
+ }
2503
+ // Values already substituted to a `${…}` reference — the volatile pass must
2504
+ // NOT regenerate these (an already-threaded txn id or a bound email is not
2505
+ // volatile). Keyed by the concrete captured value.
2506
+ const boundValues = new Set(payloadAccessorByValue.keys());
2507
+ // Captured literals that survived every pass — surfaced as a review TODO.
2508
+ const unboundLiteralKeys = new Set();
1982
2509
  // Pass 1: render every step's emitted strings; collect referenced var names.
1983
2510
  const rendered = [];
1984
2511
  for (let i = 0; i < actions.length; i++) {
@@ -1997,12 +2524,51 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
1997
2524
  const rawBodyWithFormSubs = cap.requestPostData && formSchema !== null
1998
2525
  ? applyRawOptionIdPayloadSubstitutions(applyFormSchemaOptionIdSubstitutions(applyFormSchemaSubstitutions(cap.requestPostData, fieldNameMap, outDiscoveredFields, formSchema), fieldOptionsMap, outDiscoveredOptionFields, formSchema), fieldNameMap, fieldOptionsMap, outDiscoveredRawOptionFields, formSchema)
1999
2526
  : (cap.requestPostData ?? "");
2000
- let bodyTemplate = rawBodyWithFormSubs
2001
- ? applyPayloadKeyValueSubstitutions(interpolateStateValues(rawBodyWithFormSubs, prior, payloadAccessorByValue), inputBody, additionalBodies, outDiscoveredAdditionalBodyKeys)
2527
+ // Parsed once and shared by the structured (Mechanism B) and volatile passes
2528
+ // below both walk the same body JSON, so parsing twice would be redundant.
2529
+ // null for absent/non-JSON bodies (multipart raw bytes), which both passes skip.
2530
+ const parsedBody = (() => {
2531
+ if (!cap.requestPostData)
2532
+ return null;
2533
+ try {
2534
+ return JSON.parse(cap.requestPostData);
2535
+ }
2536
+ catch {
2537
+ return null;
2538
+ }
2539
+ })();
2540
+ // Mechanism B — parameterize whole nested caller structures
2541
+ // (experienceData/educationData history, opaque eventData) BEFORE value
2542
+ // substitution reaches inside them: swallowing the entire array/object first
2543
+ // keeps interpolateStateValues from binding a code buried in the history
2544
+ // sample (e.g. a work entry's state code) to an unrelated field.
2545
+ const rawBodyWithStructuredSubs = parsedBody !== null
2546
+ ? applyStructuredValuePayloadSubstitutions(rawBodyWithFormSubs, parsedBody, outStructuredKeys)
2547
+ : rawBodyWithFormSubs;
2548
+ const bodyAfterStateAndKv = rawBodyWithStructuredSubs
2549
+ ? applyPayloadKeyValueSubstitutions(interpolateStateValues(rawBodyWithStructuredSubs, prior, payloadAccessorByValue), inputBody, additionalBodies, outDiscoveredAdditionalBodyKeys)
2002
2550
  : "";
2003
- const contentOverride = base64PatchOverride.get(step.varName);
2004
- if (contentOverride && bodyTemplate) {
2005
- bodyTemplate = bodyTemplate.replace(/"Content":"ey[A-Za-z0-9+/=]{100,}"/, contentOverride);
2551
+ // Mechanism A — generic (plain-JSON, wire-key-anchored) dropdown label→code
2552
+ // rewrite. Runs AFTER interpolateStateValues + the payload-KV pass, not
2553
+ // before: the emitted `${OPT_<Name>[]}` placeholder embeds the PascalCase
2554
+ // field name, and a wizard step-slug value (e.g. stepNum "Disability") that
2555
+ // becomes a global `.split` payload binding would otherwise rewrite the
2556
+ // matching substring INSIDE that placeholder and corrupt it. The closed-set
2557
+ // `"<key>":"<code>"` slot (numeric code, nested key) survives both earlier
2558
+ // passes untouched, so matching it here is still exact.
2559
+ let bodyTemplate = cap.requestPostData
2560
+ ? applyGenericRawCodeSubstitutions(applyGenericOptionCodeSubstitutions(bodyAfterStateAndKv, selectResolutions, fieldOptionsMap, outDiscoveredOptionFields), rawCodeFields)
2561
+ : bodyAfterStateAndKv;
2562
+ // Volatile pass: after persona/job/state/kv binding, regenerate any
2563
+ // remaining per-request UUID (fresh crypto.randomUUID()) and timestamp
2564
+ // (fresh new Date().toISOString()) so the plugin never replays the capture
2565
+ // instant. Recurses to any depth; skips schema anchors and already-bound
2566
+ // values (incl. the threaded txn id). Then flag whatever is STILL literal.
2567
+ if (bodyTemplate && parsedBody !== null) {
2568
+ bodyTemplate = applyVolatileFieldSubstitutions(bodyTemplate, parsedBody, shieldedUuids, boundValues);
2569
+ for (const key of collectUnboundLiterals(bodyTemplate, parsedBody, shieldedUuids)) {
2570
+ unboundLiteralKeys.add(key);
2571
+ }
2006
2572
  }
2007
2573
  const perCallHeaders = {};
2008
2574
  for (const [k, v] of Object.entries(cap.requestHeaders)) {
@@ -2067,24 +2633,24 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
2067
2633
  const returnAction = selectReturnAction(actions);
2068
2634
  if (returnAction)
2069
2635
  referencedNames.add(returnAction.varName);
2070
- // Base64 Content overrides reference variables inside function calls
2071
- // (e.g. buildBase64Content(payload, questionnaireId, ...)) that the
2072
- // ${name} regex above doesn't capture. Add them explicitly.
2073
- for (const [key, override] of base64PatchOverride.entries()) {
2074
- if (key === "__EXTRA_VARS__")
2075
- continue;
2076
- for (const m of override.matchAll(/\b([A-Za-z_$][A-Za-z0-9_$]*)\b/g)) {
2077
- const name = m[1];
2078
- if (/^r\d+$/.test(name))
2079
- continue;
2080
- referencedNames.add(name);
2081
- }
2082
- }
2083
2636
  // Pass 2: emit. Skip response bindings that aren't referenced; skip
2084
2637
  // produces[] entries whose name isn't referenced. A step's response var
2085
2638
  // is still needed when at least one of its produces[] entries IS
2086
2639
  // referenced — the produces line dereferences it.
2087
2640
  const lines = [];
2641
+ // Mint the threaded transaction id ONCE and reuse across every step — the
2642
+ // `${txnId}` references emitted into the bodies above all resolve to this
2643
+ // single call-time UUID, matching how the site mints one per application.
2644
+ // Emitted only when actually referenced (Biome noUnusedVariables).
2645
+ if (threadedTxnId !== null && referencedNames.has("txnId")) {
2646
+ lines.push(` const txnId = crypto.randomUUID();`);
2647
+ lines.push("");
2648
+ }
2649
+ // Surface any captured literal that no pass could bind, so a reviewer knows
2650
+ // exactly which slots still carry recon data. Comment only — never blocks emit.
2651
+ if (unboundLiteralKeys.size > 0) {
2652
+ lines.push(` // TODO: unbound captured literal(s) — verify these carry caller data, not the recon capture's: ${[...unboundLiteralKeys].join(", ")}`);
2653
+ }
2088
2654
  const declaredNames = new Set();
2089
2655
  for (let i = 0; i < actions.length; i++) {
2090
2656
  const step = actions[i];
@@ -2113,10 +2679,6 @@ function emitMultiStepExecuteHttp(actions, inputBody, errorSignals, fieldNameMap
2113
2679
  produceLines.push(` const ${p.name} = (${step.varName} as ${assertion})${pathToAccessor(p.path, { assertNonNull: false })};`);
2114
2680
  }
2115
2681
  const bindResponse = referencedNames.has(step.varName) || produceLines.length > 0;
2116
- if (base64PatchOverride.has(step.varName) && base64PatchOverride.has("__EXTRA_VARS__")) {
2117
- lines.push(base64PatchOverride.get("__EXTRA_VARS__"));
2118
- lines.push("");
2119
- }
2120
2682
  if (step.isCrossDomain) {
2121
2683
  lines.push(` // TODO: cross-domain redirect detected (${cap.url.split("/")[2]}) — likely needs browser fallback for this step.`);
2122
2684
  }
@@ -2243,7 +2805,7 @@ function bindOptionLiteral(headerBindings) {
2243
2805
  /** Generates a complete contract.ts source string for a plugin — exported so
2244
2806
  * unit tests can drive the emitter directly without spawning the CLI. */
2245
2807
  function emitContractTs(opts) {
2246
- const { siteId, pascal, baseUrl, baseHeaders, minTime, safeRps, responseBody, gql, gqlQuery, endpointPath, auxFiles, multiStepBody, inputBody, hasMultipartStep = false, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, payloadFieldNames, base64ContentHelper = "", headerBindings = [], } = opts;
2808
+ const { siteId, pascal, baseUrl, baseHeaders, minTime, safeRps, responseBody, gql, gqlQuery, endpointPath, auxFiles, multiStepBody, inputBody, hasMultipartStep = false, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, discoveredStructuredKeys, payloadFieldNames, headerBindings = [], } = opts;
2247
2809
  // This is the CLIENT-level schema — createHttpClient's default, and the
2248
2810
  // plugin's caller-facing contract (what executeHttp's return value promises
2249
2811
  // its own caller). It does NOT validate any individual call in a multi-step
@@ -2366,14 +2928,25 @@ function emitContractTs(opts) {
2366
2928
  })
2367
2929
  .join("\n")}\n})`
2368
2930
  : "";
2931
+ // Mechanism B: nested caller structures become payload fields carrying their
2932
+ // inferred schema. Emitted as an object body so multi-line z.array(z.object(
2933
+ // …)) expressions indent cleanly; a leading TSDoc flags eventData's opaque
2934
+ // passthrough so callers know its nested volatiles are theirs to mint.
2935
+ const sortedStructuredEntries = discoveredStructuredKeys
2936
+ ? [...discoveredStructuredKeys.entries()].sort(([a], [b]) => a.localeCompare(b))
2937
+ : [];
2938
+ const structuredKeysExtension = sortedStructuredEntries.length > 0
2939
+ ? `.extend({\n${sortedStructuredEntries
2940
+ .map(([name, schema]) => ` ${isValidJsIdentifier(name) ? name : JSON.stringify(name)}: ${schema},`)
2941
+ .join("\n")}\n})`
2942
+ : "";
2369
2943
  // optionSchemaExtension is appended LAST so option enums show up at the
2370
2944
  // end of the payload type — the section ordering (base, multipart fields,
2371
2945
  // form-schema fields, option enums, raw-option fields) mirrors the body
2372
2946
  // emit order and keeps the generated payload type readable.
2373
- const answersExtension = base64ContentHelper ? ".extend({ Answers: AnswersSchema })" : "";
2374
2947
  const payloadSchemaExpr = hasMultipartStep
2375
- ? `${basePayloadSchemaExpr}.extend({\n Resume: z.instanceof(Buffer),\n ResumeContentType: z.string(),\n ResumeFilename: z.string(),\n})${formFieldsExtension}${splicedFieldsExtension}${optionSchemaExtension}${rawOptionSchemaExtension}${additionalBodyKeysExtension}${answersExtension}`
2376
- : `${basePayloadSchemaExpr}${formFieldsExtension}${splicedFieldsExtension}${optionSchemaExtension}${rawOptionSchemaExtension}${additionalBodyKeysExtension}${answersExtension}`;
2948
+ ? `${basePayloadSchemaExpr}.extend({\n Resume: z.instanceof(Buffer),\n ResumeContentType: z.string(),\n ResumeFilename: z.string(),\n})${formFieldsExtension}${splicedFieldsExtension}${optionSchemaExtension}${rawOptionSchemaExtension}${additionalBodyKeysExtension}${structuredKeysExtension}`
2949
+ : `${basePayloadSchemaExpr}${formFieldsExtension}${splicedFieldsExtension}${optionSchemaExtension}${rawOptionSchemaExtension}${additionalBodyKeysExtension}${structuredKeysExtension}`;
2377
2950
  // When the payload schema uses multipartBoolean(), import the shared helper
2378
2951
  // so the generated file resolves the reference and doesn't re-inline the
2379
2952
  // preprocess expression per boolean field.
@@ -2480,7 +3053,7 @@ const ${pascal}ResponseSchema = ${responseSchemaExpr};
2480
3053
  export type ${pascal}Response = z.infer<typeof ${pascal}ResponseSchema>;
2481
3054
 
2482
3055
  export default ${pascal}ResponseSchema;
2483
- ${optionDecls}${base64ContentHelper}
3056
+ ${optionDecls}
2484
3057
  const ${pascal}PayloadSchema = ${payloadSchemaExpr};
2485
3058
 
2486
3059
  export type ${pascal}Payload = z.infer<typeof ${pascal}PayloadSchema>;
@@ -2990,8 +3563,8 @@ async function main() {
2990
3563
  body: submitBodyPattern,
2991
3564
  };
2992
3565
  // Resolved once and threaded down, never captured into a module const: a
2993
- // module-level const would freeze at import time, which is the bug that makes
2994
- // RECON_QUESTION_KEYWORDS silently inert for anyone setting it after load.
3566
+ // module-level const would freeze at import time, so an env var set after
3567
+ // module load would be silently inert for anyone reading it that way.
2995
3568
  const vocabulary = await resolveVocabulary(vocabularySpecifier);
2996
3569
  // Consumer-supplied wire keys for ATS form-schema recovery, or null. When
2997
3570
  // null the recovery functions no-op — the engine hardcodes no vendor format.
@@ -3032,6 +3605,13 @@ async function main() {
3032
3605
  // would be skipped by fieldNameMap; their field-ids still need shielding
3033
3606
  // because they appear as anchors in the T2-substituted body templates.
3034
3607
  const shieldedUuids = new Set(allSchemaUuids);
3608
+ // Persona identity bindings + entry-URL job coordinates — the value→payload
3609
+ // reconciliation the body emitter merges into its substitution map so nested
3610
+ // applicant fields and job context reach the caller's data instead of the
3611
+ // recon persona's. Both are site-agnostic: persona mapping comes from the
3612
+ // consumer vocabulary, job coordinates from the entry URL's own query keys.
3613
+ const personaBindings = harvestPersonaBindings(flowSteps, vocabulary, process.env);
3614
+ const entryUrlParams = extractEntryUrlParams(captures[0]?.url ?? "");
3035
3615
  // T4 — Phase B+C: detect a form-schema GET capture and insert it into the
3036
3616
  // action sequence at the position observed during recon, so the existing
3037
3617
  // state-threading machinery can produce its FormHistoryId / section UUIDs /
@@ -3098,6 +3678,20 @@ async function main() {
3098
3678
  // that get parameterized. Recorded with their value type so the contract
3099
3679
  // emitter can add them to the payload schema with appropriate Zod types.
3100
3680
  const discoveredAdditionalBodyKeys = new Map();
3681
+ // Mechanism A: reconcile flow SELECT steps to submitted option codes. The
3682
+ // resolutions drive a wire-key-anchored body rewrite (label→code dropdowns);
3683
+ // i18n-only dropdowns (labels all templated, e.g. gender) fall through to the
3684
+ // existing raw-option channel so their frozen code is still parameterized.
3685
+ const { resolutions: selectResolutions, rawCodeFields } = buildSelectOptionResolutions(flowSteps, captures, vocabulary, process.env);
3686
+ for (const [semanticName, { code }] of rawCodeFields) {
3687
+ const fieldName = `${semanticName}Code`;
3688
+ if (!discoveredRawOptionFields.has(fieldName))
3689
+ discoveredRawOptionFields.set(fieldName, code);
3690
+ }
3691
+ // Mechanism B: nested caller structures (experienceData/educationData
3692
+ // history, opaque eventData) discovered during the body emit, surfaced to the
3693
+ // contract's payload schema.
3694
+ const discoveredStructuredKeys = new Map();
3101
3695
  // G1+G2: partition baseHeaders into three buckets:
3102
3696
  // - static: values that don't reference baseUrl or tenant subdomain
3103
3697
  // - baseUrl-derived: values containing the recon's baseUrl as substring
@@ -3129,98 +3723,8 @@ async function main() {
3129
3723
  }
3130
3724
  }
3131
3725
  const multiStepBody = isSubmissionFlow
3132
- ? emitMultiStepExecuteHttp(actionSteps, inputBody, errorSignals, fieldNameMap, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, new Map(), formSchema)
3726
+ ? emitMultiStepExecuteHttp(actionSteps, inputBody, errorSignals, fieldNameMap, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, formSchema, personaBindings, entryUrlParams, shieldedUuids, selectResolutions, discoveredStructuredKeys, rawCodeFields)
3133
3727
  : undefined;
3134
- let base64ContentHelper = "";
3135
- const base64PatchOverride = new Map();
3136
- if (isSubmissionFlow && actionSteps.length > 0) {
3137
- const lastPatchWithContent = [...actionSteps]
3138
- .reverse()
3139
- .find((s) => s.capture.method === "PATCH" &&
3140
- s.capture.requestPostData &&
3141
- /"Content":"ey[A-Za-z0-9+/=]{100,}"/.test(s.capture.requestPostData));
3142
- if (lastPatchWithContent) {
3143
- const b64Match = lastPatchWithContent.capture.requestPostData.match(/"Content":"(ey[A-Za-z0-9+/=]{100,})"/);
3144
- if (b64Match) {
3145
- const b64 = b64Match[1];
3146
- const qMapping = buildQuestionnaireMapping(captures);
3147
- const personaValues = new Map();
3148
- const firstPost = captures.find((c) => c.method === "POST" &&
3149
- c.url.includes("recruitingCEJobApplicationDrafts") &&
3150
- c.requestPostData);
3151
- if (firstPost?.requestPostData) {
3152
- try {
3153
- const pb = JSON.parse(firstPost.requestPostData);
3154
- if (typeof pb.EmailAddress === "string")
3155
- personaValues.set(pb.EmailAddress, "payload.Email");
3156
- }
3157
- catch {
3158
- /* skip */
3159
- }
3160
- }
3161
- base64ContentHelper = emitBuildBase64ContentFunction(b64, personaValues, qMapping, pascal);
3162
- base64PatchOverride.set(lastPatchWithContent.varName,
3163
- // biome-ignore lint/suspicious/noTemplateCurlyInString: emitted as generated template-literal source
3164
- '"Content":"${buildBase64Content(payload, questionnaireId, Number(draftId), String(attachmentId))}"');
3165
- const draftPostStep = actionSteps.find((s) => s.capture.method === "POST" &&
3166
- s.capture.url.includes("recruitingCEJobApplicationDrafts"));
3167
- if (draftPostStep && !draftPostStep.produces.some((p) => p.name === "draftId")) {
3168
- draftPostStep.produces.push({
3169
- kind: "body",
3170
- name: "draftId",
3171
- path: ["APPDraftId"],
3172
- });
3173
- }
3174
- const attachPostStep = actionSteps.find((s) => s.capture.method === "POST" && s.capture.url.includes("/attachments"));
3175
- if (attachPostStep && !attachPostStep.produces.some((p) => p.name === "attachmentId")) {
3176
- attachPostStep.produces.push({
3177
- kind: "body",
3178
- name: "attachmentId",
3179
- path: ["Id"],
3180
- });
3181
- }
3182
- const questionnaireCapture = captures.find((c) => c.method === "GET" &&
3183
- c.url.includes("recruitingCEQuestions") &&
3184
- c.url.includes("expand=answers"));
3185
- let sampleQid;
3186
- if (questionnaireCapture) {
3187
- const qResp = typeof questionnaireCapture.responseBody === "string"
3188
- ? JSON.parse(questionnaireCapture.responseBody)
3189
- : questionnaireCapture.responseBody;
3190
- sampleQid = qResp?.items?.[0]?.QuestionnaireId ?? undefined;
3191
- }
3192
- const overrideValue = base64PatchOverride.values().next().value;
3193
- if (overrideValue) {
3194
- const extraVarLines = [];
3195
- if (sampleQid) {
3196
- extraVarLines.push(` const questionnaireId = ${sampleQid};`);
3197
- }
3198
- if (!attachPostStep) {
3199
- extraVarLines.push(` const attachmentId = "";`);
3200
- }
3201
- if (extraVarLines.length > 0) {
3202
- base64PatchOverride.set("__EXTRA_VARS__", extraVarLines.join("\n"));
3203
- }
3204
- }
3205
- const answersFields = (qMapping ?? []).map((m) => m.payloadField);
3206
- answersFields.push("SignatureFullName");
3207
- const answersSchemaFields = answersFields.map((f) => ` ${f}: z.string(),`).join("\n");
3208
- base64ContentHelper = `\nconst AnswersSchema = z.object({\n${answersSchemaFields}\n});\n${base64ContentHelper}`;
3209
- const inputKeys = new Set();
3210
- if (inputBody && typeof inputBody === "object" && !Array.isArray(inputBody)) {
3211
- for (const k of Object.keys(inputBody))
3212
- inputKeys.add(k);
3213
- }
3214
- for (const fld of ["FirstName", "LastName", "Email", "Phone"]) {
3215
- if (!inputKeys.has(fld))
3216
- discoveredAdditionalBodyKeys.set(fld, "string");
3217
- }
3218
- }
3219
- }
3220
- }
3221
- const processedMultiStepBody = isSubmissionFlow
3222
- ? emitMultiStepExecuteHttp(actionSteps, inputBody, errorSignals, fieldNameMap, discoveredFormFields, fieldOptionsMap, discoveredOptionFields, discoveredRawOptionFields, discoveredAdditionalBodyKeys, baseUrl, baseUrlDerivedHeaders, tenantSubdomainHeaders, base64PatchOverride, formSchema)
3223
- : multiStepBody;
3224
3728
  const hasMultipartStep = actionSteps.some((s) => s.isMultipart);
3225
3729
  const headerBindings = collectHeaderBindings(actionSteps);
3226
3730
  // Shape inference targets the SAME call executeHttp returns — see
@@ -3274,8 +3778,7 @@ async function main() {
3274
3778
  gqlQuery,
3275
3779
  endpointPath,
3276
3780
  auxFiles,
3277
- multiStepBody: processedMultiStepBody,
3278
- base64ContentHelper,
3781
+ multiStepBody,
3279
3782
  inputBody,
3280
3783
  hasMultipartStep,
3281
3784
  discoveredFormFields,
@@ -3283,6 +3786,7 @@ async function main() {
3283
3786
  discoveredOptionFields,
3284
3787
  discoveredRawOptionFields,
3285
3788
  discoveredAdditionalBodyKeys,
3789
+ discoveredStructuredKeys,
3286
3790
  payloadFieldNames: browserFlow.payloadFieldNames,
3287
3791
  headerBindings,
3288
3792
  }));