From 62664cfcc33de733dda5f4c9800d55d95f4e72e5 Mon Sep 17 00:00:00 2001 From: HiLleywyn Date: Sat, 9 May 2026 03:12:03 +0000 Subject: [PATCH] Mobile login fix: bake API URL into builds, allow Capacitor origins, surface bad responses The Android (and iOS) APK was crashing on Sign in with "Cannot read properties of null (reading 'challenge')" because: 1. The web bundle inside the Capacitor shell defaulted apiFetch to `/api`, but Capacitor serves the bundle from https://localhost on Android (capacitor://localhost on iOS). The fetch hit the shell's own host, which returned the index.html via the SPA fallback, apiFetch silently returned null, and start.challenge blew up. 2. Even if the right host had been reached, the API's CORS layer only trusted the configured allowed_origins, so a Capacitor origin was rejected. 3. apiFetch swallowed the JSON parse failure (returning null on a 2xx with non-JSON body), which masked the real cause. Fixes: * The mobile workflows (android.yml, ios.yml) now resolve VITE_API_BASE and VITE_GATEWAY_URL from repository variables, falling back to apps/mobile/.env.production. Both values are exported to the env of the web build step so Vite bakes them into the bundle. Without this, every mobile login attempt 404s. * tempest-api always trusts https://localhost, http://localhost, capacitor://localhost, ionic://localhost, and tauri://localhost in addition to the configured web origin, so the mobile shells can authenticate without per-deploy CORS surgery. * apiFetch now throws a `bad_response` TempestError with a snippet of the body when a 2xx returns non-JSON or an empty body, instead of returning null and crashing the caller five lines later. apps/mobile/.env.production seeds the user's existing Railway URLs so fresh APK builds work out of the box; override via repo vars or by editing the file before tagging a release. --- .github/workflows/android.yml | 20 ++++++++++++++++++++ .github/workflows/ios.yml | 14 ++++++++++++++ apps/mobile/.env.production | 12 ++++++++++++ apps/web/src/api/client.ts | 14 +++++++++++++- crates/tempest-api/src/main.rs | 17 ++++++++++++++++- 5 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 apps/mobile/.env.production diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index aa1a5f1..ba44831 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -49,7 +49,27 @@ jobs: - name: Install JS deps run: pnpm install --frozen-lockfile + - name: Resolve API + gateway URLs + # The mobile shell defaults apiFetch to /api which only works when + # the web bundle is hosted on the same origin as the API. Capacitor + # serves from https://localhost, so without baking VITE_API_BASE + # into the build, every login attempt 404s and `start.challenge` + # blows up. Allow the build to fall back to the values committed + # in apps/mobile/.env.production if the repo vars are not set. + run: | + : "${VITE_API_BASE:=${{ vars.VITE_API_BASE }}}" + : "${VITE_GATEWAY_URL:=${{ vars.VITE_GATEWAY_URL }}}" + if [ -z "$VITE_API_BASE" ] && [ -f apps/mobile/.env.production ]; then + set -a; . apps/mobile/.env.production; set +a + fi + echo "VITE_API_BASE=$VITE_API_BASE" >> "$GITHUB_ENV" + echo "VITE_GATEWAY_URL=$VITE_GATEWAY_URL" >> "$GITHUB_ENV" + echo "Building web bundle against API=$VITE_API_BASE GW=$VITE_GATEWAY_URL" + - name: Build web bundle + env: + VITE_API_BASE: ${{ env.VITE_API_BASE }} + VITE_GATEWAY_URL: ${{ env.VITE_GATEWAY_URL }} run: pnpm --filter tempest-web build - name: Add Android platform diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 53a87c3..87c32da 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -35,7 +35,21 @@ jobs: gem install cocoapods --no-document pod --version + - name: Resolve API + gateway URLs + run: | + : "${VITE_API_BASE:=${{ vars.VITE_API_BASE }}}" + : "${VITE_GATEWAY_URL:=${{ vars.VITE_GATEWAY_URL }}}" + if [ -z "$VITE_API_BASE" ] && [ -f apps/mobile/.env.production ]; then + set -a; . apps/mobile/.env.production; set +a + fi + echo "VITE_API_BASE=$VITE_API_BASE" >> "$GITHUB_ENV" + echo "VITE_GATEWAY_URL=$VITE_GATEWAY_URL" >> "$GITHUB_ENV" + echo "Building web bundle against API=$VITE_API_BASE GW=$VITE_GATEWAY_URL" + - name: Build web bundle + env: + VITE_API_BASE: ${{ env.VITE_API_BASE }} + VITE_GATEWAY_URL: ${{ env.VITE_GATEWAY_URL }} run: pnpm --filter tempest-web build - name: Add iOS platform diff --git a/apps/mobile/.env.production b/apps/mobile/.env.production new file mode 100644 index 0000000..3449a7c --- /dev/null +++ b/apps/mobile/.env.production @@ -0,0 +1,12 @@ +# Default API + Gateway URLs the mobile build embeds when the workflow has +# no repo vars set. Override per-deploy by setting VITE_API_BASE / +# VITE_GATEWAY_URL as repository variables in GitHub +# (Settings -> Secrets and variables -> Actions -> Variables), or by +# editing this file before tagging a release. +# +# Example (replace with the URLs printed by `railway up`): +# VITE_API_BASE=https://tempest-api-production-2899.up.railway.app +# VITE_GATEWAY_URL=wss://tempest-gateway-production-1213ce.up.railway.app/gateway + +VITE_API_BASE=https://tempest-api-production-2899.up.railway.app +VITE_GATEWAY_URL=wss://tempest-gateway-production-1213ce.up.railway.app/gateway diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 77279ec..463bbc5 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -80,12 +80,24 @@ export async function apiFetch( const text = await res.text(); let data: any = null; + let parseFailed = false; if (text.length > 0) { - try { data = JSON.parse(text); } catch { /* ignore */ } + try { data = JSON.parse(text); } catch { parseFailed = true; } } if (!res.ok) { throw new TempestError(res.status, data ?? { code: "http_error", message: res.statusText }); } + // If we got a 2xx but the body is empty or non-JSON, that almost always + // means the request was rewritten by a 404 catch-all (e.g. the mobile + // shell's webview hit its own host instead of the API). Throwing here + // gives the caller a real error instead of a null reference crash later. + if (data === null) { + const sample = text.slice(0, 80).replace(/\s+/g, " "); + const msg = parseFailed + ? `expected JSON from ${url}, got: ${sample}` + : `empty response from ${url}`; + throw new TempestError(res.status, { code: "bad_response", message: msg }); + } return data as T; } diff --git a/crates/tempest-api/src/main.rs b/crates/tempest-api/src/main.rs index ea696e7..a6131e6 100644 --- a/crates/tempest-api/src/main.rs +++ b/crates/tempest-api/src/main.rs @@ -108,11 +108,26 @@ async fn main() -> anyhow::Result<()> { .merge(routes::uploads::private_router()) .layer(axum_mw::from_fn_with_state(state.clone(), middleware::auth::require_auth)); - let allow_origins: Vec = cfg + // The public web origin lives in cfg.allowed_origins. Capacitor wraps + // the same web bundle into Android (https://localhost) and iOS + // (capacitor://localhost) shells, so we always allow those two so the + // mobile builds work without per-deploy origin config. + let mut allow_origins: Vec = cfg .allowed_origins .iter() .filter_map(|o| HeaderValue::from_str(o).ok()) .collect(); + for fixed in [ + "https://localhost", + "http://localhost", + "capacitor://localhost", + "ionic://localhost", + "tauri://localhost", + ] { + if let Ok(v) = HeaderValue::from_str(fixed) { + allow_origins.push(v); + } + } // CORS spec: when allow_credentials is true the headers and methods must // be explicit lists, not wildcards. let cors = CorsLayer::new()