Fluxo de Autenticação
Visão geral
Desde a spec user-data-out-of-ssr (2026-07-24, commit 6b1f32a90 no base), o
documento SSR é auth-agnóstico: nenhum dado do usuário logado atravessa o
server-render. O loader do _layout não retorna auth. Toda a hidratação de
auth é client-side.
┌────────────────────────────────────────────────────────────┐
│ Server (loader / API routes) │
│ `_layout` loader: brand + clientEnv (ZERO dado de user) │
│ `/api/auth/*`: login/register/logout/profile │
│ Token lido só aqui, do cookie HttpOnly │
├────────────────────────────────────────────────────────────┤
│ Client (browser) │
│ useAuthInit() → /api/auth/profile → useAccountsStore │
│ useAuthGate() → o único gate de "está logado?" pra UI │
└────────────────────────────────────────────────────────────┘
Por que isso importa: o HTML público passa a ser compartilhável entre usuários, o que é o que torna o cache de documento na borda (e no Service Worker) viável. Ver Cache architecture e Service Worker.
:::danger Nunca leia auth do loader
// ❌ O campo não existe mais. `layoutData.auth` é sempre undefined.
const layoutData = useRouteLoaderData("routes/_layout");
const isLoggedIn = Boolean(layoutData?.auth?.user?.id);
Existe um teste leak-canary que falha se alguém re-adicionar um campo derivado do
usuário ao loader: app/routes/__tests__/no-user-data-in-ssr.test.ts.
:::
useAuthGate() — o gate canônico
app/hooks/useAuthGate.ts é o único gate de auth pra UI. Todo componente que
condiciona visibilidade ou comportamento a "estar logado" usa este hook.
import { useAuthGate } from "~/hooks/useAuthGate";
function MyComponent() {
const isLoggedIn = useAuthGate();
if (!isLoggedIn) return null;
// …
}
Três fases, nesta ordem:
- SSR e primeiro render client — retorna
false. Sempre pinta a casca de convidado. O pre-paint guard segura o flash (abaixo). - Pós-mount, pré-hidratação da store — o cookie não-HttpOnly
is_authenticatedé o sinal otimista. - Pós-
authHydrated— a store reativa assume;|| cookieAuthcobre a janela do fetch de perfil.
O hook re-lê o cookie quando storeAuthenticated/authHydrated mudam e escuta
AUTH_FLAG_CLEARED_EVENT (~/utils/cookie.client), então login/logout via modal
refletem na hora — sem navegação, sem revalidator.revalidate().
Pre-paint guard data-auth-flag
app/root.tsx injeta um script síncrono que, antes do primeiro paint, lê o
cookie is_authenticated e marca data-auth-flag no <html>. O CSS esconde os
CTAs de convidado e mostra o placeholder do cluster logado.
Sem isso, o usuário logado que recebe o HTML anônimo do cache de borda veria
"Entrar / Cadastrar" por toda a janela de hidratação — parece que deslogou.
Pós-hidratação o DefaultLayout reconcilia o atributo com a verdade da store
(cookie stale / 401 → atributo sai, CTAs voltam).
Outros valores reativos
A mesma regra vale pra qualquer valor que muda em runtime sem revalidar o loader:
| O que | Fonte reativa |
|---|---|
isAuthenticated (gate de UI) | useAuthGate() |
user, userInfo, wallet, transações, coins | useAccountsStore (@cactus-agents/accounts/react) |
| Gamification (missões, torneios, níveis) | useGamificationStore |
| Favoritos (slugs) | useGamesStore.favoriteSlugs |
| Estado do modal de auth | useAuthModalStore (app/store/authModal.ts) |
Hidratação — useAuthInit()
app/hooks/useAuthInit.ts hidrata a store 100% client-side:
<AuthInitializer /> monta (dentro do DefaultLayout)
├── useAuthInit()
│ ├── hasAuthFlagCookie()?
│ │ ├── não → setAuthHydrated() (anônimo, zero request)
│ │ └── sim → setAuthHydrated() já (evita flicker de loading)
│ │ ├── fast path: cookie `gm_id` já traz o smarticoHash
│ │ │ → gamificação inicializa ANTES do RTT do profile
│ │ └── fetch("/api/auth/profile")
│ ├── useAuthProfileSync()
│ ├── useValidationRuntimeSync()
│ ├── useHeaderNotificationsSync()
│ ├── useRewardsCountSync()
│ └── useFavoritesAuthSync()
<LazyWalletInitializer /> (app/components/user/WalletInitializer.tsx) é
montado condicionalmente pelo DefaultLayout, dentro de um <Suspense>, só
quando hydrated && isAuthenticated.
Token morto × falha transitória
fetchProfileFromApi() devolve um resultado discriminado de propósito:
type ProfileResult =
| { kind: "ok"; user: AuthUser; userInfo: AuthUserInfo; smarticoHash: string | null }
| { kind: "unauthorized" } // 401 ou 440 → token morto, limpa cookies
| { kind: "error" }; // 502 / rede / shape ruim → NÃO desloga
Colapsar tudo em null deslogaria o usuário num soluço de rede. Essa distinção é
o ponto central do hook.
Memoização por TTL
A store carrega lastProfileFreshAt / lastWalletFreshAt (Date.now() da
última população por fonte confiável) e usa isso pra pular round-trips
redundantes ao BFF quando acabou de ser preenchida:
| Ação | TTL | Constante |
|---|---|---|
refreshAuthProfile() | 10s | PROFILE_FRESH_TTL_MS |
refetchWallet() | 2s | WALLET_FRESH_TTL_MS |
Ambas aceitam { force: true } pra ignorar o memo. refetchWallet também faz
dedup de in-flight, o que cobre o caso de um botão de reload manual disparar
user:wallet-refresh e um listener responder ao mesmo evento.
useAuthProfileSync (no base) se apoia nesse memo — é por isso que montar vários
consumidores de perfil não multiplica chamadas ao BFF.
Os três cookies
| Cookie | HttpOnly | Max-Age | Papel |
|---|---|---|---|
jwt_token | sim | 30d | O token. Lido só no server (getTokenFromRequest) |
is_authenticated | não | 30d | Sinal otimista pro useAuthGate + pre-paint guard |
gm_id | não | 24h | userId + smarticoHash — fast path da gamificação |
O TTL do gm_id é casado com o do hash do Smartico (24h: generateUserHash no
core embute expiry = now + 24h e assina). Um Max-Age maior serviria um hash já
expirado. Quando o cookie some, o client cai no /api/auth/profile, que gera um
hash novo e reescreve o cookie.
Definições em app/utils/cookie.server.ts.
Login
Login e registro passam por API routes no server, pra o cookie HttpOnly ser setado server-side. O client nunca manipula o token.
A rota normaliza toda ramificação do BFF num envelope com code
(FormErrorCode, de @cactus-agents/utils/forms) — não uma string type:
// POST /api/auth/login → app/routes/api/auth/login.ts
// sucesso
{ ok: true, user, userInfo, trackingHeaders? }
// falhas
{ ok: false, code: FormErrorCode.TWO_FACTOR_REQUIRED, two_factor_type?: "email" | "sms" }
{ ok: false, code: FormErrorCode.ACCOUNT_CANCELLED }
{ ok: false, code: FormErrorCode.ATTEMPT_LIMIT, recovery_token: string | null }
{ ok: false, code: FormErrorCode.BAD_REQUEST, fieldErrors }
{ ok: false, code: FormErrorCode.SERVER_ERROR, detail? }
O mapeamento vem de mapBffLoginError. Trate sempre pelo code.
2FA está implementado
O passo de 2FA existe: app/components/auth/TwoFactorStep.tsx, orquestrado
pelo LoginModal.tsx. O two_factor_type ("email" ou "sms") é encaminhado
justamente pra o modal renderizar o passo certo.
Detalhes do caminho de login
- Retry por lag de read-replica — depois do login, o fetch de
/auth/user-profileé repetido uma vez apósPROFILE_RETRY_DELAY_MS = 1000. Contorna a réplica de leitura do BFF ainda não ter o usuário. primeAuthTokenCache(access_token, profile)— semeia um cache de token no server (curto) pra a primeira request autenticada não pagar o RTT.- Três cookies setados —
jwt_token,is_authenticated,gm_id.
Registro
RegisterModal
├── Formulário dinâmico (campos conforme authConfig da marca)
├── POST /api/auth/register
│ └── Server: valida payload + resolveRegisterEndpoint(registerConfig, input.socialService)
│ + Set-Cookie (mesmos três cookies)
└── Envelope com `code` (mesmo contrato do login)
O endpoint do BFF não é hardcoded: resolveRegisterEndpoint
(app/modules/register/flow.ts) resolve em runtime a partir do
registerConfig da marca e do provider social. Não replique a lista de paths.
O fluxo social inicia em /api/auth/social/:provider, que redireciona pro
provider no BFF e retorna ao modal com os parâmetros do callback.
Logout
Header → "Sair"
├── POST /api/auth/logout
│ └── Server: logout no BFF + makeDeleteAllAuthHeaders()
└── clearAuth() na store + useAuthLogoutCleanup()
makeDeleteAllAuthHeaders() (app/utils/cookie.server.ts) apaga 7 cookies:
jwt_token, is_authenticated, gm_id, três variantes legadas
__Host-jwt_token_{strict,lax,none} (setadas pelo BFF, cada uma com o seu
SameSite) e bet7k_session.
clearAuth() — reset único, sem cross-store
clearAuth na useAccountsStore (core, packages/accounts/src/react/store.ts)
zera auth e wallet num único set() — não há chamada cross-store:
clearAuth: () => {
set({
user: null, userInfo: null, isAuthenticated: false, lastProfileFreshAt: 0,
wallet: null, lastWalletFreshAt: 0,
transactions: [], transactionsRaw: null, transactionsMeta: null,
transactionsFilter: { page: 1 },
error: null,
activeCoinId: resolveDefaultCoinId(),
coinBalances: null,
});
}
useAuthLogoutCleanup()
app/hooks/useAuthLogoutCleanup.ts roda só na transição real true → false
(um ref guarda contra o render inicial de visitante) e faz o resto da limpeza:
fecha todo modal/painel auth-scoped, reseta as stores do base que carregam dado
auth-scoped (authModal, gamification, kyc, layout, payments,
userConfigCache, validationRuntime, validationSteps, walletModal — a lista
viva são os imports no topo do arquivo), zera a identidade do Smartico
(_smartico.logout() + os globais _smartico_user_*) e chama
purgeAllClientCaches().
Exceção deliberada: quando o logout veio de sessão expirada
(authModalReason.kind === "session_expired"), o modal de auth não é
fechado — ele acabou de ser aberto pelo handler de 401 e fechá-lo aqui mataria o
propósito.
Tratamento de 401 / 440
Não é loader-driven. O chokepoint único é handleUnauthorized() em
app/utils/api-fetch.client.ts, instalado pelo interceptor de fetch
(installFetchInterceptor, chamado em app/entry.client.tsx):
- Trata 401 e 440 como token morto (440 = sessão encerrada server-side).
- Idempotente: uma segunda chamada com
isAuthenticated === falseé no-op, então rajadas de 401 concorrentes não disparam limpezas múltiplas. - Classifica o erro (
classifyApiError) e manda um beacon pra/api/logs/auth-logout(CF Observability), com uma referência#EP-EM. - Limpa o cookie HttpOnly via
deleteJwtCookie(), que fazPOST /api/auth/logout(keepalive: true, best-effort). Essa rota chamacreateAuthFromClient(client).logout(). - Captura o último identificador de login antes do
clearAuth, pra pré-preencher o modal — o usuário só redigita a senha. - Seta
authModalReason.kind = "session_expired", o que faz oapp/components/auth/SessionExpiredBanner.tsxrenderizar.
:::warning O audit log do BFF NÃO distingue logout forçado de voluntário hoje
Existe a intenção de distinguir — POST /auth/logout-auto no BFF — mas o caminho
está incompleto em três camadas, então nada disso está ativo:
- Core, root:
AuthService.logoutAuto()é real e testado (packages/accounts/src/auth-service.ts). Alcançável só peloAuthServicedo root. - Core, camada React: existe um adapter
defaultLogoutAuto()(packages/accounts/src/react/auth-http.ts) apontando pra/api/auth/logout-auto, mas ele não é exportado do barrel/reacte não tem nenhum call site. Código morto. - Core, seam de config:
AccountsConfig(react/config.ts) temlogout?mas não temlogoutAuto?— então nem dá pra um consumidor ligar o caminho forçado viainitAccounts().
E no base: deleteJwtCookie() fixa POST /api/auth/logout sem ramificação, e não
existe rota /api/auth/logout-auto em app/routes/api/auth/.
Resultado: logout voluntário e forçado são indistinguíveis pro BFF. Ligar isso
exige uma rota proxy no base e um seam novo no core (ou uma chamada
server-side ao AuthService do root) — não é só adicionar a rota.
Detalhe completo em @cactus-agents/accounts. :::
:::caution Comentário desatualizado no código
app/components/user/panel/UserPanelLogout.tsx afirma que handleUnauthorized
usa /api/auth/logout-auto "quando a brand opta via
authFeaturesConfig.useAutoLogoutEndpoint". Essa flag não existe — a única
ocorrência dela em app/ e overrides/ é dentro desse próprio comentário. Não
confie nele.
:::
Validações pós-login
Após login (ou refresh com cookie válido), o sistema de validações avalia pendências obrigatórias:
setAuthUser(user, userInfo)
▼
useValidationRuntimeSync
├── buildValidationSnapshot(user, userInfo)
└── fetchAllValidations(config, snapshot)
▼
allValidations.hasPending?
├── force / regulatory / global → ValidationBlockerOverlay
└── false → navegação normal
Três paths de bloqueio, em ordem de prioridade:
- force —
forceRequestKyc = trueno perfil (backend forçou KYC) - regulatory —
showPendingDataFlow = true(dados regulatórios pendentes) - global —
config.global.active = truee módulos do global não satisfeitos
Com pendência, o DefaultLayout renderiza o ValidationBlockerOverlay sobre o
site (blur + anti-tamper). Além do bloqueio global, cada contexto (casino,
deposit, withdraw, …) é avaliado separadamente via ValidationStepsModal quando
o usuário tenta a ação.
Ver Validações para detalhes completos.
Checklist ao criar componente auth-aware
- O gate vem de
useAuthGate()— não deuseRouteLoaderData, não deuseAccountsStore.isAuthenticatedcru? -
user/userInfo/wallet vêm deuseAccountsStore? - Não há cache de
user.idem ref/state que ignora mudanças? - Side-effects dependentes do usuário recomputam quando
user?.idmuda? - Nada derivado do usuário foi adicionado ao loader do
_layout? (o testeno-user-data-in-ssr.test.tsfalha)
Arquivos relevantes
| Arquivo | Papel |
|---|---|
app/hooks/useAuthGate.ts | Gate canônico de auth pra UI |
app/hooks/useAuthInit.ts | Hidratação client-side (cookie → /api/auth/profile) |
app/hooks/useAuthProfileSync.ts | Mantém o profile atualizado (com TTL memo) |
app/hooks/useAuthLogoutCleanup.ts | Limpeza na transição true → false |
app/hooks/useValidationRuntimeSync.ts | Computa allValidations e sincroniza |
app/components/auth/AuthInitializer.tsx | Monta os hooks de bootstrap |
app/components/user/WalletInitializer.tsx | Inicializa wallet (lazy, só logado) |
app/components/auth/LoginModal.tsx | Modal de login (POST /api/auth/login) |
app/components/auth/TwoFactorStep.tsx | Passo de 2FA (email / sms) |
app/components/auth/RegisterModal.tsx | Modal de registro |
app/components/auth/SessionExpiredBanner.tsx | Banner de sessão expirada |
app/routes/api/auth/login.ts | Envelope FormErrorCode + retry de réplica |
app/routes/api/auth/logout.ts | Logout + makeDeleteAllAuthHeaders() |
app/utils/cookie.server.ts | Os três cookies + AUTH_COOKIE_NAMES |
app/utils/cookie.client.ts | hasAuthFlagCookie, AUTH_FLAG_CLEARED_EVENT, gm_id |
app/utils/api-fetch.client.ts | handleUnauthorized() — chokepoint de 401/440 |
app/store/authModal.ts | Estado do modal de auth (base, não é o core) |
app/store/validationRuntime.ts | Resultado das validações |
app/routes/__tests__/no-user-data-in-ssr.test.ts | Leak canary do SSR |
Estado de auth, usuário, wallet, transações e coins vive em useAccountsStore
(@cactus-agents/accounts/react) — não existe app/store/auth.ts nem
app/store/wallet.ts.
Docs relacionadas
- @cactus-agents/accounts — o pacote que hospeda tudo isto:
useAccountsStoree os hooks da camada/react, os services HTTP (AuthService, incl.logoutAuto()) e as regras puras - Service Worker — por que o shell público precisa ser auth-agnóstico
- Cache architecture — tier
ssr-auth:per-session - Validações