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:
parent
5a1216992d
commit
3dc9f42c95
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
]),
|
||||
])
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user