fix(catalyst): chroot SPA 404s for /cloud/legacy + /notifications + /readyz shadow + /auth/handover html error (#1075)

Five live bugs surfaced on console.omantel.biz 2026-05-07:

  TC-090..092  /cloud/architecture, /cloud/compute, /cloud/network/ingresses
               returned the SPA shell with TanStack Router default 404 in
               sovereign mode. The legacy redirects (LEGACY_CLOUD_REDIRECTS)
               were only mounted under the mothership /provision/$id/cloud
               subtree, never at root for sovereign mode.

  TC-160       /notifications returned the SPA shell + 404 because the only
               notifications route was /provision/$id/notifications and
               NotificationsPage hard-required the URL :deploymentId param
               via useParams({ from: '/provision/$deploymentId/notifications' }).

  TC-211       /readyz returned the SPA shell (HTTP 200 + index.html)
               instead of a real Go-handler probe response, because no
               Gateway rule routed it to catalyst-api — nginx try_files
               and the SPA catch-all both shadowed the path.

  TC-004       /auth/handover with no token returned raw 401 JSON
               {"error":"missing token parameter"} to browser visits,
               breaking the seamless-handover UX promise for stale
               email-link clicks.

Fixes:

* products/catalyst/chart/templates/httproute.yaml — Exact matches
  for /readyz and /healthz on the console hostname route to catalyst-api.
  External monitors pointing at console.<sov>/readyz now hit the real
  Go probe; pod-level k8s probes still hit nginx-internal /healthz.

* products/catalyst/bootstrap/api/internal/handler/auth_handover.go —
  Browser visits (Accept: text/html or Sec-Fetch-Mode: navigate) on
  the missing-token path 302-redirect to /auth/handover-error?reason=
  missing_token. Programmatic callers (Accept: application/json or no
  Accept header) keep the legacy 401 JSON contract that the test
  matrix pins. New tests cover both branches.

* products/catalyst/bootstrap/ui/src/app/router.tsx — Adds
  authHandoverErrorRoute (/auth/handover-error) with a friendly
  error surface; consoleNotificationsRoute (/notifications under the
  Sovereign console layout); consoleLegacyCloudRedirectRoutes
  (sovereign-mode siblings of legacyCloudRedirectRoutes, reusing
  LEGACY_CLOUD_REDIRECTS verbatim so the two redirect sets cannot
  drift). consoleCloudRoute gains validateSearch matching
  provisionCloudRoute.

* products/catalyst/bootstrap/ui/src/pages/sovereign/NotificationsPage.tsx —
  Replaces strict useParams({ from: '/provision/$deploymentId/...' })
  with useResolvedDeploymentId so the page works on both /provision/$id/
  notifications (URL param) and sovereign-mode /notifications
  (/api/v1/sovereign/self self-discovery). Mirrors the pattern used by
  JobsPage / SettingsPage / Dashboard.

Verification:
  helm template products/catalyst/chart  — clean
  npm run build                          — clean (1.88MB bundle, vite v8)
  npx tsc --noEmit                       — clean
  go build ./...                         — clean
  go test -run TestAuthHandover_MissingToken — PASS (legacy + new HTML branch)

Co-authored-by: hatiyildiz <hatice.yildiz@openova.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
e3mrah 2026-05-07 20:29:49 +04:00 committed by GitHub
parent 5a1216992d
commit 3dc9f42c95
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 251 additions and 9 deletions

View File

@ -34,6 +34,7 @@ import (
"math/big"
"net/http"
"os"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
@ -83,6 +84,20 @@ type authHandoverClaims struct {
func (h *Handler) AuthHandover(w http.ResponseWriter, r *http.Request) {
raw := r.URL.Query().Get("token")
if raw == "" {
// Browser visits (e.g. an operator pasting the bare /auth/handover
// URL, or following a stale email link with the token stripped) get
// a SPA-rendered friendly error page instead of bare JSON. Programmatic
// callers (curl / health probes / API clients with explicit
// `Accept: application/json`) keep the legacy 401 JSON contract that
// auth_handover_test.go pins.
//
// Caught live on omantel.biz 2026-05-07 (TC-004): pasting
// https://console.<sov>/auth/handover with no token returned raw
// JSON to the browser, breaking the seamless-handover UX promise.
if wantsHTML(r) {
http.Redirect(w, r, "/auth/handover-error?reason=missing_token", http.StatusFound)
return
}
writeAuthError(w, "missing token parameter")
return
}
@ -400,3 +415,35 @@ func writeAuthError(w http.ResponseWriter, msg string) {
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(authHandoverError{Error: msg}) //nolint:errcheck
}
// wantsHTML returns true when the caller's Accept header prefers
// text/html over application/json. Used by AuthHandover to render a
// SPA-friendly error page for browser visits while preserving the legacy
// JSON contract for programmatic callers (tests + monitors).
//
// Heuristic: an HTML-prefering browser sends `Accept: text/html,...` or
// `Accept: */*` with `Sec-Fetch-Mode: navigate`. We check both — the
// first catches modern browsers, the second catches some legacy clients
// that send `Accept: */*`. JSON-first programmatic callers that send
// `Accept: application/json` (the auth_handover_test cases that send
// no Accept header at all also fall through to the JSON branch because
// neither marker fires) get the legacy 401 JSON.
func wantsHTML(r *http.Request) bool {
accept := r.Header.Get("Accept")
// Explicit non-HTML preference (e.g. `Accept: application/json`) —
// definitely a programmatic caller.
if accept != "" && !strings.Contains(accept, "text/html") &&
!strings.Contains(accept, "*/*") {
return false
}
// text/html anywhere in the Accept header → browser.
if strings.Contains(accept, "text/html") {
return true
}
// `Sec-Fetch-Mode: navigate` is the W3C marker for top-level browser
// navigation. Catches `Accept: */*` browser cases.
if r.Header.Get("Sec-Fetch-Mode") == "navigate" {
return true
}
return false
}

View File

@ -164,6 +164,58 @@ func TestAuthHandover_MissingToken(t *testing.T) {
assertAuthError(t, w, http.StatusUnauthorized, "missing token parameter")
}
// TC-004 / 2026-05-07 — browser visits to /auth/handover without a
// token receive a 302 redirect to the SPA error page, NOT raw JSON.
// Two browser markers exercised: explicit `Accept: text/html` and
// `Sec-Fetch-Mode: navigate` (handles `Accept: */*` browsers).
func TestAuthHandover_MissingTokenHTMLBrowser(t *testing.T) {
h, _, _ := testHandoverSetup(t)
cases := []struct {
name string
headers map[string]string
}{
{
name: "Accept text/html",
headers: map[string]string{"Accept": "text/html,application/xhtml+xml"},
},
{
name: "Sec-Fetch-Mode navigate",
headers: map[string]string{
"Accept": "*/*",
"Sec-Fetch-Mode": "navigate",
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/auth/handover", nil)
for k, v := range tc.headers {
req.Header.Set(k, v)
}
w := httptest.NewRecorder()
h.AuthHandover(w, req)
if w.Code != http.StatusFound {
t.Errorf("status: got %d, want %d", w.Code, http.StatusFound)
}
loc := w.Header().Get("Location")
if loc != "/auth/handover-error?reason=missing_token" {
t.Errorf("Location: got %q", loc)
}
})
}
}
// TC-004 / 2026-05-07 — programmatic callers (explicit JSON Accept)
// keep the legacy 401 JSON contract unchanged.
func TestAuthHandover_MissingTokenJSONClient(t *testing.T) {
h, _, _ := testHandoverSetup(t)
req := httptest.NewRequest(http.MethodGet, "/auth/handover", nil)
req.Header.Set("Accept", "application/json")
w := httptest.NewRecorder()
h.AuthHandover(w, req)
assertAuthError(t, w, http.StatusUnauthorized, "missing token parameter")
}
func TestAuthHandover_MalformedToken(t *testing.T) {
h, _, _ := testHandoverSetup(t)
req := httptest.NewRequest(http.MethodGet, "/auth/handover?token=not-a-jwt", nil)

View File

@ -164,6 +164,67 @@ const authHandoverRoute = createRoute({
component: () => null,
})
/**
* Handover-error landing page (TC-004 / 2026-05-07).
*
* The catalyst-api `AuthHandover` Go handler 302-redirects browser
* visits without a valid token to this URL with `?reason=<code>`. This
* keeps the seamless-handover UX promise even when the operator pastes
* a bare `/auth/handover` URL or follows a stale email link with the
* token stripped they see a SPA-rendered error surface instead of
* raw `{"error":"missing token parameter"}` JSON.
*
* Programmatic callers (curl / monitors with `Accept: application/json`)
* still get the legacy 401 JSON contract `wantsHTML` in the Go
* handler discriminates by Accept header.
*/
function HandoverErrorPage() {
const search =
typeof window !== 'undefined'
? new URLSearchParams(window.location.search)
: new URLSearchParams()
const reason = search.get('reason') ?? 'unknown'
const message =
reason === 'missing_token'
? 'The handover link did not include a token. Please open the link from your most recent email exactly as it was delivered, or request a fresh handover from the OpenOva mothership.'
: reason === 'expired'
? 'This handover link has expired. Handover tokens are valid for a few minutes — please request a fresh one from the OpenOva mothership.'
: reason === 'replayed'
? 'This handover link has already been used. Each token is single-use; request a fresh one from the OpenOva mothership.'
: 'We could not complete the handover. Please request a fresh handover link from the OpenOva mothership.'
return (
<div
className="mx-auto flex min-h-screen max-w-xl flex-col items-center justify-center gap-6 px-6 text-center"
data-testid="handover-error-page"
>
<h1 className="text-2xl font-semibold text-[var(--color-text)]">
Handover incomplete
</h1>
<p className="text-sm leading-relaxed text-[var(--color-text-dim)]">
{message}
</p>
<a
href="/dashboard"
className="rounded-md border border-[var(--color-border)] bg-transparent px-4 py-2 text-sm text-[var(--color-text)] hover:border-[var(--color-accent)] hover:text-[var(--color-accent)]"
>
Continue to console
</a>
</div>
)
}
const authHandoverErrorRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/auth/handover-error',
validateSearch: (raw: Record<string, unknown>): { reason?: string } => {
if (typeof raw.reason === 'string' && raw.reason.length > 0) {
return { reason: raw.reason }
}
return {}
},
component: HandoverErrorPage,
})
// App routes
const appRoute = createRoute({ getParentRoute: () => rootRoute, path: '/app', component: AppLayout })
const dashboardRoute = createRoute({ getParentRoute: () => appRoute, path: '/dashboard', component: DashboardPage })
@ -680,6 +741,15 @@ const consoleCloudRoute = createRoute({
getParentRoute: () => consoleLayoutRoute,
path: '/cloud',
component: CloudPage,
// Mirrors provisionCloudRoute.validateSearch so child legacy-redirect
// routes (TC-090..092) can pass `view` and `kind` through cleanly and
// CloudPage's useSearch reads typed values.
validateSearch: (raw: Record<string, unknown>): CloudSearch => {
const out: CloudSearch = {}
if (raw.view === 'graph' || raw.view === 'list') out.view = raw.view
if (typeof raw.kind === 'string' && raw.kind.length > 0) out.kind = raw.kind
return out
},
})
const consoleUsersRoute = createRoute({
getParentRoute: () => consoleLayoutRoute,
@ -779,6 +849,51 @@ const consoleSMECreateTenantRoute = createRoute({
component: SMECreateTenantPage,
})
/**
* Standalone notifications surface for sovereign mode (TC-160 / 2026-05-07).
*
* Sister to `provisionNotificationsRoute` (mothership-side at
* `/provision/$id/notifications`). On a Sovereign console the operator
* has no `:deploymentId` in the URL `NotificationsPage` resolves the
* id via `useResolvedDeploymentId` (URL param, then /sovereign/self).
*
* Mounted under `consoleLayoutRoute` so it inherits the sidebar + header
* + auth gate the rest of the Sovereign console pages share.
*/
const consoleNotificationsRoute = createRoute({
getParentRoute: () => consoleLayoutRoute,
path: '/notifications',
component: NotificationsPage,
})
/* ── Sovereign-mode cloud legacy redirects (TC-090..092 / 2026-05-07)
*
* Sister set to LEGACY_CLOUD_REDIRECTS (which is mounted under the
* mothership `/provision/$id/cloud` subtree). These are the SAME
* redirects but rooted at sovereign-mode `/cloud/<legacy-path>`, so
* deep-links / bookmarks / external links into a Sovereign console at
* `console.<sov>/cloud/architecture`, `/cloud/compute`, etc. resolve
* cleanly to the canonical `/cloud?view=...&kind=...` query shape
* instead of TanStack Router's bare 404 page.
*
* Reuses LEGACY_CLOUD_REDIRECTS verbatim so the two redirect sets
* cannot drift.
*/
const consoleLegacyCloudRedirectRoutes = LEGACY_CLOUD_REDIRECTS.map((r) =>
createRoute({
getParentRoute: () => consoleCloudRoute,
path: r.path,
component: NoopRedirectComponent,
beforeLoad: () => {
throw redirect({
to: '/cloud' as never,
search: r.search as never,
replace: true,
})
},
}),
)
const routeTree = rootRoute.addChildren([
indexRoute,
loginRoute,
@ -787,6 +902,7 @@ const routeTree = rootRoute.addChildren([
signupRoute,
forgotRoute,
authHandoverRoute,
authHandoverErrorRoute,
appRoute.addChildren([dashboardRoute]),
wizardLayoutRoute.addChildren([wizardRoute]),
successRoute,
@ -821,7 +937,7 @@ const routeTree = rootRoute.addChildren([
consoleJobsRoute,
consoleJobsTimelineRoute,
consoleJobDetailRoute,
consoleCloudRoute,
consoleCloudRoute.addChildren(consoleLegacyCloudRedirectRoutes),
consoleUsersRoute,
consoleUsersNewRoute,
consoleUsersEditRoute,
@ -831,6 +947,7 @@ const routeTree = rootRoute.addChildren([
consoleSMERolesRoute,
consoleParentDomainsRoute,
consoleSMECreateTenantRoute,
consoleNotificationsRoute,
]),
])

View File

@ -16,13 +16,13 @@
* the page can never drift apart.
*/
import { useParams } from '@tanstack/react-router'
import { PortalShell } from './PortalShell'
import { useDeploymentEvents } from './useDeploymentEvents'
import {
NotificationListPanel,
useNotifications,
} from '@/shared/ui/notifications'
import { useResolvedDeploymentId } from '@/shared/lib/useResolvedDeploymentId'
export interface NotificationsPageProps {
/** Test seam — disables the live SSE attach. */
@ -32,10 +32,13 @@ export interface NotificationsPageProps {
export function NotificationsPage({
disableStream = false,
}: NotificationsPageProps = {}) {
const params = useParams({
from: '/provision/$deploymentId/notifications' as never,
}) as { deploymentId: string }
const deploymentId = params.deploymentId
// Resolve deployment id from either:
// • URL :deploymentId param (Catalyst-Zero route /provision/$id/notifications)
// • /api/v1/sovereign/self (Sovereign mode /notifications, no URL param)
// Mirrors the pattern used by JobsPage / SettingsPage / Dashboard so the
// standalone notifications surface works on both topologies.
const { deploymentId: resolvedId } = useResolvedDeploymentId()
const deploymentId = resolvedId ?? ''
const { snapshot } = useDeploymentEvents({
deploymentId,

View File

@ -43,6 +43,28 @@ spec:
hostnames:
- {{ $consoleHost | quote }}
rules:
# /readyz and /healthz on the console hostname route to catalyst-api,
# not catalyst-ui. The SPA's catch-all router would otherwise shadow
# these paths with the React shell (HTTP 200 + index.html), making
# them useless as health probes for any external monitor that points
# at console.<sov>/readyz expecting a real Go-handler probe response.
#
# The catalyst-ui Pod's own k8s probes still hit the nginx-internal
# /healthz at port 8080 inside the pod (see ui-deployment.yaml) —
# this Gateway rule only affects external traffic on the console
# hostname. Caught live on omantel.biz 2026-05-07: TC-211 surfaced
# /readyz returning the SPA shell because no Gateway rule routed it
# to catalyst-api.
- matches:
- path:
type: Exact
value: "/readyz"
- path:
type: Exact
value: "/healthz"
backendRefs:
- name: catalyst-api
port: 8080
# /auth/handover on the console hostname routes to catalyst-api
# (the Go backend), not catalyst-ui (the React shell). The handover
# JWT lands at GET /auth/handover?token=… which is implemented in
@ -62,9 +84,10 @@ spec:
# operator cannot log into the Sovereign Console at all (Keycloak
# bounces between login and a 404). Verified live on otech103,
# 2026-05-05. By using Exact /auth/handover here, every other
# /auth/* path (including /auth/callback, /auth/silent-renew, any
# future client-side OIDC routes) falls through to the catalyst-ui
# default rule below and the React Router resolves them.
# /auth/* path (including /auth/callback, /auth/silent-renew,
# /auth/handover-error, any future client-side OIDC routes) falls
# through to the catalyst-ui default rule below and the React
# Router resolves them.
- matches:
- path:
type: Exact