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

369 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# StaffTraveler — complete findings
Single source of truth for the StaffTraveler reverse-engineering effort. Verified by HTTP captures, Firestore REST probes, and Hermes bytecode analysis against the live `stafftraveler-prod` Firebase project. Raw artifacts in `stafftraveler_captures/`.
Source app: `com.stafftraveler.webview` v3.12.0 (build 1880000346), iOS UA `stafftraveler/560 CFNetwork/3860.500.112 Darwin/25.4.0`. Web client at `https://app.stafftraveler.com` (same Firebase project, same Firestore rules — used for inspection because it sends the long-poll fallback transport instead of gRPC).
## TL;DR for the iOS Flights integration
To show flight loads inside our app:
1. Add a secondary `FirebaseApp` named e.g. `StaffTravelerFirebaseApp` configured with the **web** API key + GMPID below.
2. Sign in as the user with Firebase Auth (email/password) → store refresh token in Keychain.
3. To search routes, `POST https://api.stafftraveler.com/v1/commands/user/searchFlightsByRoute` with the Bearer ID token.
4. To **unlock** loads on a flight, `POST .../createLoadsRequests` with the flight object + `isPriorityRequest: false` — costs **1 credit per new flight**, idempotent on replay.
5. After unlock, attach a Firestore snapshot listener on `derivedLoadsReports.where("flightId", "==", flightIdV3).order("createdAt", desc)` to receive live load updates.
6. Optional surface for "what's happening on my own airline": query `trackedFlights.where("scheduledFlight.airlineStCode", "==", userStCode)` to see flights other employees on the same airline have requested.
You **cannot** show real load numbers for a flight without first calling `createLoadsRequests` on that specific flight. There is no bulk/peek endpoint — verified extensively (see "Security model").
---
## Architecture
React Native + Hermes bytecode iOS/Android app with a thin native shell. All business logic in `assets/index.android.bundle`. Backend is Firebase:
| Component | Service |
|-----------|---------|
| Auth | Firebase Auth (email/password, Google, Apple, Facebook) |
| Real-time data | Cloud Firestore (`stafftraveler-prod`) |
| Mutations | Custom HTTP commands API at `api.stafftraveler.com/v1/commands/user/<name>` (likely Cloud Functions) |
| Config | Firebase Remote Config (controls API base URL, Typesense host/key, feature flags) |
| Search index | Typesense (`enhk2ji1vu6csxzrp-1.a1.typesense.net`) — server-side, not hit directly by the client |
| Push | FCM / APNs |
| Static CDN | `images.stafftraveler.com` |
No SSL pinning. No App Check enforcement (Play Integrity classes are bundled but the `createFirebaseCommand` fetch doesn't attach an `X-Firebase-AppCheck` header, and the web client makes every request with just the ID token).
## Firebase project config
From APK `res/values/strings.xml` and the web client's IndexedDB:
| Key | Value |
|-----|-------|
| Project ID | `stafftraveler-prod` |
| **Web API key** (the one that works for REST) | `AIzaSyD82Hiqx-jAbHF8faMGSveqLw8CdX8a-Uc` |
| Web App ID / GMPID | `1:628258099825:web:35b20eaab4d441894041d0` |
| Android API key | `AIzaSyC2zG6ArnguzzdWsLYV1qjQznma0zl1Q0s` (locked to the signed APK) |
| Android App ID | `1:628258099825:android:5fb976ba6ad1bb05` |
| Sender ID | `628258099825` |
| Firestore | `(default)` database |
| RTDB | `https://stafftraveler-prod.firebaseio.com` (registered but unused for loads) |
| Storage | `stafftraveler-prod.appspot.com` |
For an iOS integration, register a new web/iOS app in the same Firebase console — but the existing web app credentials above are sufficient for direct REST and the web SDK in our app.
## Auth
```
POST https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=<web-key>
Content-Type: application/json
{ "email": "...", "password": "...", "returnSecureToken": true }
→ { idToken, refreshToken, expiresIn: "3600", localId: <uid>, ... }
```
The ID token is a 1-hour JWT; refresh via `securetoken.googleapis.com/v1/token?key=<web-key>` with `grant_type=refresh_token&refresh_token=...`. The Firebase SDK does this transparently.
Token claims include `email`, `user_id`, `is_registered`. Issuer `https://securetoken.google.com/stafftraveler-prod`, audience `stafftraveler-prod`.
## HTTP commands API
```
POST https://api.stafftraveler.com/v1/commands/user/<commandName>
Authorization: Bearer <firebase id token>
Content-Type: application/json
x-stafftraveler-client: mobile ← the iOS app sends this; web sends "web". Probably not enforced.
{ ... command-specific body ... }
```
Response: `{ payload: <result> }` on success, `{ isError: true, payload: <message> }` on validation/business errors. Both come back HTTP 200; check the `isError` flag.
`api_commands_url` is fetched from Remote Config at startup. The deployed value is `https://api.stafftraveler.com/v1/commands/user`. The bundle disassembly confirms there is no other path prefix — tested `/public/`, `/internal/`, `/admin/`, `/v2/`, `/flights/`, `/loads/`, `/system/`, `/airline/` → all 404.
Also exposed on the same host: `GET /health``{message:"ok", serverTime:...}`.
### Command inventory (all 34, via `createFirebaseCommand` enumeration)
**Reads (free, idempotent):**
- `searchFlightsByRoute` — flight schedule by origin/destination/date
- `searchFlightsByCode` — flight schedule by flight number
- `appActive` — heartbeat; needs `{localDateIso, appType}`; returns `{payload: null}`
- `mobile` — stub, returns `"not implemented"`
**Mutations (most of these spend credits or modify account state):**
```
addAirlineDetails addCreditsForPurchase addComment addIdentity
appLogout createLoadsRequests createTip deleteLoadsRequest
deleteUserAccount disableAllAutoRequestSubscriptions
disableFlightStatusUpdates enableFlightStatusUpdates discardHint
flagLoadsReport omitIdentity pinFlight unpinFlight
registerUser removeComment reportComment
reopenLoadsRequest reviseLoadsReport requestLockForLoadsRequest
unlockLoadsRequest setAutoRequestSubscription submitFeedback
submitLoadsReport unlikeTip updateUserProfile upgradeLoadsRequest
```
### `searchFlightsByRoute` — confirmed payload
```json
{
"originIata": "DFW",
"destinationIata": "LAS",
"dates": ["2026-04-22"],
"maxConnections": 0,
"allowNearbyDepartures": false,
"allowNearbyArrivals": false,
"payloadType": "passenger",
"includeMultipleCarriers": false
}
```
Response: `{ payload: { directFlights: [...], connectingFlights: [...], numberOfDiscardedFlights, settingsFilteredCount, messages } }`.
Flight object fields:
```
id, id_v2, id_v3 (= "AA_2178_DFW_2026_04_22"),
airlineId, airlineStCode, flightCode, flightNumber,
departureAirportId, departureAirportIata, arrivalAirportId, arrivalAirportIata,
departureTimeLocal, departureTimeUtc, arrivalTimeLocal, arrivalTimeUtc,
durationMinutes, isCargoFlight,
flightEquipmentId, flightEquipmentIata,
seatsTotal, seatsByClass: { first, business, premiumEconomy, economy }
```
`id_v3` format is `{airlineId}_{flightNumber}_{departureIata}_{YYYY}_{MM}_{DD}`. **This is the key used everywhere downstream** (Firestore docs, command bodies, etc).
`seatsByClass` is the **aircraft cabin configuration**, NOT current availability — that's a separate Firestore lookup.
### `createLoadsRequests` — confirmed payload
Body is a **bare JSON array** (no outer wrapper) of full flight objects, each with one extra field `isPriorityRequest: bool`:
```json
[
{ ...full flight from search response..., "isPriorityRequest": false }
]
```
Response:
```json
{
"payload": {
"numberOfRequests": 0,
"numberOfRequestsPaidFor": 0,
"numberOfRequestsPaidForPriority": 0,
"hasSufficientCredits": true,
"verifiedRequests": [
{
"flightId": "AA_2178_DFW_2026_04_22",
"isExisting": true,
"isDuplicateForUser": true,
"isRequestUpdateForUser": false,
"hasDeparted": false,
"isCancelled": false,
"isPriorityRequest": false,
"hasRecentLoads": false, summary flag; actual loads come from Firestore
"scheduledFlight": { ... full flight ... }
}
]
}
}
```
**Idempotency:** Replaying the same flight returns `numberOfRequests: 0` + `isDuplicateForUser: true` + **no credit charged**. Useful as a "what's the current state" probe — pay for the first call only, then replay free.
## Firestore — the data plane
REST endpoint: `https://firestore.googleapis.com/v1/projects/stafftraveler-prod/databases/(default)/documents`
**Required headers on every read:**
```
Authorization: Bearer <firebase id token>
X-Firebase-GMPID: 1:628258099825:web:35b20eaab4d441894041d0
?key=AIzaSyD82Hiqx-jAbHF8faMGSveqLw8CdX8a-Uc (URL query)
```
Omitting `?key=` or the GMPID gives `403 PERMISSION_DENIED` even with a valid token — the project-consumer context is required.
**Why mobile mitm couldn't see this:** the iOS Firebase SDK uses gRPC over HTTP/2 to `firestore.googleapis.com`. Many proxies show those streams as opaque binary frames. The web client uses the long-poll fallback (`Listen/channel?VER=8&TYPE=xmlhttp`) with URL-encoded JSON bodies — fully decodable. That's why the answer only fell out via the browser.
### Loads — `derivedLoadsReports`
The single most important query for the integration:
```json
POST documents:runQuery
{
"structuredQuery": {
"from": [{"collectionId": "derivedLoadsReports"}],
"where": {
"fieldFilter": {
"field": {"fieldPath": "flightId"},
"op": "EQUAL",
"value": {"stringValue": "AA_2178_DFW_2026_04_22"}
}
},
"orderBy": [
{"field": {"fieldPath": "createdAt"}, "direction": "DESCENDING"},
{"field": {"fieldPath": "__name__"}, "direction": "DESCENDING"}
]
}
}
```
Doc shape (verified live capture, AA2178 2026-04-22):
```json
{
"flightId": "AA_2178_DFW_2026_04_22",
"openSeats": {
"first": { "count": 3 },
"eco": { "count": 9 }
// also: business, premiumEconomy. Absent class = 0/unknown.
},
"staffListing": {
"type": "available", // also: "upgrade" variants per checkForLoads heuristic
"count": 6, // # standby/listed non-rev passengers
"countByClass": {}
},
"responseTimeSeconds": 196,
"numberOfCreditsRewarded": 1,
"isFlagged": false,
"isFlaggedConfirmed": false,
"seatsAvailabilityScore": 0.79, // 0-1; app uses for color coding
"hasClosedRequest": true,
"isJackpotHit": false,
"isPriorityRequest": false,
"createdAt": "2026-04-22T18:42:51.313Z",
"expireAt": "2026-07-21T21:45:00Z", // ~90 days
"creatorName": "M", // first letter only (privacy)
"creatorImageUrl": "https://images.stafftraveler.com/avatars/happysuitcase.png",
"creatorImagePath": "avatars/happysuitcase.png"
}
```
### `trackedFlights` — request state
One doc per (flight, anyone-who-requested-it). Doc ID = `id_v3` of the flight. Key fields:
```
scheduledFlight: { full flight object — same shape as searchFlightsByRoute }
status: "open" | "closed"
createdBy: <uid of original requester>
createdAt, statusChangedAt, expireAt
priority: integer
priorityElevatedAt
isPriorityRequest, isAutoRequest, isLocked, isCancelled, isDeleted, isDelayed, isDiverted
hasDeparted, hasArrived, hasBeenRefunded
lockedBy, lockedAt ← while a reporter is filling it in
loadsUpdateRequestedBy
currentLoadsReportId ← doc ID into derivedLoadsReports
seatsAvailabilityScore ← summary score (also in derivedLoadsReports)
reportAuthorList: [<uid>] ← who has reported on this flight
reportAuthors: { <uid>: true, ... }
subscribedUsers: { <uid>: true, ... } ← all users tracking this flight
subscribedUserList: [<uid>]
statusUpdatesSubscribedUserIds: [<uid>]
usersRequestingLoads: { <uid>: <ts>, ... } ← users currently requesting an update
usersRequestingPriority: { ... }
usersEligibleForRefund: { <uid>: 1, ... }
sita: { ... rich live SITA flight record (gate, registration, flight times, marketing carriers, etc) ... }
__checksums: { typesense: "..." }
```
### Other Firestore listeners the web client opens (captured)
From the live `Listen/channel` body capture in `firestore_listen_targets_and_queries.txt`:
| Target | Use |
|--------|-----|
| `users/{uid}` | profile |
| `userHints/{uid}`, `userRequestCounters/{uid}`, `userPriorityRequestCounters/{uid}`, `userCredits/{uid}`, `userAchievements/{uid}`, `userMetrics/{uid}`, `userFlightSearchHistory/{uid}` | per-user state |
| `airportsByIata/{IATA}` | airport metadata |
| `airlinesBySt/{stCode}` | airline metadata by ST code |
| `airlineNotesBySt/{stCode}` | non-rev policy notes |
| `flightEquipment/{type}` | aircraft type info |
| `conversations/{flightId_v3}` | per-flight comment thread |
| `users/{uid}/pinnedFlights` (subcoll. query) | pinned flights |
| `users/{uid}/connectingFlights` (subcoll. query) | connecting flight definitions |
| `trackedFlights/{flightId_v3}/statusUpdates` (subcoll. query) | flight status change feed |
| `trackedFlights` query | see "Security model" — airline-scoped |
| `autoRequestRecords` query filtered by `subscribedUserIds ARRAY_CONTAINS uid` AND `isActive == true` | user's auto-subscriptions |
## Security model (the load-data unlock)
This is the constraint that defines the integration shape. Verified by exhaustive probing.
**Rules summary** (inferred from observed access pattern):
| Collection | Rule (effective) |
|------------|------------------|
| `__system/*`, `__derived/nonRevAgreementsBySt`, `airlines/*`, `airlinesBySt/*`, `airportsByIata/*`, `airlineNotesBySt/*`, `flightEquipment/*`, `tips/*`, `autoRequestRecords/*`, `conversations/*` | Public read for any authenticated user |
| `users/{uid}`, `userCredits/{uid}`, all other `user*/{uid}` | Owner-only |
| `trackedFlights` query (`list`) | Allowed only when filtered by `scheduledFlight.airlineStCode == <user's own airline>` |
| `trackedFlights/{id}` direct read (`get`) | Allowed if same-airline OR createdBy == auth.uid |
| `trackedFlights/{id}/statusUpdates` subcollection | Allowed only if you own/created the flight |
| `derivedLoadsReports` query and direct-get | Allowed only if you have a matching `trackedFlights/{flightId}` where you're the creator/subscriber |
| `scheduledFlights/{id}`, `flightRecord/{id}`, `flights/{id}` | Denied for direct-get on any flight |
**Verified 2026-04-22:**
- AA2178 query (I have a request on it) → 200, returns the load doc.
- WN862 query (someone else's request, my airline) → 403.
- Direct GET on `derivedLoadsReports/dVb4zStjhITceArRgLBB` (WN862's load doc, ID extracted from the trackedFlight doc which IS readable as same-airline) → still 403. Same token, same headers. So knowing the doc ID is not a bypass.
- DL/AA/UA/JBU/etc trackedFlights queries → 403 across all 9 airlines tested.
- Same-airline trackedFlights query → 500+ historic WN flights returned (status=closed mostly), confirming the airline-scope leak.
**Net consequence:** there is no "peek" path. To see load numbers on a flight, you must spend a credit on it via `createLoadsRequests`. After that, both the trackedFlight and derivedLoadsReports become readable indefinitely (or until the flight expires ~90 days later), and snapshot listeners deliver new community-submitted reports for free.
The same-airline `trackedFlights` listing exposes one useful summary signal without paying: `seatsAvailabilityScore` (01) on flights other employees on your own airline have asked about. Coarse go/no-go indicator, no class-level breakdown.
## Public reference data worth caching
Pulled and saved to `stafftraveler_captures/`:
- **`__derived/nonRevAgreementsBySt`** (`nonRevAgreementsBySt.json`) — full interline matrix: 300 airlines, each with an array of airline ST codes they have non-rev agreements with. Useful for filtering search results to "airlines I can actually non-rev on" without us building this ourselves.
- **`airlinesBySt/*`** (`airlinesBySt_full.json`) — ST-code → airline directory.
Both stable, refresh occasionally.
## What the iOS app actually needs
Concrete integration plan:
1. **Secondary FirebaseApp** `StaffTravelerApp` configured with the web key + GMPID above. Initialize on first user-opt-in.
2. **Auth screen** — email/password login → store refresh token in Keychain. Surface "no StaffTraveler account? Sign up in the StaffTraveler app first" — `addAirlineDetails` verification has to happen there.
3. **Bridge user state** — read `users/{uid}` (gets `airlineStCode`, name) and `userCredits/{uid}` (gets balance). Display credit balance in our UI.
4. **Search**: `POST /v1/commands/user/searchFlightsByRoute`. Free.
5. **Flight row UI**: for each result, attach a Firestore snapshot listener on `derivedLoadsReports.where("flightId", "==", flightId_v3)`.
- If the listener gets a doc → render loads inline.
- If 403 → flight isn't unlocked yet; show a "Request loads (1 credit)" CTA.
6. **Request CTA**`POST /v1/commands/user/createLoadsRequests` with the full flight + `isPriorityRequest: false`. Idempotent — surface "you already requested this" if `isDuplicateForUser: true`.
7. **Comments / status feed (optional)** — listeners on `conversations/{flightId}` and `trackedFlights/{flightId}/statusUpdates`.
8. **Token refresh** — let Firebase iOS SDK handle it. Don't try to do it manually.
Mutations to wire later if useful: `pinFlight`/`unpinFlight`, `setAutoRequestSubscription` (auto-poll a recurring route), `submitLoadsReport` (let the user contribute back if they're at the gate). Skip everything else.
## Captures (raw artifacts)
In `stafftraveler_captures/`:
- `searchFlightsByRoute_DFW-LAS_request.json` / `_response.json` — schedule API
- `createLoadsRequests_AA2178_request.json` / `_response.json` / `_response_headers.txt` — load-request API
- `derivedLoadsReports_AA2178_request.json` / `_response.json` — actual loads readout
- `firestore_listen_targets_and_queries.txt` — all `addTarget` bodies the web client opens in a session
- `nonRevAgreementsBySt.json` — interline matrix
- `airlinesBySt_full.json` — airline directory
- `firestore_schema.md`, `url_surface_crawl.md`, `README.md` — interim notes
## Things that are NOT possible
Documenting the dead ends so we don't relitigate:
- **Cross-airline load enumeration** — blocked by the `airlineStCode` filter on `trackedFlights` and the createdBy check on `derivedLoadsReports`. Tested across 9 airline codes.
- **Bulk peek at multiple flights' loads in one call** — no API. Each flight needs its own credit unlock.
- **Reading another user's `derivedLoadsReports` by knowing the doc ID** — rule evaluates the doc's `flightId` against your tracked flights. Direct-GET on a known foreign doc ID returns 403.
- **Spoofing airline membership** — token claims are server-issued; `setAirlineStCodes` is a real command but changing your airline would affect the user's actual account.
- **Anonymous Firebase Auth on this project** — the Android API key is restricted to the signed APK package + cert SHA1. The web key is unrestricted on `signInWithPassword` but you still need real credentials.
- **App Check bypass** — moot; not enforced in practice.