Airline integration work: AirlineLoadService updates, docs, JSX scripts

- AirlineLoadService: pass airport DB for timezone-aware date strings,
  add browser-shaped headers for United, expand JetBlue/Alaska/Emirates
  signatures to take origin, log/parse fixes for Korean Air.
- FlightsApp: build AirlineLoadService with the airport DB and inject it.
- JSX: continued WebView-based fetcher work plus updated JSX_NOTES.
- Docs: add AIRLINE_INTEGRATION_GUIDE.md, drop the old AIRLINE_API_SPEC.md,
  add api_docs/ (StaffTraveler reverse-engineering captures + findings).
- Scripts: jsx_cdp_probe, jsx_live_monitor, jsx_swift_smoke for JSX
  protocol exploration.
- .gitignore: exclude airlines/ (local-only APK/IPA reverse-engineering).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-04-24 23:21:30 -05:00
parent 1e74552184
commit 6005146e75
26 changed files with 61519 additions and 1334 deletions
+1
View File
@@ -39,6 +39,7 @@ Pods/
# APK files # APK files
apps/ apps/
airlines/
# Claude # Claude
.claude/ .claude/
-792
View File
@@ -1,792 +0,0 @@
# Airline Flight Load API Specification
Integration-ready API reference for querying flight loads, standby lists, and seat availability across 7 airlines. All endpoints confirmed working as of April 2026.
---
## Architecture Overview
All airlines except Spirit require **Playwright** (headless browser) to bypass Akamai TLS fingerprinting. The pattern is:
1. Navigate to the airline's domain to establish a browser session
2. Use `page.evaluate(async () => { const r = await fetch(...); return r.json(); })` to call APIs from within the browser context
Spirit works with plain HTTP requests (curl/fetch).
---
## UNITED AIRLINES
### Base Setup
```
Domain: https://www.united.com
Auth: Anonymous token (no login required)
Method: Playwright → navigate to united.com → page.evaluate(fetch)
```
### Step 1: Get Token
```
GET /api/auth/anonymous-token
Headers: none required
```
**Response:**
```json
{
"data": {
"token": {
"hash": "DAAAA...",
"expiresAt": "2026-04-08T19:42:57Z"
}
}
}
```
Token is valid ~30 minutes. Refresh as needed.
### Step 2: Search Flights by Route
```
GET /api/flightstatus/status/{flightNumber}/{date}?carrierCode=UA&useLegDestDate=true
Path params:
flightNumber: "2238"
date: "2026-04-08"
Headers:
x-authorization-api: bearer {token.hash}
Accept: application/json
```
**Response:** Flight status with departure/arrival times, gates, terminals, aircraft type, tail number, delays.
### Step 3: Get Flight Loads + Standby List
```
GET /api/flightstatus/upgradeListExtended?flightNumber={num}&flightDate={YYYY-MM-DD}&fromAirportCode={origin}
Headers:
x-authorization-api: bearer {token.hash}
Accept: application/json
```
**Response:**
```json
{
"segment": {
"airlineCode": "UA",
"flightNumber": 2238,
"flightDate": "20260408",
"departureAirportCode": "EWR",
"arrivalAirportCode": "LAX",
"equipmentDescriptionLong": "Boeing 777-200ER",
"departed": false
},
"pbts": [
{
"cabin": "Front",
"capacity": 50,
"authorized": 50,
"booked": 50,
"held": 0,
"reserved": 0,
"revenueStandby": 0,
"waitList": 0,
"jump": 0,
"group": 0,
"ps": 1,
"sa": 5
},
{
"cabin": "Middle",
"capacity": 24,
"authorized": 24,
"booked": 16,
"held": 0,
"reserved": 0,
"revenueStandby": 0,
"waitList": 0,
"jump": 0,
"group": 0,
"ps": 0,
"sa": 0
},
{
"cabin": "Rear",
"capacity": 202,
"authorized": 202,
"booked": 164,
"held": 0,
"reserved": 0,
"revenueStandby": 2,
"waitList": 0,
"jump": 0,
"group": 0,
"ps": 0,
"sa": 4
}
],
"checkInSummaries": [
{
"cabin": "Front",
"capacity": 50,
"total": 50,
"etktPassengersCheckedIn": 50,
"revStandbyCheckedInWithoutSeats": 0,
"nonRevStandbyCheckedInWithoutSeats": 0,
"children": 0,
"infants": 0,
"bags": 0
}
],
"numberOfCabins": 3,
"front": {
"cleared": [
{
"currentCabin": "Front",
"bookedCabin": "Rear",
"firstName": "T",
"lastName": "JEN",
"passengerName": "T/JEN",
"seatNumber": "1G",
"clearanceType": "Upgrade",
"skipped": false
}
],
"standby": []
}
}
```
### Field Reference
| Field | Description |
|---|---|
| `pbts[].cabin` | "Front" (First/Polaris), "Middle" (Premium+), "Rear" (Economy) |
| `pbts[].capacity` | Total seats in cabin |
| `pbts[].booked` | Seats sold/assigned |
| `pbts[].revenueStandby` | Revenue standby passengers |
| `pbts[].sa` | Space available (non-rev standby) |
| `pbts[].ps` | Positive space |
| `pbts[].waitList` | Waitlisted passengers |
| `front.cleared[]` | Passengers cleared for upgrade |
| `front.standby[]` | Passengers on standby |
### Derived Values
```
availableSeats = capacity - booked
loadFactor = booked / capacity
```
---
## AMERICAN AIRLINES
### Base Setup
```
Domain: https://cdn.flyaa.aa.com
Auth: None required
Method: Playwright → set mobile UA headers → navigate to cdn.flyaa.aa.com → page.evaluate(fetch)
Required context headers (set via page.context().setExtraHTTPHeaders):
User-Agent: Android/2025.31 Pixel 7|14|1080|2400|1.0|AmericanAirlines
x-clientid: MOBILE
Accept: application/json
Content-Type: application/json
Device-ID: {any-uuid}
```
### Step 1: Search Flights by Route
```
GET /apiv2/mobile-flifo/flightSchedules/v1.0?origin={ORIG}&destination={DEST}&departureDay={D}&departureMonth={M}&searchType=schedule&noOfFlightsToDisplay=20
```
**Response:**
```json
{
"flightSchedules": {
"flights": [
[{
"flightKey": "AA:3390:2026-04-09:DFW:0",
"operatingCarrierCode": "AA",
"operatingCarrierName": "AMERICAN EAGLE",
"marketingCarrierCode": "AA",
"flightNumber": "3390",
"originAirportCode": "DFW",
"originCity": "Dallas/ Fort Worth",
"destinationAirportCode": "IAH",
"destinationCity": "Houston",
"departDate": "2026-04-09T07:01:00.000-05:00",
"arrivalDate": "2026-04-09T08:20:00.000-05:00",
"showUpgradeStandbyList": false,
"allowFSN": false
}]
]
}
}
```
### Step 2: Get Waitlist + Available Seats
```
GET /api/mobile/loyalty/waitlist/v1.2?carrierCode=AA&flightNumber={NUM}&departureDate={YYYY-MM-DD}&originAirportCode={ORIG}&destinationAirportCode={DEST}
Additional headers:
x-referrer: fs
```
**Response:**
```json
{
"relevantList": "First",
"footer": [
"If your upgrade has cleared or you clear the waitlist, please refresh your mobile boarding pass.",
"The order of names may change as additional customers check in."
],
"waitList": [
{
"listName": "First",
"seatsAvailableLabel": "Available seats",
"seatsAvailableValue": 1,
"seatsAvailableSemanticColor": "failure",
"passengers": [
{"order": 1, "displayName": "BRI, K", "cleared": false, "seat": null, "highlighted": false},
{"order": 2, "displayName": "MAT, R", "cleared": false, "seat": null, "highlighted": false}
]
},
{
"listName": "Standby",
"seatsAvailableLabel": "Available seats",
"seatsAvailableValue": 45,
"seatsAvailableSemanticColor": "success",
"passengers": [
{"order": 1, "displayName": "MIT, R", "cleared": false, "seat": null, "highlighted": false},
{"order": 2, "displayName": "MAR, M", "cleared": false, "seat": null, "highlighted": false}
]
}
]
}
```
### Field Reference
| Field | Description |
|---|---|
| `waitList[].listName` | "First", "Standby", etc. |
| `waitList[].seatsAvailableValue` | Number of open seats for that class |
| `waitList[].seatsAvailableSemanticColor` | "success" (green, many), "warning" (yellow, few), "failure" (red, <=1) |
| `waitList[].passengers[].displayName` | Passenger name (LAST, F) |
| `waitList[].passengers[].order` | Position on list (1-based) |
| `waitList[].passengers[].cleared` | true if cleared from list |
| `waitList[].passengers[].seat` | Seat number if cleared |
---
## SPIRIT AIRLINES
### Base Setup
```
Domain: https://api.spirit.com
Auth: APIM subscription key (no login required)
Method: Plain HTTP (curl/fetch) — no Playwright needed
```
### Step 1: Get Flight Status
```
POST https://api.spirit.com/customermobileprod/2.8.0/v3/GetFlightInfoBI
Headers:
Content-Type: application/json
Ocp-Apim-Subscription-Key: c6567af50d544dfbb3bc5dd99c6bb177
Platform: Android
Body:
{
"departureStation": "FLL",
"arrivalStation": "ATL",
"departureDate": "2026-04-08"
}
```
**Response:**
```json
{
"getFlightInfoBIResult": [
{
"flightNumber": "NK204",
"journeyID": 1,
"departureStationCode": "FLL",
"arrivalStationCode": "ATL",
"departureGate": "F4",
"arrivalGate": "C4",
"departureTerminal": "4",
"arrivalTerminal": "N",
"legStatus": "Arrived",
"departureTime": "8:57am",
"arrivalTime": "11:04am",
"scheduledDeparture": "scheduled at 8:24am",
"scheduledArrival": "scheduled at 10:22am",
"departureCity": "Fort Lauderdale, FL",
"arrivalCity": "Atlanta, GA",
"flightStatusColor": "#FF9500",
"totalDurationMinutes": 127,
"departureDateTime": "2026-04-08T08:57:00",
"arrivalDateTime": "2026-04-08T11:04:00"
}
]
}
```
### Step 2: Get Station/Route Network
```
GET https://api.spirit.com/customermobileprod/2.8.0/v1/stations
Headers:
Ocp-Apim-Subscription-Key: c6567af50d544dfbb3bc5dd99c6bb177
```
Returns all Spirit stations with airport codes, coordinates, city names, and which routes connect to each station.
### Notes
- Spirit is a ULCC — no standby lists or upgrade waitlists
- Seat-level availability requires a booking session + Akamai bypass (not publicly accessible)
- The APIM key was decrypted from the native library in the Android APK
---
## KOREAN AIR
### Base Setup
```
Domain: https://www.koreanair.com
Auth: None required for flight status; minimal headers
Method: Playwright → navigate to koreanair.com → page.evaluate(fetch)
```
### Step 1: Search Flights
```
POST /api/fs/scheduleFlightSearch/flight/status/app
Headers:
Content-Type: application/json
Accept: application/json
channel: app
Body (by flight number):
{
"departureDate": "20260408",
"departureLocationCode": "",
"arrivalLocationCode": "",
"flightNumber": "017",
"searchOption": "FLTNUM"
}
Body (by route):
{
"departureDate": "20260408",
"departureLocationCode": "ICN",
"arrivalLocationCode": "LAX",
"flightNumber": "",
"searchOption": "ROUTE"
}
```
**Response:**
```json
{
"scheduleInformation": {
"flightInformation": {
"flightDetailsList": [
{
"departureAirport": "ICN",
"arrivalAirport": "LAX",
"departureDate": "20260408",
"departureTime": "1430",
"arrivalTime": "0940",
"flightNumber": "017",
"flyingTime": "1110",
"status": {"code": "ARV", "codeByUI": "ARV"},
"cabinClassInfoList": [
{"cabinClassOfService": "1"},
{"cabinClassOfService": "2"},
{"cabinClassOfService": "3"}
],
"scheduleFlightUIInfoMsOutVo": {
"flightStatus": "arrived",
"departureUIInfo": {
"scheduledTime": "14:30",
"actualTime": "14:27"
},
"arrivalUIInfo": {
"scheduledTime": "09:40",
"actualTime": "09:47"
}
}
}
]
}
}
}
```
### Step 2: Get Seat Count
```
POST /api/et/ibeSupport/flightSeatCount
Headers:
Content-Type: application/json
Accept: application/json
channel: pc
Body:
{
"carrierCode": "KE",
"flightNumber": "017",
"departureAirport": "ICN",
"arrivalAirport": "LAX",
"departureDate": "20260409"
}
```
**Response:**
```json
{
"seatCount": 0,
"carrierCode": "KE",
"flightNumber": "017"
}
```
Note: `seatCount` returns available seats. Returns 0 for dates too far out — works best within 24-48 hours of departure.
### Step 3: Get Availability
```
POST /api/fs/scheduleFlightSearch/sdcAirMultiAvailability
Headers:
Content-Type: application/json
Accept: application/json
channel: pc
Body:
{
"departureDate": "20260409",
"departureLocationCode": "ICN",
"arrivalLocationCode": "LAX",
"flightNumber": "001",
"searchOption": "FLTNUM"
}
```
---
## Playwright Integration Pattern
All airlines except Spirit use the same Playwright wrapper pattern:
```javascript
const { chromium } = require('playwright');
async function queryAirline(airlineDomain, setupHeaders, apiCall) {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
if (setupHeaders) {
await context.setExtraHTTPHeaders(setupHeaders);
}
const page = await context.newPage();
await page.goto(airlineDomain);
await page.waitForTimeout(5000);
const result = await page.evaluate(apiCall);
await browser.close();
return result;
}
// UNITED example:
const unitedLoads = await queryAirline(
'https://www.united.com/en/us/flightstatus',
null,
async () => {
const tokenResp = await fetch('/api/auth/anonymous-token');
const { data } = await tokenResp.json();
const token = data.token.hash;
const resp = await fetch(
'/api/flightstatus/upgradeListExtended?flightNumber=2238&flightDate=2026-04-08&fromAirportCode=EWR',
{ headers: { 'Accept': 'application/json', 'x-authorization-api': 'bearer ' + token } }
);
return resp.json();
}
);
// AMERICAN example:
const aaLoads = await queryAirline(
'https://cdn.flyaa.aa.com/apiv2/mobile-flifo/flightSchedules/v1.0?origin=DFW&destination=IAH&departureDay=9&departureMonth=4&searchType=schedule&noOfFlightsToDisplay=20',
{
'User-Agent': 'Android/2025.31 Pixel 7|14|1080|2400|1.0|AmericanAirlines',
'x-clientid': 'MOBILE',
'Accept': 'application/json',
'Device-ID': 'device-001'
},
async () => {
const resp = await fetch(
'https://cdn.flyaa.aa.com/api/mobile/loyalty/waitlist/v1.2?carrierCode=AA&flightNumber=2209&departureDate=2026-04-08&originAirportCode=DFW&destinationAirportCode=IAH',
{ headers: { 'Accept': 'application/json', 'x-referrer': 'fs' } }
);
return resp.json();
}
);
// KOREAN AIR example:
const keStatus = await queryAirline(
'https://www.koreanair.com',
null,
async () => {
const resp = await fetch('/api/fs/scheduleFlightSearch/flight/status/app', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'channel': 'app' },
body: JSON.stringify({
departureDate: '20260408',
flightNumber: '017',
searchOption: 'FLTNUM',
departureLocationCode: '',
arrivalLocationCode: ''
})
});
return resp.json();
}
);
```
### Spirit (plain HTTP, no Playwright):
```javascript
const spiritStatus = await fetch('https://api.spirit.com/customermobileprod/2.8.0/v3/GetFlightInfoBI', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Ocp-Apim-Subscription-Key': 'c6567af50d544dfbb3bc5dd99c6bb177',
'Platform': 'Android'
},
body: JSON.stringify({
departureStation: 'FLL',
arrivalStation: 'ATL',
departureDate: '2026-04-08'
})
}).then(r => r.json());
```
---
## EMIRATES
### Base Setup
```
Domain: https://www.emirates.com
Auth: None required for flight status
Method: Plain HTTP (curl/fetch) — no Playwright needed
```
### Step 1: Flight Status
```
GET https://www.emirates.com/service/flight-status?departureDate={YYYY-MM-DD}&flight={flightNumber}
Headers: none required
```
**Response:**
```json
{
"results": [{
"airlineDesignator": "EK",
"flightNumber": "0221",
"flightId": "2026040700221DXB",
"flightDate": "2026-04-07",
"flightRoute": [{
"legNumber": "1",
"originActualAirportCode": "DXB",
"destinationActualAirportCode": "DFW",
"originPlannedAirportCode": "DXB",
"destinationPlannedAirportCode": "DFW",
"statusCode": "ARVD",
"flightPosition": 100,
"totalTravelDuration": "17:30",
"isIrregular": "false",
"departureTime": {
"schedule": "2026-04-08T01:10:00Z",
"estimated": "2026-04-08T01:15:00Z",
"actual": "2026-04-08T01:12:00Z"
},
"arrivalTime": {
"schedule": "2026-04-08T09:40:00Z",
"estimated": "2026-04-08T09:34:00Z",
"actual": "2026-04-08T09:32:00Z"
},
"departureTerminal": "Terminal 3",
"arrivalTerminal": "Terminal D",
"flightOutageType": 0
}]
}],
"metaLinks": []
}
```
### Step 2: Flight Load / Staff Standby (requires PNR)
```
Mobile API base: https://mobileapp.emirates.com/
GET /olci/v1/checkin/staffinformation/{pnr}/{lastName}
```
Returns `FlightLoadResponse` with:
- `isStaffSubLoadTableAvl` - whether subload table is available
- `staffPax.passengers[]` - staff passenger list with check-in status
- `flights[]` - per-flight load data
- Per passenger: `currentPriority`, `totalPriority`, `status`, `flightNumber`
### Notes
- Flight status works from **plain curl** with zero auth — simplest of all airlines
- Staff standby/flight load data requires PNR + last name (mobile app only)
- The app has a full staff travel system: standby priority tracking, class downgrade acceptance, subload questionnaires
- Internal backend leaked in response: `business-services-cache-bex-prod.dub.prd01.digitalattract.aws.emirates.prd`
---
## ALASKA AIRLINES
### Base Setup
```
Domain: https://www.alaskaair.com
Mobile API: /1/guestservices/customermobile/
Auth: Requires booking confirmation code for load data
Method: Playwright (website uses shadow DOM web components)
```
### Step 1: Flight Status
```
GET /1/guestservices/customermobile/flights/status/{airlineCode}/{flightNumber}/{departureDate}
Example: /1/guestservices/customermobile/flights/status/AS/1084/2026-04-08
```
### Step 2: Flight Status v2 (by route)
```
POST /1/guestservices/customermobile/mobileservices/reservation/flights/status
Body:
{
"airlineCode": "AS",
"flightNumber": "1084",
"departureDate": "2026-04-08",
"departureCityCode": "SEA",
"arrivalCityCode": "LAX"
}
```
Response includes `showPriorityList` boolean indicating if standby list is available for that flight.
### Step 3: Seat Availability / Remaining Seats (requires confirmation code)
```
POST /1/guestservices/customermobile/seats/SeatUpgradesByCabinRec/{confirmationCode}
Body:
{
"adobeMarketingCloudVisitorID": "{visitor_id}"
}
```
**Response:**
```json
[
{
"flightNumber": 1084,
"origin": "SEA",
"destination": "LAX",
"cabinType": "First",
"remainingSeats": 4,
"upgradePrice": 149.00,
"equipment": "Boeing 737-900ER"
}
]
```
### Notes
- Flight status is public, but `remainingSeats` data requires a valid confirmation code
- Mobile API paths start with `/1/guestservices/customermobile/`
- Uses Ktor HTTP client (modern Kotlin)
- Website uses deep shadow DOM -- Playwright automation is complex
---
## JETBLUE
### Base Setup
```
API Domain: https://az-api.jetblue.com
Auth: API key (no login required)
Method: Plain HTTP (curl/fetch) — no Playwright needed
API Key: 49fc015f1ba44abf892d2b8961612378
```
### Step 1: Flight Status by Number
```
GET https://az-api.jetblue.com/flight-status/get-by-number?number={flightNumber}&date={YYYY-MM-DD}
Headers:
apikey: 49fc015f1ba44abf892d2b8961612378
Accept: application/json
```
**Response:**
```json
{
"flights": [{
"tripOrigin": "LAX",
"tripDestination": "JFK",
"isConnecting": false,
"isThroughFlight": false,
"legs": [{
"flightNo": "524",
"flightStatus": "IN FLIGHT",
"flightStatusGroup": "standardPostDeparture",
"originAirport": "LAX",
"originGate": "16",
"originTerminal": "1",
"actualDeparture": "2026-04-08T13:19:00-07:00",
"scheduledDeparture": "2026-04-08T13:27:00-07:00",
"doorCloseTime": "2026-04-08T13:12:00-07:00",
"boardingTime": "2026-04-08T12:42:00-07:00",
"destinationAirport": "JFK",
"destinationGate": "518",
"destinationTerminal": "5",
"actualArrival": "2026-04-08T21:37:00-04:00",
"scheduledArrival": "2026-04-08T21:55:00-04:00",
"baggageClaim": "4",
"equipmentType": "3NL",
"tailNumber": "4074"
}]
}]
}
```
### Step 2: Priority List (Standby/Upgrade - requires check-in session)
The app has `retrievePriorityList` which returns `PriorityListPassenger`:
```json
{
"shortLastName": "DOE",
"shortFirstName": "J",
"code": "SA",
"order": 1,
"hasSeat": false
}
```
This requires an active check-in session (Cookie header). Accessible during check-in flow only.
### Step 3: Crystal Blue Seat Map
```
POST https://az-api.jetblue.com/mobile_seatmap
Headers:
Ocp-Apim-Subscription-Key: a5ee654e981b4577a58264fed9b1669c
Content-Type: application/json
```
### Notes
- Flight status works from **plain curl** with just the API key
- Priority list requires check-in session
- Second APIM key `a5ee654e981b4577a58264fed9b1669c` used for seat map and logging
---
## FRONTIER AIRLINES
APK not available for download from any third-party source. Frontier is a ULCC like Spirit -- minimal standby/upgrade features expected. Not yet analyzed.
---
## Rate Limits & Best Practices
- **United**: Token expires in ~30min. Cache and refresh. No known rate limit.
- **American**: No token needed. Akamai may throttle if too many requests from same browser session. Rotate browser contexts.
- **Spirit**: APIM key is shared across all app users. No known rate limit but don't abuse.
- **Korean Air**: No auth needed for status endpoints. `channel` header is required.
- **All Playwright airlines**: Reuse browser context across multiple queries to avoid re-establishing sessions. Close and recreate if you get 403s.
+398
View File
@@ -0,0 +1,398 @@
# Airline API Integration Guide
Drop-in reference for integrating flight load / seat availability data from 11 airlines. Each section tells you **what works today, how to call it, what you get back, and what's blocked**. Verified 2026-04-12.
---
## 1. United Airlines — WORKING (best US data)
**What you get:** Per-cabin capacity, booked, available, revenue standby, space-available, upgrade/standby passenger lists with names.
**Auth:** Anonymous token (~30 min lifetime), Playwright required (TLS fingerprinting blocks curl).
**Flow:**
```
GET https://www.united.com/api/auth/anonymous-token
→ { data: { token: { hash: "DAAAA..." } } }
GET https://www.united.com/api/flightstatus/upgradeListExtended
?flightNumber=2238&flightDate=2026-04-08&fromAirportCode=EWR
Headers:
x-authorization-api: bearer {token.hash}
Accept: application/json
```
**Response shape:**
```json
{
"pbts": [
{ "cabin": "Front", "capacity": 50, "booked": 50, "revenueStandby": 0, "sa": 5, "waitList": 0 },
{ "cabin": "Middle", "capacity": 24, "booked": 16 },
{ "cabin": "Rear", "capacity": 202, "booked": 164, "revenueStandby": 2, "sa": 4 }
],
"front": { "cleared": [...], "standby": [...] }
}
```
**Derived:** `availableSeats = capacity - booked`, `loadFactor = booked / capacity`.
**Cabin mapping:** `Front` = Polaris/First, `Middle` = Premium Plus, `Rear` = Economy.
**Other useful endpoints:**
- `GET /api/flightstatus/status/{num}/{date}/{origin}/{dest}?carrierCode=UA` — gates, times, equipment
- `GET /api/flightstatus/seatmap/{num}/{date}/{origin}/{dest}?carrierCode=UA` — seat map
---
## 2. American Airlines — WORKING
**What you get:** Waitlist per class (First, Standby), seats available per class with semantic color, passenger names + order.
**Auth:** None — but mobile User-Agent is mandatory. Direct curl blocked by TLS fingerprinting; use Playwright.
**Required headers (on browser context):**
```
User-Agent: Android/2025.31 Pixel 7|14|1080|2400|1.0|AmericanAirlines
x-clientid: MOBILE
Device-ID: {any-uuid}
Accept: application/json
```
**The UA format is strict.** Any deviation returns `{"error":["Invalid user-agent header"]}` from Akamai.
**Flow:**
```
# Step 1 — find the flight
GET https://cdn.flyaa.aa.com/apiv2/mobile-flifo/flightSchedules/v1.0
?origin=DFW&destination=IAH&departureDay=9&departureMonth=4
&searchType=schedule&noOfFlightsToDisplay=20
# Step 2 — get waitlist + seats
GET https://cdn.flyaa.aa.com/api/mobile/loyalty/waitlist/v1.2
?carrierCode=AA&flightNumber={num}&departureDate={YYYY-MM-DD}
&originAirportCode={origin}&destinationAirportCode={dest}
Headers: x-referrer: fs
```
**Response shape:**
```json
{
"waitList": [
{
"listName": "First",
"seatsAvailableValue": 1,
"seatsAvailableSemanticColor": "failure",
"passengers": [
{ "order": 1, "displayName": "BRI, K", "cleared": false, "seat": null }
]
},
{ "listName": "Standby", "seatsAvailableValue": 45, "seatsAvailableSemanticColor": "success", "passengers": [...] }
]
}
```
`seatsAvailableSemanticColor`: `success` (many), `warning` (few), `failure` (≤1).
---
## 3. Alaska Airlines — WORKING (easiest integration)
**What you get:** Seat map with per-seat status + `AvailableSeats` per cabin, **full standby + upgrade waitlists with passenger names**, capacity, cabin configuration.
**Auth:** Static APIM key (decrypted from APK). **Plain curl works — no Playwright needed.**
**Key:** `de1d0ff837444468a5ea868945aab738`
**Header:** `Ocp-Apim-Subscription-Key: de1d0ff837444468a5ea868945aab738`
### Seat map (per-seat availability)
```bash
curl "https://apis.alaskaair.com/1/guestservices/customermobile/viewseatmap/seatmap\
?flightnumber=308&departureairport=SEA&arrivalairport=PSP&departuredate=2026-04-13" \
-H "Ocp-Apim-Subscription-Key: de1d0ff837444468a5ea868945aab738"
```
Per-seat status: `OCCD` (occupied), `OPEN`, `PCLA` (premium class), `PREM` (premium). Returns `AvailableSeats` per cabin section.
### Standby + upgrade waitlist (the prize — **no PNR needed**)
```bash
curl -X POST "https://apis.alaskaair.com/1/guestservices/customermobile/seats/waitlist" \
-H "Ocp-Apim-Subscription-Key: de1d0ff837444468a5ea868945aab738" \
-H "Content-Type: application/json" \
-d '{
"marketedByAirlineCode":"AS",
"departureAirportCode":"SEA",
"departureLocalDate":"2026-04-12",
"flightNumber":"308",
"confirmationCode": null
}'
```
**Response includes:**
- `StandbyList.FlightLoad.Authorized` — cabin capacity
- `StandbyList.FlightLoad.PremiumClassConfigured` — first-class exists
- `StandbyList.Passengers[]``DisplayName`, `Position`, `Seat`, `UpgradedToPC`
- `UpgradeList.FlightLoad.Authorized` — first-class capacity
- `UpgradeList.Passengers[]` — upgrade waitlist
**Key insight:** `confirmationCode: null` is accepted. Works for past, current, and future flights (tested 2+ weeks out).
### Flight status
```
GET /1/guestservices/customermobile/flights/status/AS/{num}/{YYYY-MM-DD}
```
---
## 4. JSX — WORKING (per-flight seat counts)
**What you get:** Per-flight `availableCount` per fare class, full schedule, route network.
**Auth:** Anonymous JWT (15 min idle), Playwright required for the initial token call (Akamai).
**Carrier code:** X2 (ICAO: XSR).
### Step 1 — get token (Playwright)
```javascript
await page.goto('https://www.jsx.com');
const { data } = await page.evaluate(async () => {
const r = await fetch('https://api.jsx.com/api/nsk/v2/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ applicationName: 'IBE', credentials: { channelType: 'DigitalWeb' } })
});
return r.json();
});
const token = data.token; // raw JWT, no "Bearer " prefix
```
### Step 2 — per-flight availability (GraphQL)
```
POST https://api.jsx.com/api/v2/graph/searchAvailability
Headers:
authorization: {token}
Content-Type: application/json
Body:
{
"cachedResults": false,
"query": "{ availabilityv5(request: { criteria: [{ dates: { beginDate: \"2026-04-15\", endDate: \"2026-04-15\" }, stations: { originStationCodes: [\"BUR\"], destinationStationCodes: [\"LAS\"] } }], passengers: { types: [{ count: 1, type: \"ADT\" }] }, codes: { currencyCode: \"USD\" } }) { results { trips { date journeysAvailableByMarket { key value { journeyKey stops designator { arrival departure destination origin } segments { identifier { identifier carrierCode } } fares { details { availableCount classOfService productClass passengerFares { fareAmount } } } } } } } } }"
}
```
Returns per-flight seat counts per fare class (sample: BUR→LAS 6 flights, 12 seats each).
### Step 3 — low fare calendar (REST, simpler)
```
GET https://api.jsx.com/api/nsk/v1/availability/lowfare/estimate
?Origin=BUR&Destination=LAS&StartDate=2026-04-15&EndDate=2026-04-18
&IncludeTaxesAndFees=true&PassengerCount=1&CurrencyCode=USD
Headers: authorization: {token}
```
### Route network (GraphQL)
`POST /api/v2/graph/primaryResources` — returns all markets + station coords.
Script ready at `scripts/jsx_availability.js`.
---
## 5. Spirit Airlines — PARTIAL (status only, no standby)
**What you get:** Flight status, station/route data. **No standby — Spirit is a ULCC and doesn't run standby lists.**
**Auth:** Static APIM key (decrypted). Plain curl for GETs; POSTs mostly blocked by Akamai CyberFend sensor.
**Key:** `c6567af50d544dfbb3bc5dd99c6bb177`
```bash
curl -X POST "https://api.spirit.com/customermobileprod/2.8.0/v3/GetFlightInfoBI" \
-H "Ocp-Apim-Subscription-Key: c6567af50d544dfbb3bc5dd99c6bb177" \
-H "Content-Type: application/json" \
-H "Platform: Android" \
-d '{"departureStation":"FLL","arrivalStation":"ATL","departureDate":"2026-04-08"}'
```
Seat maps require JWT + CyberFend sensor data (real device + Frida hook only).
---
## 6. JetBlue — PARTIAL (status yes, loads need PNR)
**What you get without PNR:** Flight status, full route database (12MB of origin/dest pairs, Mint/seasonal flags).
**What you get with a PNR:** Per-cabin capacity, confirmed pax, authorized seats, standby/waitlist counts, passenger names.
**Auth:** Static API key.
**Keys:**
- Main: `49fc015f1ba44abf892d2b8961612378`
- Seat map: `a5ee654e981b4577a58264fed9b1669c`
- MYB/PNR: `45804e33f26b44d1b144090af2788abf`
### Flight status (no PNR)
```bash
curl "https://az-api.jetblue.com/flight-status/get-by-number?number=524&date=2026-04-08" \
-H "apikey: 49fc015f1ba44abf892d2b8961612378"
```
### Route database (no PNR)
```
GET https://azrest.jetblue.com/od/od-service/routes
```
### Priority list / loads (PNR required)
```
POST https://jbrest.jetblue.com/lookup/itinerary
Body: { "fName":"JOHN", "lName":"DOE", "from":"LAX", "pnr":"ABC123", "channelID":"M" }
→ returns jbSessionId
POST https://jbrest.jetblue.com/prioritylist/getPriorityList?jbSessionId={id}
→ numberOfCapacityJ/Y, numberOfAvailableSeatsJ/Y, numberOfAuthorizedSeatsJ/Y,
numberOfStandbyPassengers, numberOfWaitListedPassengers, priorityListPassengers[]
```
**No public load path exists without a real PNR.**
---
## 7. Korean Air — PARTIAL
**What you get:** Flight status, route availability. `flightSeatCount` endpoint exists but returns 0 for far-out dates (works best within 2448 hrs of departure).
**Auth:** None. `channel` header required (`app` for flight search, `pc` for seat count).
```
POST https://www.koreanair.com/api/fs/scheduleFlightSearch/flight/status/app
Headers: channel: app
Body: {"departureDate":"20260408","flightNumber":"017","searchOption":"FLTNUM",
"departureLocationCode":"","arrivalLocationCode":""}
POST https://www.koreanair.com/api/et/ibeSupport/flightSeatCount
Headers: channel: pc
Body: {"carrierCode":"KE","flightNumber":"017","departureAirport":"ICN",
"arrivalAirport":"LAX","departureDate":"20260409"}
```
---
## 8. Emirates — PARTIAL (zero-auth status only)
**What you get:** Flight status with gates, times, equipment — zero auth, zero headers.
**Staff load tables exist but are staff-travel only (PNR + last name required).**
```bash
curl "https://www.emirates.com/service/flight-status?departureDate=2026-04-08&flight=221"
```
---
## 9. Delta — BLOCKED (status only, no public load path)
**What you get:** Rich flight status (gates, times, equipment, amenities per cabin). **Zero seat counts anywhere public.**
**Auth:** Mobile User-Agent only for status. Shop/standby data requires SkyMiles auth + Akamai BMP sensor (blocked from scripts).
**Mobile UA:** `FlyDelta/24.10.1 (Pixel 7; Android 14; Build/UQ1A.240205.004)`
```bash
curl -X POST "https://mobile-api.delta.com/flight-status-mobile/details" \
-H "Content-Type: application/json" \
-H "User-Agent: FlyDelta/24.10.1 (Pixel 7; Android 14; Build/UQ1A.240205.004)" \
-d '{"airlineCode":"DL","flightNumber":"996","flightOriginDate":"2026-04-12"}'
```
**Load data endpoints that exist but are blocked:**
- `POST /mytrips/getUpgradeAndStandby` — needs PNR + name
- `POST /offers/shop` — returns `seatsAvailableCount` per fare, needs SkyMiles auth + SPA session flow
- `POST /mwsb/service/shop` — same data, same auth requirement
The SkyMiles login alone is not enough — the SPA sets session state that the backend validates. Direct API calls fail even with valid auth.
---
## 10. British Airways — BLOCKED (no public load data)
Flight availability search exists (`/sc4/baflt-paa/rs/v1/flightavailability/search`) but returns bookable fares, not load factors. Seat availability is SOAP and needs a booking reference.
**Public OAuth:** `POST https://oauth.baplc.com/grant` with `client_id=baflt`
**Legacy SOAP:** `Authorization: Basic cHVibGljOnB1YmxpYw==` (public:public)
No standby/waitlist endpoint found.
---
## 11. Qantas — BLOCKED
Seatmap endpoint returns `isSeatAvailable`/`isSeatOccupied` per seat, but requires a valid boarding pass (productId + surname). All upgrade endpoints require auth. Akamai BMP with native sensor SDK makes automation impractical.
---
## 12. Lufthansa — BLOCKED (developer API has maps, not occupancy)
Main API behind Cloudflare WAF (403 from curl). Official developer API at `api.lufthansa.com/v1/` has seat map **layouts** but not occupancy. Seat recommendation API needs PNR.
Register: https://developer.lufthansa.com/member/register (free, 6 req/sec, 1000/hr).
Same backend powers Lufthansa, SWISS, Austrian, Brussels.
---
## Anti-bot & auth cheat sheet
| Airline | Bypass | Effort |
|------------|--------------------|---------|
| Alaska | APIM key header | Lowest (curl works) |
| Emirates | none | Lowest (curl works) |
| Spirit | APIM key (GET only)| Low (curl works) |
| JetBlue | apikey header | Low (curl works) |
| Korean Air | `channel` header | Low (Playwright or curl) |
| JSX | Playwright → JWT | Medium |
| United | Playwright → token | Medium |
| American | Playwright + mobile UA | Medium |
| Delta | mobile UA for status; shop blocked | Low/High |
| BA / Qantas / Lufthansa | — | N/A (no public load data) |
---
# Summary — what to build against
## Tier 1: Plug-and-play (integrate today)
| Airline | Data quality | Call pattern |
|---------|--------------|--------------|
| **Alaska** | ★★★★★ seat map + full standby/upgrade lists w/ names, no PNR | Plain `curl` with APIM key |
| **United** | ★★★★★ per-cabin loads + cleared upgrades + standby list | Playwright token + API fetch |
| **American** | ★★★★ waitlist + seats per class w/ pax names | Playwright w/ mobile UA |
| **JSX** | ★★★★ per-flight seat counts per fare class | Playwright JWT + GraphQL |
These four are the core of any flight-load product. Alaska is the easiest to integrate (pure HTTP), United returns the richest data, American is close behind, JSX is the only public source for per-flight counts on a Navitaire-hosted carrier.
## Tier 2: Status only (useful, but no seat data)
- **Spirit** — status/routes, no standby (ULCC)
- **Emirates** — status, zero auth
- **Korean Air** — status; `flightSeatCount` returns 0 far out
- **JetBlue** — status + route DB; loads need PNR
- **Delta** — rich status, no seat counts anywhere public
## Tier 3: Blocked / not useful
- **BA, Qantas, Lufthansa** — no public load data. Qantas/BA need booking ref; Lufthansa dev API is layouts only.
## Recommended product shape
1. Start with Alaska (easiest, 15 min to wire up).
2. Add United for the standby/upgrade killer feature (needs Playwright worker).
3. Layer in American for the third major US carrier.
4. JSX as a bonus — only route pairs that JSX serves (private terminals).
5. For Delta/JetBlue: show flight status only, note "seat data unavailable" unless you have a PNR.
6. Use Emirates/Korean Air/Spirit for status on international/ULCC routes.
## Shared integration notes
- **Cache aggressively** — all four Tier 1 sources return stable data per flight-date; a 60-second cache dramatically cuts load.
- **Token management** — United (30 min) and JSX (15 min idle) need refresh logic.
- **Playwright workers** — run one persistent browser context per airline; reuse across requests.
- **Alaska is the exception** — no browser, no token, just HTTP.
Full endpoint-by-endpoint reference: `airlines_request.md` (1692 lines, same directory).
+14 -2
View File
@@ -3,12 +3,24 @@ import SwiftUI
@main @main
struct FlightsApp: App { struct FlightsApp: App {
let service = FlightService() let service = FlightService()
let database = AirportDatabase() let database: AirportDatabase
let favoritesManager = FavoritesManager() let favoritesManager = FavoritesManager()
let loadService: AirlineLoadService
init() {
let db = AirportDatabase()
self.database = db
self.loadService = AirlineLoadService(airportDatabase: db)
}
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView(service: service, database: database, favoritesManager: favoritesManager) ContentView(
service: service,
database: database,
loadService: loadService,
favoritesManager: favoritesManager
)
} }
} }
} }
+343 -41
View File
@@ -7,14 +7,16 @@ actor AirlineLoadService {
// MARK: - Properties // MARK: - Properties
private let session: URLSession private let session: URLSession
private let airportDatabase: AirportDatabase?
private var unitedToken: (hash: String, expiresAt: Date)? private var unitedToken: (hash: String, expiresAt: Date)?
// MARK: - Init // MARK: - Init
init() { init(airportDatabase: AirportDatabase? = nil) {
let config = URLSessionConfiguration.default let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 15 config.timeoutIntervalForRequest = 15
session = URLSession(configuration: config) session = URLSession(configuration: config)
self.airportDatabase = airportDatabase
} }
// MARK: - Public Router // MARK: - Public Router
@@ -42,9 +44,9 @@ actor AirlineLoadService {
case "AA": return await fetchAmericanLoad(flightNumber: flightNumber, date: date, origin: origin, destination: destination) case "AA": return await fetchAmericanLoad(flightNumber: flightNumber, date: date, origin: origin, destination: destination)
case "NK": return await fetchSpiritStatus(origin: origin, destination: destination, date: date) case "NK": return await fetchSpiritStatus(origin: origin, destination: destination, date: date)
case "KE": return await fetchKoreanAirLoad(flightNumber: flightNumber, date: date, origin: origin, destination: destination) case "KE": return await fetchKoreanAirLoad(flightNumber: flightNumber, date: date, origin: origin, destination: destination)
case "B6": return await fetchJetBlueStatus(flightNumber: flightNumber, date: date) case "B6": return await fetchJetBlueStatus(flightNumber: flightNumber, date: date, origin: origin)
case "AS": return await fetchAlaskaStatus(flightNumber: flightNumber, date: date) case "AS": return await fetchAlaskaLoad(flightNumber: flightNumber, date: date, origin: origin, destination: destination)
case "EK": return await fetchEmiratesStatus(flightNumber: flightNumber, date: date) case "EK": return await fetchEmiratesStatus(flightNumber: flightNumber, date: date, origin: origin)
case "XE": return await fetchJSXLoad(flightNumber: flightNumber, date: date, origin: origin, destination: destination, departureTime: departureTime) case "XE": return await fetchJSXLoad(flightNumber: flightNumber, date: date, origin: origin, destination: destination, departureTime: departureTime)
default: default:
print("[LoadService] Unsupported airline: \(code)") print("[LoadService] Unsupported airline: \(code)")
@@ -64,15 +66,24 @@ actor AirlineLoadService {
do { do {
var request = URLRequest(url: url) var request = URLRequest(url: url)
Self.applyUnitedBrowserHeaders(to: &request)
request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await session.data(for: request) let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil } let http = response as? HTTPURLResponse
print("[UA] token HTTP status: \(http?.statusCode ?? -1), \(data.count) bytes")
guard http?.statusCode == 200 else {
if let bodyStr = String(data: data, encoding: .utf8) {
print("[UA] token body (first 500): \(bodyStr.prefix(500))")
}
return nil
}
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let dataObj = json["data"] as? [String: Any], let dataObj = json["data"] as? [String: Any],
let tokenObj = dataObj["token"] as? [String: Any], let tokenObj = dataObj["token"] as? [String: Any],
let hash = tokenObj["hash"] as? String else { let hash = tokenObj["hash"] as? String else {
print("[UA] token JSON shape unexpected")
return nil return nil
} }
@@ -80,15 +91,32 @@ actor AirlineLoadService {
unitedToken = (hash: hash, expiresAt: Date().addingTimeInterval(25 * 60)) unitedToken = (hash: hash, expiresAt: Date().addingTimeInterval(25 * 60))
return hash return hash
} catch { } catch {
print("[UA] token error: \(error)")
return nil return nil
} }
} }
/// Browser-shaped headers for United. The guide notes that plain curl is
/// blocked by TLS fingerprinting; iOS `URLSession` has a different
/// fingerprint but United's Akamai rules still sniff `User-Agent` /
/// `Accept-Language`, so we mirror a desktop browser. Not a guarantee
/// if this still fails we may need a WKWebView path like JSX.
private static func applyUnitedBrowserHeaders(to request: inout URLRequest) {
request.setValue(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 "
+ "(KHTML, like Gecko) Version/17.4 Safari/605.1.15",
forHTTPHeaderField: "User-Agent"
)
request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language")
request.setValue("https://www.united.com/", forHTTPHeaderField: "Referer")
request.setValue("https://www.united.com", forHTTPHeaderField: "Origin")
}
private func fetchUnitedLoad(flightNumber: String, date: Date, origin: String) async -> FlightLoad? { private func fetchUnitedLoad(flightNumber: String, date: Date, origin: String) async -> FlightLoad? {
guard let token = await getUnitedToken() else { return nil } guard let token = await getUnitedToken() else { return nil }
let num = stripAirlinePrefix(flightNumber) let num = stripAirlinePrefix(flightNumber)
let dateStr = Self.dashDateFormatter.string(from: date) let dateStr = dayString(from: date, originIATA: origin)
guard let url = URL(string: "https://www.united.com/api/flightstatus/upgradeListExtended?flightNumber=\(num)&flightDate=\(dateStr)&fromAirportCode=\(origin.uppercased())") else { guard let url = URL(string: "https://www.united.com/api/flightstatus/upgradeListExtended?flightNumber=\(num)&flightDate=\(dateStr)&fromAirportCode=\(origin.uppercased())") else {
return nil return nil
@@ -96,6 +124,7 @@ actor AirlineLoadService {
do { do {
var request = URLRequest(url: url) var request = URLRequest(url: url)
Self.applyUnitedBrowserHeaders(to: &request)
request.setValue("bearer \(token)", forHTTPHeaderField: "x-authorization-api") request.setValue("bearer \(token)", forHTTPHeaderField: "x-authorization-api")
request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("application/json", forHTTPHeaderField: "Accept")
@@ -262,7 +291,7 @@ actor AirlineLoadService {
destination: String destination: String
) async -> FlightLoad? { ) async -> FlightLoad? {
let num = stripAirlinePrefix(flightNumber) let num = stripAirlinePrefix(flightNumber)
let dateStr = Self.dashDateFormatter.string(from: date) let dateStr = dayString(from: date, originIATA: origin)
var components = URLComponents(string: "https://cdn.flyaa.aa.com/api/mobile/loyalty/waitlist/v1.2") var components = URLComponents(string: "https://cdn.flyaa.aa.com/api/mobile/loyalty/waitlist/v1.2")
components?.queryItems = [ components?.queryItems = [
@@ -355,7 +384,7 @@ actor AirlineLoadService {
return nil return nil
} }
let dateStr = Self.dashDateFormatter.string(from: date) let dateStr = dayString(from: date, originIATA: origin)
let body: [String: String] = [ let body: [String: String] = [
"departureStation": origin.uppercased(), "departureStation": origin.uppercased(),
"arrivalStation": destination.uppercased(), "arrivalStation": destination.uppercased(),
@@ -413,7 +442,7 @@ actor AirlineLoadService {
} }
let num = stripAirlinePrefix(flightNumber) let num = stripAirlinePrefix(flightNumber)
let dateStr = Self.compactDateFormatter.string(from: date) let dateStr = compactDayString(from: date, originIATA: origin)
let body: [String: String] = [ let body: [String: String] = [
"carrierCode": "KE", "carrierCode": "KE",
@@ -423,6 +452,8 @@ actor AirlineLoadService {
"departureDate": dateStr "departureDate": dateStr
] ]
print("[KE] POST \(url) body: \(body)")
do { do {
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = "POST" request.httpMethod = "POST"
@@ -432,12 +463,36 @@ actor AirlineLoadService {
request.httpBody = try JSONSerialization.data(withJSONObject: body) request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await session.data(for: request) let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil } let http = response as? HTTPURLResponse
print("[KE] HTTP status: \(http?.statusCode ?? -1), \(data.count) bytes")
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } if let bodyStr = String(data: data, encoding: .utf8) {
print("[KE] body (first 1200): \(bodyStr.prefix(1200))")
}
let seatCount = json["seatCount"] as? Int ?? 0 guard http?.statusCode == 200 else { return nil }
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
print("[KE] JSON parse failed")
return nil
}
// Guide doesn't document the response shape; try a few likely keys
// and fall back to scanning any top-level container for Int values
// named like "seatCount". Log what we see so the real shape is
// visible in the Xcode console on first run.
print("[KE] top-level keys: \(json.keys.sorted())")
let seatCount = (json["seatCount"] as? Int)
?? (json["availableSeatCount"] as? Int)
?? (json["totalSeatCount"] as? Int)
?? ((json["data"] as? [String: Any])?["seatCount"] as? Int)
?? 0
print("[KE] parsed seatCount=\(seatCount)")
// KE doesn't expose capacity/booked report what we know as
// non-revenue-standby availability in an Economy-labeled cabin
// (matches how we presented this previously).
let cabin = CabinLoad( let cabin = CabinLoad(
name: "Economy", name: "Economy",
capacity: 0, capacity: 0,
@@ -455,15 +510,16 @@ actor AirlineLoadService {
seatAvailability: [] seatAvailability: []
) )
} catch { } catch {
print("[KE] error: \(error)")
return nil return nil
} }
} }
// MARK: - JetBlue // MARK: - JetBlue
private func fetchJetBlueStatus(flightNumber: String, date: Date) async -> FlightLoad? { private func fetchJetBlueStatus(flightNumber: String, date: Date, origin: String) async -> FlightLoad? {
let num = stripAirlinePrefix(flightNumber) let num = stripAirlinePrefix(flightNumber)
let dateStr = Self.dashDateFormatter.string(from: date) let dateStr = dayString(from: date, originIATA: origin)
guard let url = URL(string: "https://az-api.jetblue.com/flight-status/get-by-number?number=\(num)&date=\(dateStr)") else { guard let url = URL(string: "https://az-api.jetblue.com/flight-status/get-by-number?number=\(num)&date=\(dateStr)") else {
print("[B6] Invalid URL") print("[B6] Invalid URL")
@@ -517,56 +573,255 @@ actor AirlineLoadService {
// MARK: - Alaska Airlines // MARK: - Alaska Airlines
private func fetchAlaskaStatus(flightNumber: String, date: Date) async -> FlightLoad? { /// Static APIM key extracted from the Alaska mobile app, per
let num = stripAirlinePrefix(flightNumber) /// AIRLINE_INTEGRATION_GUIDE.md. Accepts `confirmationCode: null` on the
let dateStr = Self.dashDateFormatter.string(from: date) /// `/seats/waitlist` POST, so we get standby + upgrade lists without a PNR.
private static let alaskaAPIMKey = "de1d0ff837444468a5ea868945aab738"
guard let url = URL(string: "https://www.alaskaair.com/1/guestservices/customermobile/flights/status/AS/\(num)/\(dateStr)") else { private func fetchAlaskaLoad(
print("[AS] Invalid URL") flightNumber: String,
date: Date,
origin: String,
destination: String
) async -> FlightLoad? {
let num = stripAirlinePrefix(flightNumber)
let dateStr = dayString(from: date, originIATA: origin)
let upperOrigin = origin.uppercased()
let upperDestination = destination.uppercased()
print("[AS] Fetching Alaska load for AS\(num) \(upperOrigin)->\(upperDestination) on \(dateStr)")
// Run seatmap + waitlist in parallel; either may fail independently.
async let seatmap = fetchAlaskaSeatmap(
flightNumber: num,
origin: upperOrigin,
destination: upperDestination,
date: dateStr
)
async let waitlist = fetchAlaskaWaitlist(
flightNumber: num,
origin: upperOrigin,
date: dateStr
)
let cabinsFromMap = await seatmap
let waitlistResult = await waitlist
let cabinsFromWaitlist = waitlistResult.0
let standbyList = waitlistResult.1
let upgradeList = waitlistResult.2
// Prefer waitlist's `Authorized` cabin capacity (real FlightLoad numbers)
// over seatmap's `AvailableSeats` (count of open seats only).
let cabins = !cabinsFromWaitlist.isEmpty ? cabinsFromWaitlist : cabinsFromMap
if cabins.isEmpty && standbyList.isEmpty && upgradeList.isEmpty {
print("[AS] No data returned from seatmap or waitlist endpoints")
return nil return nil
} }
return FlightLoad(
airlineCode: "AS",
flightNumber: "AS\(num)",
cabins: cabins,
standbyList: standbyList,
upgradeList: upgradeList,
seatAvailability: []
)
}
/// GET /1/guestservices/customermobile/viewseatmap/seatmap
/// Returns per-seat status + `AvailableSeats` count per cabin section.
private func fetchAlaskaSeatmap(
flightNumber: String,
origin: String,
destination: String,
date: String
) async -> [CabinLoad] {
var components = URLComponents(string: "https://apis.alaskaair.com/1/guestservices/customermobile/viewseatmap/seatmap")
components?.queryItems = [
URLQueryItem(name: "flightnumber", value: flightNumber),
URLQueryItem(name: "departureairport", value: origin),
URLQueryItem(name: "arrivalairport", value: destination),
URLQueryItem(name: "departuredate", value: date)
]
guard let url = components?.url else {
print("[AS] seatmap: invalid URL")
return []
}
print("[AS] GET \(url)") print("[AS] GET \(url)")
do { do {
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.setValue(Self.alaskaAPIMKey, forHTTPHeaderField: "Ocp-Apim-Subscription-Key")
request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("application/json", forHTTPHeaderField: "Accept")
let (data, response) = try await session.data(for: request) let (data, response) = try await session.data(for: request)
let http = response as? HTTPURLResponse let http = response as? HTTPURLResponse
print("[AS] HTTP status: \(http?.statusCode ?? -1)") print("[AS] seatmap HTTP status: \(http?.statusCode ?? -1), \(data.count) bytes")
if let bodyStr = String(data: data, encoding: .utf8) { if let bodyStr = String(data: data, encoding: .utf8) {
print("[AS] Response: \(bodyStr.prefix(800))") print("[AS] seatmap body (first 1200): \(bodyStr.prefix(1200))")
} }
guard http?.statusCode == 200 else { return nil } guard http?.statusCode == 200 else { return [] }
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [] }
// Alaska flight status only seat data requires confirmation code // Per the guide, the response carries `AvailableSeats` per cabin section.
return FlightLoad( // The exact wrapper differs by route; we walk the top level looking for
airlineCode: "AS", // anything that has {AvailableSeats, ...} or a list of cabin sections.
flightNumber: "AS\(num)", var cabins: [CabinLoad] = []
cabins: [],
standbyList: [], // Common shapes: { Cabins: [{ Name, AvailableSeats, ... }] } or
upgradeList: [], // { Sections: [...] } or the cabin fields inline at root.
seatAvailability: [] if let cabinArr = (json["Cabins"] as? [[String: Any]])
) ?? (json["cabins"] as? [[String: Any]])
?? (json["Sections"] as? [[String: Any]]) {
for entry in cabinArr {
let name = (entry["Name"] as? String)
?? (entry["CabinName"] as? String)
?? (entry["Description"] as? String)
?? "Cabin"
let available = (entry["AvailableSeats"] as? Int)
?? (entry["availableSeats"] as? Int)
?? 0
// Capacity is best-effort; fall back to available so
// loadFactor stays sane when we only have open seats.
let capacity = (entry["TotalSeats"] as? Int)
?? (entry["Capacity"] as? Int)
?? available
let booked = max(0, capacity - available)
cabins.append(CabinLoad(
name: name,
capacity: capacity,
booked: booked,
revenueStandby: 0,
nonRevStandby: 0
))
}
}
print("[AS] seatmap parsed \(cabins.count) cabins")
return cabins
} catch { } catch {
print("[AS] Error: \(error)") print("[AS] seatmap error: \(error)")
return nil return []
} }
} }
/// POST /1/guestservices/customermobile/seats/waitlist
/// Returns StandbyList + UpgradeList with FlightLoad.Authorized (capacity)
/// and passenger names. `confirmationCode: null` is accepted.
private func fetchAlaskaWaitlist(
flightNumber: String,
origin: String,
date: String
) async -> ([CabinLoad], [StandbyPassenger], [StandbyPassenger]) {
guard let url = URL(string: "https://apis.alaskaair.com/1/guestservices/customermobile/seats/waitlist") else {
print("[AS] waitlist: invalid URL")
return ([], [], [])
}
let body: [String: Any?] = [
"marketedByAirlineCode": "AS",
"departureAirportCode": origin,
"departureLocalDate": date,
"flightNumber": flightNumber,
"confirmationCode": NSNull()
]
print("[AS] POST \(url) body flight=\(flightNumber) origin=\(origin) date=\(date)")
do {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue(Self.alaskaAPIMKey, forHTTPHeaderField: "Ocp-Apim-Subscription-Key")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.httpBody = try JSONSerialization.data(withJSONObject: body.compactMapValues { $0 as Any? })
let (data, response) = try await session.data(for: request)
let http = response as? HTTPURLResponse
print("[AS] waitlist HTTP status: \(http?.statusCode ?? -1), \(data.count) bytes")
if let bodyStr = String(data: data, encoding: .utf8) {
print("[AS] waitlist body (first 1500): \(bodyStr.prefix(1500))")
}
guard http?.statusCode == 200 else { return ([], [], []) }
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return ([], [], [])
}
let (mainCap, mainPax) = parseAlaskaWaitlistSection(json["StandbyList"], listLabel: "Standby")
let (upgradeCap, upgradePax) = parseAlaskaWaitlistSection(json["UpgradeList"], listLabel: "Upgrade")
var cabins: [CabinLoad] = []
if mainCap > 0 {
cabins.append(CabinLoad(
name: "Main",
capacity: mainCap,
booked: 0,
revenueStandby: 0,
nonRevStandby: 0
))
}
if upgradeCap > 0 {
cabins.append(CabinLoad(
name: "First",
capacity: upgradeCap,
booked: 0,
revenueStandby: 0,
nonRevStandby: 0
))
}
return (cabins, mainPax, upgradePax)
} catch {
print("[AS] waitlist error: \(error)")
return ([], [], [])
}
}
/// Parse one of the waitlist sections (StandbyList / UpgradeList).
/// Returns (cabinCapacity, passengers).
private func parseAlaskaWaitlistSection(_ raw: Any?, listLabel: String) -> (Int, [StandbyPassenger]) {
guard let section = raw as? [String: Any] else { return (0, []) }
let flightLoad = section["FlightLoad"] as? [String: Any] ?? [:]
let authorized = (flightLoad["Authorized"] as? Int) ?? 0
var passengers: [StandbyPassenger] = []
if let paxArr = section["Passengers"] as? [[String: Any]] {
for pax in paxArr {
let name = (pax["DisplayName"] as? String) ?? ""
let position = (pax["Position"] as? Int) ?? (passengers.count + 1)
let seat = pax["Seat"] as? String
let upgraded = (pax["UpgradedToPC"] as? Bool) ?? false
let cleared = upgraded || (seat != nil && !(seat?.isEmpty ?? true))
passengers.append(StandbyPassenger(
order: position,
displayName: name,
cleared: cleared,
seat: seat,
listName: listLabel
))
}
}
print("[AS] \(listLabel) section: authorized=\(authorized), passengers=\(passengers.count)")
return (authorized, passengers)
}
// MARK: - Emirates // MARK: - Emirates
private func fetchEmiratesStatus(flightNumber: String, date: Date) async -> FlightLoad? { private func fetchEmiratesStatus(flightNumber: String, date: Date, origin: String) async -> FlightLoad? {
let num = stripAirlinePrefix(flightNumber) let num = stripAirlinePrefix(flightNumber)
let dateStr = Self.dashDateFormatter.string(from: date) let dateStr = dayString(from: date, originIATA: origin)
// Pad flight number to 4 digits (Emirates uses 0-padded numbers like "0221") // Guide sample uses `flight=221` unpadded; padding was an unverified
let paddedNum = String(repeating: "0", count: max(0, 4 - num.count)) + num // guess and is now removed to match the documented call exactly.
guard let url = URL(string: "https://www.emirates.com/service/flight-status?departureDate=\(dateStr)&flight=\(num)") else {
guard let url = URL(string: "https://www.emirates.com/service/flight-status?departureDate=\(dateStr)&flight=\(paddedNum)") else {
print("[EK] Invalid URL") print("[EK] Invalid URL")
return nil return nil
} }
@@ -621,7 +876,7 @@ actor AirlineLoadService {
destination: String, destination: String,
departureTime: String? departureTime: String?
) async -> FlightLoad? { ) async -> FlightLoad? {
let dateStr = Self.dashDateFormatter.string(from: date) let dateStr = dayString(from: date, originIATA: origin)
let num = stripAirlinePrefix(flightNumber) let num = stripAirlinePrefix(flightNumber)
let upperOrigin = origin.uppercased() let upperOrigin = origin.uppercased()
let upperDestination = destination.uppercased() let upperDestination = destination.uppercased()
@@ -777,7 +1032,10 @@ actor AirlineLoadService {
return result.isEmpty ? trimmed : result return result.isEmpty ? trimmed : result
} }
/// "yyyy-MM-dd" formatter for United, American, Spirit /// "yyyy-MM-dd" formatter for United, American, Spirit.
/// NOTE: this is UTC-pinned and will cross the day boundary for users in
/// non-UTC timezones. Prefer `dayString(from:originIATA:)` which resolves
/// the departure airport's approximate local timezone.
private static let dashDateFormatter: DateFormatter = { private static let dashDateFormatter: DateFormatter = {
let f = DateFormatter() let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd" f.dateFormat = "yyyy-MM-dd"
@@ -786,6 +1044,50 @@ actor AirlineLoadService {
return f return f
}() }()
/// Format a `Date` as "yyyy-MM-dd" in the *departure airport's* local
/// timezone. Airlines (JSX especially) interpret the day string as the
/// calendar day at the departure airport not UTC, not the user's locale.
///
/// Timezone resolution strategy:
/// 1. Look up the airport's longitude in the bundled airports.json.
/// 2. Approximate the offset as `round(lng / 15)` hours (15° 1hr).
/// 3. Fall back to `TimeZone.current` if the airport isn't in the DB.
///
/// The 15°/hour approximation ignores political timezone boundaries and
/// DST, but it's correct to within an hour of the real offset plenty
/// precise for *which calendar day* the `Date` falls on.
private func dayString(from date: Date, originIATA: String) -> String {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = resolveTimeZone(forIATA: originIATA)
return f.string(from: date)
}
/// Same as above but for Korean Air's compact "yyyyMMdd" format.
private func compactDayString(from date: Date, originIATA: String) -> String {
let f = DateFormatter()
f.dateFormat = "yyyyMMdd"
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = resolveTimeZone(forIATA: originIATA)
return f.string(from: date)
}
private func resolveTimeZone(forIATA iata: String) -> TimeZone {
guard let db = airportDatabase,
let airport = db.airport(byIATA: iata.uppercased()) else {
print("[TZ] \(iata): airport not in DB, falling back to TimeZone.current (\(TimeZone.current.identifier))")
return .current
}
// Round lng/15 to nearest hour. Clamp to [-12, 14] (real-world range).
let rawHours = (airport.lng / 15.0).rounded()
let hours = max(-12, min(14, Int(rawHours)))
let seconds = hours * 3600
let tz = TimeZone(secondsFromGMT: seconds) ?? .current
print("[TZ] \(iata) (lng=\(airport.lng)) → offset=\(hours)h (\(tz.identifier))")
return tz
}
/// "yyyyMMdd" formatter for Korean Air /// "yyyyMMdd" formatter for Korean Air
private static let compactDateFormatter: DateFormatter = { private static let compactDateFormatter: DateFormatter = {
let f = DateFormatter() let f = DateFormatter()
File diff suppressed because it is too large Load Diff
+50 -80
View File
@@ -14,20 +14,25 @@ hardware against the live production site in April 2026.
2. You **cannot** call that endpoint from outside a loaded `jsx.com` page — 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 Akamai will reject the request with an HTTP/2 protocol error based on the
TLS fingerprint of whatever's making the call. TLS fingerprint of whatever's making the call.
3. You **can** call it from inside a loaded `jsx.com` page, using the 3. Real Chrome succeeds when you actually drive the full one-way flow:
browser's own `fetch()` with the anonymous token the SPA stashes in select `One Way`, fill origin and destination, pick the depart date,
`sessionStorage["navitaire.digital.token"]`. Same-origin + browser TLS + click `DONE`, then click `FIND FLIGHTS`. That fires
browser cookies means Akamai sees it as a normal user request. `POST /api/nsk/v4/availability/search/simple` and lands on
4. Driving the on-page UI to submit the search form is a dead end in `/booking/select`.
**WKWebView** (but works in Playwright) because of a subtle trusted-event 4. In WKWebView, the safest runtime strategy is layered: drive the same UI
issue described below. flow first, try the real `FIND FLIGHTS` button, then fall back to the
component's `search()` method, then finally to a direct in-page
`fetch()` if page state still does not materialize.
The working pattern is therefore: The working pattern is therefore:
Navigate browser → jsx.com (real page load) Navigate browser → jsx.com (real page load)
Wait for SPA bootstrap (station buttons exist, token call finishes) Wait for SPA bootstrap (station buttons exist, token call finishes)
Read token from sessionStorage Select One Way / route / date
POST /api/nsk/v4/availability/search/simple ← from inside the page Click DONE
Click FIND FLIGHTS
Wait for booking/select or Angular availability state
Fallback: component.search() or direct in-page POST if needed
Parse the response Parse the response
--- ---
@@ -281,70 +286,40 @@ won't surface any error to the DOM.
--- ---
## WKWebView vs Playwright — the trusted-events trap ## Browser validation and WKWebView strategy
This is the single most important finding from this session. The earlier "WKWebView cannot submit the form" theory was too strong. The
browser probe that only failed `POST /search/simple` had not actually driven
the same successful user flow Chrome uses.
### The symptom ### What is confirmed
In WKWebView, after driving the entire UI (one-way, origin, destination, - Real Chrome does succeed when the automation performs the whole flow:
day cell, DONE button), the depart-date input visually shows `"Sat, Apr 11"` `One Way` -> origin -> destination -> depart date -> `DONE` ->
and the Find Flights button is visually enabled. Clicking it fires the click `FIND FLIGHTS`.
handler (you can prove it with a capture listener), but no POST to - That path produces a live `POST /api/nsk/v4/availability/search/simple`
`/availability/search/simple` happens — ever. The only network call that with HTTP `200` and advances the page to `https://www.jsx.com/booking/select`.
fires after the click is the token refresh that was already scheduled. - The response contains per-flight availability counts that the app can map
back to `XE` flights.
### The cause ### What remains true in WKWebView
WKWebView's synthetic DOM events produced by Synthetic events can still be brittle. A visually-filled form is not enough;
`element.dispatchEvent(new MouseEvent('click', {...}))` have the page may still reject the search if the component's internal state is not
`event.isTrusted === false`. JSX's custom datepicker only commits its fully committed. Because of that, the iOS fetcher should not rely on exactly
day-cell selection into its Angular `FormControl` on a **trusted** user one trigger path.
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: ### Current iOS runtime strategy
```ts `Flights/Services/JSXWebViewFetcher.swift` step 17 now does this:
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 1. Prime the Angular search component with the target date.
returns early. No POST. Silent failure. 2. Click the real `FIND FLIGHTS` button and wait for availability state.
3. If that does not materialize data, call `component.search()`.
4. If that still fails, issue the direct in-page `fetch()` POST as the last fallback.
### Why Playwright doesn't hit this That keeps the app aligned with the proven Chrome behavior without betting the
entire integration on one fragile DOM event path.
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.
--- ---
@@ -357,8 +332,8 @@ JSX fronts `api.jsx.com` with Akamai Bot Manager. Observed behavior:
| Plain `curl`, `fetch` from Node, any external HTTP client | Blocked. Almost all endpoints return HTML challenge page or Akamai error. | | 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. | | 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. | | 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 on macOS / iOS | Same-origin traffic from a real loaded `jsx.com` page can work, but the most robust app strategy is still layered because DOM-driven submission can be sensitive to page state and timing. |
| 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. | | WKWebView with the current app flow | Try real `FIND FLIGHTS` first, then `component.search()`, then direct in-page `fetch()`. This is the runtime path the app now uses. |
Observations: Observations:
@@ -368,25 +343,20 @@ Observations:
--- ---
## Network interceptor pattern ## Network observation
If you want to capture JSX traffic programmatically (e.g. in WKWebView or For the app runtime, prefer passive observation over wrapping `fetch()` or
Playwright), override `window.fetch` and `XMLHttpRequest` to tee every `XMLHttpRequest`. The current iOS flow uses a `PerformanceObserver` to record
request/response to a probe object. Two things to track separately: `api.jsx.com` resource entries without touching the request pipeline.
1. **Initiated calls** — every URL passed to `fetch()` or That gives enough signal to answer:
`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 1. Did the page attempt `/availability/search/simple` at all?
in initiated calls) from "Angular fired a POST but the network rejected it" 2. Did the page advance to `/booking/select`?
(initiated but not completed, error flag set). This distinction was critical 3. Did Angular availability state appear after the search trigger?
for diagnosing the WKWebView trusted-events issue — without it, both failure
modes look like "no searchSimple response seen".
See the `window.__jsxProbe` setup in See the `window.__jsxProbe` setup in
`Flights/Services/JSXWebViewFetcher.swift` step 4 for a working `Flights/Services/JSXWebViewFetcher.swift` step 4 for the current
implementation. implementation.
--- ---
File diff suppressed because it is too large Load Diff
+291
View File
@@ -0,0 +1,291 @@
# StaffTraveler API
Extracted from `com.stafftraveler.webview` v3.12.0 (build 1880000346).
Source artifacts:
- `apps/com.stafftraveler.webview_3.12.0-1880000346_apkcombo.com.xapk`
- `/Users/m4mini/Desktop/code/airlines/extracted/stafftraveler/`
- Hermes disassembly: `extracted/stafftraveler/hermes_out/bundle.hasm`
## Architecture
React Native + Hermes bytecode. All business logic lives in `assets/index.android.bundle`. Native Android is a thin RN shell (`MainActivity`, `MainApplication`).
**Stack**
- **Firebase Auth** — user accounts, ID token issuance.
- **Firebase Remote Config** — runtime-fetched config (API base URL, Typesense host/key, feature flags, etc).
- **Firebase Firestore** — realtime store for flights, load requests, load reports, tips, user state.
- **Firebase App Check** — registered (`FirebaseAppCheckKtxRegistrar`) and Play Integrity is bundled (`com.google.android.play.core.integrity`) — but the `/v1/commands/*` fetch path does NOT attach `X-Firebase-AppCheck` (see `createFirebaseCommand` disassembly at `bundle.hasm` offset `0x017ed673`). App Check enforcement on the HTTP API is **not observed**. Firestore rules may still consult App Check.
- **Typesense** (`enhk2ji1vu6csxzrp-1.a1.typesense.net`) — flight search index. The API key is delivered via Remote Config, not hardcoded.
- **Custom "commands" HTTP API** — `POST https://api.stafftraveler.com/v1/commands/<commandName>` — all mutating/reading operations go through this, auth'd with a Firebase ID token.
## Firebase Project Config (from `res/values/strings.xml`)
| Key | Value |
|-----|-------|
| Project ID | `stafftraveler-prod` |
| Android App ID | `1:628258099825:android:5fb976ba6ad1bb05` |
| Sender ID | `628258099825` |
| Android API Key | `AIzaSyC2zG6ArnguzzdWsLYV1qjQznma0zl1Q0s` |
| Maps API Key | `AIzaSyD2yg_WuGtrAC_fyyEwvAQgycMoP3Xf9cM` |
| Storage Bucket | `stafftraveler-prod.appspot.com` |
| Realtime DB | `https://stafftraveler-prod.firebaseio.com` |
| Firestore | (default, project `stafftraveler-prod`) |
These are all public identifiers — Firebase security comes from rules + App Check, not from hiding them.
## Remote Config Keys
Fetched at startup; controls every external URL. Full shape (from `bundle.hasm` at line 140129):
| Key | Purpose |
|-----|---------|
| `api_commands_url` | Base for the commands API (production: `https://api.stafftraveler.com/v1/commands`) |
| `search_typesense_hostname` | Typesense host |
| `search_typesense_api_key` | Typesense search-only key |
| `image_cdn_hostname` | Aircraft/airline image CDN |
| `airline_logos_hostname` | Airline logo CDN |
| `google_places_api_key`, `_details_url`, `_search_url`, `_photo_url` | Places integration |
| `weather_service_url` (`api.weatherapi.com`), `weather_service_api_key` | Weather |
| `location_service_url` (`pro.ip-api.com`), `location_service_api_key` | Geo-IP |
| `google_web_client_id` | Google Sign-In |
| `loads_jackpot_enabled_airline_ids`, `onboarding_invite_reward`, `notice_*`, `promotion_*` | Feature flags / content |
To get the real values, call Firebase Remote Config REST with the project config above:
```
POST https://firebaseremoteconfig.googleapis.com/v1/projects/stafftraveler-prod/namespaces/firebase:fetch?key=AIzaSyC2zG6ArnguzzdWsLYV1qjQznma0zl1Q0s
```
## Auth
Standard Firebase Auth (email/password, Google, Apple, Facebook providers present in bundle strings). No custom auth flow.
To authenticate:
1. Sign in via Firebase Auth REST with the `stafftraveler-prod` API key above, or via the Firebase iOS SDK configured for the same project.
2. `getIdToken()` produces a JWT with ~1h TTL.
3. Use the token as `Authorization: Bearer <idToken>` on every commands-API call.
App logic in `bundle.hasm` (`createFirebaseCommand` generator, line 1827439) checks `expirationTime` and calls `getIdToken()` right before sending, so token refresh is transparent.
## Commands API
**Base**: `https://api.stafftraveler.com/v1/commands/user` (from Remote Config `api_commands_url` — confirmed from captured traffic, `2026-04-22`)
**Auth**: `Authorization: Bearer <firebase-id-token>`
**Method**: `POST`
**Headers**: `Content-Type: application/json`, `x-stafftraveler-client: mobile`
**Body**: JSON — shape is command-specific
**Response**: 200 OK with `{payload: {...}}` on success. Business errors raise `isError`/`isNetworkError` flags in the bundle.
Fetch options object literally constructed in the bundle (offset `0x017ed673`):
```js
{ method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + idToken }, body: JSON.stringify(payload) }
```
Full endpoint pattern: `POST https://api.stafftraveler.com/v1/commands/user/<commandName>`
### Confirmed Commands (built via `createFirebaseCommand(name)`)
Endpoint = `POST {api_commands_url}/<command>`
| Command | Purpose |
|---------|---------|
| `searchFlightsByRoute` | Flight search by origin+destination+date |
| `searchFlightsByCode` | Flight search by flight number + date |
| `createLoadsRequests` | Submit one or more load requests for selected flights (spends credits) |
| `deleteLoadsRequest` | Cancel a pending request |
| `reopenLoadsRequest` | Reopen an expired/cancelled request |
| `upgradeLoadsRequest` | Promote a request to Priority (uses extra credits) |
| `requestLockForLoadsRequest` / `unlockLoadsRequest` | Cooperative lock: while you're typing a load answer, others can't also submit |
| `submitLoadsReport` | Answer someone else's request (the reverse direction — you earn credits) |
| `reviseLoadsReport` | Edit your answer |
| `addComment` / `removeComment` / `reportComment` | Flight comments |
| `addAirlineDetails` | Provide airline employee credentials for verification |
| `addCreditsForPurchase` | IAP receipt validation → credits |
| `setAirlineStCodes` | Set user's airline ("ST code" = StaffTraveler internal airline code) |
Command names are stored in the bundle as plain string identifiers; the full inventory is in `/tmp/st_fn_names.txt` (extracted function names).
### `searchFlightsByRoute` — confirmed shape (captured 2026-04-22)
Request body:
```json
{
"originIata": "DFW",
"destinationIata": "LAS",
"dates": ["2026-04-22"],
"maxConnections": 0,
"allowNearbyDepartures": false,
"allowNearbyArrivals": false,
"payloadType": "passenger",
"includeMultipleCarriers": false
}
```
Field corrections from the bytecode guess: the key I thought was `stops` is actually `maxConnections`; `payloadType` takes the literal value `"passenger"` (not `"route"`); `dates` is an array of `YYYY-MM-DD` strings.
Response:
```json
{
"payload": {
"directFlights": [ /* array of flight objects */ ],
"connectingFlights": [],
"numberOfDiscardedFlights": 0,
"settingsFilteredCount": 0,
"messages": []
}
}
```
Each flight object has three IDs (`id`, `id_v2`, `id_v3` — the `id_v3` is human-readable like `AA_2998_DFW_2026_04_22`), airline info (`airlineId`, `airlineStCode`, `flightCode`, `flightNumber`), local/UTC times, equipment (`flightEquipmentId`, `flightEquipmentIata`), `durationMinutes`, `isCargoFlight`, `seatsTotal`, and `seatsByClass: {first, business, premiumEconomy, economy}`.
**Important:** `seatsByClass` is the **aircraft cabin configuration**, not current availability. Current load data comes from Firestore (see below), not this response.
See `api_docs/stafftraveler_captures/searchFlightsByRoute_DFW-LAS_*.json` for the full live capture.
### `searchFlightsByCode` — payload shape
From caller at `bundle.hasm:1948510` (Function #38848):
```json
{
"flightCode": "UA123",
"dates": ["<ISO date>", ...]
}
```
Returns flights matching the flight code on each listed date.
### `createLoadsRequests` — confirmed shape (captured 2026-04-22)
Request body is a **bare JSON array** of flight objects (no outer wrapper). Each element is the full flight object as returned by `searchFlightsByRoute`, with one added field: `isPriorityRequest: bool`.
```json
[
{
"id": "a23d6f8379f5109eddeb5bbf78bdccc1",
"id_v2": "0d9d0702b203d224ace3e6eb94531dde",
"id_v3": "AA_2178_DFW_2026_04_22",
"airlineId": "AA",
"flightNumber": 2178,
"flightCode": "AA2178",
"departureAirportId": "DFW",
"arrivalAirportId": "LAS",
"departureTimeLocal": "2026-04-22T16:45:00-05:00",
"departureTimeUtc": "2026-04-22T21:45:00Z",
"arrivalTimeLocal": "2026-04-22T17:46:00-07:00",
"arrivalTimeUtc": "2026-04-23T00:46:00Z",
"flightEquipmentId": "32Q",
"durationMinutes": 181,
"isCargoFlight": false,
"airlineStCode": "st_AAL",
"departureAirportIata": "DFW",
"arrivalAirportIata": "LAS",
"flightEquipmentIata": "32Q",
"seatsTotal": 196,
"seatsByClass": {"first": 0, "business": 20, "premiumEconomy": 0, "economy": 176},
"isPriorityRequest": false
}
]
```
Response:
```json
{
"payload": {
"numberOfRequests": 0,
"numberOfRequestsPaidFor": 0,
"numberOfRequestsPaidForPriority": 0,
"hasSufficientCredits": true,
"verifiedRequests": [
{
"flightId": "AA_2178_DFW_2026_04_22",
"isExisting": true,
"isDuplicateForUser": true,
"isRequestUpdateForUser": false,
"hasDeparted": false,
"isCancelled": false,
"isPriorityRequest": false,
"hasRecentLoads": false,
"scheduledFlight": { /* echoed full flight obj */ }
}
]
}
}
```
**Idempotent per (user, flight):** on duplicate submission, `numberOfRequests: 0`, `isDuplicateForUser: true`, **no credit charged**. This makes the command safe to replay as a "what's the current state of my request for flight X" probe — you just pay for the first request on any given flight.
**`hasRecentLoads`** is the flag the app uses to decide whether load data is already available. When true, the Firestore listener is expected to deliver load-report documents; when false, there's nothing to show until someone in the community answers.
See `api_docs/stafftraveler_captures/createLoadsRequests_AA2178_*.json` for the full live capture.
## Firestore Collections (realtime)
Once a request is created, load results stream back via Firestore listeners. Listener constructors in the bundle:
| Listener | What it subscribes to |
|----------|----------------------|
| `TrackedFlightsStoreListener` (`bundle.hasm:253969`) | The user's currently-tracked flights (including ones with pending requests) |
| `OpenRequestsStoreListener` (`bundle.hasm:443724`) | The user's open requests, filtered by `useUserAirlineStCodes()` |
| `ConnectingFlightsStoreListener` | Connecting-flight variants |
| `PinnedFlightsStoreListener` | User-pinned flights |
| `PriorityRequestCounterStoreListener` | Priority request quota counter |
| `OpenRequestsCountListener` | Open request count for UI badge |
| `DerivedLoadsReportsStoreListener` | Reports on flights the user is answering |
| `CreditsListener` / `UserSessionsListener` / `UserStoreListener` | Account state |
| `RemoteConfigListener` | Remote Config changes |
Exact collection paths are not plain strings in the bundle — they're built from user state (e.g. `users/{uid}/flightRequests/{id}` or `users/{uid}/trackedFlights/{id}`). To get them, run Firestore in debug (iOS SDK logs every collection path) or capture with Firebase Local Emulator proxy.
## Typesense (search infrastructure)
Not directly hit by the client for flight search — `searchFlightsByRoute` goes through the commands API, which internally queries Typesense. However, the bundle also contains a `typesense-instantsearch-adapter`, and strings like `searchTypesenseAndAdapt`, `searchTypesenseForFacetValuesAndAdapt`, `x-typesense-api-key` (`bundle.hasm:1995183`) suggest **direct Typesense queries are used for some facets** (probably airline filtering, autocomplete on airports/airlines).
Host: `enhk2ji1vu6csxzrp-1.a1.typesense.net`
Key: from Remote Config `search_typesense_api_key` (search-only, scoped, safe to embed).
## Integration plan for the iOS Flights app
The cleanest path — do not build a parallel HTTP client, just use Firebase directly:
1. **Embed the `stafftraveler-prod` Firebase config** in a second `FirebaseApp` instance (iOS supports multiple named apps). Use the API key + app ID + sender ID from above. Name it something like `StaffTravelerFirebaseApp` so it doesn't collide with Flights' own Firebase project.
2. **Sign in** with the user's StaffTraveler credentials using `Auth.auth(app: stApp).signIn(withEmail:password:)`. Store the refresh token in Keychain.
3. **Fetch Remote Config** once on first use to get `api_commands_url`, `search_typesense_api_key`, etc.
4. **Search a route**`POST https://api.stafftraveler.com/v1/commands/searchFlightsByRoute` with the payload shape documented above and `Authorization: Bearer <idToken>`.
5. **Request loads**`POST .../createLoadsRequests` with the flight IDs from step 4.
6. **Listen for answers**`Firestore.firestore(app: stApp).collection("users/\(uid)/flightRequests").document(requestId).addSnapshotListener { ... }` (exact path TBD — confirm via traffic capture).
### Firestore is the answer — schema captured (2026-04-22)
Loads and all real-time state live in Firestore, queried via the `Listen/channel` long-poll transport. Mobile iOS uses the gRPC variant (hard to mitm); the `app.stafftraveler.com` web client uses plain HTTP long-poll, which is fully inspectable. Playwright capture + URL-decoding gave us every `addTarget` body.
**Full schema and query recipes:** see `api_docs/stafftraveler_captures/firestore_schema.md` and the raw capture at `api_docs/stafftraveler_captures/firestore_listen_targets_and_queries.txt`.
**The one query that renders loads:**
```
collection: derivedLoadsReports
where: flightId == "{id_v3}" (e.g. "AA_2178_DFW_2026_04_22")
orderBy: createdAt DESC
```
Returns docs containing `openSeats.{first,business,premiumEconomy,eco}.count`, `staffListing.count`, `creatorName`, `createdAt`, `seatsAvailabilityScore`, etc. Example at `api_docs/stafftraveler_captures/derivedLoadsReports_AA2178_response.json`.
**Required REST headers** (earlier runQuery attempts were 403 because the API key wasn't attached):
```
Authorization: Bearer <firebase id token>
X-Firebase-GMPID: 1:628258099825:web:35b20eaab4d441894041d0
?key=AIzaSyD82Hiqx-jAbHF8faMGSveqLw8CdX8a-Uc (as URL query, web key)
```
### Remaining nuances
1. **Account eligibility**`addAirlineDetails` still required for the account to participate. Your existing account is verified (WN / `st_SWA`), so no blocker.
2. **Credits / access model**`createLoadsRequests` charges for *new* requests only. Idempotent per `(user, flight)``isDuplicateForUser: true` returns current state with no charge. **Firestore `derivedLoadsReports` reads are gated by an active `trackedFlights/{flightId}` doc**, which is only created by `createLoadsRequests`. So you must spend 1 credit per *new* flight to unlock load reads for it; after that, reads are free and unlimited. You cannot peek at other flights' loads without requesting.
3. **App Check** — not enforced on commands API or Firestore for this project (confirmed: web client makes all these requests with just ID token + API key).
### Integration for iOS Flights app (final)
- Secondary `FirebaseApp` configured against `stafftraveler-prod` with the web API key + GMPID above.
- `signInWithEmailAndPassword` → persist refresh token.
- `Firestore.firestore(app: stApp).collection("derivedLoadsReports").whereField("flightId", isEqualTo: flightIdV3).order(by: "createdAt", descending: true).addSnapshotListener { ... }` — realtime loads for any flight you know the id_v3 of.
- Plus `trackedFlights`, `airlinesBySt`, `airportsByIata`, `flightEquipment`, `conversations/{flightId}` subscriptions — full recipes in `firestore_schema.md`.
- Writes (request loads, submit report) keep using `POST https://api.stafftraveler.com/v1/commands/user/<cmd>`.
+108
View File
@@ -0,0 +1,108 @@
# StaffTraveler API captures
Real request/response pairs captured from the iOS app `com.stafftraveler.webview` v3.12.0 (build 1880000346) against `api.stafftraveler.com` on 2026-04-22. All captures are by user `3NNPesQMiMRNYnPmuQzh6w2YKyh1` (`stafftraveler@treymail.com`, WN/Southwest, airline ST code `st_SWA`).
## Auth
All command-API calls are authenticated with:
```
Authorization: Bearer <Firebase ID token>
```
Token issued by `securetoken.google.com/stafftraveler-prod`, `aud=stafftraveler-prod`, ~1h TTL. The captured tokens are already expired — do not try to reuse them. To get a new one, sign in via Firebase Auth REST against API key `AIzaSyC2zG6ArnguzzdWsLYV1qjQznma0zl1Q0s` (needs `X-Android-Package` + `X-Android-Cert` headers or iOS SDK).
Only standard headers are sent. No App Check. No SSL pinning. No device/client signing beyond the ID token.
## Endpoint path pattern
```
POST https://api.stafftraveler.com/v1/commands/user/<commandName>
```
Note the `user/` segment — the `api_commands_url` Remote Config value is `https://api.stafftraveler.com/v1/commands/user`, and the command name is appended with `/`. I initially documented this as `/v1/commands/<cmd>` which is wrong.
## Files
### `searchFlightsByRoute_DFW-LAS_*.json`
DFW→LAS direct-only search for 2026-04-22.
Request body:
```json
{
"originIata": "DFW",
"destinationIata": "LAS",
"dates": ["2026-04-22"],
"maxConnections": 0,
"allowNearbyDepartures": false,
"allowNearbyArrivals": false,
"payloadType": "passenger",
"includeMultipleCarriers": false
}
```
Response: `{payload: {directFlights: [...], connectingFlights: [...], numberOfDiscardedFlights, messages, settingsFilteredCount}}`.
Each flight carries three IDs (`id`, `id_v2`, `id_v3`), airline info, local/UTC times, equipment, and a `seatsByClass` **aircraft configuration** (NOT current availability — that's a different read path entirely).
Corrections vs the api_doc's initial guesses:
- `stops` → actually `maxConnections`
- `payloadType: "route"` → actually `"passenger"`
- No `origin`/`destination`/`date` fields — only the `*Iata` variants + `dates` array.
### `createLoadsRequests_AA2178_*.json`
Request loads for AA2178 DFW→LAS 2026-04-22.
**Request body** is a bare JSON array of flight objects (no outer wrapper). Each flight is the complete flight object from the search response with one added field: `isPriorityRequest: bool`.
**Response shape:**
```json
{
"payload": {
"numberOfRequests": 0,
"numberOfRequestsPaidFor": 0,
"numberOfRequestsPaidForPriority": 0,
"hasSufficientCredits": true,
"verifiedRequests": [
{
"flightId": "AA_2178_DFW_2026_04_22",
"isExisting": true,
"isDuplicateForUser": true,
"isRequestUpdateForUser": false,
"hasDeparted": false,
"isCancelled": false,
"isPriorityRequest": false,
"hasRecentLoads": false,
"scheduledFlight": { /* full flight obj */ }
}
]
}
}
```
### Dedup behavior (important)
On this capture, `numberOfRequests: 0` and `isDuplicateForUser: true` — the server recognized this user had already requested this flight and **did NOT charge a credit**. Replaying the same `createLoadsRequests` payload is safe (idempotent).
This means:
- `numberOfRequests` is the count of *newly created* requests in this call.
- `isDuplicateForUser` tells you whether the flight already had an open request for this user.
- **No credit is spent on duplicates.** Useful for polling the current request state without cost.
### `hasRecentLoads: false` — where loads actually come from
`hasRecentLoads` is the flag the app uses to decide whether load data is available for display. It's `false` here because nobody has submitted loads for AA2178 yet. The actual load data (when `hasRecentLoads: true`) does NOT come through the commands API — it arrives via a Firestore realtime listener on a doc that the user's account has permission to read.
To find the exact Firestore path when a flight has loads, one more capture is needed: tap a flight that already has loads populated, and grab the `firestore.googleapis.com/.../Listen/channel` traffic. That will show the watched doc path.
## Known error shapes
Not captured yet:
- What happens when you send an invalid flight ID
- What `createLoadsRequests` returns when credits are insufficient (`hasSufficientCredits: false`)
- What `numberOfRequests > 0` responses look like (fresh request, credit actually spent)
## Replay safety
To replay any of these:
1. Grab a fresh token from the app or by re-signing-in.
2. Swap it into the curl.
3. For `createLoadsRequests`, same payload = dedup-safe. Different payload = may charge credits.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,31 @@
[
{
"id": "a23d6f8379f5109eddeb5bbf78bdccc1",
"id_v2": "0d9d0702b203d224ace3e6eb94531dde",
"id_v3": "AA_2178_DFW_2026_04_22",
"airlineId": "AA",
"flightNumber": 2178,
"flightCode": "AA2178",
"departureAirportId": "DFW",
"arrivalAirportId": "LAS",
"departureTimeLocal": "2026-04-22T16:45:00-05:00",
"departureTimeUtc": "2026-04-22T21:45:00Z",
"arrivalTimeLocal": "2026-04-22T17:46:00-07:00",
"arrivalTimeUtc": "2026-04-23T00:46:00Z",
"flightEquipmentId": "32Q",
"durationMinutes": 181,
"isCargoFlight": false,
"airlineStCode": "st_AAL",
"departureAirportIata": "DFW",
"arrivalAirportIata": "LAS",
"flightEquipmentIata": "32Q",
"seatsTotal": 196,
"seatsByClass": {
"first": 0,
"business": 20,
"premiumEconomy": 0,
"economy": 176
},
"isPriorityRequest": false
}
]
@@ -0,0 +1 @@
{"payload":{"numberOfRequests":0,"verifiedRequests":[{"flightId":"AA_2178_DFW_2026_04_22","isExisting":true,"isDuplicateForUser":true,"isRequestUpdateForUser":false,"hasDeparted":false,"isCancelled":false,"isPriorityRequest":false,"hasRecentLoads":false,"scheduledFlight":{"id":"a23d6f8379f5109eddeb5bbf78bdccc1","id_v2":"0d9d0702b203d224ace3e6eb94531dde","id_v3":"AA_2178_DFW_2026_04_22","airlineId":"AA","flightCode":"AA2178","flightNumber":2178,"departureAirportId":"DFW","arrivalAirportId":"LAS","departureTimeLocal":"2026-04-22T16:45:00-05:00","arrivalTimeLocal":"2026-04-22T17:46:00-07:00","departureTimeUtc":"2026-04-22T21:45:00Z","arrivalTimeUtc":"2026-04-23T00:46:00Z","flightEquipmentId":"32Q","durationMinutes":181,"isCargoFlight":false,"isPriorityRequest":false,"airlineStCode":"st_AAL","departureAirportIata":"DFW","arrivalAirportIata":"LAS","flightEquipmentIata":"32Q","seatsTotal":196,"seatsByClass":{"first":0,"business":20,"premiumEconomy":0,"economy":176}}}],"hasSufficientCredits":true,"numberOfRequestsPaidFor":0,"numberOfRequestsPaidForPriority":0}}
@@ -0,0 +1,12 @@
HTTP/2 200
access-control-allow-credentials: true
cache-control: no-store
content-type: application/json
vary: Origin
x-cloud-trace-context: 7f1fda60d8d56f8106053d5e7df58556
date: Wed, 22 Apr 2026 18:40:54 GMT
server: Google Frontend
content-length: 1070
via: 1.1 google
alt-svc: h3=":443"; ma=2592000,h3-29=":443"; ma=2592000
@@ -0,0 +1,16 @@
{
"structuredQuery": {
"from": [{"collectionId": "derivedLoadsReports"}],
"where": {
"fieldFilter": {
"field": {"fieldPath": "flightId"},
"op": "EQUAL",
"value": {"stringValue": "AA_2178_DFW_2026_04_22"}
}
},
"orderBy": [
{"field": {"fieldPath": "createdAt"}, "direction": "DESCENDING"},
{"field": {"fieldPath": "__name__"}, "direction": "DESCENDING"}
]
}
}
@@ -0,0 +1,92 @@
[{
"document": {
"name": "projects/stafftraveler-prod/databases/(default)/documents/derivedLoadsReports/xskQc2a2oAVAwZk3Rfhg",
"fields": {
"flightId": {
"stringValue": "AA_2178_DFW_2026_04_22"
},
"openSeats": {
"mapValue": {
"fields": {
"first": {
"mapValue": {
"fields": {
"count": {
"integerValue": "3"
}
}
}
},
"eco": {
"mapValue": {
"fields": {
"count": {
"integerValue": "9"
}
}
}
}
}
}
},
"staffListing": {
"mapValue": {
"fields": {
"type": {
"stringValue": "available"
},
"count": {
"integerValue": "6"
},
"countByClass": {
"mapValue": {}
}
}
}
},
"responseTimeSeconds": {
"integerValue": "196"
},
"numberOfCreditsRewarded": {
"integerValue": "1"
},
"isFlagged": {
"booleanValue": false
},
"seatsAvailabilityScore": {
"doubleValue": 0.79
},
"hasClosedRequest": {
"booleanValue": true
},
"isJackpotHit": {
"booleanValue": false
},
"isPriorityRequest": {
"booleanValue": false
},
"expireAt": {
"timestampValue": "2026-07-21T21:45:00Z"
},
"createdAt": {
"timestampValue": "2026-04-22T18:42:51.313Z"
},
"creatorName": {
"stringValue": "M"
},
"creatorImageUrl": {
"stringValue": "https://images.stafftraveler.com/avatars/happysuitcase.png"
},
"creatorImagePath": {
"stringValue": "avatars/happysuitcase.png"
},
"isFlaggedConfirmed": {
"booleanValue": false
}
},
"createTime": "2026-04-22T18:42:51.422202Z",
"updateTime": "2026-04-22T18:42:51.530333Z"
},
"readTime": "2026-04-22T19:11:47.260445Z"
}
]
@@ -0,0 +1,90 @@
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&RID=80724&CVER=22&X-HTTP-Session-Id=gsessionid&zx=rtxwctgfqxp6&t=1 => [200]
Request body: headers=X-Goog-Api-Client%3Agl-js%2F%20fire%2F12.12.0%0D%0AContent-Type%3Atext%2Fplain%0D%0AX-Firebase-GMPID%3A1%3A628258099825%3Aweb%3A35b20eaab4d441894041d0%0D%0Ax-goog-api-key%3AAIzaSyD82Hiqx-jAbHF8faMGSveqLw8CdX8a-Uc%0D%0A&count=1&ofs=0&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22addTarget%22%3A%7B%22documents%22%3A%7B%22documents%22%3A%5B%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%2Fdocuments%2F__system%2Fmaintenance%22%5D%7D%2C%22targetId%22%3A2%7D%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&RID=9158&CVER=22&X-HTTP-Session-Id=gsessionid&zx=f83g0aueufis&t=1 => [200]
Request body: headers=X-Goog-Api-Client%3Agl-js%2F%20fire%2F12.12.0%0D%0AContent-Type%3Atext%2Fplain%0D%0AX-Firebase-GMPID%3A1%3A628258099825%3Aweb%3A35b20eaab4d441894041d0%0D%0AAuthorization%3ABearer%20eyJhbGciOiJSUzI1NiIsImtpZCI6IjNiMDk1NzQ3YmY4MzMxZWE0YWQ1M2YzNzBjNjMyNjAxNzliMGQyM2EiLCJ0eXAiOiJKV1QifQ.eyJpc19yZWdpc3RlcmVkIjp0cnVlLCJpc3MiOiJodHRwczovL3NlY3VyZXRva2VuLmdvb2dsZS5jb20vc3RhZmZ0cmF2ZWxlci1wcm9kIiwiYXVkIjoic3RhZmZ0cmF2ZWxlci1wcm9kIiwiYXV0aF90aW1lIjoxNzc2ODg0OTM2LCJ1c2VyX2lkIjoiM05OUGVzUU1pTVJOWW5QbXVRemg2dzJZS3loMSIsInN1YiI6IjNOTlBlc1FNaU1STlluUG11UXpoNncyWUt5aDEiLCJpYXQiOjE3NzY4ODQ5MzYsImV4cCI6MTc3Njg4ODUzNiwiZW1haWwiOiJzdGFmZnRyYXZlbGVyQHRyZXltYWlsLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwiZmlyZWJhc2UiOnsiaWRlbnRpdGllcyI6eyJlbWFpbCI6WyJzdGFmZnRyYXZlbGVyQHRyZXltYWlsLmNvbSJdfSwic2lnbl9pbl9wcm92aWRlciI6InBhc3N3b3JkIn19.Oi2cpketCmgJ7ND8R8SY4FNdeySoWOF2Bgzwptb1qopDDa4eEvJrXutcvrIP408MhOedXOVdSSB4J4gLZ3w3nZA939N-3DHj-rREpraF2XtuabOnragy2e9FoSSWJsB1chyvCidBh1b-b53AzdnFV3kspwtr5C7xoxSZOf32p5mwIENdFtBZg30wjRd3ZdNIbSiVbUouUcQIRiIdPdwtYG8wfAYztonWwW4K8hK4hN5LK4v6PitFOpC70Baoh-4LpfCEPGrk5zxrbPMOc95ydgWCL8Av6eFRiHJgMd4juUvzOwOOJ_77z7QCSRLwpu5XKc9Em2D5l0_UiEyn79SQfg%0D%0Ax-goog-api-key%3AAIzaSyD82Hiqx-jAbHF8faMGSveqLw8CdX8a-Uc%0D%0A&count=1&ofs=0&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22addTarget%22%3A%7B%22documents%22%3A%7B%22documents%22%3A%5B%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%2Fdocuments%2F__system%2Fmaintenance%22%5D%7D%2C%22targetId%22%3A2%2C%22resumeToken%22%3A%22CgkIh8rNkpSClAM%3D%22%2C%22expectedCount%22%3A1%7D%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9159&AID=4&zx=d2hrtufyduql&t=1 => [200]
Request body: count=1&ofs=1&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22addTarget%22%3A%7B%22documents%22%3A%7B%22documents%22%3A%5B%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%2Fdocuments%2Fusers%2F3NNPesQMiMRNYnPmuQzh6w2YKyh1%22%5D%7D%2C%22targetId%22%3A4%7D%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9160&AID=8&zx=tobs2rmzgvt7&t=1 => [200]
Request body: count=1&ofs=2&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22removeTarget%22%3A4%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9161&AID=8&zx=gv4pfepp0iut&t=1 => [200]
Request body: count=1&ofs=3&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22addTarget%22%3A%7B%22documents%22%3A%7B%22documents%22%3A%5B%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%2Fdocuments%2Fusers%2F3NNPesQMiMRNYnPmuQzh6w2YKyh1%22%5D%7D%2C%22targetId%22%3A4%2C%22resumeToken%22%3A%22CgkIoKrGm5SClAM%3D%22%2C%22expectedCount%22%3A1%7D%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9162&AID=8&zx=d0m0hj89m00m&t=1 => [200]
Request body: count=1&ofs=4&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22addTarget%22%3A%7B%22documents%22%3A%7B%22documents%22%3A%5B%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%2Fdocuments%2FuserHints%2F3NNPesQMiMRNYnPmuQzh6w2YKyh1%22%5D%7D%2C%22targetId%22%3A6%7D%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9163&AID=8&zx=ir7thnhgqtrz&t=1 => [200]
Request body: count=1&ofs=5&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22addTarget%22%3A%7B%22documents%22%3A%7B%22documents%22%3A%5B%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%2Fdocuments%2FuserRequestCounters%2F3NNPesQMiMRNYnPmuQzh6w2YKyh1%22%5D%7D%2C%22targetId%22%3A8%7D%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9164&AID=8&zx=w1dq4sb4jwq5&t=1 => [200]
Request body: count=1&ofs=6&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22addTarget%22%3A%7B%22documents%22%3A%7B%22documents%22%3A%5B%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%2Fdocuments%2FuserFlightSearchHistory%2F3NNPesQMiMRNYnPmuQzh6w2YKyh1%22%5D%7D%2C%22targetId%22%3A10%7D%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9165&AID=8&zx=ecl2ritbm1hb&t=1 => [200]
Request body: count=1&ofs=7&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22addTarget%22%3A%7B%22documents%22%3A%7B%22documents%22%3A%5B%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%2Fdocuments%2FuserMetrics%2F3NNPesQMiMRNYnPmuQzh6w2YKyh1%22%5D%7D%2C%22targetId%22%3A12%7D%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9166&AID=8&zx=wvohx872im0n&t=1 => [200]
Request body: count=1&ofs=8&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22addTarget%22%3A%7B%22query%22%3A%7B%22structuredQuery%22%3A%7B%22from%22%3A%5B%7B%22collectionId%22%3A%22autoRequestRecords%22%7D%5D%2C%22where%22%3A%7B%22compositeFilter%22%3A%7B%22op%22%3A%22AND%22%2C%22filters%22%3A%5B%7B%22fieldFilter%22%3A%7B%22field%22%3A%7B%22fieldPath%22%3A%22subscribedUserIds%22%7D%2C%22op%22%3A%22ARRAY_CONTAINS%22%2C%22value%22%3A%7B%22stringValue%22%3A%223NNPesQMiMRNYnPmuQzh6w2YKyh1%22%7D%7D%7D%2C%7B%22fieldFilter%22%3A%7B%22field%22%3A%7B%22fieldPath%22%3A%22isActive%22%7D%2C%22op%22%3A%22EQUAL%22%2C%22value%22%3A%7B%22booleanValue%22%3Atrue%7D%7D%7D%5D%7D%7D%2C%22orderBy%22%3A%5B%7B%22field%22%3A%7B%22fieldPath%22%3A%22__name__%22%7D%2C%22direction%22%3A%22ASCENDING%22%7D%5D%7D%2C%22parent%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%2Fdocuments%22%7D%2C%22targetId%22%3A14%7D%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9167&AID=8&zx=vnoas3e35db&t=1 => [200]
Request body: count=1&ofs=9&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22addTarget%22%3A%7B%22query%22%3A%7B%22structuredQuery%22%3A%7B%22from%22%3A%5B%7B%22collectionId%22%3A%22pinnedFlights%22%7D%5D%2C%22orderBy%22%3A%5B%7B%22field%22%3A%7B%22fieldPath%22%3A%22__name__%22%7D%2C%22direction%22%3A%22ASCENDING%22%7D%5D%7D%2C%22parent%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%2Fdocuments%2Fusers%2F3NNPesQMiMRNYnPmuQzh6w2YKyh1%22%7D%2C%22targetId%22%3A16%7D%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9168&AID=8&zx=laziejjz5fkl&t=1 => [200]
Request body: count=1&ofs=10&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22addTarget%22%3A%7B%22documents%22%3A%7B%22documents%22%3A%5B%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%2Fdocuments%2FairportsByIata%2FDAL%22%5D%7D%2C%22targetId%22%3A18%7D%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9169&AID=9&zx=nicmm3gy44sg&t=1 => [200]
Request body: count=1&ofs=11&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22addTarget%22%3A%7B%22documents%22%3A%7B%22documents%22%3A%5B%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%2Fdocuments%2FairportsByIata%2FDFW%22%5D%7D%2C%22targetId%22%3A20%7D%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9170&AID=9&zx=xxcf384tpe8r&t=1 => [200]
Request body: count=1&ofs=12&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22addTarget%22%3A%7B%22documents%22%3A%7B%22documents%22%3A%5B%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%2Fdocuments%2FairportsByIata%2FLAS%22%5D%7D%2C%22targetId%22%3A22%7D%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9171&AID=45&zx=zaws1snjd3dz&t=1 => [200]
Request body: count=1&ofs=13&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22removeTarget%22%3A18%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9172&AID=45&zx=foe8j91xoirj&t=1 => [200]
Request body: count=1&ofs=14&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22removeTarget%22%3A20%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9173&AID=45&zx=ytkmsor9yf6y&t=1 => [200]
Request body: count=1&ofs=15&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22removeTarget%22%3A22%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9174&AID=45&zx=z4vurp4vvm9d&t=1 => [200]
Request body: count=1&ofs=16&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22addTarget%22%3A%7B%22documents%22%3A%7B%22documents%22%3A%5B%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%2Fdocuments%2FuserPriorityRequestCounters%2F3NNPesQMiMRNYnPmuQzh6w2YKyh1%22%5D%7D%2C%22targetId%22%3A24%7D%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9175&AID=45&zx=m5cg66e9m500&t=1 => [200]
Request body: count=1&ofs=17&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22addTarget%22%3A%7B%22documents%22%3A%7B%22documents%22%3A%5B%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%2Fdocuments%2FuserCredits%2F3NNPesQMiMRNYnPmuQzh6w2YKyh1%22%5D%7D%2C%22targetId%22%3A26%7D%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9176&AID=45&zx=tdpwb8n7t6n5&t=1 => [200]
Request body: count=1&ofs=18&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22addTarget%22%3A%7B%22query%22%3A%7B%22structuredQuery%22%3A%7B%22from%22%3A%5B%7B%22collectionId%22%3A%22trackedFlights%22%7D%5D%2C%22where%22%3A%7B%22compositeFilter%22%3A%7B%22op%22%3A%22AND%22%2C%22filters%22%3A%5B%7B%22fieldFilter%22%3A%7B%22field%22%3A%7B%22fieldPath%22%3A%22status%22%7D%2C%22op%22%3A%22EQUAL%22%2C%22value%22%3A%7B%22stringValue%22%3A%22open%22%7D%7D%7D%2C%7B%22fieldFilter%22%3A%7B%22field%22%3A%7B%22fieldPath%22%3A%22hasDeparted%22%7D%2C%22op%22%3A%22EQUAL%22%2C%22value%22%3A%7B%22booleanValue%22%3Afalse%7D%7D%7D%2C%7B%22fieldFilter%22%3A%7B%22field%22%3A%7B%22fieldPath%22%3A%22scheduledFlight.airlineStCode%22%7D%2C%22op%22%3A%22EQUAL%22%2C%22value%22%3A%7B%22stringValue%22%3A%22st_SWA%22%7D%7D%7D%5D%7D%7D%2C%22orderBy%22%3A%5B%7B%22field%22%3A%7B%22fieldPath%22%3A%22priority%22%7D%2C%22direction%22%3A%22DESCENDING%22%7D%2C%7B%22field%22%3A%7B%22fieldPath%22%3A%22scheduledFlight.departureTimeUtc%22%7D%2C%22direction%22%3A%22ASCENDING%22%7D%2C%7B%22field%22%3A%7B%22fieldPath%22%3A%22__name__%22%7D%2C%22direction%22%3A%22ASCENDING%22%7D%5D%2C%22limit%22%3A25%7D%2C%22parent%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%2Fdocuments%22%7D%2C%22targetId%22%3A28%7D%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9177&AID=45&zx=vjcqutd14qa4&t=1 => [200]
Request body: count=1&ofs=19&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22addTarget%22%3A%7B%22query%22%3A%7B%22structuredQuery%22%3A%7B%22from%22%3A%5B%7B%22collectionId%22%3A%22trackedFlights%22%7D%5D%2C%22where%22%3A%7B%22compositeFilter%22%3A%7B%22op%22%3A%22AND%22%2C%22filters%22%3A%5B%7B%22fieldFilter%22%3A%7B%22field%22%3A%7B%22fieldPath%22%3A%22status%22%7D%2C%22op%22%3A%22EQUAL%22%2C%22value%22%3A%7B%22stringValue%22%3A%22open%22%7D%7D%7D%2C%7B%22fieldFilter%22%3A%7B%22field%22%3A%7B%22fieldPath%22%3A%22hasDeparted%22%7D%2C%22op%22%3A%22EQUAL%22%2C%22value%22%3A%7B%22booleanValue%22%3Afalse%7D%7D%7D%2C%7B%22fieldFilter%22%3A%7B%22field%22%3A%7B%22fieldPath%22%3A%22isPriorityRequest%22%7D%2C%22op%22%3A%22EQUAL%22%2C%22value%22%3A%7B%22booleanValue%22%3Atrue%7D%7D%7D%2C%7B%22fieldFilter%22%3A%7B%22field%22%3A%7B%22fieldPath%22%3A%22scheduledFlight.airlineStCode%22%7D%2C%22op%22%3A%22IN%22%2C%22value%22%3A%7B%22arrayValue%22%3A%7B%22values%22%3A%5B%7B%22stringValue%22%3A%22st_SWA%22%7D%5D%7D%7D%7D%7D%5D%7D%7D%2C%22orderBy%22%3A%5B%7B%22field%22%3A%7B%22fieldPath%22%3A%22__name__%22%7D%2C%22direction%22%3A%22ASCENDING%22%7D%5D%2C%22limit%22%3A25%7D%2C%22parent%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%2Fdocuments%22%7D%2C%22targetId%22%3A30%7D%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9178&AID=45&zx=kyar8tjvbzg0&t=1 => [200]
Request body: count=1&ofs=20&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22addTarget%22%3A%7B%22documents%22%3A%7B%22documents%22%3A%5B%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%2Fdocuments%2FuserAchievements%2F3NNPesQMiMRNYnPmuQzh6w2YKyh1%22%5D%7D%2C%22targetId%22%3A32%7D%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9179&AID=45&zx=xf2m25yhj5d&t=1 => [200]
Request body: count=1&ofs=21&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22addTarget%22%3A%7B%22documents%22%3A%7B%22documents%22%3A%5B%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%2Fdocuments%2FairportsByIata%2FIAH%22%5D%7D%2C%22targetId%22%3A34%7D%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9180&AID=45&zx=8fhokbce9hm7&t=1 => [200]
Request body: count=1&ofs=22&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22addTarget%22%3A%7B%22documents%22%3A%7B%22documents%22%3A%5B%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%2Fdocuments%2FairportsByIata%2FCLL%22%5D%7D%2C%22targetId%22%3A36%7D%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9181&AID=45&zx=41ahzajvy06l&t=1 => [200]
Request body: count=6&ofs=23&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22addTarget%22%3A%7B%22documents%22%3A%7B%22documents%22%3A%5B%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%2Fdocuments%2FairportsByIata%2FSJU%22%5D%7D%2C%22targetId%22%3A38%7D%7D&req1___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22addTarget%22%3A%7B%22documents%22%3A%7B%22documents%22%3A%5B%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%2Fdocuments%2FairportsByIata%2FBOS%22%5D%7D%2C%22targetId%22%3A40%7D%7D&req2___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22addTarget%22%3A%7B%22documents%22%3A%7B%22documents%22%3A%5B%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%2Fdocuments%2FairportsByIata%2FCUN%22%5D%7D%2C%22targetId%22%3A42%7D%7D&req3___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22addTarget%22%3A%7B%22documents%22%3A%7B%22documents%22%3A%5B%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%2Fdocuments%2FairportsByIata%2FSYD%22%5D%7D%2C%22targetId%22%3A44%7D%7D&req4___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22addTarget%22%3A%7B%22documents%22%3A%7B%22documents%22%3A%5B%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%2Fdocuments%2FairportsByIata%2FALH%22%5D%7D%2C%22targetId%22%3A46%7D%7D&req5___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22addTarget%22%3A%7B%22documents%22%3A%7B%22documents%22%3A%5B%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%2Fdocuments%2FairportsByIata%2FNTL%22%5D%7D%2C%22targetId%22%3A48%7D%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9182&AID=93&zx=7u11wjuyzy3c&t=1 => [200]
Request body: count=1&ofs=29&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22addTarget%22%3A%7B%22query%22%3A%7B%22structuredQuery%22%3A%7B%22from%22%3A%5B%7B%22collectionId%22%3A%22trackedFlights%22%7D%5D%2C%22where%22%3A%7B%22compositeFilter%22%3A%7B%22op%22%3A%22AND%22%2C%22filters%22%3A%5B%7B%22fieldFilter%22%3A%7B%22field%22%3A%7B%22fieldPath%22%3A%22subscribedUsers.%603NNPesQMiMRNYnPmuQzh6w2YKyh1%60%22%7D%2C%22op%22%3A%22EQUAL%22%2C%22value%22%3A%7B%22booleanValue%22%3Atrue%7D%7D%7D%2C%7B%22fieldFilter%22%3A%7B%22field%22%3A%7B%22fieldPath%22%3A%22isDeleted%22%7D%2C%22op%22%3A%22EQUAL%22%2C%22value%22%3A%7B%22booleanValue%22%3Afalse%7D%7D%7D%5D%7D%7D%2C%22orderBy%22%3A%5B%7B%22field%22%3A%7B%22fieldPath%22%3A%22__name__%22%7D%2C%22direction%22%3A%22ASCENDING%22%7D%5D%7D%2C%22parent%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%2Fdocuments%22%7D%2C%22targetId%22%3A50%7D%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9183&AID=93&zx=16mwafi45zsw&t=1 => [200]
Request body: count=1&ofs=30&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22addTarget%22%3A%7B%22query%22%3A%7B%22structuredQuery%22%3A%7B%22from%22%3A%5B%7B%22collectionId%22%3A%22connectingFlights%22%7D%5D%2C%22orderBy%22%3A%5B%7B%22field%22%3A%7B%22fieldPath%22%3A%22__name__%22%7D%2C%22direction%22%3A%22ASCENDING%22%7D%5D%2C%22limit%22%3A500%7D%2C%22parent%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%2Fdocuments%2Fusers%2F3NNPesQMiMRNYnPmuQzh6w2YKyh1%22%7D%2C%22targetId%22%3A52%7D%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9184&AID=104&zx=lhcjya6q73dp&t=1 => [200]
Request body: count=1&ofs=31&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22removeTarget%22%3A34%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9185&AID=104&zx=6qh74wz9it19&t=1 => [200]
Request body: count=1&ofs=32&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22removeTarget%22%3A36%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9186&AID=104&zx=66tex5sth8cf&t=1 => [200]
Request body: count=1&ofs=33&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22removeTarget%22%3A38%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9187&AID=104&zx=8g9i20onobi5&t=1 => [200]
Request body: count=1&ofs=34&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22removeTarget%22%3A40%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9188&AID=104&zx=flmjlaq6s76r&t=1 => [200]
Request body: count=1&ofs=35&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22removeTarget%22%3A42%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9189&AID=104&zx=x8vcama6wdcw&t=1 => [200]
Request body: count=1&ofs=36&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22removeTarget%22%3A44%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9190&AID=104&zx=cq90glstafxi&t=1 => [200]
Request body: count=1&ofs=37&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22removeTarget%22%3A46%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9191&AID=104&zx=iidxjkascr6p&t=1 => [200]
Request body: count=1&ofs=38&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22removeTarget%22%3A48%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9192&AID=104&zx=ib3004hvhsyb&t=1 => [200]
Request body: count=1&ofs=39&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22addTarget%22%3A%7B%22query%22%3A%7B%22structuredQuery%22%3A%7B%22from%22%3A%5B%7B%22collectionId%22%3A%22derivedLoadsReports%22%7D%5D%2C%22where%22%3A%7B%22fieldFilter%22%3A%7B%22field%22%3A%7B%22fieldPath%22%3A%22flightId%22%7D%2C%22op%22%3A%22EQUAL%22%2C%22value%22%3A%7B%22stringValue%22%3A%22AA_2178_DFW_2026_04_22%22%7D%7D%7D%2C%22orderBy%22%3A%5B%7B%22field%22%3A%7B%22fieldPath%22%3A%22createdAt%22%7D%2C%22direction%22%3A%22DESCENDING%22%7D%2C%7B%22field%22%3A%7B%22fieldPath%22%3A%22__name__%22%7D%2C%22direction%22%3A%22DESCENDING%22%7D%5D%7D%2C%22parent%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%2Fdocuments%22%7D%2C%22targetId%22%3A54%7D%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9193&AID=104&zx=vdy4t3cplgip&t=1 => [200]
Request body: count=1&ofs=40&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22addTarget%22%3A%7B%22documents%22%3A%7B%22documents%22%3A%5B%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%2Fdocuments%2FairlinesBySt%2Fst_AAL%22%5D%7D%2C%22targetId%22%3A56%7D%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9194&AID=106&zx=vm7g1bolx517&t=1 => [200]
Request body: count=5&ofs=41&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22addTarget%22%3A%7B%22documents%22%3A%7B%22documents%22%3A%5B%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%2Fdocuments%2FflightEquipment%2F32Q%22%5D%7D%2C%22targetId%22%3A58%7D%7D&req1___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22addTarget%22%3A%7B%22documents%22%3A%7B%22documents%22%3A%5B%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%2Fdocuments%2FairlineNotesBySt%2Fst_AAL%22%5D%7D%2C%22targetId%22%3A60%7D%7D&req2___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22addTarget%22%3A%7B%22documents%22%3A%7B%22documents%22%3A%5B%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%2Fdocuments%2Fconversations%2FAA_2178_DFW_2026_04_22%22%5D%7D%2C%22targetId%22%3A62%7D%7D&req3___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22addTarget%22%3A%7B%22query%22%3A%7B%22structuredQuery%22%3A%7B%22from%22%3A%5B%7B%22collectionId%22%3A%22statusUpdates%22%7D%5D%2C%22orderBy%22%3A%5B%7B%22field%22%3A%7B%22fieldPath%22%3A%22createdAt%22%7D%2C%22direction%22%3A%22DESCENDING%22%7D%2C%7B%22field%22%3A%7B%22fieldPath%22%3A%22__name__%22%7D%2C%22direction%22%3A%22DESCENDING%22%7D%5D%7D%2C%22parent%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%2Fdocuments%2FtrackedFlights%2FAA_2178_DFW_2026_04_22%22%7D%2C%22targetId%22%3A64%7D%7D&req4___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22addTarget%22%3A%7B%22documents%22%3A%7B%22documents%22%3A%5B%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%2Fdocuments%2FderivedLoadsReports%2FxskQc2a2oAVAwZk3Rfhg%22%5D%7D%2C%22targetId%22%3A66%7D%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9195&AID=136&zx=4xj7drdge1b4&t=1 => [200]
Request body: count=1&ofs=46&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22removeTarget%22%3A56%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9196&AID=136&zx=u6l12lhidxh7&t=1 => [200]
Request body: count=1&ofs=47&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22removeTarget%22%3A58%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9197&AID=136&zx=v3wmv0ko207f&t=1 => [200]
Request body: count=1&ofs=48&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22removeTarget%22%3A60%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9198&AID=141&zx=2dyg9dv4hlx7&t=1 => [200]
Request body: count=1&ofs=49&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22addTarget%22%3A%7B%22documents%22%3A%7B%22documents%22%3A%5B%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%2Fdocuments%2FtrackedFlights%2FWN_2101_ELP_2026_05_03%22%5D%7D%2C%22targetId%22%3A1%7D%2C%22labels%22%3A%7B%22goog-listen-tags%22%3A%22limbo-document%22%7D%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9199&AID=145&zx=96e5i6boihdb&t=1 => [200]
Request body: count=1&ofs=50&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22removeTarget%22%3A1%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9200&AID=154&zx=mpc9gcjtbo7r&t=1 => [200]
Request body: count=1&ofs=51&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22addTarget%22%3A%7B%22documents%22%3A%7B%22documents%22%3A%5B%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%2Fdocuments%2FtrackedFlights%2FWN_3187_ELP_2026_05_03%22%5D%7D%2C%22targetId%22%3A3%7D%2C%22labels%22%3A%7B%22goog-listen-tags%22%3A%22limbo-document%22%7D%7D
[POST] https://firestore.googleapis.com/google.firestore.v1.Firestore/Listen/channel?VER=8&database=projects%2Fstafftraveler-prod%2Fdatabases%2F(default)&gsessionid=HFF5EogcWeK0-zCr8BqQiP1LG6gZwKIpFMpTR4_G01uQgOSO1g6VYA&SID=a-XMXYp-Vwc6W3fSsZRnKA&RID=9201&AID=158&zx=yqxzfkm1k2r3&t=1 => [200]
Request body: count=1&ofs=52&req0___data__=%7B%22database%22%3A%22projects%2Fstafftraveler-prod%2Fdatabases%2F(default)%22%2C%22removeTarget%22%3A3%7D
@@ -0,0 +1,192 @@
# StaffTraveler Firestore schema (live-extracted)
Captured 2026-04-22 via Playwright MCP on `https://app.stafftraveler.com` while logged in as `stafftraveler@treymail.com` (uid `3NNPesQMiMRNYnPmuQzh6w2YKyh1`, airline `st_SWA`). All queries/docs below are copied from the web client's `firestore.googleapis.com/.../Listen/channel` `addTarget` bodies — not guessed.
## Why iOS mitm didn't show this
The iOS Firebase SDK and Android SDK both use the gRPC transport to `firestore.googleapis.com` (HTTP/2 with protobuf frames). Many mitm setups decode HTTPS but not gRPC framing, so the requests appear as opaque binary. **The web client uses the `VER=8 TYPE=xmlhttp` long-poll fallback — plain HTTP POST with URL-encoded JSON bodies** — which is fully readable. That's why we only saw this via the browser.
## Required headers for REST access
Direct REST (`/v1/projects/.../documents:runQuery` or `/documents/...`) works with:
```
Authorization: Bearer <firebase id token>
X-Firebase-GMPID: 1:628258099825:web:35b20eaab4d441894041d0
X-Goog-Api-Client: gl-js/ fire/12.12.0
?key=AIzaSyD82Hiqx-jAbHF8faMGSveqLw8CdX8a-Uc (URL query param)
```
Note: **web API key** (`AIzaSyD82...`), not the Android key from strings.xml. Omitting `?key=...` or the GMPID header causes `403 PERMISSION_DENIED` — the rules consult the key for the project consumer context before evaluating `allow read`.
## Single-document subscriptions
These are fetched as `addTarget.documents.documents`:
| Path | What |
|------|------|
| `__system/maintenance` | Server maintenance flags |
| `__system/version` | Required client versions (public) |
| `users/{uid}` | Profile: airlineStCode, airlineId, firstName, gender, … |
| `userHints/{uid}` | UI hints the user has dismissed |
| `userRequestCounters/{uid}` | Regular-request quota state |
| `userPriorityRequestCounters/{uid}` | Priority-request quota state |
| `userCredits/{uid}` | Credit balance |
| `userAchievements/{uid}` | Unlocked achievements |
| `userMetrics/{uid}` | Usage telemetry |
| `userFlightSearchHistory/{uid}` | Recent searches |
| `airportsByIata/{IATA}` | Public airport lookup (DFW, LAS, …) |
| `airlinesBySt/{stCode}` | Airline lookup by StaffTraveler code (`st_AAL`, `st_SWA`, …) |
| `airlineNotesBySt/{stCode}` | Non-rev agreement notes per airline |
| `flightEquipment/{iataOrId}` | Aircraft type info (`32Q`, `321`, `738`, …) |
| `conversations/{flightId_v3}` | Flight-level comment thread |
| `derivedLoadsReports/{reportId}` | **Individual load report doc — see below** |
| `trackedFlights/{flightId_v3}` | User's tracked flight state |
## Collection queries
### `derivedLoadsReports` — THE LOADS DATA
```json
{
"from": [{"collectionId": "derivedLoadsReports"}],
"where": {
"fieldFilter": {
"field": {"fieldPath": "flightId"},
"op": "EQUAL",
"value": {"stringValue": "AA_2178_DFW_2026_04_22"}
}
},
"orderBy": [
{"field": {"fieldPath": "createdAt"}, "direction": "DESCENDING"},
{"field": {"fieldPath": "__name__"}, "direction": "DESCENDING"}
]
}
```
**This is the query you want for the Flights integration.** Pass the flight `id_v3` (e.g. `AA_2178_DFW_2026_04_22` — format is `{airlineId}_{flightNumber}_{departureIata}_{YYYY}_{MM}_{DD}`).
**Access control (verified 2026-04-22):** this query returns `403 PERMISSION_DENIED` unless the user has an active `trackedFlights/{flightId_v3}` doc, which is created only by `POST /v1/commands/user/createLoadsRequests`. That call spends a credit on flights the user has never requested; replay on an already-requested flight is idempotent (no charge). So:
- Freely re-read `derivedLoadsReports` for any flight you've already requested — no cost, no rate limit observed.
- You cannot peek at loads without first requesting — the "instant results" in the app for flights with recent community reports rely on the user having previously paid the credit.
- On the 15 DFW-LAS flights tested, only `AA_2178_DFW_2026_04_22` (the one with an open request) returned 200; the other 14 all returned 403. Same for 29 LAX-JFK flights the user hadn't requested — all 403.
Example doc (captured AA2178 2026-04-22 12:42 PM CT):
```json
{
"flightId": "AA_2178_DFW_2026_04_22",
"openSeats": {
"first": {"count": 3},
"eco": {"count": 9}
},
"staffListing": {
"type": "available",
"count": 6,
"countByClass": {}
},
"responseTimeSeconds": 196,
"numberOfCreditsRewarded": 1,
"isFlagged": false,
"isFlaggedConfirmed": false,
"seatsAvailabilityScore": 0.79,
"hasClosedRequest": true,
"isJackpotHit": false,
"isPriorityRequest": false,
"createdAt": "2026-04-22T18:42:51.313Z",
"expireAt": "2026-07-21T21:45:00Z",
"creatorName": "M",
"creatorImageUrl": "https://images.stafftraveler.com/avatars/happysuitcase.png",
"creatorImagePath": "avatars/happysuitcase.png"
}
```
Field notes:
- `openSeats.<class>.count`**exactly what the app shows as "OPEN SEATS"**. Classes seen: `first`, `business`, `premiumEconomy`, `eco`. Absent class = 0 or unknown.
- `staffListing.count`**the "LISTED NON-REV PASSENGERS" number**.
- `staffListing.type``"available"` means loads were given; other types likely include `"upgrade"` variants (see `checkForLoads` heuristic in `bundle.hasm`).
- `seatsAvailabilityScore` — 0-1, app uses this to color-code the flight.
- `creatorName` is the first letter only (privacy).
- `expireAt` — ~90 days out, so load reports stay queryable.
### `trackedFlights` — your open requests
```json
{
"from": [{"collectionId": "trackedFlights"}],
"where": {
"compositeFilter": {"op": "AND", "filters": [
{"fieldFilter": {"field": {"fieldPath": "status"}, "op": "EQUAL", "value": {"stringValue": "open"}}},
{"fieldFilter": {"field": {"fieldPath": "hasDeparted"}, "op": "EQUAL", "value": {"booleanValue": false}}},
{"fieldFilter": {"field": {"fieldPath": "scheduledFlight.airlineStCode"}, "op": "EQUAL", "value": {"stringValue": "st_SWA"}}}
]}
},
"orderBy": [
{"field": {"fieldPath": "priority"}, "direction": "DESCENDING"},
{"field": {"fieldPath": "scheduledFlight.departureTimeUtc"}, "direction": "ASCENDING"},
{"field": {"fieldPath": "__name__"}, "direction": "ASCENDING"}
],
"limit": 25
}
```
Returns the logged-in user's open requests filtered by their airline. Doc ID = flight id_v3. `scheduledFlight` is the embedded full flight object (matches `searchFlightsByRoute` item shape).
### `trackedFlights/{flightId}/statusUpdates`
```json
{
"from": [{"collectionId": "statusUpdates"}],
"orderBy": [
{"field": {"fieldPath": "createdAt"}, "direction": "DESCENDING"},
{"field": {"fieldPath": "__name__"}, "direction": "DESCENDING"}
],
"parent": "projects/stafftraveler-prod/databases/(default)/documents/trackedFlights/AA_2178_DFW_2026_04_22"
}
```
Delay / gate / equipment changes for that flight over time.
### `users/{uid}/pinnedFlights`, `users/{uid}/connectingFlights`
Subcollections under the user doc. Empty for this account.
### `autoRequestRecords` where `subscribedUserIds ARRAY_CONTAINS {uid}` AND `isActive`
Auto-request subscriptions — flights the user has set up to auto-request loads for on a recurring schedule.
### `trackedFlights` where `subscribedUsers.{uid} == true` AND `isDeleted == false`
Other users' requests that THIS user has subscribed to (so they also see the loads when answered).
## Idempotent REST access pattern (integration recipe)
```bash
# 1. sign in (you already do this)
TOKEN=$(curl -sX POST "https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=$WEB_KEY" \
-d "{\"email\":\"$EMAIL\",\"password\":\"$PW\",\"returnSecureToken\":true}" | jq -r .idToken)
# 2. read loads for any flight
BASE='https://firestore.googleapis.com/v1/projects/stafftraveler-prod/databases/(default)/documents'
KEY='AIzaSyD82Hiqx-jAbHF8faMGSveqLw8CdX8a-Uc'
curl -sX POST "$BASE:runQuery?key=$KEY" \
-H "Authorization: Bearer $TOKEN" \
-H "X-Firebase-GMPID: 1:628258099825:web:35b20eaab4d441894041d0" \
-H "Content-Type: application/json" \
-d '{"structuredQuery":{"from":[{"collectionId":"derivedLoadsReports"}],"where":{"fieldFilter":{"field":{"fieldPath":"flightId"},"op":"EQUAL","value":{"stringValue":"AA_2178_DFW_2026_04_22"}}},"orderBy":[{"field":{"fieldPath":"createdAt"},"direction":"DESCENDING"}]}}'
```
Returns empty array if no report exists yet (meaning you'd need `createLoadsRequests` first to ask the crew). If a report exists, you get it without spending a credit.
## Integration plan for the iOS Flights app (final)
1. Secondary `FirebaseApp` configured against `stafftraveler-prod` with the web API key + GMPID above.
2. `Auth.auth(app: stApp).signIn(withEmail:password:)` with user's StaffTraveler credentials, refresh token stored in Keychain.
3. Use `Firestore.firestore(app: stApp)` and wire:
- `collection("derivedLoadsReports").whereField("flightId", isEqualTo: flightIdV3).addSnapshotListener { ... }` — live load updates per flight
- `collection("trackedFlights").whereField("status", isEqualTo: "open")....whereField("scheduledFlight.airlineStCode", isEqualTo: userStCode).limit(to: 25).addSnapshotListener` — user's open requests list
- `document("airlinesBySt/\(stCode)")` — airline metadata
- `document("airportsByIata/\(iata)")` — airport metadata
4. For writes (request loads, submit report, etc.), keep using the HTTP commands API at `https://api.stafftraveler.com/v1/commands/user/<cmd>`.
No App Check enforcement observed from the web client — plain Firebase ID token + API key is sufficient.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
{"originIata":"DFW","destinationIata":"LAS","dates":["2026-04-22"],"maxConnections":0,"allowNearbyDepartures":false,"allowNearbyArrivals":false,"payloadType":"passenger","includeMultipleCarriers":false}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,100 @@
# StaffTraveler — URL surface crawl (2026-04-22)
Full enumeration of what is and isn't accessible with a valid user Firebase ID token from the `stafftraveler-prod` project. Tested as user `3NNPesQMiMRNYnPmuQzh6w2YKyh1` (WN/`st_SWA`) using fresh tokens from `app.stafftraveler.com`.
## HTTP commands (`api.stafftraveler.com/v1/commands/user/<name>`)
All 34 commands confirmed via `createFirebaseCommand` enumeration in the Hermes bundle. Only the `user/` prefix exists — tried `public/`, `internal/`, `admin/`, `v2/`, `flights/`, `loads/`, `system/`, `airline/` → all 404.
**Read-ish (test with safe payloads):**
- `appActive` — heartbeat. Requires `{localDateIso: ISO8601, appType: "mobile"|"web"}`. Returns `{payload: null}`.
- `mobile` — stub, returns `"Command mobile is not implemented"`.
- `searchFlightsByCode`, `searchFlightsByRoute` — flight search (free, any route).
**Everything else is a mutation:** `createLoadsRequests`, `deleteLoadsRequest`, `reopenLoadsRequest`, `upgradeLoadsRequest`, `requestLockForLoadsRequest`, `unlockLoadsRequest`, `submitLoadsReport`, `reviseLoadsReport`, `flagLoadsReport`, `addAirlineDetails`, `addComment`, `addIdentity`, `addCreditsForPurchase`, `createTip`, `deleteUserAccount`, `disableAllAutoRequestSubscriptions`, `disableFlightStatusUpdates`, `enableFlightStatusUpdates`, `discardHint`, `omitIdentity`, `pinFlight`, `unpinFlight`, `registerUser`, `removeComment`, `reportComment`, `setAutoRequestSubscription`, `submitFeedback`, `unlikeTip`, `updateUserProfile`, `appLogout`.
**`api.stafftraveler.com/health`** returns `{message:"ok", serverTime:...}` — open.
## Firestore collections (`firestore.googleapis.com/v1/projects/stafftraveler-prod/databases/(default)/documents`)
All reads require `Authorization: Bearer <idToken>` + `X-Firebase-GMPID: 1:628258099825:web:35b20eaab4d441894041d0` + `?key=AIzaSyD82Hiqx-jAbHF8faMGSveqLw8CdX8a-Uc`.
### Publicly readable, cross-airline (globally listable or get-by-id)
| Path | Content | Size |
|------|---------|------|
| `__system/maintenance` | service maintenance flags | 1 doc |
| `__system/version` | required client versions (android/ios blacklists) | 1 doc |
| `__system/supportedCurrencies` | currency support map | 1 doc |
| `__derived/nonRevAgreementsBySt` | **Full interline non-rev agreement matrix**: `agreements: { st_XXX: [st_YYY, ...], ... }` | 300 airline keys — pulled and saved to `nonRevAgreementsBySt.json` |
| `airlinesBySt/*` | Airline ST-code directory | 300+ docs — pulled to `airlinesBySt_full.json` |
| `airlines/*` | Airline IATA directory | publicly readable per-doc (tested `AA`) |
| `airportsByIata/*` | Airport IATA directory | publicly readable per-doc |
| `airlineNotesBySt/*` | Per-airline non-rev notes | publicly readable per-doc |
| `flightEquipment/*` | Aircraft type directory | publicly readable per-doc |
| `conversations/*` | Flight comment threads | 404 for empty (rule allows, doc absent); readable where they exist |
| `tips/*` | Travel recommendations (restaurants, lounges, etc.) | 5+ docs listable (pagination not tested to completion) |
| `autoRequestRecords/*` | **Global** auto-request subscription configs — user uids, trigger schedules, blocked users | 500+ docs listable; flight metadata is NOT in the doc itself (likely derivable from the doc ID hash), but user-activity is |
### Airline-scoped (only MY airline == `st_SWA`)
| Query | Behavior |
|-------|----------|
| `trackedFlights where scheduledFlight.airlineStCode == "st_SWA"` | ✅ 500+ docs (historic WN load requests, all status=closed) |
| `trackedFlights where scheduledFlight.airlineStCode == "st_AAL/DAL/UAL/JBU/..."` | ❌ 403 — rules scope to own airline |
| `trackedFlights/{flightId_v3}` direct get, same-airline | ✅ readable (even if I'm not the creator) — exposes `currentLoadsReportId`, `reportAuthorList`, `seatsAvailabilityScore` |
| `trackedFlights/{flightId_v3}` direct get, other airline | ❌ 403 |
### Strictly user-scoped (me only)
| Path | Notes |
|------|-------|
| `users/{MY uid}` | My profile. Other uids → 403. |
| `userCredits/{MY uid}`, `userHints/{MY uid}`, `userRequestCounters/{MY uid}`, `userPriorityRequestCounters/{MY uid}`, `userMetrics/{MY uid}`, `userAchievements/{MY uid}`, `userFlightSearchHistory/{MY uid}` | my state only |
| `users/{MY uid}/pinnedFlights`, `users/{MY uid}/connectingFlights` | my subcollections only |
| `derivedLoadsReports where flightId == "<flight I created request on>"` | ✅ readable |
| `derivedLoadsReports/{docId}` direct get, my flight | ✅ readable |
| `derivedLoadsReports where flightId == "<other flight>"` | ❌ 403 (cross-checked with direct-get of `dVb4zStjhITceArRgLBB` — a WN862 load doc I don't own) |
| `trackedFlights/{flight_I_created}/statusUpdates` subcollection | readable only for flights I own |
### Explicitly denied for listing / querying
| Path | Error |
|------|-------|
| `LIST derivedLoadsReports` without a matching where-filter | 403 |
| `LIST trackedFlights` without airlineStCode filter | 403 |
| `runQuery` on `derivedLoadsReports`, `trackedFlights`, `scheduledFlights`, `flights`, `flightRecord`, `userRegistrations`, `userEmailVerifications`, `loadsReports` (as singular), `conversations` (list-only, 403) without proper predicates | 403 |
| Collection-group queries on sensitive collections | 403 |
| `scheduledFlights/{id}`, `flights/{id}`, `flightRecord/{id}` direct get for ANY flight | 403 |
## Other domains on stafftraveler.com (tested unauthenticated GET)
| URL | Response |
|-----|----------|
| `app.stafftraveler.com/` | web app (Next.js); redirects to `/login` if unauth |
| `share.stafftraveler.com/` | 200 HTML — share landing page (Next.js). Tried `/request/<id>`, `/flight/<id>`, `/loads/<id>`, `/r/<code>` → all 404 |
| `blog.stafftraveler.com/` | blog (not tested deeply) |
| `hotels.stafftraveler.com/` | hotels deals (WebView target) |
| `carrental.stafftraveler.com/` | car rentals (WebView target, URL pattern `<base>/mobile.html?...`) |
| `support.stafftraveler.com/` | support/help |
| `shop.stafftraveler.com/` | merch |
| `links.stafftraveler.com/` | 404 not-found page (linktree-style, but empty) |
| `webhooks.stafftraveler.com/` | Fastify API; `{error:"Not Found"}` on `/`; `/request-password-reset` returns `{error:"Invalid arguments"}` on empty body |
| `stafftraveler.com/r/<code>` | Returns generic HTML (any path) |
| `images.stafftraveler.com/avatars/*` | Public avatar CDN |
## Goal analysis: "find all filled requests"
- **For my own airline (WN/`st_SWA`):** ✅ possible — enumerate all `trackedFlights` with `airlineStCode == "st_SWA"` and `status == "closed"`. 500+ historic records. But actually reading the per-request **load data** (`openSeats.{first,business,economy}.count`, `staffListing.count`) still requires having personally called `createLoadsRequests` on each flight.
- **For other airlines:** ❌ blocked. The rule `scheduledFlight.airlineStCode == <your airline>` is the hard wall. Verified negatively across 9 airline codes (AAL/DAL/UAL/JBU/ACA/KLM/AFR/BAW/DLH).
- **Community-aggregated filled-request data, cross-airline:** not exposed anywhere I found. `autoRequestRecords` comes closest — it shows *who's auto-subscribing*, not what loads were reported.
The design is intentional: StaffTraveler's value proposition is a credit-gated access to crowd-sourced data on your own airline's routes. The rules don't let you enumerate outside that scope.
## Useful artifacts saved
- `nonRevAgreementsBySt.json` — complete 300-airline interline matrix
- `airlinesBySt_full.json` — ST code → airline directory (partial if stopped paging)
- `firestore_listen_targets_and_queries.txt` — all Listen-channel queries captured from the web client
- `derivedLoadsReports_AA2178_*.json` — single known-working load response capture
- `searchFlightsByRoute_DFW-LAS_*.json` / `createLoadsRequests_AA2178_*.json` — HTTP command captures
+368
View File
@@ -0,0 +1,368 @@
# StaffTraveler — complete findings
Single source of truth for the StaffTraveler reverse-engineering effort. Verified by HTTP captures, Firestore REST probes, and Hermes bytecode analysis against the live `stafftraveler-prod` Firebase project. Raw artifacts in `stafftraveler_captures/`.
Source app: `com.stafftraveler.webview` v3.12.0 (build 1880000346), iOS UA `stafftraveler/560 CFNetwork/3860.500.112 Darwin/25.4.0`. Web client at `https://app.stafftraveler.com` (same Firebase project, same Firestore rules — used for inspection because it sends the long-poll fallback transport instead of gRPC).
## TL;DR for the iOS Flights integration
To show flight loads inside our app:
1. Add a secondary `FirebaseApp` named e.g. `StaffTravelerFirebaseApp` configured with the **web** API key + GMPID below.
2. Sign in as the user with Firebase Auth (email/password) → store refresh token in Keychain.
3. To search routes, `POST https://api.stafftraveler.com/v1/commands/user/searchFlightsByRoute` with the Bearer ID token.
4. To **unlock** loads on a flight, `POST .../createLoadsRequests` with the flight object + `isPriorityRequest: false` — costs **1 credit per new flight**, idempotent on replay.
5. After unlock, attach a Firestore snapshot listener on `derivedLoadsReports.where("flightId", "==", flightIdV3).order("createdAt", desc)` to receive live load updates.
6. Optional surface for "what's happening on my own airline": query `trackedFlights.where("scheduledFlight.airlineStCode", "==", userStCode)` to see flights other employees on the same airline have requested.
You **cannot** show real load numbers for a flight without first calling `createLoadsRequests` on that specific flight. There is no bulk/peek endpoint — verified extensively (see "Security model").
---
## Architecture
React Native + Hermes bytecode iOS/Android app with a thin native shell. All business logic in `assets/index.android.bundle`. Backend is Firebase:
| Component | Service |
|-----------|---------|
| Auth | Firebase Auth (email/password, Google, Apple, Facebook) |
| Real-time data | Cloud Firestore (`stafftraveler-prod`) |
| Mutations | Custom HTTP commands API at `api.stafftraveler.com/v1/commands/user/<name>` (likely Cloud Functions) |
| Config | Firebase Remote Config (controls API base URL, Typesense host/key, feature flags) |
| Search index | Typesense (`enhk2ji1vu6csxzrp-1.a1.typesense.net`) — server-side, not hit directly by the client |
| Push | FCM / APNs |
| Static CDN | `images.stafftraveler.com` |
No SSL pinning. No App Check enforcement (Play Integrity classes are bundled but the `createFirebaseCommand` fetch doesn't attach an `X-Firebase-AppCheck` header, and the web client makes every request with just the ID token).
## Firebase project config
From APK `res/values/strings.xml` and the web client's IndexedDB:
| Key | Value |
|-----|-------|
| Project ID | `stafftraveler-prod` |
| **Web API key** (the one that works for REST) | `AIzaSyD82Hiqx-jAbHF8faMGSveqLw8CdX8a-Uc` |
| Web App ID / GMPID | `1:628258099825:web:35b20eaab4d441894041d0` |
| Android API key | `AIzaSyC2zG6ArnguzzdWsLYV1qjQznma0zl1Q0s` (locked to the signed APK) |
| Android App ID | `1:628258099825:android:5fb976ba6ad1bb05` |
| Sender ID | `628258099825` |
| Firestore | `(default)` database |
| RTDB | `https://stafftraveler-prod.firebaseio.com` (registered but unused for loads) |
| Storage | `stafftraveler-prod.appspot.com` |
For an iOS integration, register a new web/iOS app in the same Firebase console — but the existing web app credentials above are sufficient for direct REST and the web SDK in our app.
## Auth
```
POST https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=<web-key>
Content-Type: application/json
{ "email": "...", "password": "...", "returnSecureToken": true }
→ { idToken, refreshToken, expiresIn: "3600", localId: <uid>, ... }
```
The ID token is a 1-hour JWT; refresh via `securetoken.googleapis.com/v1/token?key=<web-key>` with `grant_type=refresh_token&refresh_token=...`. The Firebase SDK does this transparently.
Token claims include `email`, `user_id`, `is_registered`. Issuer `https://securetoken.google.com/stafftraveler-prod`, audience `stafftraveler-prod`.
## HTTP commands API
```
POST https://api.stafftraveler.com/v1/commands/user/<commandName>
Authorization: Bearer <firebase id token>
Content-Type: application/json
x-stafftraveler-client: mobile ← the iOS app sends this; web sends "web". Probably not enforced.
{ ... command-specific body ... }
```
Response: `{ payload: <result> }` on success, `{ isError: true, payload: <message> }` on validation/business errors. Both come back HTTP 200; check the `isError` flag.
`api_commands_url` is fetched from Remote Config at startup. The deployed value is `https://api.stafftraveler.com/v1/commands/user`. The bundle disassembly confirms there is no other path prefix — tested `/public/`, `/internal/`, `/admin/`, `/v2/`, `/flights/`, `/loads/`, `/system/`, `/airline/` → all 404.
Also exposed on the same host: `GET /health``{message:"ok", serverTime:...}`.
### Command inventory (all 34, via `createFirebaseCommand` enumeration)
**Reads (free, idempotent):**
- `searchFlightsByRoute` — flight schedule by origin/destination/date
- `searchFlightsByCode` — flight schedule by flight number
- `appActive` — heartbeat; needs `{localDateIso, appType}`; returns `{payload: null}`
- `mobile` — stub, returns `"not implemented"`
**Mutations (most of these spend credits or modify account state):**
```
addAirlineDetails addCreditsForPurchase addComment addIdentity
appLogout createLoadsRequests createTip deleteLoadsRequest
deleteUserAccount disableAllAutoRequestSubscriptions
disableFlightStatusUpdates enableFlightStatusUpdates discardHint
flagLoadsReport omitIdentity pinFlight unpinFlight
registerUser removeComment reportComment
reopenLoadsRequest reviseLoadsReport requestLockForLoadsRequest
unlockLoadsRequest setAutoRequestSubscription submitFeedback
submitLoadsReport unlikeTip updateUserProfile upgradeLoadsRequest
```
### `searchFlightsByRoute` — confirmed payload
```json
{
"originIata": "DFW",
"destinationIata": "LAS",
"dates": ["2026-04-22"],
"maxConnections": 0,
"allowNearbyDepartures": false,
"allowNearbyArrivals": false,
"payloadType": "passenger",
"includeMultipleCarriers": false
}
```
Response: `{ payload: { directFlights: [...], connectingFlights: [...], numberOfDiscardedFlights, settingsFilteredCount, messages } }`.
Flight object fields:
```
id, id_v2, id_v3 (= "AA_2178_DFW_2026_04_22"),
airlineId, airlineStCode, flightCode, flightNumber,
departureAirportId, departureAirportIata, arrivalAirportId, arrivalAirportIata,
departureTimeLocal, departureTimeUtc, arrivalTimeLocal, arrivalTimeUtc,
durationMinutes, isCargoFlight,
flightEquipmentId, flightEquipmentIata,
seatsTotal, seatsByClass: { first, business, premiumEconomy, economy }
```
`id_v3` format is `{airlineId}_{flightNumber}_{departureIata}_{YYYY}_{MM}_{DD}`. **This is the key used everywhere downstream** (Firestore docs, command bodies, etc).
`seatsByClass` is the **aircraft cabin configuration**, NOT current availability — that's a separate Firestore lookup.
### `createLoadsRequests` — confirmed payload
Body is a **bare JSON array** (no outer wrapper) of full flight objects, each with one extra field `isPriorityRequest: bool`:
```json
[
{ ...full flight from search response..., "isPriorityRequest": false }
]
```
Response:
```json
{
"payload": {
"numberOfRequests": 0,
"numberOfRequestsPaidFor": 0,
"numberOfRequestsPaidForPriority": 0,
"hasSufficientCredits": true,
"verifiedRequests": [
{
"flightId": "AA_2178_DFW_2026_04_22",
"isExisting": true,
"isDuplicateForUser": true,
"isRequestUpdateForUser": false,
"hasDeparted": false,
"isCancelled": false,
"isPriorityRequest": false,
"hasRecentLoads": false, summary flag; actual loads come from Firestore
"scheduledFlight": { ... full flight ... }
}
]
}
}
```
**Idempotency:** Replaying the same flight returns `numberOfRequests: 0` + `isDuplicateForUser: true` + **no credit charged**. Useful as a "what's the current state" probe — pay for the first call only, then replay free.
## Firestore — the data plane
REST endpoint: `https://firestore.googleapis.com/v1/projects/stafftraveler-prod/databases/(default)/documents`
**Required headers on every read:**
```
Authorization: Bearer <firebase id token>
X-Firebase-GMPID: 1:628258099825:web:35b20eaab4d441894041d0
?key=AIzaSyD82Hiqx-jAbHF8faMGSveqLw8CdX8a-Uc (URL query)
```
Omitting `?key=` or the GMPID gives `403 PERMISSION_DENIED` even with a valid token — the project-consumer context is required.
**Why mobile mitm couldn't see this:** the iOS Firebase SDK uses gRPC over HTTP/2 to `firestore.googleapis.com`. Many proxies show those streams as opaque binary frames. The web client uses the long-poll fallback (`Listen/channel?VER=8&TYPE=xmlhttp`) with URL-encoded JSON bodies — fully decodable. That's why the answer only fell out via the browser.
### Loads — `derivedLoadsReports`
The single most important query for the integration:
```json
POST documents:runQuery
{
"structuredQuery": {
"from": [{"collectionId": "derivedLoadsReports"}],
"where": {
"fieldFilter": {
"field": {"fieldPath": "flightId"},
"op": "EQUAL",
"value": {"stringValue": "AA_2178_DFW_2026_04_22"}
}
},
"orderBy": [
{"field": {"fieldPath": "createdAt"}, "direction": "DESCENDING"},
{"field": {"fieldPath": "__name__"}, "direction": "DESCENDING"}
]
}
}
```
Doc shape (verified live capture, AA2178 2026-04-22):
```json
{
"flightId": "AA_2178_DFW_2026_04_22",
"openSeats": {
"first": { "count": 3 },
"eco": { "count": 9 }
// also: business, premiumEconomy. Absent class = 0/unknown.
},
"staffListing": {
"type": "available", // also: "upgrade" variants per checkForLoads heuristic
"count": 6, // # standby/listed non-rev passengers
"countByClass": {}
},
"responseTimeSeconds": 196,
"numberOfCreditsRewarded": 1,
"isFlagged": false,
"isFlaggedConfirmed": false,
"seatsAvailabilityScore": 0.79, // 0-1; app uses for color coding
"hasClosedRequest": true,
"isJackpotHit": false,
"isPriorityRequest": false,
"createdAt": "2026-04-22T18:42:51.313Z",
"expireAt": "2026-07-21T21:45:00Z", // ~90 days
"creatorName": "M", // first letter only (privacy)
"creatorImageUrl": "https://images.stafftraveler.com/avatars/happysuitcase.png",
"creatorImagePath": "avatars/happysuitcase.png"
}
```
### `trackedFlights` — request state
One doc per (flight, anyone-who-requested-it). Doc ID = `id_v3` of the flight. Key fields:
```
scheduledFlight: { full flight object — same shape as searchFlightsByRoute }
status: "open" | "closed"
createdBy: <uid of original requester>
createdAt, statusChangedAt, expireAt
priority: integer
priorityElevatedAt
isPriorityRequest, isAutoRequest, isLocked, isCancelled, isDeleted, isDelayed, isDiverted
hasDeparted, hasArrived, hasBeenRefunded
lockedBy, lockedAt ← while a reporter is filling it in
loadsUpdateRequestedBy
currentLoadsReportId ← doc ID into derivedLoadsReports
seatsAvailabilityScore ← summary score (also in derivedLoadsReports)
reportAuthorList: [<uid>] ← who has reported on this flight
reportAuthors: { <uid>: true, ... }
subscribedUsers: { <uid>: true, ... } ← all users tracking this flight
subscribedUserList: [<uid>]
statusUpdatesSubscribedUserIds: [<uid>]
usersRequestingLoads: { <uid>: <ts>, ... } ← users currently requesting an update
usersRequestingPriority: { ... }
usersEligibleForRefund: { <uid>: 1, ... }
sita: { ... rich live SITA flight record (gate, registration, flight times, marketing carriers, etc) ... }
__checksums: { typesense: "..." }
```
### Other Firestore listeners the web client opens (captured)
From the live `Listen/channel` body capture in `firestore_listen_targets_and_queries.txt`:
| Target | Use |
|--------|-----|
| `users/{uid}` | profile |
| `userHints/{uid}`, `userRequestCounters/{uid}`, `userPriorityRequestCounters/{uid}`, `userCredits/{uid}`, `userAchievements/{uid}`, `userMetrics/{uid}`, `userFlightSearchHistory/{uid}` | per-user state |
| `airportsByIata/{IATA}` | airport metadata |
| `airlinesBySt/{stCode}` | airline metadata by ST code |
| `airlineNotesBySt/{stCode}` | non-rev policy notes |
| `flightEquipment/{type}` | aircraft type info |
| `conversations/{flightId_v3}` | per-flight comment thread |
| `users/{uid}/pinnedFlights` (subcoll. query) | pinned flights |
| `users/{uid}/connectingFlights` (subcoll. query) | connecting flight definitions |
| `trackedFlights/{flightId_v3}/statusUpdates` (subcoll. query) | flight status change feed |
| `trackedFlights` query | see "Security model" — airline-scoped |
| `autoRequestRecords` query filtered by `subscribedUserIds ARRAY_CONTAINS uid` AND `isActive == true` | user's auto-subscriptions |
## Security model (the load-data unlock)
This is the constraint that defines the integration shape. Verified by exhaustive probing.
**Rules summary** (inferred from observed access pattern):
| Collection | Rule (effective) |
|------------|------------------|
| `__system/*`, `__derived/nonRevAgreementsBySt`, `airlines/*`, `airlinesBySt/*`, `airportsByIata/*`, `airlineNotesBySt/*`, `flightEquipment/*`, `tips/*`, `autoRequestRecords/*`, `conversations/*` | Public read for any authenticated user |
| `users/{uid}`, `userCredits/{uid}`, all other `user*/{uid}` | Owner-only |
| `trackedFlights` query (`list`) | Allowed only when filtered by `scheduledFlight.airlineStCode == <user's own airline>` |
| `trackedFlights/{id}` direct read (`get`) | Allowed if same-airline OR createdBy == auth.uid |
| `trackedFlights/{id}/statusUpdates` subcollection | Allowed only if you own/created the flight |
| `derivedLoadsReports` query and direct-get | Allowed only if you have a matching `trackedFlights/{flightId}` where you're the creator/subscriber |
| `scheduledFlights/{id}`, `flightRecord/{id}`, `flights/{id}` | Denied for direct-get on any flight |
**Verified 2026-04-22:**
- AA2178 query (I have a request on it) → 200, returns the load doc.
- WN862 query (someone else's request, my airline) → 403.
- Direct GET on `derivedLoadsReports/dVb4zStjhITceArRgLBB` (WN862's load doc, ID extracted from the trackedFlight doc which IS readable as same-airline) → still 403. Same token, same headers. So knowing the doc ID is not a bypass.
- DL/AA/UA/JBU/etc trackedFlights queries → 403 across all 9 airlines tested.
- Same-airline trackedFlights query → 500+ historic WN flights returned (status=closed mostly), confirming the airline-scope leak.
**Net consequence:** there is no "peek" path. To see load numbers on a flight, you must spend a credit on it via `createLoadsRequests`. After that, both the trackedFlight and derivedLoadsReports become readable indefinitely (or until the flight expires ~90 days later), and snapshot listeners deliver new community-submitted reports for free.
The same-airline `trackedFlights` listing exposes one useful summary signal without paying: `seatsAvailabilityScore` (01) on flights other employees on your own airline have asked about. Coarse go/no-go indicator, no class-level breakdown.
## Public reference data worth caching
Pulled and saved to `stafftraveler_captures/`:
- **`__derived/nonRevAgreementsBySt`** (`nonRevAgreementsBySt.json`) — full interline matrix: 300 airlines, each with an array of airline ST codes they have non-rev agreements with. Useful for filtering search results to "airlines I can actually non-rev on" without us building this ourselves.
- **`airlinesBySt/*`** (`airlinesBySt_full.json`) — ST-code → airline directory.
Both stable, refresh occasionally.
## What the iOS app actually needs
Concrete integration plan:
1. **Secondary FirebaseApp** `StaffTravelerApp` configured with the web key + GMPID above. Initialize on first user-opt-in.
2. **Auth screen** — email/password login → store refresh token in Keychain. Surface "no StaffTraveler account? Sign up in the StaffTraveler app first" — `addAirlineDetails` verification has to happen there.
3. **Bridge user state** — read `users/{uid}` (gets `airlineStCode`, name) and `userCredits/{uid}` (gets balance). Display credit balance in our UI.
4. **Search**: `POST /v1/commands/user/searchFlightsByRoute`. Free.
5. **Flight row UI**: for each result, attach a Firestore snapshot listener on `derivedLoadsReports.where("flightId", "==", flightId_v3)`.
- If the listener gets a doc → render loads inline.
- If 403 → flight isn't unlocked yet; show a "Request loads (1 credit)" CTA.
6. **Request CTA**`POST /v1/commands/user/createLoadsRequests` with the full flight + `isPriorityRequest: false`. Idempotent — surface "you already requested this" if `isDuplicateForUser: true`.
7. **Comments / status feed (optional)** — listeners on `conversations/{flightId}` and `trackedFlights/{flightId}/statusUpdates`.
8. **Token refresh** — let Firebase iOS SDK handle it. Don't try to do it manually.
Mutations to wire later if useful: `pinFlight`/`unpinFlight`, `setAutoRequestSubscription` (auto-poll a recurring route), `submitLoadsReport` (let the user contribute back if they're at the gate). Skip everything else.
## Captures (raw artifacts)
In `stafftraveler_captures/`:
- `searchFlightsByRoute_DFW-LAS_request.json` / `_response.json` — schedule API
- `createLoadsRequests_AA2178_request.json` / `_response.json` / `_response_headers.txt` — load-request API
- `derivedLoadsReports_AA2178_request.json` / `_response.json` — actual loads readout
- `firestore_listen_targets_and_queries.txt` — all `addTarget` bodies the web client opens in a session
- `nonRevAgreementsBySt.json` — interline matrix
- `airlinesBySt_full.json` — airline directory
- `firestore_schema.md`, `url_surface_crawl.md`, `README.md` — interim notes
## Things that are NOT possible
Documenting the dead ends so we don't relitigate:
- **Cross-airline load enumeration** — blocked by the `airlineStCode` filter on `trackedFlights` and the createdBy check on `derivedLoadsReports`. Tested across 9 airline codes.
- **Bulk peek at multiple flights' loads in one call** — no API. Each flight needs its own credit unlock.
- **Reading another user's `derivedLoadsReports` by knowing the doc ID** — rule evaluates the doc's `flightId` against your tracked flights. Direct-GET on a known foreign doc ID returns 403.
- **Spoofing airline membership** — token claims are server-issued; `setAirlineStCodes` is a real command but changing your airline would affect the user's actual account.
- **Anonymous Firebase Auth on this project** — the Android API key is restricted to the signed APK package + cert SHA1. The web key is unrestricted on `signInWithPassword` but you still need real credentials.
- **App Check bypass** — moot; not enforced in practice.
File diff suppressed because it is too large Load Diff
+294
View File
@@ -0,0 +1,294 @@
import { spawn } from "node:child_process";
import { mkdir, rm } from "node:fs/promises";
import { createWriteStream } from "node:fs";
import process from "node:process";
import { setTimeout as delay } from "node:timers/promises";
const chromePath = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
const port = Number(process.env.CDP_PORT || 9230);
const navigationUrl = process.env.JSX_URL || "https://www.jsx.com/";
const userDataDir = process.env.CHROME_USER_DATA_DIR || `/tmp/jsx-live-monitor-${Date.now()}`;
const artifactsDir = process.env.JSX_ARTIFACTS_DIR || "/tmp/jsx-live-monitor";
const logPath = `${artifactsDir}/network-events.ndjson`;
const requestTimeoutMs = Number(process.env.REQUEST_TIMEOUT_MS || 30000);
await mkdir(artifactsDir, { recursive: true });
const logStream = createWriteStream(logPath, { flags: "a" });
function writeLine(line) {
console.log(line);
logStream.write(`${line}\n`);
}
function log(message, details) {
if (details === undefined) {
writeLine(`[monitor] ${message}`);
return;
}
writeLine(`[monitor] ${message} ${JSON.stringify(details)}`);
}
async function fetchJson(url, init = undefined) {
const response = await fetch(url, init);
if (!response.ok) {
throw new Error(`HTTP ${response.status} for ${url}`);
}
return response.json();
}
async function waitForDebugger() {
const deadline = Date.now() + requestTimeoutMs;
while (Date.now() < deadline) {
try {
return await fetchJson(`http://127.0.0.1:${port}/json/version`);
} catch {
await delay(250);
}
}
throw new Error("Timed out waiting for Chrome remote debugger");
}
async function waitForPageTarget() {
const deadline = Date.now() + requestTimeoutMs;
while (Date.now() < deadline) {
try {
const targets = await fetchJson(`http://127.0.0.1:${port}/json/list`);
const pageTarget = targets.find(target =>
target.type === "page" &&
typeof target.url === "string" &&
target.url.startsWith("http") &&
target.webSocketDebuggerUrl
);
if (pageTarget) {
return pageTarget;
}
} catch {
// Ignore transient startup failures while Chrome is coming up.
}
await delay(250);
}
throw new Error("Timed out waiting for initial Chrome page target");
}
function launchChrome() {
const args = [
`--remote-debugging-port=${port}`,
`--user-data-dir=${userDataDir}`,
"--no-first-run",
"--no-default-browser-check",
"--disable-default-apps",
"--disable-popup-blocking",
"--window-size=1440,1200",
navigationUrl,
];
log("Launching Chrome", { chromePath, port, userDataDir, navigationUrl, logPath });
const child = spawn(chromePath, args, {
detached: false,
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout.on("data", chunk => {
const text = chunk.toString().trim();
if (text) {
writeLine(`[chrome] ${text}`);
}
});
child.stderr.on("data", chunk => {
const text = chunk.toString().trim();
if (text) {
writeLine(`[chrome] ${text}`);
}
});
return child;
}
class CDPClient {
constructor(socketUrl) {
this.socketUrl = socketUrl;
this.ws = new WebSocket(socketUrl);
this.nextId = 1;
this.pending = new Map();
this.eventHandlers = new Map();
this.openPromise = new Promise((resolve, reject) => {
this.ws.addEventListener("open", () => resolve());
this.ws.addEventListener("error", event => reject(event.error || new Error("WebSocket open failed")));
});
this.ws.addEventListener("message", event => {
const message = JSON.parse(event.data.toString());
if (message.id) {
const pending = this.pending.get(message.id);
if (!pending) {
return;
}
this.pending.delete(message.id);
if (message.error) {
pending.reject(new Error(message.error.message || JSON.stringify(message.error)));
} else {
pending.resolve(message.result);
}
return;
}
const handlers = this.eventHandlers.get(message.method) || [];
for (const handler of handlers) {
handler(message.params || {});
}
});
}
async ready() {
await this.openPromise;
}
async send(method, params = {}) {
await this.ready();
const id = this.nextId++;
const payload = JSON.stringify({ id, method, params });
const promise = new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
setTimeout(() => {
if (this.pending.has(id)) {
this.pending.delete(id);
reject(new Error(`Timed out waiting for ${method}`));
}
}, requestTimeoutMs);
});
this.ws.send(payload);
return promise;
}
on(method, handler) {
const handlers = this.eventHandlers.get(method) || [];
handlers.push(handler);
this.eventHandlers.set(method, handlers);
}
async close() {
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
this.ws.close();
}
}
}
let shuttingDown = false;
async function main() {
const chrome = launchChrome();
let cdp;
const shutdown = async signal => {
if (shuttingDown) {
return;
}
shuttingDown = true;
log("Shutting down", { signal });
if (cdp) {
await cdp.close();
}
chrome.kill("SIGTERM");
await delay(500);
await rm(userDataDir, { recursive: true, force: true });
logStream.end();
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
try {
await waitForDebugger();
const target = await waitForPageTarget();
cdp = new CDPClient(target.webSocketDebuggerUrl);
await cdp.ready();
log("Attached to page target", { id: target.id, url: target.url });
log("Manual monitor ready", { instructions: "Drive the JSX page in Chrome. All api.jsx.com traffic will stream here." });
const responseMetaByRequestId = new Map();
cdp.on("Page.frameNavigated", params => {
const url = params.frame?.url;
if (url) {
log("Page navigated", { url });
}
});
cdp.on("Network.requestWillBeSent", params => {
const url = params.request?.url || "";
if (!url.includes("api.jsx.com")) {
return;
}
const record = {
stage: "request",
type: params.type || "",
method: params.request?.method || "",
url,
postData: params.request?.postData || "",
hasAuthHeader: Boolean(params.request?.headers?.Authorization),
};
writeLine(JSON.stringify(record));
});
cdp.on("Network.responseReceived", params => {
const url = params.response?.url || "";
if (!url.includes("api.jsx.com")) {
return;
}
const record = {
stage: "response",
type: params.type || "",
status: params.response?.status || 0,
mimeType: params.response?.mimeType || "",
url,
};
responseMetaByRequestId.set(params.requestId, record);
writeLine(JSON.stringify(record));
});
cdp.on("Network.loadingFinished", async params => {
const meta = responseMetaByRequestId.get(params.requestId);
if (!meta || !meta.url.includes("api.jsx.com")) {
return;
}
try {
const body = await cdp.send("Network.getResponseBody", { requestId: params.requestId });
writeLine(JSON.stringify({
stage: "body",
status: meta.status,
url: meta.url,
bodySnippet: (body.body || "").slice(0, 4000),
base64Encoded: Boolean(body.base64Encoded),
}));
} catch (error) {
writeLine(JSON.stringify({
stage: "body-error",
url: meta.url,
error: error.message,
}));
}
});
await cdp.send("Page.enable");
await cdp.send("Runtime.enable");
await cdp.send("Network.enable", {
maxTotalBufferSize: 100_000_000,
maxResourceBufferSize: 10_000_000,
maxPostDataSize: 1_000_000,
});
await cdp.send("Network.setCacheDisabled", { cacheDisabled: true });
await new Promise(() => {});
} catch (error) {
log("Monitor failed", { error: error.message });
await shutdown("error");
}
}
await main();
+219
View File
@@ -0,0 +1,219 @@
import AppKit
import Darwin
import Foundation
import WebKit
private func logSmoke(_ message: String) {
fputs("[JSX-SMOKE] \(message)\n", stderr)
fflush(stderr)
}
final class JSXSwiftSmokeApp: NSObject {
private let origin: String
private let destination: String
private let date: String
private let useService: Bool
private let flightNumber: String
private let departureTime: String?
var exitCode: Int32 = 1
init(
origin: String,
destination: String,
date: String,
useService: Bool,
flightNumber: String,
departureTime: String?
) {
self.origin = origin
self.destination = destination
self.date = date
self.useService = useService
self.flightNumber = flightNumber
self.departureTime = departureTime
}
private func parsedDate() -> Date? {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(secondsFromGMT: 0)
formatter.dateFormat = "yyyy-MM-dd"
return formatter.date(from: date)
}
private func writeSummary(_ summary: [String: Any]) {
if JSONSerialization.isValidJSONObject(summary),
let data = try? JSONSerialization.data(withJSONObject: summary, options: [.prettyPrinted, .sortedKeys]),
let text = String(data: data, encoding: .utf8) {
print(text)
} else {
print("Failed to encode summary")
}
}
@MainActor
private func runFetcherMode() async {
logSmoke("starting fetcher \(origin)->\(destination) on \(date)")
let fetcher = JSXWebViewFetcher()
let result = await fetcher.fetchAvailability(
origin: origin,
destination: destination,
date: date
)
logSmoke("fetcher returned")
let flights = result.flights.map { flight in
[
"flightNumber": flight.flightNumber,
"origin": flight.origin,
"destination": flight.destination,
"departureLocal": flight.departureLocal,
"arrivalLocal": flight.arrivalLocal,
"stops": flight.stops,
"equipmentType": flight.equipmentType ?? "",
"totalAvailable": flight.totalAvailable,
"lowestFareTotal": flight.lowestFareTotal ?? NSNull(),
"classes": flight.classes.map { fareClass in
[
"classOfService": fareClass.classOfService,
"productClass": fareClass.productClass,
"availableCount": fareClass.availableCount,
"fareTotal": fareClass.fareTotal,
"revenueTotal": fareClass.revenueTotal,
"fareBasisCode": fareClass.fareBasisCode ?? NSNull()
]
}
] as [String: Any]
}
var summary: [String: Any] = [
"mode": "fetcher",
"origin": origin,
"destination": destination,
"date": date,
"flightCount": result.flights.count,
"rawSearchBodyLength": result.rawSearchBody?.count ?? 0,
"error": result.error ?? NSNull(),
"flights": flights
]
if let lowFare = result.lowFareFallback {
summary["lowFareFallback"] = [
"date": lowFare.date,
"available": lowFare.available,
"lowestPrice": lowFare.lowestPrice as Any? ?? NSNull()
]
}
writeSummary(summary)
// Verification requires true per-flight data, not only the route-level fallback.
exitCode = (result.error == nil && !result.flights.isEmpty) ? 0 : 1
}
@MainActor
private func runServiceMode() async {
logSmoke("starting service \(flightNumber) \(origin)->\(destination) on \(date)"
+ (departureTime.map { " @ \($0)" } ?? ""))
guard let queryDate = parsedDate() else {
writeSummary([
"mode": "service",
"error": "invalid date \(date)"
])
exitCode = 1
return
}
let service = AirlineLoadService()
let result = await service.fetchLoad(
airlineCode: "XE",
flightNumber: flightNumber,
date: queryDate,
origin: origin,
destination: destination,
departureTime: departureTime
)
logSmoke("service returned")
let cabins = result?.cabins.map { cabin in
[
"name": cabin.name,
"capacity": cabin.capacity,
"booked": cabin.booked,
"available": cabin.available,
"revenueStandby": cabin.revenueStandby,
"nonRevStandby": cabin.nonRevStandby,
"waitListCount": cabin.waitListCount,
"jumpSeat": cabin.jumpSeat
]
} ?? []
let summary: [String: Any] = [
"mode": "service",
"queryFlightNumber": flightNumber,
"queryDepartureTime": departureTime ?? NSNull(),
"origin": origin,
"destination": destination,
"date": date,
"result": result.map { load in
[
"airlineCode": load.airlineCode,
"flightNumber": load.flightNumber,
"totalAvailable": load.totalAvailable,
"totalCapacity": load.totalCapacity,
"cabins": cabins
] as [String: Any]
} ?? NSNull()
]
writeSummary(summary)
let isPerFlightCabin = cabins.contains { ($0["name"] as? String) == "Cabin" }
exitCode = (result != nil && isPerFlightCabin) ? 0 : 1
}
@MainActor
func start() async {
if useService {
await runServiceMode()
} else {
await runFetcherMode()
}
NSApp.terminate(nil)
}
}
@main
struct JSXSwiftSmokeMain {
static func main() {
let env = ProcessInfo.processInfo.environment
let origin = (env["JSX_ORIGIN"] ?? "DAL").uppercased()
let destination = (env["JSX_DESTINATION"] ?? "HOU").uppercased()
let date = env["JSX_DATE"] ?? "2026-04-15"
let useService = env["JSX_USE_SERVICE"] == "1"
let flightNumber = env["JSX_FLIGHT_NUMBER"] ?? "XE280"
let departureTime = env["JSX_DEPARTURE_TIME"]
let app = NSApplication.shared
let delegate = JSXSwiftSmokeApp(
origin: origin,
destination: destination,
date: date,
useService: useService,
flightNumber: flightNumber,
departureTime: departureTime
)
logSmoke("booting app")
app.setActivationPolicy(.prohibited)
Timer.scheduledTimer(withTimeInterval: 120, repeats: false) { _ in
logSmoke("timeout after 120s")
NSApp.terminate(nil)
}
Task { @MainActor in
await delegate.start()
}
app.run()
exit(delegate.exitCode)
}
}