Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,11 @@ app.use('/api/*', cors({
credentials: true,
}))

// Health check
// Health check. Both `/health` and `/healthz` are liveness aliases (the Coolify
// probe + tooling hit `/healthz`); `/healthz/deep` (introspectApi below) is the
// bearer-gated readiness endpoint, NOT a substitute for this cheap liveness ping.
app.get('/health', (c) => c.json({ ok: true }))
app.get('/healthz', (c) => c.json({ ok: true }))

// B4 (obs): /healthz/deep + /metrics. Bearer-gated via HUB_INTROSPECT_TOKEN.
// Mounted at root — bypasses /api/* auth, CSRF, license-gate, rate-limit
Expand Down
6 changes: 6 additions & 0 deletions web/src/hooks/useProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ export function useProfile(token: string | null) {

const fetchProfile = useCallback(async () => {
if (!token) { setLoading(false); return }
// Reset to loading on every (re)fetch — critically when `token` transitions
// null → set on sign-in. Without this the flag stays at the stale `false`
// left by the no-token branch, so App's dead-credential effect sees
// `!profileLoading && !profile && token` for one render and force-signs-out
// the user the instant they log in (auto-logout race).
setLoading(true)
try {
const p = await hubFetch<Profile>(token, '/api/profile')
setProfile(p)
Expand Down
14 changes: 12 additions & 2 deletions web/src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,22 @@ export interface AuthUser {
* regardless of outcome.
*/
export async function requestMagicLink(email: string): Promise<void> {
await fetch(`${HUB_URL}/api/auth/login/request-link`, {
const res = await fetch(`${HUB_URL}/api/auth/login/request-link`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
credentials: 'include',
}).catch(() => {})
}).catch(() => null)
// Magic-link is globally disabled when the hub runs Titanium-bypassed
// (503 `{ error: "titanium_disabled" }`). That is a deployment-wide config
// state, NOT a per-email signal — surfacing it leaks no enumeration info, and
// silently rendering "check your inbox" would strand the user waiting for an
// email that will never send. Signal the caller to fall back to password
// sign-in. Every other outcome stays silent (enumeration safety).
if (res && res.status === 503) {
const body = (await res.json().catch(() => null)) as { error?: string } | null
if (body?.error === 'titanium_disabled') throw new Error('magic_link_disabled')
}
}

/**
Expand Down
14 changes: 11 additions & 3 deletions web/src/pages/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,17 @@ export function Login({ onLegacyAuth }: Props) {
await requestMagicLink(email)
// Always show the success state regardless of API response — enumeration prevention.
setSent(true)
} catch {
// Network-level failure — still show success (don't leak signal).
setSent(true)
} catch (err) {
// Magic-link globally disabled (Titanium bypass) — route to password
// sign-in instead of faking "check your inbox" for an email that the
// hub will never send. This is a config state, not a per-email signal.
if (err instanceof Error && err.message === 'magic_link_disabled') {
setMode('password')
setError('Magic-link sign-in is unavailable right now — please sign in with your password.')
} else {
// Network-level failure — still show success (don't leak signal).
setSent(true)
}
} finally {
setLoading(false)
}
Expand Down
Loading