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>
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
# 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>`.
|
||||
Reference in New Issue
Block a user