SuperdindaDocs

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 contaStream da pessoa protegida
RotaGET /accounts/{uid}/eventsGET /consent-requests/{token}/events
Quem lêResponsáveis da contaA página pública da pessoa (link de consentimento)
Autenticação?ticket= de 60 sO próprio token do link
Stream Redisevents:account:{accountId} (MAXLEN ~10.000)events:dependent:seat:{seatId} (MAXLEN ~200)
data de cada frameUm EventDto: { id, type, occurredAt, seatUid, payload }{ type, occurredAt, pairing }, com pairing sendo o PublicPairing inteiro depois da mudança
Sem since nem Last-Event-IDLê desde o começo do stream (0-0)Só o que acontecer a partir de agora
Limite de taxaNenhum30 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 é:

  1. POST /accounts/{uid}/events/ticket, com Bearer e sendo membro da conta. Responde 201 com { ticket, url }.
  2. Abra o EventSource em url (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 último id como header Last-Event-ID (o navegador faz isso sozinho ao reconectar o mesmo EventSource) ou como ?since=.
  • Quando nada chega em 5 s, sai um event: ping. Isso fica bem abaixo do idleTimeout de 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 por addEventListener("event", …), não por onmessage.
Ouvindo o stream da conta
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)

typeQuando disparaPayload (resumo)
consent.requestedPedido de consentimento criado ou reenviadokind, send, expiresAt
consent.openedA pessoa abriu o link pela primeira vezat
consent.givenA pessoa deu o sim (evidência aprovada)givenAt
consent.declinedA pessoa recusou (o motivo nunca vai junto)at
consent.expiredO link venceu sem resposta, ou foi trocado por um novoreason (timeout ou superseded)
consent.revokedO responsável retirou o pedido, ou os dados mudaram com o link abertoby
background_check.updatedA checagem de vínculo mudou de estadostatus
pairing.startedPareamento abertopairingUid, channel, method, challengeShownTo
pairing.challengeQR ou código novo (o código de quem digita no próprio celular não vai pros responsáveis)challenge
pairing.statusMudança de estado (connecting, degraded, disconnected…)status, reason, nextRetryAt
pairing.pairedO número certo conectoupairingUid, phone
pairing.identity_mismatchConectou outro número, e o pareamento foi desfeitoconnectedPhone (mascarado)
pairing.endedPareamento encerrado (desvincular, parada, reset)pairingUid, status, reason
pairing.avatarA foto de perfil da pessoa foi copiada—
trial.startedA primeira conexão começou o teste grátisstartedAt, endsAt, seat, account
seat.paused / seat.resumedPausa começou ou mudou de fim / proteção voltoucause, pausedAt, until / resumedAt, actorName, changed
seat.stoppedA própria pessoa parou a proteçãocause: "dependent", stoppedAt
seat.state_changedO estado da proteção da pessoa mudou (recalculado depois de eventos de consentimento, pareamento, pausa, parada e alerta)state, reason, since, action
analysis.warmupA primeira leitura do histórico começou (running) ou terminou (done)status, etaAt
alert.opened / alert.updatedAlerta novo / severidade subiu ou achado agrupadoalertUid, seatUid, kind, subtype, severity (+ status, change)
notification.createdUma notificação chegou na caixa de entradanotificationId, kind, título e corpo
queue.position / queue.offered / queue.standby / queue.started / queue.doneFila de importação de históricoposition / expiresAt / reason / — / outcome
sync.progressProgresso da importaçãostage, 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 since lê 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 events deveria cobrir um push perdido ("SSE catch-up"), e EventEntity.listAfter existe pra isso, mas a rota lê só o Redis. Um evento gravado cujo push falhou não chega por SSE.
  • sync.progress ainda leva seatId interno e contagem de mensagens. O G-11 do api-gaps.md pede trocar por seatUid e 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 de GET /accounts/{uid}/home já vem certa.
  • Ainda não existem member.updated, billing.updated, reconnect.requested nem summary.ready (G-11 do api-gaps.md).

Fontes

  • backend/src/http/routes/events/stream.route.ts, backend/src/http/routes/events/ticket.route.ts
  • backend/src/http/routes/consents/pairing.route.ts (DependentEventStreamRoute)
  • backend/src/domains/events/use-cases/publish-event.ts, sse-ticket.ts, entities/event.entity.ts
  • backend/src/domains/channels/use-cases/publish-pairing-event.ts, apply-provider-state.ts
  • backend/src/domains/consents/use-cases/publish-consent-event.ts, backend/src/domains/ingest/use-cases/publish-queue-event.ts
  • backend/src/domains/subscriptions/use-cases/monitoring.ts, backend/src/domains/alerts/use-cases/open-or-attach.ts, backend/src/domains/notifications/use-cases/dispatch.ts
  • backend/src/domains/protection/use-cases/state-watch.ts
  • backend/src/infra/streams.ts, backend/src/infra/redis.ts
  • .specs/features/redesign/api-map.md (§0.4), api-gaps.md (G-11)
  • AD-010

On this page