mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
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:
co-authored by
Claude Opus 5
parent
f33119b506
commit
24b9ec234e
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user