console: boot session load times out (no infinite splash) + kill auto welcome-credit

Two fixes:
1) BOOT HANG (console.hanzo.ai splash): the session bootstrap awaited
   AccountApi.session() (→ /v1/iam/get-account) with NO timeout, so a degraded
   backend — the beego IAM proxy hop, a dead pruned route blocking 12s — left the
   splash pending forever (diagnosed live: get-account PENDING >25s, body empty).
   New reusable withTimeout() primitive caps the boot resolve at 8s → on timeout
   the visitor is anonymous and the sign-in card renders; a later reload/refresh
   resolves the real session. An app must never hand the browser to one request.
   (Root cause is the beego get-account proxy — fixed for real by the iam2 flip.)
2) AUTO-CREDIT: removed claimWelcomeGrantOnce() from the session bootstrap — the
   console auto-claimed the $5 welcome trial credit on every authenticated load.
   No more automatic credit; admin grants at admin.hanzo.ai only.

The 19 local tsc errors are a node_modules gap (@hanzo/capture ^0.1.0 not npm-installed
locally); CI resolves it. session.tsx + with-timeout.ts are clean.
This commit is contained in:
z
2026-07-15 23:40:38 -07:00
parent 1a12c55a1e
commit 2fe3e197a0
2 changed files with 38 additions and 6 deletions
+15 -6
View File
@@ -18,11 +18,11 @@
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react'
import { AccountApi, type Account } from '~/lib/api'
import { withTimeout } from '~/lib/with-timeout'
import { isAdminHost } from '~/config'
import { getProviderSigninUrl, getSigninUrl, stashReturnTo } from './iam'
import { refreshSession } from './refresh'
import { setCurrentActor } from '~/lib/actor-scope'
import { claimWelcomeGrantOnce } from '~/lib/billing/welcome'
import { claimReferralOnce, stashReferralCode } from '~/lib/referrals/claim'
import { attributeAffiliateOnce, stashAffiliateCode } from '~/lib/affiliates/claim'
@@ -47,6 +47,10 @@ const SessionContext = createContext<SessionState | null>(null)
const MIN_PROACTIVE_MS = 30_000
const MAX_PROACTIVE_MS = 2 * 60 * 60 * 1000 // 2h
/** Cap the boot session resolve so a slow/hung backend can't hold the splash
* forever — on timeout the visitor is anonymous and the sign-in card renders. */
const SESSION_BOOT_TIMEOUT_MS = 8_000
export function SessionProvider({ children }: { children: ReactNode }) {
const [account, setAccount] = useState<Account | null>(null)
const [loading, setLoading] = useState(true)
@@ -58,11 +62,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
const applyAccount = useCallback((a: Account | null) => {
setAccount(a)
setCurrentActor(a && a.owner && a.name ? `${a.owner}/${a.name}` : '')
// Self-heal the one-time $5 welcome trial credit for a just-resolved authenticated
// account (social-login + pre-existing $0 users the signup-time grant never reached).
// Idempotent server-side; guarded to fire at most once per browser session per org.
if (a && a.owner && a.name) {
claimWelcomeGrantOnce(a.owner)
// Claim a stashed referral (?ref= captured at signup) → binds this org as the
// referee. Once per session per org, server-idempotent, best-effort.
claimReferralOnce(a.owner)
@@ -92,7 +92,16 @@ export function SessionProvider({ children }: { children: ReactNode }) {
const reload = useCallback(async () => {
setLoading(true)
try {
const { account: acct, expiresIn } = await AccountApi.session()
// The boot "who am I" (AccountApi.session → /v1/iam/get-account) must never
// pin the splash: a degraded backend (the beego IAM proxy hop, a dead pruned
// route) can leave it pending indefinitely. Cap it — on timeout treat the
// visitor as anonymous so the sign-in card renders. A later reload/refresh
// (or a recovered backend) resolves the real session.
const { account: acct, expiresIn } = await withTimeout(
AccountApi.session(),
SESSION_BOOT_TIMEOUT_MS,
{ account: null, expiresIn: null },
)
applyAccount(acct)
armRefresh(acct ? expiresIn : null)
} finally {
+23
View File
@@ -0,0 +1,23 @@
/**
* withTimeout — race a promise against a deadline, resolving to `fallback` if it
* doesn't settle in time instead of awaiting forever. The ONE reusable primitive
* for "a UX path must degrade gracefully, never hang on a slow/dead backend".
*
* Client-safe (no next/server); the server BFF has its own AbortSignal-based
* `lib/server/fetch-timeout` for outbound fetch — this is the client-side,
* promise-level twin for boot/auth gates that can't hold the browser hostage to
* one request. A rejection is treated exactly like a timeout: the fallback wins.
*/
export function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
return new Promise<T>((resolve) => {
let settled = false
const done = (v: T) => {
if (settled) return
settled = true
clearTimeout(timer)
resolve(v)
}
const timer = setTimeout(() => done(fallback), ms)
promise.then(done, () => done(fallback))
})
}