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,192 @@
|
||||
# StaffTraveler Firestore schema (live-extracted)
|
||||
|
||||
Captured 2026-04-22 via Playwright MCP on `https://app.stafftraveler.com` while logged in as `stafftraveler@treymail.com` (uid `3NNPesQMiMRNYnPmuQzh6w2YKyh1`, airline `st_SWA`). All queries/docs below are copied from the web client's `firestore.googleapis.com/.../Listen/channel` `addTarget` bodies — not guessed.
|
||||
|
||||
## Why iOS mitm didn't show this
|
||||
|
||||
The iOS Firebase SDK and Android SDK both use the gRPC transport to `firestore.googleapis.com` (HTTP/2 with protobuf frames). Many mitm setups decode HTTPS but not gRPC framing, so the requests appear as opaque binary. **The web client uses the `VER=8 TYPE=xmlhttp` long-poll fallback — plain HTTP POST with URL-encoded JSON bodies** — which is fully readable. That's why we only saw this via the browser.
|
||||
|
||||
## Required headers for REST access
|
||||
|
||||
Direct REST (`/v1/projects/.../documents:runQuery` or `/documents/...`) works with:
|
||||
|
||||
```
|
||||
Authorization: Bearer <firebase id token>
|
||||
X-Firebase-GMPID: 1:628258099825:web:35b20eaab4d441894041d0
|
||||
X-Goog-Api-Client: gl-js/ fire/12.12.0
|
||||
?key=AIzaSyD82Hiqx-jAbHF8faMGSveqLw8CdX8a-Uc (URL query param)
|
||||
```
|
||||
|
||||
Note: **web API key** (`AIzaSyD82...`), not the Android key from strings.xml. Omitting `?key=...` or the GMPID header causes `403 PERMISSION_DENIED` — the rules consult the key for the project consumer context before evaluating `allow read`.
|
||||
|
||||
## Single-document subscriptions
|
||||
|
||||
These are fetched as `addTarget.documents.documents`:
|
||||
|
||||
| Path | What |
|
||||
|------|------|
|
||||
| `__system/maintenance` | Server maintenance flags |
|
||||
| `__system/version` | Required client versions (public) |
|
||||
| `users/{uid}` | Profile: airlineStCode, airlineId, firstName, gender, … |
|
||||
| `userHints/{uid}` | UI hints the user has dismissed |
|
||||
| `userRequestCounters/{uid}` | Regular-request quota state |
|
||||
| `userPriorityRequestCounters/{uid}` | Priority-request quota state |
|
||||
| `userCredits/{uid}` | Credit balance |
|
||||
| `userAchievements/{uid}` | Unlocked achievements |
|
||||
| `userMetrics/{uid}` | Usage telemetry |
|
||||
| `userFlightSearchHistory/{uid}` | Recent searches |
|
||||
| `airportsByIata/{IATA}` | Public airport lookup (DFW, LAS, …) |
|
||||
| `airlinesBySt/{stCode}` | Airline lookup by StaffTraveler code (`st_AAL`, `st_SWA`, …) |
|
||||
| `airlineNotesBySt/{stCode}` | Non-rev agreement notes per airline |
|
||||
| `flightEquipment/{iataOrId}` | Aircraft type info (`32Q`, `321`, `738`, …) |
|
||||
| `conversations/{flightId_v3}` | Flight-level comment thread |
|
||||
| `derivedLoadsReports/{reportId}` | **Individual load report doc — see below** |
|
||||
| `trackedFlights/{flightId_v3}` | User's tracked flight state |
|
||||
|
||||
## Collection queries
|
||||
|
||||
### `derivedLoadsReports` — THE LOADS DATA
|
||||
|
||||
```json
|
||||
{
|
||||
"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"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**This is the query you want for the Flights integration.** Pass the flight `id_v3` (e.g. `AA_2178_DFW_2026_04_22` — format is `{airlineId}_{flightNumber}_{departureIata}_{YYYY}_{MM}_{DD}`).
|
||||
|
||||
**Access control (verified 2026-04-22):** this query returns `403 PERMISSION_DENIED` unless the user has an active `trackedFlights/{flightId_v3}` doc, which is created only by `POST /v1/commands/user/createLoadsRequests`. That call spends a credit on flights the user has never requested; replay on an already-requested flight is idempotent (no charge). So:
|
||||
|
||||
- Freely re-read `derivedLoadsReports` for any flight you've already requested — no cost, no rate limit observed.
|
||||
- You cannot peek at loads without first requesting — the "instant results" in the app for flights with recent community reports rely on the user having previously paid the credit.
|
||||
- On the 15 DFW-LAS flights tested, only `AA_2178_DFW_2026_04_22` (the one with an open request) returned 200; the other 14 all returned 403. Same for 29 LAX-JFK flights the user hadn't requested — all 403.
|
||||
|
||||
Example doc (captured AA2178 2026-04-22 12:42 PM CT):
|
||||
```json
|
||||
{
|
||||
"flightId": "AA_2178_DFW_2026_04_22",
|
||||
"openSeats": {
|
||||
"first": {"count": 3},
|
||||
"eco": {"count": 9}
|
||||
},
|
||||
"staffListing": {
|
||||
"type": "available",
|
||||
"count": 6,
|
||||
"countByClass": {}
|
||||
},
|
||||
"responseTimeSeconds": 196,
|
||||
"numberOfCreditsRewarded": 1,
|
||||
"isFlagged": false,
|
||||
"isFlaggedConfirmed": false,
|
||||
"seatsAvailabilityScore": 0.79,
|
||||
"hasClosedRequest": true,
|
||||
"isJackpotHit": false,
|
||||
"isPriorityRequest": false,
|
||||
"createdAt": "2026-04-22T18:42:51.313Z",
|
||||
"expireAt": "2026-07-21T21:45:00Z",
|
||||
"creatorName": "M",
|
||||
"creatorImageUrl": "https://images.stafftraveler.com/avatars/happysuitcase.png",
|
||||
"creatorImagePath": "avatars/happysuitcase.png"
|
||||
}
|
||||
```
|
||||
|
||||
Field notes:
|
||||
- `openSeats.<class>.count` — **exactly what the app shows as "OPEN SEATS"**. Classes seen: `first`, `business`, `premiumEconomy`, `eco`. Absent class = 0 or unknown.
|
||||
- `staffListing.count` — **the "LISTED NON-REV PASSENGERS" number**.
|
||||
- `staffListing.type` — `"available"` means loads were given; other types likely include `"upgrade"` variants (see `checkForLoads` heuristic in `bundle.hasm`).
|
||||
- `seatsAvailabilityScore` — 0-1, app uses this to color-code the flight.
|
||||
- `creatorName` is the first letter only (privacy).
|
||||
- `expireAt` — ~90 days out, so load reports stay queryable.
|
||||
|
||||
### `trackedFlights` — your open requests
|
||||
|
||||
```json
|
||||
{
|
||||
"from": [{"collectionId": "trackedFlights"}],
|
||||
"where": {
|
||||
"compositeFilter": {"op": "AND", "filters": [
|
||||
{"fieldFilter": {"field": {"fieldPath": "status"}, "op": "EQUAL", "value": {"stringValue": "open"}}},
|
||||
{"fieldFilter": {"field": {"fieldPath": "hasDeparted"}, "op": "EQUAL", "value": {"booleanValue": false}}},
|
||||
{"fieldFilter": {"field": {"fieldPath": "scheduledFlight.airlineStCode"}, "op": "EQUAL", "value": {"stringValue": "st_SWA"}}}
|
||||
]}
|
||||
},
|
||||
"orderBy": [
|
||||
{"field": {"fieldPath": "priority"}, "direction": "DESCENDING"},
|
||||
{"field": {"fieldPath": "scheduledFlight.departureTimeUtc"}, "direction": "ASCENDING"},
|
||||
{"field": {"fieldPath": "__name__"}, "direction": "ASCENDING"}
|
||||
],
|
||||
"limit": 25
|
||||
}
|
||||
```
|
||||
|
||||
Returns the logged-in user's open requests filtered by their airline. Doc ID = flight id_v3. `scheduledFlight` is the embedded full flight object (matches `searchFlightsByRoute` item shape).
|
||||
|
||||
### `trackedFlights/{flightId}/statusUpdates`
|
||||
|
||||
```json
|
||||
{
|
||||
"from": [{"collectionId": "statusUpdates"}],
|
||||
"orderBy": [
|
||||
{"field": {"fieldPath": "createdAt"}, "direction": "DESCENDING"},
|
||||
{"field": {"fieldPath": "__name__"}, "direction": "DESCENDING"}
|
||||
],
|
||||
"parent": "projects/stafftraveler-prod/databases/(default)/documents/trackedFlights/AA_2178_DFW_2026_04_22"
|
||||
}
|
||||
```
|
||||
|
||||
Delay / gate / equipment changes for that flight over time.
|
||||
|
||||
### `users/{uid}/pinnedFlights`, `users/{uid}/connectingFlights`
|
||||
|
||||
Subcollections under the user doc. Empty for this account.
|
||||
|
||||
### `autoRequestRecords` where `subscribedUserIds ARRAY_CONTAINS {uid}` AND `isActive`
|
||||
|
||||
Auto-request subscriptions — flights the user has set up to auto-request loads for on a recurring schedule.
|
||||
|
||||
### `trackedFlights` where `subscribedUsers.{uid} == true` AND `isDeleted == false`
|
||||
|
||||
Other users' requests that THIS user has subscribed to (so they also see the loads when answered).
|
||||
|
||||
## Idempotent REST access pattern (integration recipe)
|
||||
|
||||
```bash
|
||||
# 1. sign in (you already do this)
|
||||
TOKEN=$(curl -sX POST "https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=$WEB_KEY" \
|
||||
-d "{\"email\":\"$EMAIL\",\"password\":\"$PW\",\"returnSecureToken\":true}" | jq -r .idToken)
|
||||
|
||||
# 2. read loads for any flight
|
||||
BASE='https://firestore.googleapis.com/v1/projects/stafftraveler-prod/databases/(default)/documents'
|
||||
KEY='AIzaSyD82Hiqx-jAbHF8faMGSveqLw8CdX8a-Uc'
|
||||
curl -sX POST "$BASE:runQuery?key=$KEY" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "X-Firebase-GMPID: 1:628258099825:web:35b20eaab4d441894041d0" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"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"}]}}'
|
||||
```
|
||||
|
||||
Returns empty array if no report exists yet (meaning you'd need `createLoadsRequests` first to ask the crew). If a report exists, you get it without spending a credit.
|
||||
|
||||
## Integration plan for the iOS Flights app (final)
|
||||
|
||||
1. Secondary `FirebaseApp` configured against `stafftraveler-prod` with the web API key + GMPID above.
|
||||
2. `Auth.auth(app: stApp).signIn(withEmail:password:)` with user's StaffTraveler credentials, refresh token stored in Keychain.
|
||||
3. Use `Firestore.firestore(app: stApp)` and wire:
|
||||
- `collection("derivedLoadsReports").whereField("flightId", isEqualTo: flightIdV3).addSnapshotListener { ... }` — live load updates per flight
|
||||
- `collection("trackedFlights").whereField("status", isEqualTo: "open")....whereField("scheduledFlight.airlineStCode", isEqualTo: userStCode).limit(to: 25).addSnapshotListener` — user's open requests list
|
||||
- `document("airlinesBySt/\(stCode)")` — airline metadata
|
||||
- `document("airportsByIata/\(iata)")` — airport metadata
|
||||
4. For writes (request loads, submit report, etc.), keep using the HTTP commands API at `https://api.stafftraveler.com/v1/commands/user/<cmd>`.
|
||||
|
||||
No App Check enforcement observed from the web client — plain Firebase ID token + API key is sufficient.
|
||||
Reference in New Issue
Block a user