@enricai/barnacle 1.9.2 → 1.9.4

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.
@@ -26,6 +26,7 @@ exports.inferZodSchemaFromSamples = inferZodSchemaFromSamples;
26
26
  exports.selectPayloadAction = selectPayloadAction;
27
27
  exports.selectReturnAction = selectReturnAction;
28
28
  exports.selectEffectiveResponseBody = selectEffectiveResponseBody;
29
+ exports.resolveManifestActionSequence = resolveManifestActionSequence;
29
30
  exports.extractActionSequence = extractActionSequence;
30
31
  exports.extractGraphQLActionSequence = extractGraphQLActionSequence;
31
32
  exports.detectFormSchemaFieldNames = detectFormSchemaFieldNames;
@@ -344,7 +345,7 @@ const IGNORE_REQUEST_HEADERS = new Set([
344
345
  * headers (a `X-CSRF-Token`, a `Job-Boards-API-Token`, an `API-ShortName`,
345
346
  * etc.) without the generator needing to know about any particular site.
346
347
  */
347
- function deriveRequestHeaders(captures, replays, baseUrl) {
348
+ function deriveRequestHeaders(captures, replays, baseUrl, submitPatterns = null) {
348
349
  const successfulUrls = new Set(replays.filter((r) => r.success).map((r) => endpointKey(r.url)));
349
350
  // Prefer ACTION captures (non-GET 2xx to baseUrl host, non-telemetry) as
350
351
  // the authoritative header source. Replay-matched static-asset GETs lack
@@ -353,7 +354,7 @@ function deriveRequestHeaders(captures, replays, baseUrl) {
353
354
  // captures exist (multi-step submission flows), use them. For sites
354
355
  // where the flow is a single REST call (no detectable action sequence),
355
356
  // fall back to the replay-matched captures.
356
- const actionCaptures = extractActionSequence(captures, baseUrl).map((a) => a.capture);
357
+ const actionCaptures = extractActionSequence(captures, baseUrl, submitPatterns).map((a) => a.capture);
357
358
  const replayMatchedCaptures = captures.filter((c) => successfulUrls.has(endpointKey(c.url)));
358
359
  const relevantCaptures = actionCaptures.length > 0 ? actionCaptures : replayMatchedCaptures;
359
360
  const counts = new Map();
@@ -409,16 +410,75 @@ function firstEndpointPath(captures) {
409
410
  }
410
411
  return "/api/search";
411
412
  }
413
+ /**
414
+ * Builds the compiled submit-pattern predicate. A flow-declared regex that
415
+ * fails to compile is a broken flow (recon-browser validates it eagerly too),
416
+ * so we let the `RegExp` constructor throw rather than silently reverting to
417
+ * unfiltered selection, which would re-admit the page-chrome bloat this gate
418
+ * exists to remove.
419
+ */
420
+ function compileSubmitMatcher(patterns) {
421
+ if (patterns === null || (patterns.endpoint === null && patterns.body === null)) {
422
+ return () => true;
423
+ }
424
+ const endpointRx = patterns.endpoint === null ? null : new RegExp(patterns.endpoint);
425
+ const bodyRx = patterns.body === null ? null : new RegExp(patterns.body);
426
+ return (capture) => {
427
+ if (endpointRx !== null && !endpointRx.test(capture.url))
428
+ return false;
429
+ if (bodyRx !== null && !bodyRx.test(capture.requestPostData ?? ""))
430
+ return false;
431
+ return true;
432
+ };
433
+ }
434
+ /**
435
+ * Reads `submit-manifest.json` (written by recon-browser) and resolves it to the
436
+ * authoritative submission action sequence. This is the deepest submit-selection
437
+ * signal: recon-browser matched these captures against the flow's declared submit
438
+ * patterns at run time, so generate emits exactly them instead of re-deriving the
439
+ * submission from raw traffic. Returns null when no manifest exists (older runs,
440
+ * `recon-http`-only) so the caller falls back to pattern/heuristic extraction.
441
+ *
442
+ * The manifest's `index` is the capture's sort-order position, and `captures`
443
+ * arrives already `.sort()`ed by `readJsonDir`, so `captures[index]` is the same
444
+ * capture recon-browser recorded — cross-checked on `url` as a guard against a
445
+ * capture set that drifted between runs.
446
+ */
447
+ function resolveManifestActionSequence(runRoot, captures) {
448
+ const manifestPath = (0, node_path_1.join)(runRoot, "submit-manifest.json");
449
+ let entries;
450
+ try {
451
+ entries = JSON.parse((0, node_fs_1.readFileSync)(manifestPath, "utf8"));
452
+ }
453
+ catch {
454
+ return null;
455
+ }
456
+ if (!Array.isArray(entries) || entries.length === 0)
457
+ return null;
458
+ const resolved = [];
459
+ for (const entry of entries) {
460
+ const capture = captures[entry.index];
461
+ if (capture === undefined || capture.url !== entry.url)
462
+ return null;
463
+ resolved.push({ capture, index: entry.index });
464
+ }
465
+ return resolved;
466
+ }
412
467
  /**
413
468
  * Extracts the ordered sequence of meaningful POSTs that represent the
414
469
  * transactional flow: same-host 2xx POSTs, minus telemetry and error-reporting
415
470
  * sinks. Assets need no filter of their own — they arrive as GETs.
416
471
  *
472
+ * When the flow declares submit patterns, only POSTs matching them survive —
473
+ * this isolates the submission from same-origin page chrome (bootstrap, chatbot,
474
+ * JWT refresh, reference-lookup) that a browser fires incidentally. Absent
475
+ * patterns preserve the host/noise heuristic exactly.
476
+ *
417
477
  * Exported for tests: this predicate decides what a generated plugin will POST
418
478
  * at a live site, and it is the only gate between a browser's incidental
419
479
  * chatter and the emitted hot path.
420
480
  */
421
- function extractActionSequence(captures, baseUrl) {
481
+ function extractActionSequence(captures, baseUrl, submitPatterns = null) {
422
482
  let host;
423
483
  try {
424
484
  host = new URL(baseUrl).host;
@@ -426,6 +486,7 @@ function extractActionSequence(captures, baseUrl) {
426
486
  catch {
427
487
  host = "";
428
488
  }
489
+ const matchesSubmit = compileSubmitMatcher(submitPatterns);
429
490
  return captures
430
491
  .map((capture, index) => ({ capture, index }))
431
492
  .filter(({ capture }) => {
@@ -444,6 +505,8 @@ function extractActionSequence(captures, baseUrl) {
444
505
  return false;
445
506
  if ((0, capture_filters_1.isNoiseUrl)(capture.url))
446
507
  return false;
508
+ if (!matchesSubmit(capture))
509
+ return false;
447
510
  return true;
448
511
  });
449
512
  }
@@ -460,7 +523,7 @@ function extractActionSequence(captures, baseUrl) {
460
523
  * Exported for tests: this predicate decides what a generated GraphQL plugin
461
524
  * will send at a live site.
462
525
  */
463
- function extractGraphQLActionSequence(captures, baseUrl) {
526
+ function extractGraphQLActionSequence(captures, baseUrl, submitPatterns = null) {
464
527
  let host;
465
528
  try {
466
529
  host = new URL(baseUrl).host;
@@ -468,6 +531,7 @@ function extractGraphQLActionSequence(captures, baseUrl) {
468
531
  catch {
469
532
  host = "";
470
533
  }
534
+ const matchesSubmit = compileSubmitMatcher(submitPatterns);
471
535
  return captures
472
536
  .map((capture, index) => ({ capture, index }))
473
537
  .filter(({ capture }) => {
@@ -486,6 +550,8 @@ function extractGraphQLActionSequence(captures, baseUrl) {
486
550
  return false;
487
551
  if ((0, capture_filters_1.isNoiseUrl)(capture.url))
488
552
  return false;
553
+ if (!matchesSubmit(capture))
554
+ return false;
489
555
  return capture.query !== null && /^\s*mutation\b/.test(capture.query);
490
556
  });
491
557
  }
@@ -533,6 +599,34 @@ function* walkStringLeaves(value, path = []) {
533
599
  }
534
600
  }
535
601
  }
602
+ /**
603
+ * Parses a captured request body as JSON and returns its string leaf *values*
604
+ * (object keys are excluded — a key is never a JSON value). Returns null when
605
+ * the body is absent or not JSON (e.g. multipart raw bytes), so callers can
606
+ * fall back to whole-body substring matching. This lets the produces-filter
607
+ * match state values against JSON values only, so a response string that
608
+ * appears downstream solely in a JSON *key* position is not mistaken for a
609
+ * reused value — while a value legitimately embedded inside a longer value
610
+ * still matches via substring.
611
+ */
612
+ function jsonBodyLeafValues(requestPostData) {
613
+ if (typeof requestPostData !== "string" || requestPostData.length === 0)
614
+ return null;
615
+ const parsed = (() => {
616
+ try {
617
+ return JSON.parse(requestPostData);
618
+ }
619
+ catch {
620
+ return undefined;
621
+ }
622
+ })();
623
+ if (parsed === undefined)
624
+ return null;
625
+ const values = [];
626
+ for (const { value } of walkStringLeaves(parsed))
627
+ values.push(value);
628
+ return values;
629
+ }
536
630
  /**
537
631
  * Yields every primitive leaf (string, number, boolean, null) in the JSON
538
632
  * value with its path. Used by the body-literal substitution pass to find
@@ -1314,12 +1408,31 @@ function compileActionSteps(actions, stateIndex) {
1314
1408
  // Pre-scan: collect all state values referenced by ANY action's URL/headers/body
1315
1409
  // so we only "produce" the values that are actually consumed downstream.
1316
1410
  for (const { capture } of actions) {
1317
- const haystacks = [capture.url];
1318
- if (capture.requestPostData)
1319
- haystacks.push(capture.requestPostData);
1411
+ const bodyLeafValues = jsonBodyLeafValues(capture.requestPostData);
1320
1412
  for (const sv of stateIndex.values()) {
1321
- if (haystacks.some((h) => h.includes(sv.value)))
1413
+ if (capture.url.includes(sv.value)) {
1414
+ usedValues.add(sv.value);
1415
+ continue;
1416
+ }
1417
+ // Body consumption is matched against JSON *values* only, never keys: a
1418
+ // state value is a real cross-step dependency when a later request
1419
+ // re-sends it inside a JSON value — either standalone or embedded in a
1420
+ // composite value (e.g. a jobId reused inside a longer jobSeqNo). A
1421
+ // match that lands on a JSON *key* is not reuse: binding then splicing it
1422
+ // would emit a variable into a key position and produce uncompilable
1423
+ // `"${var}":…` / `${${var}}` (e.g. a response echoing field NAMES like
1424
+ // `tokens:["firstName","lastName"]` — those strings appear downstream
1425
+ // only as keys, never within any value). Object keys are never JSON
1426
+ // string leaves, so testing against leaf values alone excludes them while
1427
+ // preserving substring-in-value reuse. Non-JSON bodies (multipart raw
1428
+ // bytes) keep whole-body substring matching.
1429
+ if (bodyLeafValues === null) {
1430
+ if (capture.requestPostData?.includes(sv.value))
1431
+ usedValues.add(sv.value);
1432
+ }
1433
+ else if (bodyLeafValues.some((leaf) => leaf.includes(sv.value))) {
1322
1434
  usedValues.add(sv.value);
1435
+ }
1323
1436
  }
1324
1437
  for (const [headerName, headerValue] of Object.entries(capture.requestHeaders)) {
1325
1438
  for (const sv of stateIndex.values()) {
@@ -2814,25 +2927,52 @@ async function main() {
2814
2927
  return [];
2815
2928
  }
2816
2929
  })();
2817
- const { flowSteps, frameSelector } = (() => {
2930
+ const { flowSteps, frameSelector, submitEndpointPattern, submitBodyPattern } = (() => {
2818
2931
  const flowFile = `src/sites/${siteId}/recon-flow.json`;
2819
2932
  try {
2820
2933
  const raw = JSON.parse((0, node_fs_1.readFileSync)(flowFile, "utf8"));
2821
2934
  if (Array.isArray(raw))
2822
- return { flowSteps: raw, frameSelector: undefined };
2935
+ return {
2936
+ flowSteps: raw,
2937
+ frameSelector: undefined,
2938
+ submitEndpointPattern: null,
2939
+ submitBodyPattern: null,
2940
+ };
2823
2941
  if (raw !== null &&
2824
2942
  typeof raw === "object" &&
2825
2943
  "steps" in raw &&
2826
2944
  Array.isArray(raw.steps)) {
2827
2945
  const obj = raw;
2828
- return { flowSteps: obj.steps, frameSelector: obj.frameSelector };
2946
+ return {
2947
+ flowSteps: obj.steps,
2948
+ frameSelector: obj.frameSelector,
2949
+ submitEndpointPattern: obj.submitEndpointPattern ?? null,
2950
+ submitBodyPattern: obj.submitBodyPattern ?? null,
2951
+ };
2829
2952
  }
2830
- return { flowSteps: [], frameSelector: undefined };
2953
+ return {
2954
+ flowSteps: [],
2955
+ frameSelector: undefined,
2956
+ submitEndpointPattern: null,
2957
+ submitBodyPattern: null,
2958
+ };
2831
2959
  }
2832
2960
  catch {
2833
- return { flowSteps: [], frameSelector: undefined };
2961
+ return {
2962
+ flowSteps: [],
2963
+ frameSelector: undefined,
2964
+ submitEndpointPattern: null,
2965
+ submitBodyPattern: null,
2966
+ };
2834
2967
  }
2835
2968
  })();
2969
+ // Flow-declared signals that isolate the submission POSTs from same-origin
2970
+ // page chrome. Threaded into action-sequence extraction and header derivation
2971
+ // so both draw from the real submission, not incidental widget/chatbot POSTs.
2972
+ const submitPatterns = {
2973
+ endpoint: submitEndpointPattern,
2974
+ body: submitBodyPattern,
2975
+ };
2836
2976
  // Resolved once and threaded down, never captured into a module const: a
2837
2977
  // module-level const would freeze at import time, which is the bug that makes
2838
2978
  // RECON_QUESTION_KEYWORDS silently inert for anyone setting it after load.
@@ -2842,7 +2982,7 @@ async function main() {
2842
2982
  const formSchema = await resolveFormSchema(formSchemaSpecifier);
2843
2983
  const pascal = toPascalCase(siteId);
2844
2984
  const baseUrl = deriveBaseUrl(captures);
2845
- const baseHeaders = deriveRequestHeaders(captures, replays, baseUrl);
2985
+ const baseHeaders = deriveRequestHeaders(captures, replays, baseUrl, submitPatterns);
2846
2986
  const minTime = deriveMinTime(rateLimits);
2847
2987
  const safeRps = rateLimits.find((f) => f.safeRps !== null)?.safeRps ?? Math.floor(1000 / minTime);
2848
2988
  const responseBody = firstSuccessfulReplayBody(replays);
@@ -2852,9 +2992,19 @@ async function main() {
2852
2992
  // Detect a multi-step submission flow (transactional sites like apply forms,
2853
2993
  // checkout, etc.). When the action sequence has 2+ POSTs, switch the
2854
2994
  // contract template to emit a state-threaded executeHttp.
2855
- const rawActionCaptures = gql
2856
- ? extractGraphQLActionSequence(captures, baseUrl)
2857
- : collapseRedundantPatches(extractActionSequence(captures, baseUrl));
2995
+ //
2996
+ // Selection precedence: (A) the authoritative submit-manifest recon-browser
2997
+ // wrote from the verified submission; else (B/C) pattern/heuristic extraction.
2998
+ // The manifest is the only signal that separates a submission POST from a
2999
+ // page-chrome POST sharing its URL, so it wins when present.
3000
+ const manifestActionCaptures = resolveManifestActionSequence(runRoot, captures);
3001
+ if (manifestActionCaptures !== null) {
3002
+ logger.info(`submission selection: using submit-manifest.json (${manifestActionCaptures.length} authoritative capture(s))`);
3003
+ }
3004
+ const rawActionCaptures = manifestActionCaptures ??
3005
+ (gql
3006
+ ? extractGraphQLActionSequence(captures, baseUrl, submitPatterns)
3007
+ : collapseRedundantPatches(extractActionSequence(captures, baseUrl, submitPatterns)));
2858
3008
  // Form-schema detection runs BEFORE state-indexing so the field-id/option-id
2859
3009
  // UUIDs can be shielded from indexing — those UUIDs are stable schema
2860
3010
  // anchors that T2/T3 substitution depends on remaining literal in body