diff --git a/TODO.md b/TODO.md
index d281f60..13f5af6 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1788,6 +1788,46 @@ secao 96-103, 124, 169) + achado real de autorização em `/ai/models`
mobile — logos aparecendo corretos em todos, sem o artefato de
retângulo branco no Handix
+## PHASE 51 — Renovação automática de sessão (achado real, reportado pelo
+usuário: `Ctrl+F5` depois de um tempo logado quebrava com 401 cru)
+- [x] achado real: `ACCESS_TOKEN_TTL = "15m"` (secao 148) e `apiFetch`
+ nunca tentava nenhum refresh — qualquer reload (ou até só navegar)
+ depois de 15min logado batia 401 em toda Server Component que
+ chamasse a API, e o erro (`ApiError` cru, JSON da API) subia sem
+ tratamento nenhum até virar a tela de erro genérica do Next.js. Não
+ era um caso raro: qualquer sessão de teste mais longa que 15min
+ batia nisso
+- [x] `apps/frontend/src/middleware.ts` (novo) — decodifica o `exp` do
+ access token guardado no cookie (sem verificar assinatura, só
+ leitura — a verificação de verdade continua sendo feita pela API) e,
+ se faltar menos de 60s pra vencer, chama `POST /auth/refresh` com o
+ refresh token *antes* da Server Component rodar, gravando o cookie
+ novo tanto na resposta (`response.cookies`) quanto na própria
+ requisição (`request.cookies`, via `NextResponse.next({ request })`)
+ — sem isto, a MESMA requisição que disparou o refresh ainda leria o
+ cookie velho (padrão documentado do Next.js pra refresh de auth em
+ middleware que precisa valer já na requisição atual, não só na
+ próxima)
+- [x] Rede de segurança pro caso do refresh token TAMBÉM já ter vencido/
+ sido revogado (sessão parada por mais de 30 dias, ou logout em outro
+ lugar): `apps/frontend/src/app/error.tsx` (Error Boundary do App
+ Router) detecta uma mensagem de sessão expirada/401, desloga
+ (`POST /api/logout`) e manda pro `/login` sozinho, em vez de mostrar
+ a tela crua "Application error: a server-side exception...". Tem
+ que ficar na raiz de `app/`, não dentro de `app/app/` ou
+ `app/platform/` — `error.tsx` nunca pega erro do PRÓPRIO layout do
+ mesmo segmento (é o layout de `/app`/`/platform` que chama
+ `/auth/me` e lança o erro), só de layouts/páginas aninhados abaixo
+ (achado testando: o primeiro corte com um `error.tsx` por segmento
+ não pegava nada, confirmado só depois de mover pra raiz)
+- [x] Testado ponta a ponta com refresh token de verdade: token forjado
+ pra vencer em 20s + refresh token real → renovação automática
+ confirmada (token novo no cookie, página protegida carrega normal,
+ sem nenhum 401 visível). Com refresh token inválido de propósito →
+ confirmado o fallback: erro 500 na resposta inicial (esperado, SSR),
+ mas o error boundary do client detecta, desloga e redireciona pro
+ login sozinho — sem crash na tela pro usuário
+
---
## Riscos conhecidos
diff --git a/apps/frontend/src/app/error.tsx b/apps/frontend/src/app/error.tsx
new file mode 100644
index 0000000..b626e26
--- /dev/null
+++ b/apps/frontend/src/app/error.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { SessionErrorView } from "@/components/session-error-view";
+
+export default function Error(props: { error: Error & { digest?: string }; reset: () => void }) {
+ return ;
+}
diff --git a/apps/frontend/src/components/session-error-view.tsx b/apps/frontend/src/components/session-error-view.tsx
new file mode 100644
index 0000000..34b57f2
--- /dev/null
+++ b/apps/frontend/src/components/session-error-view.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import { useEffect } from "react";
+import { useRouter } from "next/navigation";
+import { Button } from "@/components/ui/button";
+
+/** Next.js `error.tsx` global-error boundary por segmento (`/app`,
+ * `/platform`) — pega qualquer erro não tratado durante o render de uma
+ * Server Component, inclusive um `ApiError` 401 propagado cru de
+ * `apiFetch` (achado real: sem isto, um access token vencido — TTL de
+ * 15min, secao 148 — sem sessão de refresh ativa quebrava com uma tela de
+ * erro genérica do Next.js em vez de mandar de volta pro login).
+ * `middleware.ts` já renova o token proativamente antes de vencer; este
+ * boundary é só a rede de segurança pro caso raro do refresh token também
+ * ter expirado/sido revogado (30 dias de sessão parada, ou logout em
+ * outro lugar). */
+function isSessionExpired(message: string): boolean {
+ return /token de acesso|unauthorized|statuscode":401/i.test(message);
+}
+
+export function SessionErrorView({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
+ const router = useRouter();
+ const expired = isSessionExpired(error.message);
+
+ useEffect(() => {
+ if (!expired) return;
+ fetch("/api/logout", { method: "POST" }).finally(() => {
+ router.push("/login");
+ });
+ }, [expired, router]);
+
+ if (expired) {
+ return (
+
+
Sua sessão expirou — redirecionando pro login…
+
+ );
+ }
+
+ return (
+
+
Algo deu errado
+
+ Tente de novo — se persistir, avise o suporte com o que você estava fazendo quando aconteceu.
+
+
+
+
+
+
+ );
+}
diff --git a/apps/frontend/src/middleware.ts b/apps/frontend/src/middleware.ts
new file mode 100644
index 0000000..9a78140
--- /dev/null
+++ b/apps/frontend/src/middleware.ts
@@ -0,0 +1,86 @@
+import { NextResponse, type NextRequest } from "next/server";
+
+const COOKIE_NAME = "b2bcall_session";
+const API_BASE_URL = process.env.B2BCALL_API_URL ?? "http://localhost:3000";
+
+// Renova o access token um pouco antes de vencer (secao 148: TTL de 15min)
+// pra nunca deixar uma requisição de verdade bater 401 no meio de uso normal
+// — sem isto, qualquer reload (ou até só navegar) depois de 15min logado
+// quebrava com "Token de acesso invalido ou expirado" sem nenhum tratamento
+// (achado real, reportado pelo usuário testando: `apiFetch` nunca tenta
+// refresh nenhum, só propaga o erro cru). Buffer de 60s: espaço suficiente
+// pro request atual terminar antes do token realmente vencer.
+const REFRESH_BUFFER_MS = 60_000;
+
+interface SessionCookie {
+ accessToken: string;
+ refreshToken: string;
+}
+
+function decodeExpiry(jwt: string): number | null {
+ try {
+ const payload = jwt.split(".")[1];
+ const base64 = payload.replace(/-/g, "+").replace(/_/g, "/").padEnd(payload.length + ((4 - (payload.length % 4)) % 4), "=");
+ const json = JSON.parse(atob(base64)) as { exp?: number };
+ return typeof json.exp === "number" ? json.exp * 1000 : null;
+ } catch {
+ return null;
+ }
+}
+
+export async function middleware(request: NextRequest) {
+ const raw = request.cookies.get(COOKIE_NAME)?.value;
+ if (!raw) return NextResponse.next();
+
+ let session: SessionCookie;
+ try {
+ session = JSON.parse(raw);
+ } catch {
+ return NextResponse.next();
+ }
+
+ const expiresAt = decodeExpiry(session.accessToken);
+ if (expiresAt == null || expiresAt - Date.now() > REFRESH_BUFFER_MS) {
+ return NextResponse.next();
+ }
+
+ try {
+ const res = await fetch(`${API_BASE_URL}/auth/refresh`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ refreshToken: session.refreshToken }),
+ cache: "no-store",
+ });
+ if (!res.ok) throw new Error("refresh failed");
+ const { accessToken, refreshToken } = (await res.json()) as { accessToken: string; refreshToken: string };
+ const newCookieValue = JSON.stringify({ accessToken, refreshToken });
+
+ // Grava no `request` também (não só na resposta) — sem isto, a
+ // renovação só valeria a partir da PRÓXIMA navegação; a própria
+ // Server Component desta requisição (que disparou o refresh) ainda
+ // leria o cookie antigo via `cookies()` e podia bater 401 de novo na
+ // hora. Padrão documentado do Next.js pra "refresh de token em
+ // middleware que precisa valer já nesta requisição".
+ request.cookies.set(COOKIE_NAME, newCookieValue);
+ const response = NextResponse.next({ request });
+ response.cookies.set(COOKIE_NAME, newCookieValue, {
+ httpOnly: true,
+ sameSite: "lax",
+ secure: process.env.NODE_ENV === "production",
+ path: "/",
+ maxAge: 60 * 60 * 8,
+ });
+ return response;
+ } catch {
+ // Refresh token também inválido/expirado/revogado (sessão de mais de
+ // 30 dias, ou já deslogada em outro lugar) — deixa passar com o token
+ // velho; a página vai bater 401 normalmente e cair no tratamento de
+ // sessão expirada (layout/error boundary), não trava aqui no meio do
+ // middleware.
+ return NextResponse.next();
+ }
+}
+
+export const config = {
+ matcher: ["/app/:path*", "/platform/:path*"],
+};