@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
|
-
*
|
|
1701
|
-
*
|
|
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
|
-
*
|
|
1704
|
-
* (JSON)
|
|
1705
|
-
*
|
|
1706
|
-
*
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
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
|
-
*
|
|
1723
|
-
*
|
|
1724
|
-
*
|
|
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
|
|
1727
|
-
const
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
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
|
-
|
|
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 (
|
|
2289
|
+
const key = path[path.length - 1] ?? "";
|
|
2290
|
+
if (seen.has(key))
|
|
1761
2291
|
continue;
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
2001
|
-
|
|
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
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
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,
|
|
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}${
|
|
2376
|
-
: `${basePayloadSchemaExpr}${formFieldsExtension}${splicedFieldsExtension}${optionSchemaExtension}${rawOptionSchemaExtension}${additionalBodyKeysExtension}${
|
|
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}
|
|
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,
|
|
2994
|
-
//
|
|
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,
|
|
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
|
|
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
|
}));
|