- 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>
19 KiB
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:
- Add a secondary
FirebaseAppnamed e.g.StaffTravelerFirebaseAppconfigured with the web API key + GMPID below. - Sign in as the user with Firebase Auth (email/password) → store refresh token in Keychain.
- To search routes,
POST https://api.stafftraveler.com/v1/commands/user/searchFlightsByRoutewith the Bearer ID token. - To unlock loads on a flight,
POST .../createLoadsRequestswith the flight object +isPriorityRequest: false— costs 1 credit per new flight, idempotent on replay. - After unlock, attach a Firestore snapshot listener on
derivedLoadsReports.where("flightId", "==", flightIdV3).order("createdAt", desc)to receive live load updates. - 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/datesearchFlightsByCode— flight schedule by flight numberappActive— 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 (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:
- Secondary FirebaseApp
StaffTravelerAppconfigured with the web key + GMPID above. Initialize on first user-opt-in. - Auth screen — email/password login → store refresh token in Keychain. Surface "no StaffTraveler account? Sign up in the StaffTraveler app first" —
addAirlineDetailsverification has to happen there. - Bridge user state — read
users/{uid}(getsairlineStCode, name) anduserCredits/{uid}(gets balance). Display credit balance in our UI. - Search:
POST /v1/commands/user/searchFlightsByRoute. Free. - 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.
- Request CTA →
POST /v1/commands/user/createLoadsRequestswith the full flight +isPriorityRequest: false. Idempotent — surface "you already requested this" ifisDuplicateForUser: true. - Comments / status feed (optional) — listeners on
conversations/{flightId}andtrackedFlights/{flightId}/statusUpdates. - 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 APIcreateLoadsRequests_AA2178_request.json/_response.json/_response_headers.txt— load-request APIderivedLoadsReports_AA2178_request.json/_response.json— actual loads readoutfirestore_listen_targets_and_queries.txt— alladdTargetbodies the web client opens in a sessionnonRevAgreementsBySt.json— interline matrixairlinesBySt_full.json— airline directoryfirestore_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
airlineStCodefilter ontrackedFlightsand the createdBy check onderivedLoadsReports. 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
derivedLoadsReportsby knowing the doc ID — rule evaluates the doc'sflightIdagainst your tracked flights. Direct-GET on a known foreign doc ID returns 403. - Spoofing airline membership — token claims are server-issued;
setAirlineStCodesis 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
signInWithPasswordbut you still need real credentials. - App Check bypass — moot; not enforced in practice.