# 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` (0–1) 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.
