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()