web-infra
Camada infra do web do Smaug — api-client (baseURL "/api", Bearer, renovação silenciosa, só 401 encerra sessão), auth-refresh (single-flight por aba), session.ts (access token em memória + indicador s
Files1
View on GitHubSKILL.md.claude/skills/web-infra/SKILL.md
GitHubinfra/ — cliente HTTP, sessão, renovação, erros e navegação
Quando usar esta skill
Ao mexer em web/infra/*, ao adicionar um código de erro da API, ou quando precisar de sessão,
navegação ou configuração do TanStack Query. O repasse /api em si (route handler do App Router)
está descrito na skill web-arquitetura.
Mapa da sessão (quem guarda o quê)
| Peça | Onde vive | Quem lê |
|---|---|---|
| refresh token | cookie httpOnly smaug_refresh (emitido pela API, Path=/api/auth) |
só o navegador/servidor — nenhum script |
| access token | variável de módulo em infra/session.ts (só memória) |
interceptor do apiClient |
| indicador de sessão | cookie smaug_session=1 (sem segredo, escrito por session.ts) |
proxy.ts e o bootstrap do useAuth |
A fonte da verdade é o servidor. Recarregar a página perde o access token; a sessão é retomada
por uma renovação (POST /auth/refresh) que o cookie httpOnly autentica sozinho.
infra/session.ts — token em memória + indicador
const HINT_COOKIE = "smaug_session";
const LEGACY_COOKIE = "userId";
const HINT_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;
let accessToken: string | null = null;
function writeCookie(name: string, value: string, maxAge: number): void {
document.cookie = `${name}=${value};path=/;max-age=${maxAge};SameSite=Lax`;
}
if (typeof document !== "undefined") writeCookie(LEGACY_COOKIE, "", 0);
/** Access token da aba, ou `null` quando ainda não houve login/renovação. */
export function getAccessToken(): string | null {
return accessToken;
}
/** Guarda o access token em memória e escreve o indicador de sessão. */
export function setSession(token: string): void {
accessToken = token;
if (typeof document === "undefined") return;
writeCookie(HINT_COOKIE, "1", HINT_MAX_AGE_SECONDS);
}
/** Esquece o access token e apaga o indicador de sessão. */
export function clearSession(): void; // accessToken = null + indicador com max-age=0
/** Indica se o navegador tem o indicador de sessão — usado para decidir se vale renovar. */
export function hasSessionHint(): boolean; // document.cookie contém "smaug_session=1"
- O access token nunca vai para cookie,
localStoragenemsessionStorage. - O indicador vive 30 dias (vida máxima da sessão, PRD RN-07): um cookie de sessão do browser sumiria
ao fechá-lo e mandaria para
/loginquem ainda tem refresh válido. - O indicador pode divergir do estado real (refresh expirado com indicador presente) sem recriar o
laço
/login↔/dashboard, porque o único caminho que encerra a sessão — renovação recusada com 401 — chamaclearSession()antes deredirectToLogin(). - O cookie
userIdda versão anterior (login por id) é apagado no carregamento do módulo; não o leia. - Guardas de SSR (
typeof document === "undefined") em tudo que tocadocument; cobertas porinfra/session.node.test.ts(@vitest-environment node).
infra/auth-refresh.ts — renovação single-flight por aba
/** Caminho da renovação visto pelo navegador: o repasse `/api` do web, na mesma origem. */
export const REFRESH_URL = "/api/auth/refresh";
const CONFLICT_RETRY_DELAY_MS = 250;
let inFlight: Promise<AuthSession> | null = null;
async function postRefresh(): Promise<AuthSession> {
const { data } = await axios.post<AuthSession>(REFRESH_URL, undefined, { timeout: 10000 });
return data;
}
async function renew(): Promise<AuthSession> {
try {
let session: AuthSession;
try {
session = await postRefresh();
} catch (error) {
if ((error as AxiosError).response?.status !== 409) throw error;
await wait(CONFLICT_RETRY_DELAY_MS);
session = await postRefresh();
}
setSession(session.accessToken);
return session;
} catch (error) {
// Só a renovação recusada com 401 encerra a sessão. O indicador sai ANTES do redirect, senão o
// proxy devolveria o usuário a `/dashboard` e recriaria o laço com `/login`.
if ((error as AxiosError).response?.status === 401) {
clearSession();
redirectToLogin();
}
throw error;
}
}
export function refreshSession(): Promise<AuthSession> {
inFlight ??= renew().finally(() => {
inFlight = null;
});
return inFlight;
}
export async function settleRefresh(): Promise<void> {
await inFlight?.catch(() => undefined);
}
Regras:
- Usa
axiospuro, não oapiClient: o interceptor doapiClientdepende deste módulo, e a renovação não leva Bearer nem pode disparar outra renovação. O cookie vai sozinho (mesma origem, semwithCredentials). - Single-flight por aba: chamadas simultâneas a
refreshSession()recebem a mesma promessa. - 409
REFRESH_TOKEN_CONFLICT(outra aba girou o token) → espera 250 ms e tenta uma vez. - 401 →
clearSession()e depoisredirectToLogin(), e rejeita. Rede/5xx só rejeitam, sem encerrar nada. settleRefresh()espera a renovação em voo sem propagar a falha — é o que o interceptor de request usa para sair com o token mais novo.- Exporta também os tipos
SessionUsereAuthSession(corpo de/auth/register|login|refresh).
infra/api-client.ts — a única instância axios
const apiClient = axios.create({
baseURL: "/api",
timeout: 10000,
headers: {
"Content-Type": "application/json",
},
});
interface RetriableConfig extends InternalAxiosRequestConfig {
/** Marca a requisição que já foi refeita, para não renovar em laço. */
_retried?: boolean;
/** Access token com que a requisição saiu (`null` sem sessão em memória). */
_sentToken?: string | null;
}
apiClient.interceptors.request.use(async (config: RetriableConfig) => {
await settleRefresh();
const token = getAccessToken();
config._sentToken = token;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const original = error.config as RetriableConfig | undefined;
// 401 em `/auth/*` (login errado, renovação recusada) não é "token expirado": só rejeita.
const isAuthRoute = original?.url?.startsWith("/auth/") ?? false;
if (error.response?.status !== 401 || !original || isAuthRoute || original._retried) {
return Promise.reject(error);
}
const current = getAccessToken();
if (current === null || current === original._sentToken) {
await refreshSession();
}
original._retried = true;
return apiClient(original);
},
);
Regras:
baseURL: "/api"— o navegador só fala com a origem do web; o route handlerapp/api/[...path]/route.tsrepassa para a API (API_ORIGIN, lido em runtime). Por isso não háwithCredentialsnem CORS do lado do browser, e o cookiesmaug_refreshvai sozinho.- Bearer injetado aqui, lendo
getAccessToken(). Nenhum service monta header de autenticação. - O request interceptor é
async:await settleRefresh()faz a requisição que sai durante uma renovação esperar o token novo, em vez de sair sem ele e voltar 401. O token enviado fica gravado emconfig._sentToken. - 401 fora de
/auth/*:- se o token atual difere do enviado (outra requisição já renovou) → refaz com o atual, sem renovar — renovar de novo giraria o refresh à toa e daria 409 na outra aba;
- se é o mesmo (ou não há token) →
refreshSession()e refaz uma vez (_retried).
- 401 em
/auth/*só rejeita (login errado, logout-all com token vencido — quem chama decide). - Só 401 encerra a sessão, e só por
refreshSession(). Rede fora, timeout e 5xx (inclusive o 500/502 do repasse) são transitórios — o erro chega a quem chamou e a sessão continua.
infra/api-error.ts — a tabela de mensagens
Estrutura: uma const MESSAGES: Record<string, string> privada, com cabeçalho JSDoc
/** Mensagens em pt-BR para os códigos de erro emitidos pela API. */, agrupada por blocos
separados por linha em branco, nesta ordem: genéricos (validação, UNAUTHORIZED, origem, rate
limit) → autenticação → usuário → categoria → "não encontrado" → regras de data → estado →
parcelamento → exportação/importação. ~50 entradas. A chave é o error SCREAMING_SNAKE que o server
devolve; o valor é uma frase completa em pt-BR terminando em ponto.
const MESSAGES: Record<string, string> = {
VALIDATION_ERROR: "Dados inválidos. Revise os campos e tente novamente.",
UNAUTHORIZED: "Sessão inválida. Faça login novamente.",
ORIGIN_NOT_ALLOWED: "Origem da requisição não permitida. Recarregue a página e tente novamente.",
TOO_MANY_REQUESTS: "Muitas tentativas. Aguarde alguns minutos e tente novamente.",
INVALID_CREDENTIALS: "E-mail ou senha incorretos.",
INVALID_REFRESH_TOKEN: "Sua sessão expirou. Faça login novamente.",
REFRESH_TOKEN_CONFLICT: "Não foi possível renovar a sessão. Tente novamente.",
USER_NOT_FOUND: "Usuário não encontrado.",
EMAIL_ALREADY_EXISTS: "Já existe uma conta com este e-mail.",
// ...
IMPORT_NO_VALID_ROWS: "O arquivo não contém nenhum lançamento válido.",
};
Funções exportadas, todas recebendo error: unknown (ou código) e fazendo o cast internamente:
export function isInvalidSessionError(error: unknown): boolean {
return (error as AxiosError)?.response?.status === 401;
}
export function getApiErrorMessage(error: unknown, fallback: string): string;
export function getErrorMessageByCode(code: string, fallback: string): string; // guarda do cliente
export function getImportRowMessage(code: string, value?: string): string; // linhas recusadas
isInvalidSessionError é só 401. 404 (inclusive USER_NOT_FOUND), timeout, rede e 5xx são
transitórios ou de negócio e não deslogam ninguém.
Ordem de resolução de getApiErrorMessage (decore, os testes dependem dela):
- O corpo passa por
parseErrorBody— que decodificaArrayBuffer/string JSON (o download do CSV usaresponseType: "arraybuffer"). - Se o corpo tem
error === "VALIDATION_ERROR"edetails, devolve o primeiro detalhe de campo (Object.values(body.details).flat()[0]). - Senão, procura
MESSAGES[body.error]. code === "ECONNABORTED"→"A requisição demorou demais. Verifique sua conexão e tente novamente."- Sem
responsemas comrequest(offline) →"Não foi possível falar com o servidor. Verifique sua conexão." - Caso contrário, o
fallbackque o chamador passou.
Regra operacional: código de erro novo no server entra nessa tabela no mesmo PR. Sem isso o
usuário vê o texto genérico de fallback. Nunca escreva a mensagem à mão dentro de um componente ou
hook — sempre getApiErrorMessage(error, "Erro ao ….").
infra/navigation.ts — seam de teste
/** Manda o usuário para o login, a menos que ele já esteja lá. */
export function redirectToLogin(): void {
if (typeof window === "undefined") return;
if (window.location.pathname === "/login") return;
// A troca de documento é proposital: descarta o cache do React Query da sessão encerrada.
// eslint-disable-next-line @next/next/no-location-assign-relative-destination
window.location.href = "/login";
}
Existe porque window.location não é configurável no jsdom 29 — não dá para espionar. Toda
navegação "dura" passa por aqui (renovação recusada, logout, logout-all), e os testes fazem mock
deste módulo (vi.mock("@/infra/navigation", () => ({ redirectToLogin: vi.fn() }))).
infra/router-adapter.ts — Next atrás de uma interface
"use client";
export interface RouterAdapter {
push: (path: string) => void;
replace: (path: string) => void;
back: () => void;
}
export function useRouter(): RouterAdapter;
export function useSearchParams(); // { get(key), getAll(key) }
Hook de negócio nunca importa useRouter/useSearchParams de next/navigation — é regra
normativa da constituição (princípio III do frontend). Passe por este adapter.
(Sidebar.tsx importa usePathname direto porque o adapter não cobre esse caso; se precisar dele
em hook, estenda o adapter.) O ?next= do login não é lido por useSearchParams: a página de
login (Server Component) o lê de searchParams e passa por prop — ver web-arquitetura.
infra/query-client.ts — singleton
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000,
gcTime: 5 * 60 * 1000,
retry: 3,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
refetchOnWindowFocus: false,
},
mutations: { retry: 1 },
},
});
Consumido por app/providers.tsx. Nunca use este singleton em teste — use
createTestQueryClient() (skill web-testes), senão o retry: 3 faz os testes de erro pendurarem.
Estilo da pasta
Arquivos planos, kebab-case (api-client.ts, api-error.ts, auth-refresh.ts,
router-adapter.ts, query-client.ts, file-download.ts), só exports nomeados, sem barril, imports
internos relativos (./session, ./auth-refresh).
Checklist — adicionar um código de erro novo
- Classe de erro no
server/src/domain/errors/domain-error.ts(skillserver-erros-dominio). - Controller (ou middleware) mapeando para o status certo.
- Entrada em
MESSAGESdeinfra/api-error.ts, no bloco temático correspondente, em pt-BR, terminando em ponto. - Teste em
infra/api-error.test.ts. - Se algum fluxo de tela depender dele, teste de integração de página assertando a mensagem.
- Se for um 401, confira que ele deve encerrar a sessão (só a renovação recusada encerra).
Não faça
- Não crie uma segunda instância de axios (a única exceção é o
axios.postpuro deauth-refresh.ts, que não pode passar pelo interceptor). - Não chame a API pelo domínio dela (
http://localhost:3000,API_ORIGIN) a partir do browser — tudo sai por/api. - Não deslogue o usuário em erro de rede, timeout, 404 ou 5xx.
- Não chame
refreshSession()fora do interceptor e douseAuth(bootstrap elogoutAll). - Não guarde o access token em cookie,
localStorageousessionStorage; não leia o cookiesmaug_refresh(éhttpOnly) nem tratesmaug_sessioncomo credencial. - Não redirecione para
/loginsem antesclearSession()— o indicador presente recriaria o laço com/dashboard. - Não espione
window.locationno teste — use o seam@/infra/navigation. - Não importe
next/navigationem hook de negócio. - Não use o
queryClientsingleton em teste.