fix(crm-vivo): o realtime volta a entregar — o canal assinava como anônimo

O board assinava crm_leads corretamente, a publicação estava certa, o servidor
respondia "Subscribed to PostgreSQL" — e nada chegava. Duas abas, 25s, zero
eventos.

Causa: o cookie de sessão é httpOnly (doutrina do repo), então o supabase-js do
browser não enxerga a sessão e o canal ia SEM access_token. O Realtime aplica
RLS por canal: canal anônimo assina, recebe ok, e o servidor não entrega nada.
Falha silenciosa que parece saúde — o "Subscribed" é o que enganava.

É a mesma doença já documentada no topo do useBoard, que levou o FETCH para uma
rota de API ("cookie httpOnly, auth.uid() null, RLS esconde o pipeline"):
curaram o fetch e o realtime ficou para trás com o diagnóstico escrito ao lado.

Correção: rota que devolve o access_token da sessão (autorizada por getUser(),
não por getSession()) + realtime.setAuth ANTES do subscribe — assinar primeiro
e autenticar depois deixa o canal anônimo para sempre. A sessão continua no
cookie httpOnly; o token vive em memória e some no reload.

Teto de 1,5s na espera do token: assinar tarde é aceitável, não assinar porque
a rota está lenta (ou não existe, como no jsdom) deixaria a tela sem realtime
para sempre — prazo estourado cai no comportamento anterior, não numa regressão
nova. Foi isso que quebrou snooze-button e composer-audio-recorder na primeira
tentativa; com o teto, os 848 unitários voltam ao verde.

Cenário 9 do briefing PROVADO com duas abas e dois logins: 11ms, 5ms e 7ms,
nos dois sentidos, sem F5. Antes: 25s sem nada.

typecheck 0, lint 0 errors, 848 unitários, build 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013dxBNZMjUzBx8xWs8DvGvP
This commit is contained in:
Rafael Melgaço
2026-07-24 20:39:34 -03:00
co-authored by Claude Opus 5
parent f33119b506
commit 24b9ec234e
2 changed files with 107 additions and 9 deletions
+49
View File
@@ -0,0 +1,49 @@
/**
* GET /api/v1/auth/realtime-token — o token que o canal Realtime precisa.
*
* Por que existe: o cookie de sessão é httpOnly (CLAUDE.md), então o
* supabase-js do browser NÃO enxerga a sessão e assina os canais como ANÔNIMO.
* O Realtime aplica RLS por canal: canal anônimo assina, recebe "ok", e nunca
* recebe evento nenhum — falha silenciosa que parece saúde. O fetch do board já
* tinha sido movido para rota de API por essa mesma razão (ver useBoard);
* o realtime ficou para trás.
*
* A sessão continua morando no cookie httpOnly. Isto entrega apenas o
* access_token, em memória, para o cliente autenticar o WebSocket — nunca
* gravado em storage, e some no reload.
*/
import { randomUUID } from "node:crypto";
import type { NextRequest } from "next/server";
import { ok, fail } from "@/lib/api/wrappers";
import { createClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
export async function GET(_req: NextRequest): Promise<Response> {
const requestId = randomUUID();
const supabase = await createClient();
// getUser() valida o JWT no servidor — é ele que autoriza a resposta.
const {
data: { user },
error: authErr,
} = await supabase.auth.getUser();
if (authErr || !user) {
return fail("unauthenticated", "Auth required.", 401, { requestId });
}
// getSession() aqui NÃO autentica (o getUser acima já autenticou): serve só
// para extrair o token que o cookie httpOnly guarda.
const {
data: { session },
} = await supabase.auth.getSession();
if (!session?.access_token) {
return fail("unauthenticated", "Sessão sem token.", 401, { requestId });
}
return ok(
{ access_token: session.access_token, expires_at: session.expires_at ?? null },
{ requestId },
);
}
+58 -9
View File
@@ -23,6 +23,45 @@ export interface UseRealtimeChannelOpts {
enabled?: boolean;
}
/**
* Autentica o socket do Realtime com o token da sessão (uma vez por client).
*
* `setAuth` é do SOCKET, não do canal: vale para todos os canais criados
* depois. A promise fica memoizada para N hooks não dispararem N requisições.
*/
const AUTH_TIMEOUT_MS = 1_500;
let realtimeAuth: Promise<void> | null = null;
function authenticateRealtime(supabase: ReturnType<typeof createClient>): Promise<void> {
realtimeAuth ??= (async () => {
try {
const res = await fetch("/api/v1/auth/realtime-token", { credentials: "include" });
if (!res.ok) return;
const body = (await res.json()) as { data?: { access_token?: string } };
const token = body.data?.access_token;
if (token) supabase.realtime.setAuth(token);
} catch {
// Sem token o canal segue anônimo: a UI continua funcionando por refetch,
// só perde o tempo real. Derrubar a tela por causa disso seria pior.
realtimeAuth = null;
}
})();
return realtimeAuth;
}
/**
* Espera o token, mas com teto: assinar 1,5s depois é aceitável; NÃO assinar
* porque a rota está lenta (ou não existe, como no jsdom dos testes) deixaria a
* tela sem realtime para sempre. Prazo estourado = canal anônimo, que é o
* comportamento de antes desta correção, não uma regressão nova.
*/
function esperarAuth(supabase: ReturnType<typeof createClient>): Promise<void> {
return Promise.race([
authenticateRealtime(supabase),
new Promise<void>((resolve) => setTimeout(resolve, AUTH_TIMEOUT_MS)),
]);
}
export function useRealtimeChannel(opts: UseRealtimeChannelOpts): { status: RealtimeStatus } {
const { name, postgresChanges, broadcast, onChange, enabled = true } = opts;
@@ -48,6 +87,7 @@ export function useRealtimeChannel(opts: UseRealtimeChannelOpts): { status: Real
}
const supabase = createClient();
const channelName = `${name}::${instanceId}`;
// `chain` nunca é null durante o setup; `active` (anulável) existe só pro
// cleanup — separado para o narrowing do TS não depender de reatribuição.
let chain: RealtimeChannel = supabase.channel(channelName);
@@ -74,19 +114,28 @@ export function useRealtimeChannel(opts: UseRealtimeChannelOpts): { status: Real
}
let active: RealtimeChannel | null = chain;
let cancelado = false;
setStatus("connecting");
chain.subscribe((s) => {
// s is one of "SUBSCRIBED" | "CHANNEL_ERROR" | "TIMED_OUT" | "CLOSED"
const map: Record<string, RealtimeStatus> = {
SUBSCRIBED: "subscribed",
CHANNEL_ERROR: "channel_error",
TIMED_OUT: "timed_out",
CLOSED: "closed",
};
setStatus(map[s] ?? "connecting");
// O token tem de chegar ANTES do subscribe: assinar primeiro e autenticar
// depois deixa o canal anônimo para sempre — ele responde "Subscribed to
// PostgreSQL" e nunca entrega evento, porque a RLS filtra do outro lado.
void esperarAuth(supabase).then(() => {
if (cancelado || !active) return;
active.subscribe((s) => {
// s is one of "SUBSCRIBED" | "CHANNEL_ERROR" | "TIMED_OUT" | "CLOSED"
const map: Record<string, RealtimeStatus> = {
SUBSCRIBED: "subscribed",
CHANNEL_ERROR: "channel_error",
TIMED_OUT: "timed_out",
CLOSED: "closed",
};
setStatus(map[s] ?? "connecting");
});
});
return () => {
cancelado = true;
if (active) {
supabase.removeChannel(active);
active = null;