fix(api): stop reporting a misconfigured server as an unreachable platform

`POST /api/auth/login` answered 502 platform_unreachable — "Could not reach
Loyaly. Check your connection and try again." — for a fault that is entirely
ours and that no connection check can fix.

`buildUrl()` is where LOYALY_API_BASE is read, and it was called INSIDE the
try block whose catch turns a failed fetch into UpstreamError(0, 'network').
So the config guard threw, the catch swallowed it, and "nobody set
LOYALY_API_BASE" arrived at the route indistinguishable from "the platform is
down". Measured on the pre-fix code, all of these produced the identical
UpstreamError(status=0, code=network):

  LOYALY_API_BASE unset
  LOYALY_API_BASE=https://platform.loyaly.ai   (the known-wrong host)
  LOYALY_API_BASE=not-a-url
  nothing listening on the far end             (the only real network failure)

The message survived, so the truth was reachable, but only by reading the
prose of an error the code had already classified as a network fault — and
the login route had by then replaced it with advice about the user's wifi.

ConfigError now exists for this, buildUrl is resolved before the try in both
upstreamRequest and upstreamRaw, and callers branch on it: 500 misconfigured,
not 502 unreachable. 500 is the honest status — a bad gateway says the thing
upstream is unwell, and this server has not got as far as having an upstream.
Verified after the change: the four config faults raise ConfigError, and a
dead port still raises UpstreamError(0, 'network').

The detail is logged, never returned. It names an environment variable and the
hosts this console accepts, which belongs in the Dokploy log pane rather than
in an anonymous sign-in form's response body. `[loyaly] configuration error:`
is now the line to grep for.

Also fixes failJson labelling a 5xx as `unauthorized` in the envelope: the
code is derived from the status now, so a misconfigured server can no longer
tell a browser the password was wrong. That one costs somebody a password
reset for a fault they cannot see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 19:35:54 +05:30
parent a40afb9e7a
commit 3dc0bba6f4
4 changed files with 112 additions and 8 deletions

View File

@@ -1,6 +1,6 @@
import 'server-only';
import type {NextRequest} from 'next/server';
import {UpstreamError} from '@/services/api/apiClient';
import {ConfigError, UpstreamError} from '@/services/api/apiClient';
import {NoSessionError, withUpstream} from '@/features/auth/services/upstreamSession';
import {ok, parseQuery, type Query} from '@/shared/services/apiRoute';
import type {ApiErrorCode} from '@/shared/types/api';
@@ -46,6 +46,28 @@ export function failureFrom(err: unknown): UpstreamFailure {
return {status: 401, code: 'unauthorized', message: err.message};
}
/**
* A misconfigured deployment. 500, never 502 — a bad gateway says "the thing
* upstream is unwell, try later", and this server has not got as far as
* having an upstream. Retrying and checking the platform's health both waste
* the reader's time.
*
* The detail is LOGGED, never returned. It names an environment variable and
* the hosts this console does and does not accept, which is operator
* information and not something to hand an anonymous browser. The log line is
* the only copy, and it is what shows up in `docker logs` / the Dokploy log
* pane the moment the first request comes in.
*/
if (err instanceof ConfigError) {
console.error('[loyaly] configuration error:', err.message);
return {
status: 500,
code: 'internal',
message: 'This console is not configured correctly. Please contact support.',
reason: 'misconfigured',
};
}
if (err instanceof UpstreamError) {
// 501 is "the feature is off for this deployment", not a fault. It is
// surfaced as its own reason so a panel can say "not available here"