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