Files
Flights/api_docs/stafftraveler_findings.md
T
Trey t 6005146e75 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>
2026-04-24 23:21:30 -05:00

19 KiB
Raw Blame History

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

{
  "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:

[
  { ...full flight from search response..., "isPriorityRequest": false }
]

Response:

{
  "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:

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):

{
  "flightId": "AA_2178_DFW_2026_04_22",
  "openSeats": {
    "first":  { "count": 3 },
    "eco":    { "count": 9 }
    // also: business, premiumEconomy. Absent class = 0/unknown.
  },
  "staffListing": {
    "type": "available",         // also: "upgrade" variants per checkForLoads heuristic
    "count": 6,                  // # standby/listed non-rev passengers
    "countByClass": {}
  },
  "responseTimeSeconds": 196,
  "numberOfCreditsRewarded": 1,
  "isFlagged": false,
  "isFlaggedConfirmed": false,
  "seatsAvailabilityScore": 0.79,   // 0-1; app uses for color coding
  "hasClosedRequest": true,
  "isJackpotHit": false,
  "isPriorityRequest": false,
  "createdAt": "2026-04-22T18:42:51.313Z",
  "expireAt":  "2026-07-21T21:45:00Z",   // ~90 days
  "creatorName": "M",                    // first letter only (privacy)
  "creatorImageUrl": "https://images.stafftraveler.com/avatars/happysuitcase.png",
  "creatorImagePath": "avatars/happysuitcase.png"
}

trackedFlights — request state

One doc per (flight, anyone-who-requested-it). Doc ID = id_v3 of the flight. Key fields:

scheduledFlight: { full flight object — same shape as searchFlightsByRoute }
status: "open" | "closed"
createdBy: <uid of original requester>
createdAt, statusChangedAt, expireAt
priority: integer
priorityElevatedAt
isPriorityRequest, isAutoRequest, isLocked, isCancelled, isDeleted, isDelayed, isDiverted
hasDeparted, hasArrived, hasBeenRefunded
lockedBy, lockedAt                           ← while a reporter is filling it in
loadsUpdateRequestedBy
currentLoadsReportId                         ← doc ID into derivedLoadsReports
seatsAvailabilityScore                       ← summary score (also in derivedLoadsReports)
reportAuthorList: [<uid>]                    ← who has reported on this flight
reportAuthors: { <uid>: true, ... }
subscribedUsers: { <uid>: true, ... }        ← all users tracking this flight
subscribedUserList: [<uid>]
statusUpdatesSubscribedUserIds: [<uid>]
usersRequestingLoads: { <uid>: <ts>, ... }   ← users currently requesting an update
usersRequestingPriority: { ... }
usersEligibleForRefund: { <uid>: 1, ... }
sita: { ... rich live SITA flight record (gate, registration, flight times, marketing carriers, etc) ... }
__checksums: { typesense: "..." }

Other Firestore listeners the web client opens (captured)

From the live Listen/channel body capture in firestore_listen_targets_and_queries.txt:

Target Use
users/{uid} profile
userHints/{uid}, userRequestCounters/{uid}, userPriorityRequestCounters/{uid}, userCredits/{uid}, userAchievements/{uid}, userMetrics/{uid}, userFlightSearchHistory/{uid} per-user state
airportsByIata/{IATA} airport metadata
airlinesBySt/{stCode} airline metadata by ST code
airlineNotesBySt/{stCode} non-rev policy notes
flightEquipment/{type} aircraft type info
conversations/{flightId_v3} per-flight comment thread
users/{uid}/pinnedFlights (subcoll. query) pinned flights
users/{uid}/connectingFlights (subcoll. query) connecting flight definitions
trackedFlights/{flightId_v3}/statusUpdates (subcoll. query) flight status change feed
trackedFlights query see "Security model" — airline-scoped
autoRequestRecords query filtered by subscribedUserIds ARRAY_CONTAINS uid AND isActive == true user's auto-subscriptions

Security model (the load-data unlock)

This is the constraint that defines the integration shape. Verified by exhaustive probing.

Rules summary (inferred from observed access pattern):

Collection Rule (effective)
__system/*, __derived/nonRevAgreementsBySt, airlines/*, airlinesBySt/*, airportsByIata/*, airlineNotesBySt/*, flightEquipment/*, tips/*, autoRequestRecords/*, conversations/* Public read for any authenticated user
users/{uid}, userCredits/{uid}, all other user*/{uid} Owner-only
trackedFlights query (list) Allowed only when filtered by scheduledFlight.airlineStCode == <user's own airline>
trackedFlights/{id} direct read (get) Allowed if same-airline OR createdBy == auth.uid
trackedFlights/{id}/statusUpdates subcollection Allowed only if you own/created the flight
derivedLoadsReports query and direct-get Allowed only if you have a matching trackedFlights/{flightId} where you're the creator/subscriber
scheduledFlights/{id}, flightRecord/{id}, flights/{id} Denied for direct-get on any flight

Verified 2026-04-22:

  • AA2178 query (I have a request on it) → 200, returns the load doc.
  • WN862 query (someone else's request, my airline) → 403.
  • Direct GET on derivedLoadsReports/dVb4zStjhITceArRgLBB (WN862's load doc, ID extracted from the trackedFlight doc which IS readable as same-airline) → still 403. Same token, same headers. So knowing the doc ID is not a bypass.
  • DL/AA/UA/JBU/etc trackedFlights queries → 403 across all 9 airlines tested.
  • Same-airline trackedFlights query → 500+ historic WN flights returned (status=closed mostly), confirming the airline-scope leak.

Net consequence: there is no "peek" path. To see load numbers on a flight, you must spend a credit on it via createLoadsRequests. After that, both the trackedFlight and derivedLoadsReports become readable indefinitely (or until the flight expires ~90 days later), and snapshot listeners deliver new community-submitted reports for free.

The same-airline trackedFlights listing exposes one useful summary signal without paying: seatsAvailabilityScore (01) on flights other employees on your own airline have asked about. Coarse go/no-go indicator, no class-level breakdown.

Public reference data worth caching

Pulled and saved to stafftraveler_captures/:

  • __derived/nonRevAgreementsBySt (nonRevAgreementsBySt.json) — full interline matrix: 300 airlines, each with an array of airline ST codes they have non-rev agreements with. Useful for filtering search results to "airlines I can actually non-rev on" without us building this ourselves.
  • airlinesBySt/* (airlinesBySt_full.json) — ST-code → airline directory.

Both stable, refresh occasionally.

What the iOS app actually needs

Concrete integration plan:

  1. Secondary FirebaseApp StaffTravelerApp configured with the web key + GMPID above. Initialize on first user-opt-in.
  2. Auth screen — email/password login → store refresh token in Keychain. Surface "no StaffTraveler account? Sign up in the StaffTraveler app first" — addAirlineDetails verification has to happen there.
  3. Bridge user state — read users/{uid} (gets airlineStCode, name) and userCredits/{uid} (gets balance). Display credit balance in our UI.
  4. Search: POST /v1/commands/user/searchFlightsByRoute. Free.
  5. Flight row UI: for each result, attach a Firestore snapshot listener on derivedLoadsReports.where("flightId", "==", flightId_v3).
    • If the listener gets a doc → render loads inline.
    • If 403 → flight isn't unlocked yet; show a "Request loads (1 credit)" CTA.
  6. Request CTAPOST /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.