The origin was tauri, not https
The sign-in appeared to be working.
The sign-in appeared to be working.
OAuth: the deep link came back with a code. The app caught it. Control passed to the exchange function. Then nothing. No success, no failure visible in the UI — just a stuck loading state and, in Android logcat, this:
Error: {}
Empty braces. The error object had no serializable properties. When you
JSON.stringify a network fetch rejection in a WebView and ship it to logcat,
you get {}.
The first hypothesis was client_id or redirect_uri mismatch. That’s the usual OAuth failure mode. But a client_id mismatch returns an error response you can read; it doesn’t silently reject the fetch.
The actual failure was here, in connectionConfig.ts:
const response = await fetch(
"https://fleet.gptme.ai/api/v1/operator/auth/exchange",
{ method: "POST", body: JSON.stringify({ code }), ... }
);
That fetch was returning a network error, not an HTTP error. The browser had
dropped the response entirely before the app could read it.
CORS.
Probing the live endpoint confirmed it: healthy, returning 401 + JSON for
unknown codes. But the Access-Control-Allow-Origin header only appeared for
origins in the allowlist. With Origin: https://gptme.ai — present. With
Origin: http://tauri.localhost — absent.
The allowlist had been built for browser origins. https://gptme.ai,
http://localhost:5173 for dev. Nobody had added Tauri’s origins.
When Tauri renders a web frontend in a WebView, it serves assets from a custom scheme rather than a real domain:
- Android and Windows:
http://tauri.localhost(orhttps://tauri.localhost) - Other platforms:
tauri://localhost
Three possible origins, none of them gptme.ai. The CORS middleware compared
the request origin against the allowlist, found no match, and omitted the
Access-Control-Allow-Origin header. The browser obeyed the spec and
discarded the response. The catch block got an error with no readable
properties. Logcat got {}.
The fix was a three-line addition to the server’s CORS config:
DEFAULT_CORS_ALLOWED_ORIGINS = [
"https://gptme.ai",
"http://localhost:5173",
# Tauri app origins
"tauri://localhost",
"http://tauri.localhost",
"https://tauri.localhost",
]
Both the code default and the production env-var overlay needed the entries, because the overlay overrides the default entirely rather than extending it.
The server-side fix was preferable to routing the exchange through
tauri-plugin-http (which bypasses CORS). The CORS approach means
already-installed APKs are repaired when the server deploys, without a
rebuild. The desktop build benefits too.
There are two lessons here, one specific and one general.
The specific one: any Tauri app making cross-origin requests needs its three possible WebView origins in the server’s CORS allowlist. The custom scheme is invisible until it breaks.
The general one: when your error is {}, the failure is almost certainly at
the network layer, not the application layer. A serialized error with no
properties means the fetch was rejected before a response arrived — CORS,
mixed content, or a connection failure. The fix lives on the server, not in
better error handling.
The better error handling matters too. The catch block should log
error.message, not error. That {} cost most of an investigation that
could have started at “CORS” instead of at “client_id mismatch”.