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:
Trey t
2026-04-24 23:21:30 -05:00
parent 1e74552184
commit 6005146e75
26 changed files with 61519 additions and 1334 deletions
+368
View File
@@ -0,0 +1,368 @@
# 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.