# 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/` (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= Content-Type: application/json { "email": "...", "password": "...", "returnSecureToken": true } → { idToken, refreshToken, expiresIn: "3600", localId: , ... } ``` The ID token is a 1-hour JWT; refresh via `securetoken.googleapis.com/v1/token?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/ Authorization: Bearer Content-Type: application/json x-stafftraveler-client: mobile ← the iOS app sends this; web sends "web". Probably not enforced. { ... command-specific body ... } ``` Response: `{ payload: }` on success, `{ isError: true, payload: }` 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 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: 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: [] ← who has reported on this flight reportAuthors: { : true, ... } subscribedUsers: { : true, ... } ← all users tracking this flight subscribedUserList: [] statusUpdatesSubscribedUserIds: [] usersRequestingLoads: { : , ... } ← users currently requesting an update usersRequestingPriority: { ... } usersEligibleForRefund: { : 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 == ` | | `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.