Tempo real (SSE)
Os dois streams de Server-Sent Events (o da conta e o da pessoa protegida), o ticket de 60 segundos, a retomada por Last-Event-ID, os heartbeats e o catálogo de eventos.
A API empurra mudanças de estado por Server-Sent Events, não por WebSocket: SSE reconecta sozinho e roda em HTTP puro (AD-010). Atrás dele há Redis Streams, e a tabela events é a fonte da verdade (veja Webhooks e eventos).
Os dois streams
| Stream da conta | Stream da pessoa protegida | |
|---|---|---|
| Rota | GET /accounts/{uid}/events | GET /consent-requests/{token}/events |
| Quem lê | Responsáveis da conta | A página pública da pessoa (link de consentimento) |
| Autenticação | ?ticket= de 60 s | O próprio token do link |
| Stream Redis | events:account:{accountId} (MAXLEN ~10.000) | events:dependent:seat:{seatId} (MAXLEN ~200) |
data de cada frame | Um EventDto: { id, type, occurredAt, seatUid, payload } | { type, occurredAt, pairing }, com pairing sendo o PublicPairing inteiro depois da mudança |
Sem since nem Last-Event-ID | Lê desde o começo do stream (0-0) | Só o que acontecer a partir de agora |
| Limite de taxa | Nenhum | 30 conexões por 10 min por link, 600 por IP |
O stream da pessoa existe à parte porque o da conta é assunto dos responsáveis: nada dele é da pessoa (publish-pairing-event.ts).
O pairing de cada frame do stream da pessoa é este tipo (backend/src/domains/channels/dtos/public-pairing.dto.ts), sem nada sobre os responsáveis:
Prop
Type
O ticket
EventSource não manda header, então o Bearer não serve. O fluxo é:
POST /accounts/{uid}/events/ticket, com Bearer e sendo membro da conta. Responde201com{ ticket, url }.- Abra o
EventSourceemurl(que já leva?ticket=) em até 60 segundos.
O ticket é um JWT HS256 com typ: "sse", sub (uid do usuário) e acc (uid da conta). Um access token comum não serve como ticket, e um ticket de outra conta recebe 401. Na conexão, o servidor confere de novo se o usuário ainda é membro vivo; se não for, 404. Gere um ticket por conexão: ao reconectar depois de um erro, peça outro.
Frames, retomada e heartbeat
id: 1760884500000-0
event: event
data: {"id":"01J…","type":"pairing.paired","occurredAt":"…","seatUid":"…","payload":{…}}
event: ping
data:idé a posição no Redis Stream (<ms>-<seq>). Pra retomar sem buraco, mande o últimoidcomo headerLast-Event-ID(o navegador faz isso sozinho ao reconectar o mesmoEventSource) ou como?since=.- Quando nada chega em 5 s, sai um
event: ping. Isso fica bem abaixo doidleTimeoutde 10 s do Bun e dos limites comuns de proxy. A AD-010 fala em 15 s, mas o código usa 5 s. - Os frames de dados usam
event: event. Escute poraddEventListener("event", …), não poronmessage.
async function listen(accountUid: string, token: string, since?: string) {
const res = await fetch(`${API}/accounts/${accountUid}/events/ticket`, {
method: "POST",
headers: { authorization: `Bearer ${token}` },
}).then((r) => r.json());
const url = new URL(res.data.url, API);
if (since) url.searchParams.set("since", since);
const source = new EventSource(url);
source.addEventListener("event", (e) => {
const event = JSON.parse(e.data); // EventDto
lastId = e.lastEventId;
if (event.type.startsWith("seat.")) refetchSeat(event.seatUid);
});
source.onerror = () => {
source.close();
setTimeout(() => listen(accountUid, token, lastId), 2000); // ticket novo
};
}Catálogo de eventos (stream da conta)
type | Quando dispara | Payload (resumo) |
|---|---|---|
consent.requested | Pedido de consentimento criado ou reenviado | kind, send, expiresAt |
consent.opened | A pessoa abriu o link pela primeira vez | at |
consent.given | A pessoa deu o sim (evidência aprovada) | givenAt |
consent.declined | A pessoa recusou (o motivo nunca vai junto) | at |
consent.expired | O link venceu sem resposta, ou foi trocado por um novo | reason (timeout ou superseded) |
consent.revoked | O responsável retirou o pedido, ou os dados mudaram com o link aberto | by |
background_check.updated | A checagem de vínculo mudou de estado | status |
pairing.started | Pareamento aberto | pairingUid, channel, method, challengeShownTo |
pairing.challenge | QR ou código novo (o código de quem digita no próprio celular não vai pros responsáveis) | challenge |
pairing.status | Mudança de estado (connecting, degraded, disconnected…) | status, reason, nextRetryAt |
pairing.paired | O número certo conectou | pairingUid, phone |
pairing.identity_mismatch | Conectou outro número, e o pareamento foi desfeito | connectedPhone (mascarado) |
pairing.ended | Pareamento encerrado (desvincular, parada, reset) | pairingUid, status, reason |
pairing.avatar | A foto de perfil da pessoa foi copiada | — |
trial.started | A primeira conexão começou o teste grátis | startedAt, endsAt, seat, account |
seat.paused / seat.resumed | Pausa começou ou mudou de fim / proteção voltou | cause, pausedAt, until / resumedAt, actorName, changed |
seat.stopped | A própria pessoa parou a proteção | cause: "dependent", stoppedAt |
seat.state_changed | O estado da proteção da pessoa mudou (recalculado depois de eventos de consentimento, pareamento, pausa, parada e alerta) | state, reason, since, action |
analysis.warmup | A primeira leitura do histórico começou (running) ou terminou (done) | status, etaAt |
alert.opened / alert.updated | Alerta novo / severidade subiu ou achado agrupado | alertUid, seatUid, kind, subtype, severity (+ status, change) |
notification.created | Uma notificação chegou na caixa de entrada | notificationId, kind, título e corpo |
queue.position / queue.offered / queue.standby / queue.started / queue.done | Fila de importação de histórico | position / expiresAt / reason / — / outcome |
sync.progress | Progresso da importação | stage, percent … |
Os eventos de alerta nunca carregam trecho, contraparte nem justificativa (regra P1). No stream da pessoa, os tipos são pairing.started, pairing.challenge, pairing.status, pairing.paired e pairing.ended.
Em aberto
- A conexão sem
sincelê o stream da conta desde o começo (até cerca de 10.000 entradas retidas). Ainda não existe um "só a partir de agora" nessa rota. - A tabela
eventsdeveria cobrir um push perdido ("SSE catch-up"), eEventEntity.listAfterexiste pra isso, mas a rota lê só o Redis. Um evento gravado cujo push falhou não chega por SSE. sync.progressainda levaseatIdinterno e contagem de mensagens. O G-11 doapi-gaps.mdpede trocar porseatUide tirar volumes do stream dos responsáveis.- Ativar e arquivar uma vaga não publicam evento, então não disparam
seat.state_changed. A próxima leitura deGET /accounts/{uid}/homejá vem certa. - Ainda não existem
member.updated,billing.updated,reconnect.requestednemsummary.ready(G-11 doapi-gaps.md).
Fontes
backend/src/http/routes/events/stream.route.ts,backend/src/http/routes/events/ticket.route.tsbackend/src/http/routes/consents/pairing.route.ts(DependentEventStreamRoute)backend/src/domains/events/use-cases/publish-event.ts,sse-ticket.ts,entities/event.entity.tsbackend/src/domains/channels/use-cases/publish-pairing-event.ts,apply-provider-state.tsbackend/src/domains/consents/use-cases/publish-consent-event.ts,backend/src/domains/ingest/use-cases/publish-queue-event.tsbackend/src/domains/subscriptions/use-cases/monitoring.ts,backend/src/domains/alerts/use-cases/open-or-attach.ts,backend/src/domains/notifications/use-cases/dispatch.tsbackend/src/domains/protection/use-cases/state-watch.tsbackend/src/infra/streams.ts,backend/src/infra/redis.ts.specs/features/redesign/api-map.md(§0.4),api-gaps.md(G-11)- AD-010
