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:
@@ -39,6 +39,7 @@ Pods/
|
|||||||
|
|
||||||
# APK files
|
# APK files
|
||||||
apps/
|
apps/
|
||||||
|
airlines/
|
||||||
|
|
||||||
# Claude
|
# Claude
|
||||||
.claude/
|
.claude/
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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, 1–2 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 24–48 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).
|
||||||
@@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>`.
|
||||||
@@ -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
|
||||||
@@ -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` (0–1) 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
@@ -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();
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user