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,7 +1,7 @@
import {NextResponse} from 'next/server';
import type {NextRequest} from 'next/server';
import {authApi} from '@/services/api/authApi';
import {UpstreamError} from '@/services/api/apiClient';
import {ConfigError, UpstreamError} from '@/services/api/apiClient';
import {
LOGIN_ERROR_PARAM,
type LoginErrorCode,
@@ -71,9 +71,19 @@ async function parse(req: NextRequest): Promise<ParsedLogin> {
};
}
/**
* The envelope `code` is derived from the STATUS, not passed in, so the two can
* never disagree. `reason` carries the specific login code alongside it.
*
* 5xx maps to 'internal' rather than falling through to 'unauthorized': a
* misconfigured server telling the browser the credentials were rejected is a
* lie that costs somebody a password reset.
*/
function failJson(code: LoginErrorCode, message: string, status: number) {
const envelopeCode =
status >= 500 ? 'internal' : status === 429 ? 'bad_request' : 'unauthorized';
return Response.json(
{error: {code: status === 429 ? 'bad_request' : 'unauthorized', message}, field: 'form', reason: code},
{error: {code: envelopeCode, message}, field: 'form', reason: code},
{status, headers: {'cache-control': 'no-store'}},
);
}
@@ -96,6 +106,35 @@ export async function POST(req: NextRequest) {
try {
bundle = await authApi.login(email, password);
} catch (err) {
/*
* A misconfigured server, before anything about the credentials matters.
*
* Checked FIRST and kept out of the unreachable branch below. Both used to
* land on 502 platform_unreachable — "Could not reach Loyaly, check your
* connection" — for a fault that is entirely ours and that no amount of
* checking a connection will fix. 500 is the honest status: this server
* cannot serve, as opposed to an upstream that is unwell.
*
* The detail names an environment variable, so it is logged rather than
* returned. An anonymous sign-in form is the last place to publish which
* hosts a deployment accepts.
*/
if (err instanceof ConfigError) {
console.error('[loyaly] configuration error:', err.message);
const code: LoginErrorCode = 'misconfigured';
if (isForm) {
return NextResponse.redirect(
new URL(`/login?${LOGIN_ERROR_PARAM}=${code}`, req.url),
303,
);
}
return failJson(
code,
'Sign-in is unavailable right now. Please contact support.',
500,
);
}
const up = err instanceof UpstreamError ? err : null;
// The platform answers wrong-password and no-such-account identically, on