Files
Flights/api_docs/stafftraveler_api.md
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

292 lines
16 KiB
Markdown

# 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>`.