From c9992e2d11d59a9b5eb492977a1adc8dcfb4f46c Mon Sep 17 00:00:00 2001 From: Trey t Date: Sat, 11 Apr 2026 10:54:56 -0500 Subject: [PATCH] Add JSX reverse-engineering notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture the findings from the JSXWebViewFetcher rewrite session: - The Navitaire /api/nsk/v4/availability/search/simple endpoint that actually contains the flight load data, including the request body shape and the nested response structure (journeysAvailableByMarket → fares joined with faresAvailable for price/class). - How the anonymous auth token lands in sessionStorage (navitaire.digital.token) and how to use it as the Authorization header on a direct fetch() from inside the page context. - The jsx.com SPA one-way form structure (trip-type mat-select, station pickers, custom two-month range picker with DONE button, Find Flights submit), and the selectors / strategies that work for each one. - The Osano cookie banner gotcha — its role="dialog" fools calendar detection, so the banner node must be force-removed after accepting. - The datepicker quirks: JSX uses a custom picker (not mat-calendar), renders in two phases (shell then cells), and day cells carry aria-labels in the format "Saturday, April 11, 2026" with a weekday prefix — so exact-match "April 11, 2026" fails but loose month+year+day-word-boundary matching works. - The central finding: WKWebView's synthetic DOM events have isTrusted=false, so JSX's datepicker never commits its day-cell selection into the Angular FormControl, Angular's search() sees the form as invalid, and no POST fires. Playwright doesn't hit this because CDP's Input.dispatchMouseEvent produces trusted events. - The Akamai surface: external HTTP clients are blocked, Playwright's own launch() is blocked on POST /search/simple (ERR_HTTP2_PROTOCOL _ERROR), connectOverCDP to a plain Chrome works, and WKWebView's same-origin fetch() from inside a loaded jsx.com page works. - The working approach (direct POST from page context using the sessionStorage token) and why it sidesteps both the trusted-events and the Akamai problems. - The network interceptor pattern that distinguishes "Angular never fired the POST" from "Angular fired it but the network rejected it" — critical for diagnosing the trusted-events trap. - Code pointers to the Swift runtime (JSXWebViewFetcher.swift), the iOS call site (AirlineLoadService.fetchJSXLoad), and the Playwright reference (scripts/jsx_playwright_search.mjs). Co-Authored-By: Claude Opus 4.6 (1M context) --- JSX_NOTES.md | 444 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 444 insertions(+) create mode 100644 JSX_NOTES.md diff --git a/JSX_NOTES.md b/JSX_NOTES.md new file mode 100644 index 0000000..6872750 --- /dev/null +++ b/JSX_NOTES.md @@ -0,0 +1,444 @@ +# JSX / JetSuiteX — Reverse Engineering Notes + +How to extract per-flight loads from `jsx.com` from an iOS app (WKWebView) or +a command-line harness (Playwright). Findings from debugging this on real +hardware against the live production site in April 2026. + +--- + +## TL;DR + +1. JSX's website runs on Navitaire. The data you want (seats available per + fare class, prices per class, all flights for the day) lives in the + `POST /api/nsk/v4/availability/search/simple` response body. +2. You **cannot** call that endpoint from outside a loaded `jsx.com` page — + Akamai will reject the request with an HTTP/2 protocol error based on the + TLS fingerprint of whatever's making the call. +3. You **can** call it from inside a loaded `jsx.com` page, using the + browser's own `fetch()` with the anonymous token the SPA stashes in + `sessionStorage["navitaire.digital.token"]`. Same-origin + browser TLS + + browser cookies means Akamai sees it as a normal user request. +4. Driving the on-page UI to submit the search form is a dead end in + **WKWebView** (but works in Playwright) because of a subtle trusted-event + issue described below. + +The working pattern is therefore: + + Navigate browser → jsx.com (real page load) + Wait for SPA bootstrap (station buttons exist, token call finishes) + Read token from sessionStorage + POST /api/nsk/v4/availability/search/simple ← from inside the page + Parse the response + +--- + +## Endpoints + +Base: `https://api.jsx.com` + +| Method | Path | Purpose | +|---|---|---| +| `POST` | `/api/nsk/v2/token` | Anonymous auth token. Fired by the SPA automatically on page load. Response body is `{ data: { token: "eyJhbGci...", idleTimeoutInMinutes: 15 } }`. | +| `POST` | `/api/v2/graph/primaryResources` | Route network / station list. SPA bootstrap. | +| `POST` | `/api/v2/graph/secondaryResources` | Extra static resources. SPA bootstrap. | +| `POST` | `/api/v2/graph/setCulture` | Locale setup. SPA bootstrap. | +| `GET` | `/api/nsk/v1/resources/contents?Type=GeneralReference` | Reference data. SPA bootstrap. | +| `GET` | `/api/nsk/v1/availability/lowfare/estimate?origin=X&destination=Y&...` | Per-day cheapest fare for a route across a date range. Fires when the user selects origin+destination. Useful as a warm-up / liveness check. | +| `POST` | `/api/nsk/v4/availability/search/simple` | **The data you want.** Fired when the user clicks Find Flights. Contains every flight for the route/date plus per-fare-class prices and counts. | +| `PUT` | `/api/nsk/v2/token` | Token refresh. Happens after a successful search. | + +All endpoints require the browser's Akamai cookies from a real `jsx.com` page load. The `search/simple` endpoint additionally requires the `Authorization` header set to the token (raw, **no** `Bearer ` prefix — just the JWT string). + +--- + +## Auth + +The SPA stores its anonymous token in `sessionStorage["navitaire.digital.token"]` as JSON: + +```json +{ + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "idleTimeoutInMinutes": 15 +} +``` + +Read it from inside the page: + +```js +const raw = sessionStorage.getItem("navitaire.digital.token"); +const token = raw ? (JSON.parse(raw).token || "") : ""; +``` + +Wait up to ~3 seconds after page load for it to appear — the `POST /api/nsk/v2/token` call that populates it is async. + +Use it as the `Authorization` header value on the `search/simple` POST: + +```js +fetch("https://api.jsx.com/api/nsk/v4/availability/search/simple", { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + "Accept": "application/json, text/plain, */*", + "Authorization": token + }, + body: JSON.stringify(requestBody) +}); +``` + +--- + +## Request body (`/availability/search/simple`) + +Byte-identical to what the JSX website's own `search()` method posts when +its form is valid. Confirmed by intercepting the real call in Playwright. + +```json +{ + "beginDate": "2026-04-15", + "destination": "HOU", + "origin": "DAL", + "passengers": { "types": [{ "count": 1, "type": "ADT" }] }, + "taxesAndFees": 2, + "filters": { + "maxConnections": 4, + "compressionType": 1, + "sortOptions": [4], + "fareTypes": ["R"], + "exclusionType": 2 + }, + "numberOfFaresPerJourney": 10, + "codes": { "currencyCode": "USD" }, + "ssrCollectionsMode": 1 +} +``` + +`beginDate` is `YYYY-MM-DD`. No `endDate` needed for one-way. `origin` and `destination` are IATA codes. + +--- + +## Response shape (`/availability/search/simple`) + +The response nests per-flight data inside a by-market dict, with fare +pricing split into a separate top-level dict that you join via key. Here's +the bit that matters: + +``` +data +├── results[] +│ └── trips[] +│ ├── date: "2026-04-15T00:00:00" +│ └── journeysAvailableByMarket +│ └── "DAL|HOU": [ ← key is `${origin}|${destination}` +│ { +│ stops: 0, +│ designator: { origin, destination, departure, arrival }, +│ segments: [ +│ { +│ identifier: { carrierCode: "XE", identifier: "280" }, +│ designator: { origin, destination, departure, arrival }, +│ legs: [{ +│ legInfo: { equipmentType: "ER4", ... } +│ }] +│ } +│ ], +│ fares: [ +│ { +│ fareAvailabilityKey: "MH5Rfn5YRX5R...", +│ details: [{ availableCount: 1, status: 1, ... }] +│ } +│ ] +│ }, +│ ... more journeys +│ ] +└── faresAvailable ← keyed by fareAvailabilityKey + └── "MH5Rfn5YRX5R...": { + totals: { fareTotal: 339, revenueTotal: 329.2, ... }, + fares: [ + { + classOfService: "Q", + productClass: "HO", ← HO = Hop-On, AI = All-In + fareBasisCode: "Q00AHOXA", + passengerFares: [{ passengerType: "ADT", fareAmount: 339, ... }] + } + ] + } +``` + +**To build a per-flight load summary**, for each journey in `journeysAvailableByMarket[]`: + +1. Flight number = `segments[0].identifier.carrierCode + segments[0].identifier.identifier` (e.g. `"XE" + "280"` → `"XE280"`). For multi-segment journeys concatenate all segments' identifiers. +2. Origin / destination / times come from `journey.designator` (or fall back to `segments[0].designator` / `segments[last].designator`). +3. Equipment type is on `segments[0].legs[0].legInfo.equipmentType`. +4. For each entry in `journey.fares[]`, look up `fareAvailabilityKey` in `data.faresAvailable`. From that record: + - `totals.fareTotal` = price including taxes + - `totals.revenueTotal` = base fare + - `fares[0].classOfService` = single-letter fare code + - `fares[0].productClass` = "HO" (Hop-On) or "AI" (All-In) +5. Sum `details[].availableCount` for that fare bucket — that's the seats sellable at that price point. +6. Total sellable seats for the flight = sum across all fare buckets on that journey. +7. Lowest price = `min` of all `fareTotal` values. + +JSX flies ERJ-145 and Embraer 135 variants with ~30 seats configured 1x2. `availableCount` numbers in the single digits are normal. + +Example parsed output for `DAL → HOU` on a given day: + +``` +XE280 DAL→HOU 07:15→08:30 stops=0 seats=8 from=$317 + class=N bundle=HO count=4 fare=$317 rev=$307.2 + class=N bundle=AI count=4 fare=$479 rev=$469.2 +XE292 DAL→HOU 10:35→11:50 stops=0 seats=2 from=$409 + class=W bundle=HO count=1 fare=$409 rev=$399.2 + class=W bundle=AI count=1 fare=$649 rev=$639.2 +``` + +--- + +## The jsx.com SPA UI — what the user does + +This is only relevant if you're **driving the UI** (not doing a direct API +call). Useful for smoke testing that the page is functional and the +anonymous token is fresh, but see the WKWebView caveats below. + +Structure of the one-way search form at `https://www.jsx.com/home/search`: + +1. **Trip type** — a Material `mat-select` with combobox role. Options: "Round Trip", "One Way", "Multi City". Default is Round Trip. +2. **Origin station** — `button[aria-label='Station select']` (index 0). Opens a dropdown with a search input (placeholder "Airport or city") and a list of `li[role='option'].station-options__item` entries. +3. **Destination station** — `button[aria-label='Station select']` (index 1). Same structure as origin. +4. **Depart date** — `input[aria-label='Depart Date']` opens a custom two-month range picker overlay on click. +5. **Return date** — only visible when trip type is Round Trip. +6. **Passengers** — defaults to 1 adult, fine to leave alone. +7. **Find Flights** button — submits; Angular's `search()` handler POSTs `/availability/search/simple` if the form validates. + +### The custom datepicker + +JSX does **not** use Angular Material's `mat-calendar`. It's a custom +component and none of the standard Material selectors match: + +- `mat-calendar`, `.mat-calendar`, `mat-datepicker-content`, `[class*='mat-datepicker']` — all return null. +- `.mat-calendar-period-button` — doesn't exist. There's no period button to parse "April 2026" out of. +- `.mat-calendar-body-cell` — doesn't exist. Day cells are something else entirely. + +What **does** work: + +- The picker mounts under `.cdk-overlay-container` in a pane that visibly contains month-name text (the Angular CDK overlay system is Material-ish even if the picker isn't). +- Day cells carry `aria-label` attributes in the format `"Saturday, April 11, 2026"` — **weekday prefix included**. That's why an exact match against `"April 11, 2026"` fails but a loose contains-month-AND-year-AND-day-word-boundary match succeeds. +- Two months are shown side by side (e.g. April + May in April). Clicking a day cell doesn't close the picker. +- There's a **"DONE"** button in the bottom right of the picker. It **must** be clicked to commit the selection. Without it, the picker stays open and the depart-date input value isn't set (when the picker is open there are *two* depart-date inputs in the DOM; after DONE there's one). + +### The picker renders in two phases + +First phase ~150–400 ms after click: overlay shell with month-name text (so selectors matching on month-name text succeed). Second phase a few hundred ms later: the actual day cells with aria-labels become queryable. + +If you have separate script evaluations for "open picker" and "click day cell" (i.e. cross-bridge calls in WKWebView), the second call can race the second phase and see no cells. Always poll for at least one `[aria-label]` matching `/\b(January|…|December)\s+\d{1,2},?\s+\d{4}/` before trying to click a specific cell. Polling timeout of ~5 seconds is sufficient. + +### Mat-select `One Way` option + +The trip-type combobox needs a multi-strategy opener. None of these alone is +reliable across browser engines: + +1. Plain `.click()` on the combobox +2. `focus()` + `keydown` of `Enter`, `Space`, or `ArrowDown` +3. Walk `__ngContext__` and call the Angular mat-select instance's `.open()` method + +Once a visible `mat-option` appears, click the "One Way" option with the full +event sequence (`pointerdown` → `mousedown` → `pointerup` → `mouseup` → +`click` → `element.click()`). If the return-date input is still visible after +that, fall back to walking `__ngContext__` on the mat-option for +`_selectViaInteraction()` or `select()` and calling it directly. + +### Station picker + +- Click the `button[aria-label='Station select']` at the desired index (0 = origin, 1 = destination). +- The dropdown renders `li[role='option'].station-options__item` entries plus a search `input[placeholder='Airport or city']`. +- Type the IATA code into the search input via the native value setter + `input`/`change` events, wait ~500 ms for filtering, then click the option whose text contains the code at a word boundary. +- Fall back to `__ngContext__._selectViaInteraction()` / `.select()` if the click doesn't update the station button's text. + +### Osano cookie banner + +JSX embeds Osano. The banner is a `div[role="dialog"]` with class +`osano-cm-window` / `osano-cm-dialog`. Two problems: + +1. It intercepts clicks on the page behind it. +2. Its `role="dialog"` matches any selector that looks for open dialogs, so code trying to find the datepicker via `[role='dialog']` will match Osano instead. + +Fix: after accepting it (class-based: `.osano-cm-accept-all`, `.osano-cm-accept`; or text-based: "Accept", "Accept All", "I Agree", "Got it"), **force-remove the banner node**: + +```js +document.querySelectorAll(".osano-cm-window, .osano-cm-dialog").forEach(el => el.remove()); +``` + +### Find Flights button + +Visible on the main form. Angular click handler calls `search()`, which: + +1. Checks `form.invalid`. If true, marks touched and returns without firing a request. +2. Otherwise builds the request body (same shape documented above) and POSTs `/availability/search/simple`. + +The button being visually enabled does **not** mean Angular thinks the form +is valid — Angular rechecks on every click. If `search()` silently bails, it +won't surface any error to the DOM. + +--- + +## WKWebView vs Playwright — the trusted-events trap + +This is the single most important finding from this session. + +### The symptom + +In WKWebView, after driving the entire UI (one-way, origin, destination, +day cell, DONE button), the depart-date input visually shows `"Sat, Apr 11"` +and the Find Flights button is visually enabled. Clicking it fires the click +handler (you can prove it with a capture listener), but no POST to +`/availability/search/simple` happens — ever. The only network call that +fires after the click is the token refresh that was already scheduled. + +### The cause + +WKWebView's synthetic DOM events produced by +`element.dispatchEvent(new MouseEvent('click', {...}))` have +`event.isTrusted === false`. JSX's custom datepicker only commits its +day-cell selection into its Angular `FormControl` on a **trusted** user +gesture. So the synthetic click on a day cell visually highlights the cell, +lets DONE close the picker, and updates the input's display value — but the +underlying FormControl stays `null`. + +When Find Flights is then clicked, the handler runs `search()`, which does: + +```ts +search() { + if (this.form.invalid) { + this.form.markAllAsTouched(); + return; + } + this.api.post('/availability/search/simple', this.buildBody()); +} +``` + +Form is invalid because `departDate` FormControl is `null`, so the method +returns early. No POST. Silent failure. + +### Why Playwright doesn't hit this + +Playwright's `page.click()` and `locator.click()` don't produce synthetic +DOM events — they go through CDP's `Input.dispatchMouseEvent`, which +produces events indistinguishable from real user input, including +`isTrusted === true`. JSX's picker accepts those and commits the date. + +### Why Angular's `markAllAsTouched` workaround doesn't help + +You can walk `__ngContext__` on every element looking for `FormControl` +instances and call `markAllAsTouched()` + `updateValueAndValidity()`. This +does trigger revalidation and reveals all the currently-invalid fields, but +it doesn't populate the empty date control — it just marks it dirty. The +underlying value is still `null` because nothing wrote to it. + +### The WKWebView workaround + +Don't click Find Flights. Instead, call +`/api/nsk/v4/availability/search/simple` directly via `fetch()` from the +page context after reading the token out of `sessionStorage`. You still do +the UI-driving steps (for page warm-up, session establishment, and as a +smoke test that the site is functional), but the actual data fetch happens +via a direct API call whose request shape matches exactly what +`search()` would have posted. + +This is what `Flights/Services/JSXWebViewFetcher.swift` step 17 does. + +--- + +## Anti-bot surface (Akamai) + +JSX fronts `api.jsx.com` with Akamai Bot Manager. Observed behavior: + +| Request source | Result | +|---|---| +| Plain `curl`, `fetch` from Node, any external HTTP client | Blocked. Almost all endpoints return HTML challenge page or Akamai error. | +| Playwright's built-in `chromium.launch()` (both bundled chromium and `channel: "chrome"`) | GET requests succeed, but `POST /availability/search/simple` specifically returns `ERR_HTTP2_PROTOCOL_ERROR`. Playwright injects enough automation bits for Akamai to flag the TLS/H2 fingerprint. | +| Real Chrome spawned as a plain process + Playwright attached via `chromium.connectOverCDP()` | **Works reliably.** Chrome has the expected fingerprint and Playwright is only driving it via CDP, not altering it. | +| WKWebView on macOS / iOS | GET requests succeed. Direct `POST /availability/search/simple` from inside a loaded `jsx.com` page via `fetch()` also succeeds. The browser session's cookies and TLS fingerprint are trusted. | +| WKWebView with UI-driven Find Flights click | Fails — but for an unrelated reason (the trusted-events trap above). Angular never fires the POST in the first place, so Akamai never sees it. | + +Observations: + +- The Akamai block is on the request fingerprint, not on specific URLs. The same binary can reach some endpoints and not others because different endpoints attract different scrutiny. +- Cookies set by a real page load are transferable to `fetch()` calls made from the same page context via `credentials: "include"`, and Akamai accepts them. +- The `lowfare/estimate` endpoint is a useful liveness probe — it fires automatically when the SPA has origin+destination, and if it succeeds you know the Akamai session is good. + +--- + +## Network interceptor pattern + +If you want to capture JSX traffic programmatically (e.g. in WKWebView or +Playwright), override `window.fetch` and `XMLHttpRequest` to tee every +request/response to a probe object. Two things to track separately: + +1. **Initiated calls** — every URL passed to `fetch()` or + `XMLHttpRequest.send()`, recorded *before* awaiting the response. +2. **Completed calls** — every call that returned a response (any status), + plus an error marker on network failure. + +This lets you tell apart "Angular's `search()` never fired a POST" (nothing +in initiated calls) from "Angular fired a POST but the network rejected it" +(initiated but not completed, error flag set). This distinction was critical +for diagnosing the WKWebView trusted-events issue — without it, both failure +modes look like "no searchSimple response seen". + +See the `window.__jsxProbe` setup in +`Flights/Services/JSXWebViewFetcher.swift` step 4 for a working +implementation. + +--- + +## Per-step verification + +Every step in the WKWebView flow has an **action** and one or more +**post-condition verifications**. The action runs and returns success +or failure; then every verification runs regardless and logs its result. +This matters because a step can have an action that returns "ok" but +actually left the page in a broken state — e.g. step 12 can return +"datepicker open" because it found the overlay shell, while step 13's +check reveals the day cells haven't rendered yet. Running all the +verifications and surfacing their results per-line in the log (rather +than stopping at the first failure) lets you see the full state the +failing step produced, not just the first mismatch. + +On failure, the step runner dumps: + +- The action's returned data fields (e.g. `action.sample: [...]`, + `action.monthMatches: [...]`) — any diagnostic payload the action JS + chose to include gets surfaced automatically. +- Page state: current URL, visible `[role='combobox']` count, visible + `mat-option` count, Find Flights button disabled/aria-disabled state, + recently initiated and completed api.jsx.com calls, and any elements + matching `ng-invalid` / `mat-form-field-invalid` / `error` classes so + form validation errors are visible. + +--- + +## Code references + +- **iOS runtime flow**: `Flights/Services/JSXWebViewFetcher.swift` — the + 19-step WKWebView-based flow with per-step verification. Returns + `JSXSearchResult { flights: [JSXFlight], rawSearchBody, error }` with one + `JSXFlight` entry per unique flight in the search response. + +- **iOS call site**: `Flights/Services/AirlineLoadService.fetchJSXLoad` — + invokes the fetcher, logs every returned flight, and picks the + caller-requested flight number via digit match. + +- **Playwright reference implementation**: `scripts/jsx_playwright_search.mjs` + — standalone command-line harness that drives the real jsx.com SPA from a + real Chrome attached via `chromium.connectOverCDP()`. Used as the source + of truth for the UI flow and request/response shapes that the Swift + rewrite mirrors. + + ```bash + npx playwright install chromium # one-time + node scripts/jsx_playwright_search.mjs --origin DAL --destination HOU --date 2026-04-16 --headful + ``` + + Artifacts land in `/tmp/jsx-playwright/` (raw `search-simple.json`, the + `lowfare.json` low-fare estimate, and a `calls.json` log of every + `api.jsx.com` URL/status the session saw).