Add JSX reverse-engineering notes

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) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-04-11 10:54:56 -05:00
parent 77c59ce2c2
commit c9992e2d11

444
JSX_NOTES.md Normal file
View File

@@ -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[<market>]`:
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 ~150400 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).