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,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` (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.
|
||||
Reference in New Issue
Block a user