C-1 — Cadastro de Cidadãos
Fase 2 — Microsserviço independente
Propósito
Seção intitulada “Propósito”A C-1 é a fonte da verdade da identidade do cidadão e de suas autodeclarações de perfil. Responde a duas perguntas: quem é o cidadão_id que está operando e o que esse cidadão declara sobre si?
Gerencia três providers de autenticação — device anônimo (UUID v4), login social Google (OAuth 2.0) e gov.br (OIDC) — e mantém os metadados de perfil: nome, email, avatar, endereço autodeclarado e unidade cívica de residência autodeclarada. Consome vínculo.validado da D-9 para manter projeção local do status de vínculo por UC.
A C-1 guarda a declaração. A verificação é outra camada. Quando o cidadão declara “moro na Rua X, UC Jardim das Flores”, a C-1 armazena. A D-9 (Validação de Vínculo) verifica independentemente essa declaração e publica o resultado. A C-1 projeta o resultado para exibição no perfil.
A C-1 não valida vínculo (D-9), não gerencia elegibilidade de conselheiro (D-6a) nem mandato e progressão (D-17), não toma decisão sobre quem pode fazer o quê. Apenas estabelece identidade e armazena autodeclarações.
No MVP (Fase 1), a C-1 não existe como módulo separado. Os dados de cidadão residem no estado próprio do BFF da D-1a, schema d1a. Na Fase 2, a C-1 é extraída para módulo independente com schema próprio c1. O BFF da D-1a passa a consumi-la por API REST para operações síncronas (login, atualização de perfil) e por eventos para projeções locais de leitura. Nenhuma outra colônia acessa o banco da C-1.
A C-1 expõe REST API interna — consumida pelo BFF da D-1a como proxy, não diretamente pelo front-end. O BFF recebe a requisição HTTP do front-end, roteia para a C-1 e retorna a resposta. O BFF não toma decisão sobre dados de cidadão, apenas faz proxy. A C-1 publica e consome eventos via barramento para notificações assíncronas.
1. Estrutura do Módulo NestJS
Seção intitulada “1. Estrutura do Módulo NestJS”A C-1 é um módulo NestJS com encapsulamento próprio dentro do monolito modular da Fase 2. Expõe controllers REST internos (consumidos pelo BFF da D-1a via HTTP local), publica eventos no barramento via EventBusService (N-0a) e consome vínculo.validado da D-9 para projeção local.
1.1 Árvore de diretórios
Seção intitulada “1.1 Árvore de diretórios”src/cidadao/c-1-cadastro-cidadaos/├── c1.module.ts # Module definition├── controllers/│ ├── cidadao.controller.ts # POST /anonymous, /google, /govbr, GET/PATCH /:id│ └── vinculo.controller.ts # GET /:id/vinculos├── services/│ ├── cidadao.service.ts # Core: findOrCreate, vincular, updatePerfil│ ├── google-auth.service.ts # Verificação de token Google OAuth│ ├── govbr-auth.service.ts # Verificação de token gov.br OIDC + JWKS│ └── vinculo.service.ts # Handler de vínculo.validado + query de projeção├── dto/│ ├── criar-anonimo.dto.ts # Contrato POST /cidadaos/anonymous│ ├── criar-google.dto.ts # Contrato POST /cidadaos/google│ ├── criar-govbr.dto.ts # Contrato POST /cidadaos/govbr│ ├── vincular-cidadaos.dto.ts # Contrato POST /cidadaos/vinculos│ └── atualizar-perfil.dto.ts # Contrato PATCH /cidadaos/:id├── entities/│ ├── cidadao.entity.ts # Prisma entity para c1.cidadaos│ ├── auth-provider.entity.ts # Prisma entity para c1.auth_providers│ ├── vinculo.entity.ts # Prisma entity para c1.vinculos│ └── consumer-offset.entity.ts # Prisma entity para c1.consumer_offset├── repositories/│ ├── cidadao.repository.ts # Acesso a c1.cidadaos│ ├── auth-provider.repository.ts # Acesso a c1.auth_providers│ ├── vinculo.repository.ts # Acesso a c1.vinculos│ └── consumer-offset.repository.ts # Acesso a c1.consumer_offset├── guards/│ └── rate-limit.guard.ts # Guard que estende ThrottlerGuard com lógica por IP└── c1.constants.ts # Constantes: limites, timeouts, cache TTL1.2 Module definition
Seção intitulada “1.2 Module definition”@Module({ imports: [ ThrottlerModule.forRoot([{ ttl: 60000, limit: 10, }]), ], controllers: [ CidadaoController, VinculoController, ], providers: [ CidadaoService, GoogleAuthService, GovbrAuthService, VinculoService, CidadaoRepository, AuthProviderRepository, VinculoRepository, ConsumerOffsetRepository, ], exports: [],})export class C1Module implements OnModuleInit { constructor( private readonly vinculoService: VinculoService, ) {}
async onModuleInit() { await this.vinculoService.iniciar(); }}1.3 Pontos de atenção
Seção intitulada “1.3 Pontos de atenção”- O módulo não é
@Global(). A C-1 não é dependência de nenhuma outra colônia. - O módulo não importa
EventBusModuleexplicitamente.EventBusModuleé@Global(), e oEventBusServiceé injetável sem import. - O módulo não importa
RegistryModule. A validação de schema dos eventos publicados é feita pelo próprio Event Bus (N-0a) no momento dopublicar(). - O módulo importa
ThrottlerModule.forRoot()com configuração de fallback para os endpoints REST internos. ORateLimitGuardcustomizado sobrescreve por rota. Na Fase 2, como os endpoints são internos (consumidos apenas pelo BFF), o rate limiting é conservador — proteção contra loops internos, não contra abuso de usuário final. - O
OnModuleInitdispara o protocolo de inicialização doVinculoService: replay de eventos perdidos (vínculo.validado) + registro de handler. - O módulo registra consumer offset — a C-1 consome
vínculo.validadoe precisa de cursor de replay. - O módulo não registra handlers para eventos que não existem na Fase 1. O handler de
vínculo.validadosó é registrado quando a D-9 estiver implementada. Antes disso,onModuleInitapenas inicializa a estrutura sem registrar consumers.
1.4 Serviços — responsabilidades e contratos
Seção intitulada “1.4 Serviços — responsabilidades e contratos”interface ICidadaoService { findOrCreateAnonymous(deviceId: string): Promise<{ cidadao: Cidadao; isNew: boolean }>; findOrCreateFromGoogle(idToken: string): Promise<{ cidadao: Cidadao; isNew: boolean }>; findOrCreateFromGovbr(idToken: string): Promise<{ cidadao: Cidadao; isNew: boolean }>; vincularDispositivos(origemId: string, destinoId: string): Promise<void>; findById(cidadaoId: string): Promise<Cidadao | null>; updatePerfil(cidadaoId: string, dto: AtualizarPerfilDto): Promise<Cidadao>; getVinculos(cidadaoId: string): Promise<Vinculo[]>;}
interface IGoogleAuthService { verifyToken(idToken: string): Promise<GoogleTokenPayload>;}
interface IGovbrAuthService { verifyToken(idToken: string): Promise<GovbrTokenPayload>;}
interface IVinculoService { iniciar(): Promise<void>; onVinculoValidado(event: EventLog): Promise<void>;}Além dos serviços, o VinculoService depende do ConsumerOffsetRepository:
interface IConsumerOffsetRepository { seed(tipos: string[], sequenciaInicial: number | bigint): Promise<void>; buscarTodos(): Promise<Array<{ tipo_evento: string; last_sequence: number }>>; upsert(tipoEvento: string, lastSequence: bigint): Promise<void>;}1.5 Controllers — endpoints expostos
Seção intitulada “1.5 Controllers — endpoints expostos”| Método | Rota | Controller | Descrição |
|---|---|---|---|
| POST | /api/c1/cidadaos/anonymous |
CidadaoController | Cria ou retorna cidadão anônimo por device ID. |
| POST | /api/c1/cidadaos/google |
CidadaoController | Cria ou retorna cidadão a partir de token Google. |
| POST | /api/c1/cidadaos/govbr |
CidadaoController | Cria ou retorna cidadão a partir de token gov.br. |
| POST | /api/c1/cidadaos/vinculos |
CidadaoController | Vincula duas identidades (ex: anônimo → Google). |
| GET | /api/c1/cidadaos/:id |
CidadaoController | Retorna perfil completo do cidadão com providers. |
| PATCH | /api/c1/cidadaos/:id |
CidadaoController | Atualiza endereço e UC de residência. |
| GET | /api/c1/cidadaos/:id/vinculos |
VinculoController | Retorna status de vínculo por UC. |
Os endpoints são prefixados com /api/c1/ para isolar o namespace da C-1 no roteamento do monolito. Na Fase 2, quando a C-1 for extraída para microsserviço independente, o prefixo pode ser removido ou mantido conforme a configuração de API gateway.
1.6 Colônia com BFF acoplado
Seção intitulada “1.6 Colônia com BFF acoplado”A C-1 é uma colônia com BFF acoplado — expõe endpoints REST internos. Diferente da D-1a (BFF de entrada do sistema, voltado ao front-end), a C-1 expõe endpoints consumidos pelo BFF da D-1a como proxy. O front-end nunca chama a C-1 diretamente.
Essa separação é arquitetural, não de runtime. No monolito, ambos os módulos coexistem no mesmo processo. A chamada do BFF para a C-1 é HTTP local (localhost). Quando a C-1 for extraída para microsserviço, a única mudança é o destino da chamada HTTP — de localhost para o endereço do serviço.
A C-1 não faz chamadas HTTP para outras colônias. Comunicação assíncrona é via barramento de eventos. A C-1 não atua como proxy para nenhuma outra colônia.
2. Banco de Dados — Schema e Entidades
Seção intitulada “2. Banco de Dados — Schema e Entidades”2.1 Schema c1
Seção intitulada “2.1 Schema c1”Todas as tabelas da C-1 residem no schema c1 do PostgreSQL. Este schema é de uso exclusivo do módulo C-1. Nenhuma outra colônia lê ou escreve nestas tabelas.
2.2 Tabela c1.cidadaos
Seção intitulada “2.2 Tabela c1.cidadaos”Registro de identidade do cidadão. Contém apenas dados de perfil e autodeclarações. Providers de autenticação e status de vínculo são tabelas separadas.
CREATE SCHEMA IF NOT EXISTS c1;
CREATE TABLE c1.cidadaos ( id UUID PRIMARY KEY, nome VARCHAR(200), email VARCHAR(255), avatar_url VARCHAR(500), endereco VARCHAR(500), uc_residencia UUID, vinculado_a UUID, criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(), atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW());
CREATE INDEX idx_c1_cidadaos_email ON c1.cidadaos (email) WHERE email IS NOT NULL;CREATE INDEX idx_c1_cidadaos_vinculado ON c1.cidadaos (vinculado_a) WHERE vinculado_a IS NOT NULL;Colunas — detalhamento
Seção intitulada “Colunas — detalhamento”| Coluna | Tipo | Descrição |
|---|---|---|
id |
UUID PK | cidadão_id usado em todo o sistema. Para device anônimo, é o próprio UUID gerado pelo front-end. Para Google e gov.br, é um novo UUID gerado pela C-1. |
nome |
VARCHAR(200) | Nome de exibição. Do Google ou gov.br. Nulo para device anônimo. |
email |
VARCHAR(255) | Email principal. Do Google ou gov.br. Nulo para device anônimo. |
avatar_url |
VARCHAR(500) | URL do avatar. Do Google. Nulo para gov.br e anônimo. |
endereco |
VARCHAR(500) | Endereço autodeclarado pelo cidadão. Nulo até preenchimento. |
uc_residencia |
UUID | unidade_cívica_id de residência autodeclarada. Nulo até preenchimento. |
vinculado_a |
UUID | Se esta identidade foi vinculada a outra, aponta para o cidadão_id de destino. GET por este ID retorna 301 para o destino. |
criado_em |
TIMESTAMPTZ | Data de criação do registro. |
atualizado_em |
TIMESTAMPTZ | Data da última alteração de perfil. |
2.3 Tabela c1.auth_providers
Seção intitulada “2.3 Tabela c1.auth_providers”Mapeamento entre um cidadão e seus providers de autenticação. Um cidadão pode ter múltiplos providers (ex: começou como anônimo, vinculou Google e gov.br). Apenas um provider é marcado como primário.
CREATE TABLE c1.auth_providers ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), cidadao_id UUID NOT NULL, provider VARCHAR(20) NOT NULL, provider_sub VARCHAR(255), provider_data JSONB NOT NULL DEFAULT '{}', is_primary BOOLEAN NOT NULL DEFAULT false, criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_c1_provider_tipo CHECK (provider IN ('anonymous', 'google', 'govbr')), CONSTRAINT uq_c1_provider_sub UNIQUE (provider, provider_sub), CONSTRAINT fk_c1_providers_cidadao FOREIGN KEY (cidadao_id) REFERENCES c1.cidadaos(id));
CREATE INDEX idx_c1_providers_cidadao ON c1.auth_providers (cidadao_id);CREATE INDEX idx_c1_providers_lookup ON c1.auth_providers (provider, provider_sub) WHERE provider_sub IS NOT NULL;Colunas — detalhamento
Seção intitulada “Colunas — detalhamento”| Coluna | Tipo | Descrição |
|---|---|---|
id |
UUID PK | Identificador interno do mapeamento. |
cidadao_id |
UUID FK | FK para c1.cidadaos.id. |
provider |
VARCHAR(20) | anonymous, google ou govbr. CHECK constraint no banco. |
provider_sub |
VARCHAR(255) | Identificador único do usuário no provider. Para Google e gov.br, é o sub do token OIDC. Nulo para anonymous. |
provider_data |
JSONB | Dados adicionais do provider. Para gov.br: { "cpf": "...", "phone_number": "..." }. Para Google: {}. |
is_primary |
BOOLEAN | Se este é o provider principal do cidadão. Apenas um por cidadão. |
criado_em |
TIMESTAMPTZ | Data de criação do mapeamento. |
Nota sobre uq_c1_provider_sub: PostgreSQL trata múltiplos NULLs como distintos em constraints UNIQUE. Linhas com provider='anonymous' e provider_sub=NULL não conflitam entre si — cada device anônimo terá seu próprio cidadao_id e, portanto, seu próprio auth_provider distinto. O índice parcial idx_c1_providers_lookup cobre apenas buscas por (provider, provider_sub) com provider_sub IS NOT NULL.
2.4 Tabela c1.vinculos
Seção intitulada “2.4 Tabela c1.vinculos”Projeção local do status de vínculo do cidadão com cada unidade cívica. Alimentada exclusivamente pelo evento vínculo.validado da D-9 (Fase 2). É uma tabela de leitura. A C-1 não escreve por iniciativa própria.
CREATE TABLE c1.vinculos ( cidadao_id UUID NOT NULL, unidade_civica_id UUID NOT NULL, status VARCHAR(20) NOT NULL, metodo_validacao VARCHAR(50), data_validade TIMESTAMPTZ, hash_evidencia VARCHAR(128), event_id_origem UUID NOT NULL, atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_c1_vinculo_status CHECK (status IN ('valido', 'invalido', 'provisorio')), CONSTRAINT uq_c1_vinculo_cidadao_uc UNIQUE (cidadao_id, unidade_civica_id));
CREATE INDEX idx_c1_vinculos_cidadao ON c1.vinculos (cidadao_id);CREATE INDEX idx_c1_vinculos_status ON c1.vinculos (cidadao_id, status);Colunas — detalhamento
Seção intitulada “Colunas — detalhamento”| Coluna | Tipo | Descrição |
|---|---|---|
cidadao_id |
UUID | FK lógica para c1.cidadaos.id. |
unidade_civica_id |
UUID | UC do vínculo. |
status |
VARCHAR(20) | valido, invalido ou provisorio. |
metodo_validacao |
VARCHAR(50) | Método usado: documental, social_pares, presenca_verificada. |
data_validade |
TIMESTAMPTZ | Data de expiração do vínculo. Nulo se permanente. |
hash_evidencia |
VARCHAR(128) | Hash da evidência documental (não o documento). |
event_id_origem |
UUID | event_id do vínculo.validado que originou esta linha. Para idempotência. |
atualizado_em |
TIMESTAMPTZ | Data da última atualização. |
Nota sobre ausência de FK: A FK para c1.cidadaos não é criada como constraint formal. Se um cidadão for removido (LGPD), a linha em vínculos permanece como registro histórico de que aquele cidadão_id teve vínculo validado. A integridade referencial é garantida em aplicação: o handler de vínculo.validado verifica que o cidadao_id existe antes de upsertar.
2.5 Tabela c1.consumer_offset
Seção intitulada “2.5 Tabela c1.consumer_offset”Controla o cursor de processamento para replay seletivo após falha ou reinicialização. Segue o padrão definido pela N-0a (Event Bus).
CREATE TABLE c1.consumer_offset ( tipo_evento VARCHAR(255) PRIMARY KEY, last_sequence BIGINT NOT NULL DEFAULT 0, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW());No MVP da Fase 2, a tabela tem 1 linha: vínculo.validado.
2.6 Migrations esperadas
Seção intitulada “2.6 Migrations esperadas”Quatro migrations iniciais:
- V001 — Create schema c1 and cidadaos — Cria schema
c1, tabelac1.cidadaoscom índices. - V002 — Create auth_providers — Cria
c1.auth_providerscom FK parac1.cidadaos, constraint UNIQUE e índices. - V003 — Create vinculos — Cria
c1.vinculoscom constraint UNIQUE e índices. - V004 — Create consumer_offset — Cria
c1.consumer_offsetcom PK emtipo_eventoe seed inicial.
Migrations futuras (Fase 3): índices GIN em provider_data para consulta por CPF, índices compostos para queries de agregação de vínculo por UC.
2.7 Relações internas
Seção intitulada “2.7 Relações internas”A única FK formal é auth_providers.cidadao_id → cidadaos.id. As demais — vínculos.cidadao_id, cidadaos.vinculado_a — são correlações lógicas sem constraint. O motivo: na Fase 3 a tabela vinculos pode ser movida para leitura em cache Redis com write-through a partir do barramento, e constraints seriam obstáculo.
2.8 Decisões de schema
Seção intitulada “2.8 Decisões de schema”auth_providers como tabela separada de cidadaos.
No MVP (D-1a), a tabela d1a.cidadaos tem as colunas auth_provider e google_sub, sem govbr_sub. Para a Fase 2, com três providers e a possibilidade de um cidadão ter múltiplos (anônimo vinculado a Google e gov.br), colunas na tabela principal não escalam. A tabela de junção permite N providers por cidadão, consulta por provider_sub indexada e adição de novos providers sem alterar o schema de c1.cidadaos.
vinculado_a em c1.cidadaos em vez de soft-delete.
Quando um cidadão anônimo vincula a uma conta Google, a identidade anônima não é removida. Demandas e outros registros em colônias externas referenciam o cidadão_id original. Soft-delete quebraria essas referências. O campo vinculado_a é um redirect: GET /cidadaos/:id para um ID vinculado retorna 301 com o destino, e internamente a C-1 trata o ID de origem como alias do destino.
id do cidadão gerado pela aplicação, não pelo banco.
Para device anônimo, o id é o UUID v4 gerado pelo front-end. Para Google e gov.br, é um novo UUID v4 gerado pelo CidadaoService. Gerar no banco com gen_random_uuid() criaria uma assimetria: anônimos teriam ID do front-end, Google teriam ID do banco. A aplicação é a única fonte de cidadão_id.
Ausência de FK de vínculos para cidadaos.
A D-9 publica vínculo.validado com cidadão_id. Se a C-1 receber o evento antes do cidadão estar cadastrado (eventos fora de ordem), o handler não rejeita o evento. A ausência de FK permite o upsert sem validação de integridade no banco, e a linha fica armazenada até o cadastro chegar.
provider_data como JSONB.
gov.br retorna campos específicos (CPF, phone_number) que não existem no Google. Colunas fixas para cada provider poluiriam o schema. JSONB permite que cada provider armazene seus dados específicos sem alteração de schema. Consultas por CPF podem usar índice GIN quando necessário (Fase 3).
3. Eventos — Contratos Detalhados
Seção intitulada “3. Eventos — Contratos Detalhados”A C-1 publica três tipos de evento e consome um. Os schemas completos (JSON Schema draft-2020-12) estão definidos no Registry (N-0b). Esta seção descreve os contratos do ponto de vista da C-1.
Os contratos de evento são os mesmos do MVP. A migração da D-1a para a C-1 é transparente para os consumidores: o campo origem no envelope muda de D-1a para C-1 e a C-1 passa a preencher campos opcionais que o BFF não enviava (nome, email e avatar_url no cadastro e no perfil atualizado). O tipo e o schema não mudam.
3.1 Evento produzido: cidadão.cadastrado
Seção intitulada “3.1 Evento produzido: cidadão.cadastrado”| Propriedade | Valor |
|---|---|
| Tipo | cidadão.cadastrado |
| Schema version | 1.0.0 |
| Produtor | C-1 (Fase 2) / D-1a (Fase 1) |
| Consumidores | D-7 (Transparência), colônias com projeção de perfil |
| Descrição | Novo cidadão registrado. Identidade básica estabelecida. |
Payload publicado:
interface CidadaoCadastradoPayload { cidadao_id: string; nome?: string; email?: string; avatar_url?: string; auth_provider: string; // 'anonymous' | 'google' | 'govbr'}3.2 Evento produzido: cidadão.perfil_atualizado
Seção intitulada “3.2 Evento produzido: cidadão.perfil_atualizado”| Propriedade | Valor |
|---|---|
| Tipo | cidadão.perfil_atualizado |
| Schema version | 1.0.0 |
| Produtor | C-1 (Fase 2) / D-1a (Fase 1) |
| Consumidores | Colônias com projeção local de perfil, D-7 (Transparência) |
| Descrição | Cidadão alterou dados de perfil (endereço ou UC de residência). |
Payload publicado:
interface CidadaoPerfilAtualizadoPayload { cidadao_id: string; campos_alterados: string[]; // ex: ['endereco', 'uc_residencia'] nome?: string; email?: string; avatar_url?: string; endereco?: string; uc_residencia?: string;}3.3 Evento produzido: cidadão.vinculado
Seção intitulada “3.3 Evento produzido: cidadão.vinculado”| Propriedade | Valor |
|---|---|
| Tipo | cidadão.vinculado |
| Schema version | 1.0.0 |
| Produtor | C-1 (Fase 2) / D-1a (Fase 1) |
| Consumidores | D-7 (Transparência), colônias com projeção de perfil |
| Descrição | Uma identidade foi vinculada a outra (ex: device anônimo → conta Google). |
Payload publicado:
interface CidadaoVinculadoPayload { cidadao_id_origem: string; cidadao_id_destino: string; auth_provider: string; // provider do destino (para quem foi vinculado)}3.4 Evento consumido: vínculo.validado
Seção intitulada “3.4 Evento consumido: vínculo.validado”| Propriedade | Valor |
|---|---|
| Tipo | vínculo.validado |
| Schema version | 1.0.0 |
| Produtor | D-9 (Validação de Vínculo — Fase 2) |
| Consumidor | C-1 (esta colônia — projeção local) |
| Descrição | Resultado de validação de vínculo de um cidadão com uma unidade cívica. |
Payload esperado (conforme Registry N-0b, v1.0.0):
interface VinculoValidadoPayload { cidadao_id: string; unidade_civica_id: string; status: string; // 'valido' | 'invalido' | 'provisorio' metodo_validacao: string; // 'documental' | 'social_pares' | 'presenca_verificada' data_validade?: string; // ISO-8601, se provisório hash_evidencia?: string; // hash SHA-256 do documento (sem o documento)}3.5 Ordem de operações — findOrCreateAnonymous
Seção intitulada “3.5 Ordem de operações — findOrCreateAnonymous”O fluxo no CidadaoService.findOrCreateAnonymous() segue esta ordem exata:
1. BFF (D-1a) recebe requisição do front-end com header X-Cidadao-Id → BFF faz POST /api/c1/cidadaos/anonymous { device_id: "<UUID>" }
2. C1Controller recebe a requisição → validar DTO: device_id deve ser UUID v4 válido → se inválido: HTTP 400
3. CidadaoService.findOrCreateAnonymous(deviceId) → consultar c1.cidadaos WHERE id = deviceId → se encontrado: → verificar se vinculado_a IS NOT NULL → se sim: retornar { cidadao: destino, isNew: false } → se não: retornar { cidadao: encontrado, isNew: false } → se não encontrado: → PERSISTIR em transação: → INSERT INTO c1.cidadaos (id) VALUES (deviceId) → INSERT INTO c1.auth_providers (cidadao_id, provider, is_primary) VALUES (deviceId, 'anonymous', true) → PUBLICAR cidadão.cadastrado payload: { cidadao_id: deviceId, auth_provider: 'anonymous' } → retornar { cidadao: novo, isNew: true }
4. HTTP 201 (se novo) ou 200 (se existente) → Body: { cidadao_id, nome: null, email: null, auth_providers: ['anonymous'], is_new }3.6 Ordem de operações — findOrCreateFromGoogle
Seção intitulada “3.6 Ordem de operações — findOrCreateFromGoogle”1. BFF recebe requisição do front-end com Google ID token → BFF faz POST /api/c1/cidadaos/google { id_token: "<token>" }
2. C1Controller recebe a requisição → validar DTO: id_token não vazio → se inválido: HTTP 400
3. GoogleAuthService.verifyToken(idToken) → google-auth-library verifica assinatura, audience, expiration → audience deve corresponder a GOOGLE_CLIENT_ID configurado → se inválido: HTTP 401 "Token Google inválido ou expirado" → se válido: extrai { sub, email, name, picture }
4. CidadaoService.findOrCreateFromGoogle(payload) → consultar c1.auth_providers WHERE provider = 'google' AND provider_sub = payload.sub JOIN c1.cidadaos ON cidadaos.id = auth_providers.cidadao_id → se encontrado: → verificar se o cidadão está vinculado_a outro: → se sim: retornar o destino → se não: retornar o encontrado → atualizar metadados (nome, email, avatar podem ter mudado no Google): → UPDATE c1.cidadaos SET nome=?, email=?, avatar_url=?, atualizado_em=NOW() → se não encontrado: → gerar novo UUID v4 para cidadão → PERSISTIR em transação: → INSERT INTO c1.cidadaos (id, nome, email, avatar_url) → INSERT INTO c1.auth_providers (cidadao_id, provider, provider_sub, is_primary) → PUBLICAR cidadão.cadastrado payload: { cidadao_id, nome, email, avatar_url, auth_provider: 'google' }
5. HTTP 201 (se novo) ou 200 (se existente) → Body: { cidadao_id, nome, email, avatar_url, auth_providers: ['google'], is_new }3.7 Ordem de operações — findOrCreateFromGovbr
Seção intitulada “3.7 Ordem de operações — findOrCreateFromGovbr”1. BFF recebe do front-end o resultado do fluxo OIDC gov.br (authorization code trocado por tokens no BFF) → BFF faz POST /api/c1/cidadaos/govbr { id_token: "<token>" }
2. C1Controller recebe a requisição → validar DTO: id_token não vazio → se inválido: HTTP 400
3. GovbrAuthService.verifyToken(idToken) → obter JWKS do endpoint de chaves públicas do gov.br (https://sso.acesso.gov.br/jwks) → verificar assinatura do token com chave pública → verificar claims: iss, aud, exp, iat → aud deve corresponder ao client_id registrado → se inválido: HTTP 401 "Token gov.br inválido ou expirado" → se válido: extrai { sub, name, email, cpf, phone_number }
4. CidadaoService.findOrCreateFromGovbr(payload) → consultar c1.auth_providers WHERE provider = 'govbr' AND provider_sub = payload.sub → se encontrado: → verificar vinculado_a, retornar destino se aplicável → atualizar metadados (nome, email, provider_data com CPF e phone) → se não encontrado: → gerar novo UUID v4 → PERSISTIR em transação: → INSERT INTO c1.cidadaos (id, nome, email) → INSERT INTO c1.auth_providers (cidadao_id, provider, provider_sub, is_primary, provider_data) VALUES (?, 'govbr', ?, true, '{ "cpf": "...", "phone_number": "..." }') → PUBLICAR cidadão.cadastrado payload: { cidadao_id, nome, email, auth_provider: 'govbr' }
5. HTTP 201 ou 200Nota sobre CPF: O CPF é armazenado em provider_data (JSONB), não em coluna dedicada. O campo nome e email vão para c1.cidadaos. A justificativa: CPF é dado sensível com requisitos específicos de LGPD. Mantê-lo em JSONB permite criptografia seletiva da coluna no futuro (pgcrypto) sem alterar schema. O acesso é restrito ao GovbrAuthService e a consultas administrativas autorizadas.
3.8 Ordem de operações — vincularDispositivos
Seção intitulada “3.8 Ordem de operações — vincularDispositivos”1. BFF recebe requisição de vinculação do front-end (após login Google, com X-Cidadao-Id do device anônimo) → BFF faz POST /api/c1/cidadaos/vinculos { cidadao_origem_id, cidadao_destino_id }
2. C1Controller recebe a requisição → validar DTO: ambos os campos são UUID v4 → se inválido: HTTP 400
3. CidadaoService.vincularDispositivos(origemId, destinoId)
→ buscar origem: cidadaoRepo.findById(origemId) se não encontrado: HTTP 404 "Cidadão de origem não encontrado" se origem.vinculado_a IS NOT NULL: HTTP 409 "Cidadão já vinculado"
→ buscar destino: cidadaoRepo.findById(destinoId) se não encontrado: HTTP 404 "Cidadão de destino não encontrado"
→ PERSISTIR em transação: → Migrar auth_providers da origem para o destino: UPDATE c1.auth_providers SET cidadao_id = destinoId, is_primary = false WHERE cidadao_id = origemId
→ Marcar origem como vinculada: UPDATE c1.cidadaos SET vinculado_a = destinoId, atualizado_em = NOW() WHERE id = origemId
→ Garantir que há exatamente um provider primário no destino: se todos os providers do destino têm is_primary = false: UPDATE c1.auth_providers SET is_primary = true WHERE cidadao_id = destinoId ORDER BY criado_em DESC LIMIT 1
→ PUBLICAR cidadão.vinculado eventBus.publicar({ tipo: 'cidadão.vinculado', origem: 'C-1', event_id: UUID v4, payload: { cidadao_id_origem: origemId, cidadao_id_destino: destinoId, auth_provider: 'google' | 'govbr', // provider do destino } })
4. HTTP 200 { vinculado: true, cidadao_id: destinoId }3.9 Ordem de operações — updatePerfil
Seção intitulada “3.9 Ordem de operações — updatePerfil”1. BFF recebe PATCH /cidadaos/me do front-end → BFF faz PATCH /api/c1/cidadaos/:id { endereco?, uc_residencia? }
2. C1Controller recebe a requisição → validar DTO: → endereco: opcional, max 500 caracteres → uc_residencia: opcional, UUID v4 → ao menos um campo deve estar presente → se inválido: HTTP 400
3. CidadaoService.updatePerfil(cidadaoId, dto)
→ buscar cidadão: cidadaoRepo.findById(cidadaoId) se não encontrado: HTTP 404 se vinculado_a IS NOT NULL: → redirecionar atualização para o destino cidadaoId = cidadao.vinculado_a
→ determinar campos alterados: campos = [] se dto.endereco != cidadao.endereco: campos.push('endereco') se dto.uc_residencia != cidadao.uc_residencia: campos.push('uc_residencia')
→ se campos vazio: HTTP 200, body com cidadão atual (sem publicar evento)
→ PERSISTIR: UPDATE c1.cidadaos SET endereco = COALESCE(dto.endereco, cidadao.endereco), uc_residencia = COALESCE(dto.uc_residencia, cidadao.uc_residencia), atualizado_em = NOW() WHERE id = cidadaoId
→ PUBLICAR cidadão.perfil_atualizado payload: { cidadao_id: cidadaoId, campos_alterados: campos, endereco: dto.endereco ?? cidadao.endereco, uc_residencia: dto.uc_residencia ?? cidadao.uc_residencia, nome: cidadao.nome, email: cidadao.email, avatar_url: cidadao.avatar_url, }
4. HTTP 200 { cidadao_id, endereco, uc_residencia, ... }3.10 Ordem de operações — handler de vínculo.validado
Seção intitulada “3.10 Ordem de operações — handler de vínculo.validado”1. Event Bus entrega vínculo.validado ao handler registrado
2. VinculoService.onVinculoValidado(event)
→ verificar idempotência: SELECT FROM c1.vinculos WHERE event_id_origem = event.event_id se encontrado: log.info, retornar
→ validar payload mínimo: cidadao_id, unidade_civica_id, status — obrigatórios se ausente: log.error, retornar
→ upsert em c1.vinculos: INSERT INTO c1.vinculos (cidadao_id, unidade_civica_id, status, metodo_validacao, data_validade, hash_evidencia, event_id_origem) VALUES (?, ?, ?, ?, ?, ?, event.event_id) ON CONFLICT (cidadao_id, unidade_civica_id) DO UPDATE SET status = EXCLUDED.status, metodo_validacao = EXCLUDED.metodo_validacao, data_validade = EXCLUDED.data_validade, hash_evidencia = EXCLUDED.hash_evidencia, event_id_origem = EXCLUDED.event_id_origem, atualizado_em = NOW()
→ atualizar consumer offset: UPDATE c1.consumer_offset SET last_sequence = event.sequence_number WHERE tipo_evento = 'vínculo.validado'O upsert com ON CONFLICT ... DO UPDATE garante que múltiplas validações para o mesmo par (cidadao_id, unidade_civica_id) atualizem a linha existente em vez de gerar duplicata. O último evento recebido é o estado corrente.
3.11 Idempotência na publicação de eventos
Seção intitulada “3.11 Idempotência na publicação de eventos”A C-1 implementa idempotência em duas camadas:
Camada 1 — Aplicação (C-1):
Para cidadão.cadastrado, a idempotência é natural: o método findOrCreate* primeiro consulta o banco. Se o cidadão já existe, retorna sem publicar evento. Se o INSERT falhar por violação de unicidade (race condition em auth_providers.provider_sub), o método captura a exceção, busca o registro existente e retorna sem publicar.
Camada 2 — Barramento (N-0a):
O EventBusService.publicar() usa event_id como chave de idempotência. Se a C-1 chamar publicar() com um evento que já foi publicado (mesmo event_id), o barramento retorna o registro existente sem reemitir aos consumidores.
3.12 Tratamento de erro e reentrega
Seção intitulada “3.12 Tratamento de erro e reentrega”| Cenário | Comportamento |
|---|---|
| Token Google expirado ou inválido | HTTP 401. GoogleAuthService propaga TokenExpiredError. Nenhum efeito colateral. |
| Token gov.br expirado ou inválido | HTTP 401. GovbrAuthService propaga erro de verificação. |
Provider sub já existe (Google/gov.br) |
Cidadão existente retornado. HTTP 200. Nenhum evento publicado. |
cidadao_id de origem não encontrado em vinculação |
HTTP 404. |
cidadao_id de destino não encontrado em vinculação |
HTTP 404. |
| Vinculação de cidadão já vinculado | HTTP 409. vinculado_a IS NOT NULL. |
vínculo.validado para cidadao_id inexistente |
Upsert realizado mesmo assim; a projeção pode ser populada antes do cadastro em eventos fora de ordem. |
vínculo.validado reentregue (replay/DLQ) |
Detectado por event_id_origem. Log.info, retorna. |
INSERT em auth_providers falha com unique violation |
Indica race condition. Captura exceção, busca registro existente, retorna sucesso. |
eventBus.publicar() falha |
Log.error. Estado já persistido. Cidadão funcional mesmo sem evento publicado. Outras colônias recuperam a projeção via replay do event_log ou reconciliação. |
| Rate limit excedido | HTTP 429. Retry-After header. |
3.13 Decisões de design com justificativa
Seção intitulada “3.13 Decisões de design com justificativa”Fire-and-forget para eventos de cidadão.
Diferente do pipeline de demandas, onde demanda.recebida dispara uma cadeia de processamento obrigatória, os eventos de cidadão (cadastrado, perfil_atualizado, vinculado) são notificações. Se falharem, o cidadão está funcional. Outras colônias podem não ter a projeção atualizada, mas isso é recuperável via replay do event_log ou via scheduled job de reconciliação. O trade-off é disponibilidade do cadastro vs. consistência de projeções. Para o perfil de uso (cidadão quer usar o sistema imediatamente após cadastro), disponibilidade vence.
vínculo.validado upsert mesmo com cidadao_id inexistente.
Eventos podem chegar fora de ordem: a D-9 pode publicar vínculo.validado antes de a C-1 ter processado cidadão.cadastrado. Rejeitar o evento criaria uma lacuna que exigiria reentrega. O upsert armazena o dado e a aplicação consulta c1.vinculos pelo cidadao_id apenas quando o cidadão existir. O endpoint GET /cidadaos/:id/vinculos faz o join naturalmente — se o cidadão não existe, retorna 404. Se existe mas o vínculo foi armazenado antes do cadastro, retorna o vínculo normalmente.
endereco e uc_residencia como campos editáveis; nome, email, avatar_url como somente-leitura.
nome, email e avatar_url são gerenciados pelo provider (Google, gov.br) e atualizados no momento do login. O cidadão pode alterar endereço e UC de residência a qualquer momento porque são autodeclarações, não dados verificados. A verificação dessas declarações é responsabilidade da D-9.
gov.br — CPF em provider_data, não em coluna dedicada.
CPF é dado sensível. Coluna dedicada facilita queries, mas também facilita vazamentos acidentais (SELECT *). JSONB permite:
- Criptografia seletiva da coluna com
pgcrypto - Auditoria de acesso: qualquer query que acessa
provider_data->>'cpf'é visível no log - Adição de novos campos do gov.br sem migration
4. Lógica de Negócio — Algoritmos e Fluxos
Seção intitulada “4. Lógica de Negócio — Algoritmos e Fluxos”4.1 CidadaoService.findOrCreateAnonymous() — pseudocódigo
Seção intitulada “4.1 CidadaoService.findOrCreateAnonymous() — pseudocódigo”função findOrCreateAnonymous(deviceId: string) -> { cidadao: Cidadao, isNew: boolean }:
// 1. Validar formato se !isValidUUID(deviceId): lançar BadRequestException("device_id inválido")
// 2. Buscar cidadão existente cidadao = cidadaoRepo.findById(deviceId) se cidadao não é null: // Verificar redirecionamento de identidade vinculada se cidadao.vinculado_a não é null: destino = cidadaoRepo.findById(cidadao.vinculado_a) retornar { cidadao: destino, isNew: false } retornar { cidadao, isNew: false }
// 3. Criar novo cidadão anônimo EM TRANSAÇÃO: cidadao = cidadaoRepo.insert({ id: deviceId, // nome, email, avatar_url, endereco, uc_residencia = null })
authProviderRepo.insert({ cidadao_id: deviceId, provider: 'anonymous', provider_sub: null, provider_data: {}, is_primary: true, })
// 4. Publicar evento (fire-and-forget) tentar: await eventBus.publicar({ tipo: 'cidadão.cadastrado', origem: 'C-1', event_id: deviceId, correlacao_id: deviceId, payload: { cidadao_id: deviceId, auth_provider: 'anonymous', }, }) capturar erro: log.error("Falha ao publicar cidadão.cadastrado (anônimo)", erro, { cidadao_id: deviceId, })
retornar { cidadao, isNew: true }4.2 CidadaoService.findOrCreateFromGoogle() — pseudocódigo
Seção intitulada “4.2 CidadaoService.findOrCreateFromGoogle() — pseudocódigo”função findOrCreateFromGoogle(payload: GoogleTokenPayload) -> { cidadao: Cidadao, isNew: boolean }:
// 1. Buscar por provider_sub authProvider = authProviderRepo.findByProviderAndSub('google', payload.sub) se authProvider não é null: cidadao = cidadaoRepo.findById(authProvider.cidadao_id)
// Redirecionar se vinculado se cidadao.vinculado_a não é null: cidadao = cidadaoRepo.findById(cidadao.vinculado_a)
// Atualizar metadados do Google (podem ter mudado) cidadaoRepo.update(cidadao.id, { nome: payload.name ?? cidadao.nome, email: payload.email ?? cidadao.email, avatar_url: payload.picture ?? cidadao.avatar_url, atualizado_em: now(), })
retornar { cidadao: cidadaoRepo.findById(cidadao.id), isNew: false }
// 2. Criar novo cidadão Google novoId = UUIDv4() EM TRANSAÇÃO: cidadao = cidadaoRepo.insert({ id: novoId, nome: payload.name, email: payload.email, avatar_url: payload.picture, })
authProviderRepo.insert({ cidadao_id: novoId, provider: 'google', provider_sub: payload.sub, provider_data: {}, is_primary: true, })
// 3. Publicar evento tentar: await eventBus.publicar({ tipo: 'cidadão.cadastrado', origem: 'C-1', event_id: novoId, correlacao_id: novoId, payload: { cidadao_id: novoId, nome: payload.name, email: payload.email, avatar_url: payload.picture, auth_provider: 'google', }, }) capturar erro: log.error("Falha ao publicar cidadão.cadastrado (Google)", erro, { cidadao_id: novoId, })
retornar { cidadao, isNew: true }4.3 CidadaoService.findOrCreateFromGovbr() — pseudocódigo
Seção intitulada “4.3 CidadaoService.findOrCreateFromGovbr() — pseudocódigo”função findOrCreateFromGovbr(payload: GovbrTokenPayload) -> { cidadao: Cidadao, isNew: boolean }:
// Fluxo idêntico ao Google, com diferenças: // 1. provider = 'govbr' // 2. provider_data contém CPF e phone_number // 3. avatar_url não é preenchido (gov.br não retorna avatar)
authProvider = authProviderRepo.findByProviderAndSub('govbr', payload.sub) se authProvider não é null: cidadao = cidadaoRepo.findById(authProvider.cidadao_id) se cidadao.vinculado_a não é null: cidadao = cidadaoRepo.findById(cidadao.vinculado_a)
cidadaoRepo.update(cidadao.id, { nome: payload.name ?? cidadao.nome, email: payload.email ?? cidadao.email, atualizado_em: now(), })
// Atualizar provider_data authProviderRepo.updateProviderData(authProvider.id, { cpf: payload.cpf, phone_number: payload.phone_number, })
retornar { cidadao: cidadaoRepo.findById(cidadao.id), isNew: false }
// Criar novo novoId = UUIDv4() EM TRANSAÇÃO: cidadao = cidadaoRepo.insert({ id: novoId, nome: payload.name, email: payload.email, })
authProviderRepo.insert({ cidadao_id: novoId, provider: 'govbr', provider_sub: payload.sub, provider_data: { cpf: payload.cpf, phone_number: payload.phone_number, }, is_primary: true, })
// Publicar evento (fire-and-forget) tentar: await eventBus.publicar({ tipo: 'cidadão.cadastrado', origem: 'C-1', event_id: novoId, correlacao_id: novoId, payload: { cidadao_id: novoId, nome: payload.name, email: payload.email, auth_provider: 'govbr', }, }) capturar erro: log.error(...)
retornar { cidadao, isNew: true }4.4 CidadaoService.vincularDispositivos() — pseudocódigo
Seção intitulada “4.4 CidadaoService.vincularDispositivos() — pseudocódigo”função vincularDispositivos(origemId: string, destinoId: string):
// 1. Validar IDs se !isValidUUID(origemId) ou !isValidUUID(destinoId): lançar BadRequestException("UUID inválido") se origemId == destinoId: lançar BadRequestException("Origem e destino são o mesmo cidadão")
// 2. Buscar cidadãos origem = cidadaoRepo.findById(origemId) destino = cidadaoRepo.findById(destinoId) se origem é null: lançar NotFoundException("Cidadão de origem não encontrado") se destino é null: lançar NotFoundException("Cidadão de destino não encontrado")
// 3. Verificar se já vinculado se origem.vinculado_a não é null: se origem.vinculado_a == destinoId: retornar // já vinculado, idempotente senão: lançar ConflictException("Cidadão de origem já vinculado a outro")
// Prevenir cadeia: destino também não pode estar vinculado se destino.vinculado_a não é null: destino = cidadaoRepo.findById(destino.vinculado_a)
// 4. Migrar providers e marcar vinculação (transação) EM TRANSAÇÃO: // Migrar auth_providers da origem para o destino authProviderRepo.migrarCidadao(origemId, destinoId) // UPDATE c1.auth_providers SET cidadao_id = destinoId, is_primary = false // WHERE cidadao_id = origemId
// Marcar origem como vinculada cidadaoRepo.update(origemId, { vinculado_a: destinoId, atualizado_em: now(), })
// Garantir is_primary consistente primarios = authProviderRepo.countByCidadaoAndPrimary(destinoId) se primarios == 0: // Define o provider mais recente como primário authProviderRepo.setOldestAsPrimary(destinoId)
// 5. Publicar evento providerDestino = authProviderRepo.findPrimaryByCidadao(destinoId) tentar: await eventBus.publicar({ tipo: 'cidadão.vinculado', origem: 'C-1', event_id: UUIDv4(), payload: { cidadao_id_origem: origemId, cidadao_id_destino: destinoId, auth_provider: providerDestino.provider, }, }) capturar erro: log.error("Falha ao publicar cidadão.vinculado", erro, { origem: origemId, destino: destinoId, })4.5 CidadaoService.updatePerfil() — pseudocódigo
Seção intitulada “4.5 CidadaoService.updatePerfil() — pseudocódigo”função updatePerfil(cidadaoId: string, dto: AtualizarPerfilDto) -> Cidadao:
// 1. Validar DTO se !dto.endereco && !dto.uc_residencia: lançar BadRequestException("Ao menos um campo deve ser informado")
// 2. Buscar cidadão cidadao = cidadaoRepo.findById(cidadaoId) se cidadao é null: lançar NotFoundException("Cidadão não encontrado")
// Redirecionar se vinculado se cidadao.vinculado_a não é null: cidadao = cidadaoRepo.findById(cidadao.vinculado_a) se cidadao é null: lançar NotFoundException("Cidadão de destino não encontrado")
// 3. Determinar campos alterados campos = [] se dto.endereco != undefined e dto.endereco != cidadao.endereco: campos.push('endereco') se dto.uc_residencia != undefined e dto.uc_residencia != cidadao.uc_residencia: campos.push('uc_residencia')
// 4. Se nada mudou, retornar sem publicar se campos.length == 0: retornar cidadao
// 5. Persistir enderecoFinal = dto.endereco ?? cidadao.endereco ucFinal = dto.uc_residencia ?? cidadao.uc_residencia
cidadaoRepo.update(cidadao.id, { endereco: enderecoFinal, uc_residencia: ucFinal, atualizado_em: now(), })
// 6. Publicar evento tentar: await eventBus.publicar({ tipo: 'cidadão.perfil_atualizado', origem: 'C-1', event_id: UUIDv4(), correlacao_id: cidadaoId, payload: { cidadao_id: cidadaoId, campos_alterados: campos, nome: cidadao.nome, email: cidadao.email, avatar_url: cidadao.avatar_url, endereco: enderecoFinal, uc_residencia: ucFinal, }, }) capturar erro: log.error("Falha ao publicar cidadão.perfil_atualizado", erro, { cidadao_id: cidadaoId, campos, })
retornar cidadaoRepo.findById(cidadao.id)4.6 VinculoService.onVinculoValidado() — pseudocódigo
Seção intitulada “4.6 VinculoService.onVinculoValidado() — pseudocódigo”função onVinculoValidado(event: EventLog):
payload = event.payload as VinculoValidadoPayload
// 1. Verificar idempotência existente = vinculoRepo.findByEventIdOrigem(event.event_id) se existente não é null: log.info("vínculo.validado já processado", { event_id: event.event_id }) retornar
// 2. Validar payload mínimo se !payload.cidadao_id ou !payload.unidade_civica_id ou !payload.status: log.error("Payload de vínculo.validado incompleto", { event_id: event.event_id }) retornar
// 3. Upsert vinculoRepo.upsert({ cidadao_id: payload.cidadao_id, unidade_civica_id: payload.unidade_civica_id, status: payload.status, metodo_validacao: payload.metodo_validacao ?? null, data_validade: payload.data_validade ? new Date(payload.data_validade) : null, hash_evidencia: payload.hash_evidencia ?? null, event_id_origem: event.event_id, atualizado_em: now(), })
// 4. Atualizar consumer offset consumerOffsetRepo.upsert('vínculo.validado', event.sequence_number)4.7 GoogleAuthService.verifyToken() — pseudocódigo
Seção intitulada “4.7 GoogleAuthService.verifyToken() — pseudocódigo”função verifyToken(idToken: string) -> GoogleTokenPayload:
// google-auth-library: OAuth2Client.verifyIdToken() ticket = await googleClient.verifyIdToken({ idToken: idToken, audience: GOOGLE_CLIENT_ID, // configurado via env })
payload = ticket.getPayload()
// Validar claims obrigatórios se !payload.sub: lançar UnauthorizedException("Token Google sem subject")
retornar { sub: payload.sub, email: payload.email ?? null, name: payload.name ?? null, picture: payload.picture ?? null, }4.8 GovbrAuthService.verifyToken() — pseudocódigo
Seção intitulada “4.8 GovbrAuthService.verifyToken() — pseudocódigo”função verifyToken(idToken: string) -> GovbrTokenPayload:
// 1. Decodificar header para obter kid header = JSON.parse(base64urlDecode(idToken.split('.')[0])) kid = header.kid
// 2. Obter JWKS (com cache de 1 hora) jwks = await getJwksWithCache() // GET https://sso.acesso.gov.br/jwks // Cache em memória: Map<string, jwks>, TTL 3600s
chave = jwks.keys.find(k => k.kid == kid) se !chave: lançar UnauthorizedException("Chave de assinatura não encontrada no JWKS")
// 3. Verificar assinatura // Usar jose (implementação JS de JOSE) ou jsonwebtoken com chave pública payload = await jose.jwtVerify(idToken, await jose.importJWK(chave), { issuer: 'https://sso.acesso.gov.br', audience: GOVBR_CLIENT_ID, })
se !payload.sub: lançar UnauthorizedException("Token gov.br sem subject")
retornar { sub: payload.sub, name: payload.name ?? null, email: payload.email ?? null, cpf: payload.cpf ?? null, phone_number: payload.phone_number ?? null, }4.9 Casos de borda
Seção intitulada “4.9 Casos de borda”| Caso | Comportamento |
|---|---|
| Device ID não é UUID v4 | HTTP 400. class-validator rejeita no pipe. |
| Cidadão anônimo consultado por ID, mas já vinculado | GET /cidadaos/:id retorna 301 com header Location: /cidadaos/<destino>. Body contém { redirecionar_para: "<destino>" }. |
| Cidadão vinculado tenta se vincular novamente | HTTP 409 “Cidadão já vinculado”. |
| Vincular origem e destino iguais | HTTP 400 “Origem e destino são o mesmo cidadão”. |
| Vincular cadeia (A → B, e agora tentar B → C) | Permitido. B não está marcado como vinculado_a. A já está resolvido. A migração de providers de B para C funciona. GET por A redireciona para B, GET por B redireciona para C. |
Google sub já existe em provider, mas vinculado a outro cidadão |
Login retorna o cidadão existente (isNew=false). Login não gera nova identidade. |
Gov.br CPF já existe em provider_data de outro cidadão gov.br |
Não detectado no MVP. A constraint UNIQUE é sobre (provider, provider_sub), não sobre CPF. CPFs duplicados são improváveis mas possíveis se o gov.br emitir subs diferentes para o mesmo CPF. Tratamento na Fase 3 com reconciliação. |
vínculo.validado para cidadão que não existe |
Upsert realizado. Projeção populada. GET por aquele cidadao_id retorna 404. Se o cidadão for criado depois, o vínculo já estará lá. |
vínculo.validado com status provisorio e data_validade no passado |
Armazenado. A interpretação de expiração é do consumer (front-end/D-7), não da C-1. |
Múltiplos vínculo.validado para o mesmo par (cidadão, UC) |
Upsert garante que o último evento é o estado corrente. ON CONFLICT DO UPDATE. |
cidadão.perfil_atualizado sem campos alterados |
HTTP 200. Nenhum evento publicado. Idempotente. |
cidadão.perfil_atualizado com endereco idêntico ao atual |
Campo não incluído em campos_alterados. Nenhum evento se for o único campo. |
| Rollback de transação durante criação de cidadão | Nenhum efeito colateral. Cliente (BFF) recebe HTTP 500 e retry. Idempotência por provider_sub garante que retry não duplique. |
Concorrência: duas requisições simultâneas de criação com mesmo Google sub |
Primeira: INSERT bem-sucedido. Segunda: violação de UNIQUE em auth_providers. Capturada, busca registro existente, retorna sucesso. |
4.10 Decisões de design com justificativa
Seção intitulada “4.10 Decisões de design com justificativa”Cadeia de vinculação (A → B → C) é permitida.
No MVP da D-1a, a vinculação migrava demandas da origem para o destino — uma operação que alterava dados em tabela de outra responsabilidade (d1a.demandas_recebidas.cidadao_id). Na C-1, a vinculação apenas migra auth_providers e marca vinculado_a. Os dados migrados de outras colônias (demandas, lugares) são responsabilidade do BFF (D-1a) ou de scheduled jobs de reconciliação. A C-1 não escreve fora do schema c1.
vinculado_a é resolvido em cascata na aplicação, não no banco.
PostgreSQL suporta CTE recursiva, mas a profundidade esperada de vinculação é no máximo 2 (anônimo → Google → gov.br). Resolver em aplicação é mais simples e evita queries recursivas desnecessárias. Se no futuro a cadeia crescer (improvável), o limite prático é a leitura de 3-4 linhas.
gov.br: id_token verificado, não access_token.
O fluxo gov.br usa OIDC, onde o id_token contém as claims do usuário (sub, name, CPF). O access_token é opaco e usado para chamar APIs protegidas do gov.br. A C-1 não faz isso. O BFF troca o authorization code por tokens e envia apenas o id_token para a C-1. A C-1 não conhece o client_secret do gov.br. A troca do code é responsabilidade do BFF.
Cache de JWKS do gov.br por 1 hora. O endpoint JWKS do gov.br retorna as chaves públicas de verificação de assinatura. Baixar a cada requisição de login adiciona latência de rede e depende da disponibilidade do endpoint externo. Cache de 1 hora alinha com a rotação típica de chaves OIDC. Em caso de falha do JWKS após expiração do cache, a C-1 tenta baixar novamente. Se indisponível, retorna 503 (não 401 — o problema é infraestrutura, não credencial).
5. Integração com o Barramento e Outras Colônias
Seção intitulada “5. Integração com o Barramento e Outras Colônias”5.1 Publicação e consumo via EventBusService
Seção intitulada “5.1 Publicação e consumo via EventBusService”A C-1 injeta EventBusService (do módulo @Global() N-0a) para publicar e consumir eventos:
@Injectable()export class CidadaoService { constructor( private readonly eventBus: EventBusService, private readonly cidadaoRepo: CidadaoRepository, private readonly authProviderRepo: AuthProviderRepository, ) {}
async findOrCreateAnonymous(deviceId: string) { // ... lógica de criação ...
await this.eventBus.publicar({ tipo: 'cidadão.cadastrado', origem: 'C-1', event_id: deviceId, correlacao_id: deviceId, payload: { cidadao_id: deviceId, auth_provider: 'anonymous' }, }); }}Para consumo, o VinculoService registra o handler no OnModuleInit:
@Injectable()export class VinculoService implements OnModuleInit { constructor( private readonly eventBus: EventBusService, private readonly vinculoRepo: VinculoRepository, private readonly consumerOffsetRepo: ConsumerOffsetRepository, ) {}
async onModuleInit() { await this.iniciar(); }
async iniciar() { // Garantir o cursor no boot; o seed não sobrescreve cursor existente const maiorSequence = await this.eventBus.obterMaiorSequence(); await this.consumerOffsetRepo.seed(['vínculo.validado'], maiorSequence);
// Replay de eventos perdidos a partir do cursor const offsets = await this.consumerOffsetRepo.buscarTodos(); const cursor = offsets.find((o) => o.tipo_evento === 'vínculo.validado')?.last_sequence ?? 0; const eventosPerdidos = await this.eventBus.replayDeSequence(cursor, ['vínculo.validado']); for (const evento of eventosPerdidos) { await this.onVinculoValidado(evento); }
// Registrar handler para eventos futuros this.eventBus.inscrever('vínculo.validado', 'C-1', this.onVinculoValidado.bind(this)); }}5.2 Relação com outras colônias — fluxo de eventos
Seção intitulada “5.2 Relação com outras colônias — fluxo de eventos”C-1 publica: cidadão.cadastrado → D-7 (Transparência), colônias com projeção de perfil cidadão.perfil_atualizado → D-7 (Transparência), colônias com projeção de perfil cidadão.vinculado → D-7 (Transparência)
C-1 consome: vínculo.validado (D-9, Fase 2) → projeção local em c1.vinculosA C-1 não invoca outras colônias diretamente. Apenas publica no barramento. As colônias consumidoras são notificadas sem que a C-1 saiba quais são.
5.3 Chamadas síncronas — BFF da D-1a como proxy
Seção intitulada “5.3 Chamadas síncronas — BFF da D-1a como proxy”Na Fase 2, o BFF da D-1a chama a C-1 via HTTP para operações síncronas de cidadão. O fluxo completo de uma requisição do front-end:
Front-end → D-1a BFF → (HTTP localhost) → C-1 ↓ Event Bus → D-7 (projeção de leitura)O BFF da D-1a não processa, transforma ou decide sobre dados de cidadão. Apenas roteia. Exemplo de proxy:
// D-1a BFF — AuthController (Fase 2)@Post('auth/google')async googleLogin(@Body() dto: GoogleAuthDto, @Headers('x-cidadao-id') deviceId?: string) { // 1. Chamar C-1 para verificar/criar identidade Google const { cidadao_id, nome, email, auth_providers, is_new } = await this.httpService.post('http://localhost:3001/api/c1/cidadaos/google', { id_token: dto.id_token, });
// 2. Se veio de device anônimo, vincular if (deviceId && deviceId !== cidadao_id) { await this.httpService.post('http://localhost:3001/api/c1/cidadaos/vinculos', { cidadao_origem_id: deviceId, cidadao_destino_id: cidadao_id, });
// 3. Migrar dados locais (demandas, lugares) do device anônimo para Google await this.demandaRepo.transferirCidadao(deviceId, cidadao_id); }
// 4. Gerar JWT de sessão (responsabilidade do BFF) const jwt = this.authService.gerarToken({ sub: cidadao_id });
return { cidadao_id, nome, email, token: jwt };}A C-1 não sabe sobre JWTs, sessões ou migração de dados de outras colônias. Apenas gerencia identidade e providers.
5.4 Dependências de projeções de leitura
Seção intitulada “5.4 Dependências de projeções de leitura”A C-1 não consome projeções de leitura de outras colônias. A projeção local que a C-1 mantém (c1.vinculos) é alimentada exclusivamente por eventos do barramento (vínculo.validado).
5.5 Relação com a D-9 (Validação de Vínculo)
Seção intitulada “5.5 Relação com a D-9 (Validação de Vínculo)”A C-1 e a D-9 têm responsabilidades complementares sem acoplamento direto:
- C-1: armazena a autodeclaração (
endereco,uc_residencia). Projeta o resultado da validação (c1.vinculos). - D-9: consome a solicitação de validação (
cidadão.solicitou_validação_vínculo, publicado pelo BFF), executa o método de validação e publicavínculo.validadocom o resultado.
A C-1 não publica cidadão.solicitou_validação_vínculo. Esse evento é publicado pelo BFF da D-1a quando o cidadão aciona a validação pelo front-end. A decisão segue o princípio: ações iniciadas pelo usuário no front-end passam pelo BFF.
5.6 Relação com a D-7 (Transparência)
Seção intitulada “5.6 Relação com a D-7 (Transparência)”A D-7 consome os três eventos de cidadão para alimentar projeções públicas:
cidadão.cadastrado→ registro de novo cidadão na projeçãocidadão.perfil_atualizado→ atualização de perfil na timelinecidadão.vinculado→ registro de vinculação de identidades
A C-1 não conhece a D-7. Apenas publica no barramento.
6. Performance e Limites
Seção intitulada “6. Performance e Limites”6.1 Rate limiting
Seção intitulada “6.1 Rate limiting”| Rota | Limite | Janela | Chave | Observação |
|---|---|---|---|---|
POST /c1/cidadaos/anonymous |
30 | 1 minuto | IP | Chamado a cada nova sessão de app |
POST /c1/cidadaos/google |
20 | 1 minuto | IP | Login Google é raro |
POST /c1/cidadaos/govbr |
20 | 1 minuto | IP | Login gov.br é raro |
POST /c1/cidadaos/vinculos |
10 | 1 minuto | IP | Vinculação ocorre uma vez por usuário |
GET /c1/cidadaos/:id |
100 | 1 minuto | IP | Chamado pelo BFF a cada requisição autenticada |
PATCH /c1/cidadaos/:id |
10 | 1 minuto | IP | Atualização de perfil é rara |
GET /c1/cidadaos/:id/vinculos |
60 | 1 minuto | IP | Carregado na tela de perfil |
Os limites são mais altos que os da D-1a porque os endpoints da C-1 são internos — consumidos apenas pelo BFF, não pelo front-end. O IP é do servidor local, não do usuário. A proteção é contra loops e bugs, não contra abuso externo. Na extração para microsserviço, o rate limiting pode ser substituído por autenticação de serviço (mTLS ou API key).
6.2 Cotas e limites de tamanho
Seção intitulada “6.2 Cotas e limites de tamanho”| Limite | Valor | Justificativa |
|---|---|---|
endereco |
500 caracteres | Suficiente para endereço completo. |
nome |
200 caracteres | Limitado pelo provider. |
email |
255 caracteres | RFC 5321. |
avatar_url |
500 caracteres | URL do Google. |
provider_data (JSONB) |
10 KB | CPF + phone_number + campos futuros. |
| Payload REST máximo | 10 KB | Suficiente para tokens JWT (~2-4 KB). |
6.3 Índices e padrões de query
Seção intitulada “6.3 Índices e padrões de query”| Índice | Query atendida |
|---|---|
cidadaos_pkey (id) |
findById() — toda requisição autenticada |
idx_c1_cidadaos_email (parcial) |
Busca por email (administrativo, Fase 3) |
idx_c1_cidadaos_vinculado (parcial) |
“Quem está vinculado a X?” |
auth_providers_pkey (id) |
Acesso direto |
uq_c1_provider_sub (UNIQUE) |
findByProviderAndSub() — login Google/gov.br |
idx_c1_providers_cidadao |
“Quais providers este cidadão tem?” |
idx_c1_providers_lookup (parcial) |
Busca por (provider, provider_sub) |
vinculos_pkey (cidadao_id, unidade_civica_id) |
getVinculos() — tela de perfil |
idx_c1_vinculos_cidadao |
Listar todos os vínculos de um cidadão |
idx_c1_vinculos_status |
“Quais vínculos válidos este cidadão tem?” |
6.4 Padrões de query esperados
Seção intitulada “6.4 Padrões de query esperados”O padrão de acesso dominante é SELECT por id:
findById(): 1 query por requisição autenticada que o BFF proxy. Cache em memória reduz carga.findByProviderAndSub(): apenas no fluxo de login Google/gov.br. Esperado < 10 queries/minuto por provider na Fase 2.getVinculos(): carregado na tela de perfil. Esperado < 1 query por sessão de usuário.- INSERT em
c1.cidadaos+c1.auth_providers: apenas no primeiro login de cada provider. Transação de 2 INSERTs.
6.5 Estratégia de cache
Seção intitulada “6.5 Estratégia de cache”Cache LRU em memória no CidadaoService para findById():
// Estrutura: Map<string, { data: Cidadao, ts: number }>// TTL: 300 segundos (5 minutos)// Capacidade máxima: 10.000 entradas (LRU eviction)// Invalidado em: updatePerfil(), vincularDispositivos()A justificativa para o TTL de 5 minutos: com a C-1 em serviço independente, o custo de uma query ao banco é maior (latência de rede). Um TTL maior reduz queries. Atualizações de perfil são raras, e a janela de até 5 minutos com dados desatualizados para nome e email é aceitável. O MVP da D-1a não tem cache de cidadão.
Cache NÃO é aplicado a auth_providers — a consulta de login precisa ser sempre fresca. Cache NÃO é aplicado a vinculos — os dados mudam por evento externo (vínculo.validado), não por ação do cidadão.
6.6 Projeção de volume (Fase 2)
Seção intitulada “6.6 Projeção de volume (Fase 2)”| Cenário | Cidadãos cadastrados | Providers | Logins/dia | GET /:id/dia | Tamanho estimado do banco (ano) |
|---|---|---|---|---|---|
| 1 município (~100k hab) | ~5.000 | ~7.000 | ~500 | ~50.000 | < 500 MB |
| 10 municípios | ~50.000 | ~70.000 | ~5.000 | ~500.000 | < 5 GB |
| Regional (~1M hab) | ~100.000 | ~150.000 | ~10.000 | ~1.000.000 | < 10 GB |
O crescimento é linear com a base de cidadãos. A tabela vinculos cresce com cidadãos × UCs (um cidadão pode ter vínculo com múltiplas UCs), mas na prática a maioria dos cidadãos tem vínculo com 1-3 UCs.
7. Testabilidade
Seção intitulada “7. Testabilidade”7.1 Como testar o módulo isolado
Seção intitulada “7.1 Como testar o módulo isolado”Teste unitário do CidadaoService:
beforeEach(async () => { const module = await Test.createTestingModule({ providers: [ CidadaoService, { provide: EventBusService, useValue: mockEventBus }, { provide: CidadaoRepository, useValue: mockCidadaoRepo }, { provide: AuthProviderRepository, useValue: mockAuthProviderRepo }, ], }).compile();
service = module.get(CidadaoService);
mockCidadaoRepo.findById.mockResolvedValue(null); mockCidadaoRepo.insert.mockImplementation((data) => Promise.resolve({ ...data, criado_em: new Date().toISOString() }) ); mockAuthProviderRepo.findByProviderAndSub.mockResolvedValue(null); mockAuthProviderRepo.insert.mockImplementation((data) => Promise.resolve({ ...data, criado_em: new Date().toISOString() }) ); mockEventBus.publicar.mockResolvedValue({ sequence_number: 1, event_id: 'test-uuid', tipo: 'cidadão.cadastrado', });});Teste unitário do VinculoService:
beforeEach(async () => { const module = await Test.createTestingModule({ providers: [ VinculoService, { provide: EventBusService, useValue: mockEventBus }, { provide: VinculoRepository, useValue: mockVinculoRepo }, { provide: ConsumerOffsetRepository, useValue: mockOffsetRepo }, ], }).compile();
service = module.get(VinculoService);});Teste e2e dos controllers (supertest):
const app = await Test.createTestingModule({ imports: [C1Module],}).compile();
const httpServer = app.createNestApplication();await httpServer.init();
// Testar POST /api/c1/cidadaos/anonymousreturn request(httpServer.getHttpServer()) .post('/api/c1/cidadaos/anonymous') .send({ device_id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' }) .expect(201) .expect((res) => { expect(res.body.cidadao_id).toBeDefined(); expect(res.body.auth_providers).toContain('anonymous'); });7.2 Cenários de teste críticos
Seção intitulada “7.2 Cenários de teste críticos”Happy path:
| # | Cenário | Verificação |
|---|---|---|
| T1 | POST /anonymous com device ID novo |
HTTP 201. c1.cidadaos tem 1 linha. c1.auth_providers tem 1 linha com provider=‘anonymous’. Evento cidadão.cadastrado publicado. |
| T2 | POST /anonymous com device ID existente |
HTTP 200. is_new: false. Nenhum evento publicado. count(*) inalterado. |
| T3 | POST /google com token válido, usuário novo |
HTTP 201. Cidadão criado com nome, email, avatar. auth_providers com provider=‘google’ e provider_sub. Evento publicado. |
| T4 | POST /google com token válido, usuário existente |
HTTP 200. Nome e email atualizados se mudaram no Google. Nenhum evento publicado. |
| T5 | POST /govbr com token válido, usuário novo |
HTTP 201. provider_data contém CPF. Evento publicado. |
| T6 | POST /vinculos com IDs válidos |
HTTP 200. auth_providers migrados. Origem com vinculado_a preenchido. Evento cidadão.vinculado publicado. |
| T7 | PATCH /:id com endereço novo |
HTTP 200. campos_alterados contém ‘endereco’. Evento cidadão.perfil_atualizado publicado. |
| T8 | GET /:id de cidadão existente |
HTTP 200. Body contém todos os campos + auth_providers. |
| T9 | GET /:id/vinculos |
HTTP 200. Array de vínculos por UC. |
Falhas e bordas:
| # | Cenário | Verificação |
|---|---|---|
| T10 | POST /anonymous com device ID inválido (não-UUID) |
HTTP 400. class-validator. |
| T11 | POST /google com token expirado |
HTTP 401. GoogleAuthService propaga erro. |
| T12 | POST /govbr com token de audience diferente |
HTTP 401. Verificação de aud falha. |
| T13 | POST /govbr com JWKS indisponível |
HTTP 503. Erro de infraestrutura, não de credencial. |
| T14 | POST /vinculos com origem inexistente |
HTTP 404 “Cidadão de origem não encontrado”. |
| T15 | POST /vinculos com destino inexistente |
HTTP 404 “Cidadão de destino não encontrado”. |
| T16 | POST /vinculos com origem já vinculada |
HTTP 409 “Cidadão já vinculado”. |
| T17 | PATCH /:id sem campos |
HTTP 400 “Ao menos um campo deve ser informado”. |
| T18 | PATCH /:id com endereço idêntico |
HTTP 200. Nenhum evento publicado. |
| T19 | GET /:id de cidadão vinculado |
HTTP 301. Header Location. Body contém redirecionar_para. |
| T20 | GET /:id inexistente |
HTTP 404. |
| T21 | vínculo.validado com payload incompleto |
Log.error. Evento descartado. Offset NÃO atualizado (será reentregue). |
| T22 | vínculo.validado reentregue (mesmo event_id) |
Detectado por event_id_origem. Log.info, retorna. |
| T23 | vínculo.validado para cidadão inexistente + cidadão criado depois |
Upsert realizado. GET /:id/vinculos retorna o vínculo após criação do cidadão. |
| T24 | eventBus.publicar() falha em findOrCreateAnonymous |
Cidadão persistido. Evento não publicado. Log.error. HTTP 201. Cidadão funcional. |
Teste de integração (com PostgreSQL de teste):
| # | Cenário | Verificação |
|---|---|---|
| T25 | Criação anônima + Google + vinculação | c1.cidadaos: 2 linhas (anon, google). Anon com vinculado_a = google.id. c1.auth_providers: 2 linhas, ambas com cidadao_id = google.id. core.event_log: 2 eventos (cidadão.cadastrado ×2) + 1 (cidadão.vinculado). |
| T26 | Transação de criação de cidadão Google: INSERT em cidadaos ok, INSERT em auth_providers falha | Rollback. count(*) em c1.cidadaos inalterado. Nenhum evento. |
| T27 | vínculo.validado upsert + re-entrega |
Primeira entrega: INSERT. Segunda: ON CONFLICT DO UPDATE. count(*) = 1. event_id_origem atualizado. |
| T28 | Concorrência: 2 POSTs /google simultâneos com mesmo sub | Um INSERT em auth_providers bem-sucedido. Outro viola UNIQUE, capturado, retorna cidadão existente. count(*) = 1. |
7.3 Dados de seed para desenvolvimento local
Seção intitulada “7.3 Dados de seed para desenvolvimento local”-- Cidadão anônimoINSERT INTO c1.cidadaos (id)VALUES ('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
INSERT INTO c1.auth_providers (cidadao_id, provider, is_primary)VALUES ('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'anonymous', true);
-- Cidadão GoogleINSERT INTO c1.cidadaos (id, nome, email, avatar_url, endereco, uc_residencia)VALUES ( 'b2c3d4e5-f6a7-8901-bcde-f12345678901', 'Maria Silva', 'maria@gmail.com', 'https://lh3.googleusercontent.com/a/avatar-maria', 'Rua das Flores, 123', 'c3d4e5f6-a7b8-9012-cdef-123456789012');
INSERT INTO c1.auth_providers (cidadao_id, provider, provider_sub, is_primary)VALUES ( 'b2c3d4e5-f6a7-8901-bcde-f12345678901', 'google', 'google-sub-maria-98765', true);
-- Cidadão gov.brINSERT INTO c1.cidadaos (id, nome, email)VALUES ( 'c3d4e5f6-a7b8-9012-cdef-123456789012', 'João Souza', 'joao@email.com');
INSERT INTO c1.auth_providers (cidadao_id, provider, provider_sub, provider_data, is_primary)VALUES ( 'c3d4e5f6-a7b8-9012-cdef-123456789012', 'govbr', 'govbr-sub-joao-54321', '{"cpf": "***.123.456-**", "phone_number": "+5511999999999"}', true);
-- Vínculo validadoINSERT INTO c1.vinculos (cidadao_id, unidade_civica_id, status, metodo_validacao, data_validade, hash_evidencia, event_id_origem)VALUES ( 'b2c3d4e5-f6a7-8901-bcde-f12345678901', 'c3d4e5f6-a7b8-9012-cdef-123456789012', 'valido', 'documental', '2028-01-15T00:00:00Z', 'abc123def456', 'd4e5f6a7-b8c9-0123-defa-123456789abc');
-- Consumer offsetINSERT INTO c1.consumer_offset (tipo_evento, last_sequence)VALUES ('vínculo.validado', 0);8. Alinhamento com o MVP
Seção intitulada “8. Alinhamento com o MVP”8.1 O que é Fase 1 (D-1a) e o que é Fase 2 (C-1)
Seção intitulada “8.1 O que é Fase 1 (D-1a) e o que é Fase 2 (C-1)”| Funcionalidade | Fase 1 (MVP — D-1a) | Fase 2 (C-1) |
|---|---|---|
| Residência dos dados | Schema d1a, tabela d1a.cidadaos |
Schema c1, tabelas c1.cidadaos, c1.auth_providers, c1.vinculos |
| Providers de autenticação | anonymous, google |
anonymous, google, govbr |
| Mapeamento de providers | Colunas auth_provider, google_sub em d1a.cidadaos |
Tabela c1.auth_providers (N providers por cidadão) |
| Interface de acesso | Interna ao BFF (in-process) | REST API interna (HTTP localhost → microsserviço) |
| Vinculação de identidades | Migração de demandas no BFF + evento | Migração de providers na C-1 + evento. Dados externos migrados pelo BFF |
| Projeção de vínculo | Não existe (D-9 é Fase 2) | c1.vinculos, alimentada por vínculo.validado |
| JWT de sessão | Gerado pelo BFF | Gerado pelo BFF (C-1 não gerencia sessões) |
| Cache de cidadão | Sem cache, consulta direta ao banco | In-memory LRU, TTL 300s, capacidade 10k |
| Endpoints REST | GET/PATCH /api/cidadaos/me no BFF |
GET/PATCH/POST /api/c1/cidadaos/* na C-1 |
| Resolução de UC de residência | Nenhuma — armazena o UUID declarado | Nenhuma — armazena o UUID declarado (idem) |
8.2 Plano de migração — d1a.cidadaos → c1.*
Seção intitulada “8.2 Plano de migração — d1a.cidadaos → c1.*”A migração da Fase 1 para a Fase 2 segue esta sequência:
Passo 1 — Criar schema c1 e tabelas.
Rodar migrations V001-V004. O schema d1a continua operando normalmente. A C-1 existe como módulo, mas ainda não recebe tráfego.
Passo 2 — Migrar dados existentes. Script de migração única:
-- Migrar cidadãosINSERT INTO c1.cidadaos (id, nome, email, avatar_url, endereco, uc_residencia, criado_em, atualizado_em)SELECT id, nome, email, avatar_url, endereco, uc_residencia, criado_em, atualizado_emFROM d1a.cidadaos;
-- Migrar providersINSERT INTO c1.auth_providers (cidadao_id, provider, provider_sub, is_primary, criado_em)SELECT id AS cidadao_id, auth_provider AS provider, CASE WHEN auth_provider = 'google' THEN google_sub ELSE NULL END AS provider_sub, true AS is_primary, criado_emFROM d1a.cidadaos;O script é idempotente — pode ser reexecutado sem duplicar (PK + UNIQUE constraints previnem).
Passo 3 — Ativar C-1 com tráfego espelhado.
O BFF passa a chamar a C-1 para novas operações (findOrCreate, updatePerfil, vinculação) e ainda consulta a tabela d1a.cidadaos para cidadãos legados como fallback. Operações de leitura: tenta C-1 primeiro, fallback para d1a.cidadaos. Operações de escrita: sempre na C-1 (com upsert para tratar cidadãos legados que ainda não foram migrados).
Passo 4 — Desligar fallback. Após período de validação (todas as operações de escrita passando pela C-1 sem erro por N dias), remover fallback de leitura. Todas as operações de cidadão passam exclusivamente pela C-1.
Passo 5 — Remover tabela legada.
DROP TABLE d1a.cidadaos (mantida como backup por período de segurança). Remover dependências residuais no BFF.
8.3 Simplificações aceitáveis na Fase 2
Seção intitulada “8.3 Simplificações aceitáveis na Fase 2”| Simplificação | Justificativa | Quando remover |
|---|---|---|
| Cache LRU em memória (sem Redis) | Monolito de processo único. Volume de leitura moderado na Fase 2. | Migrar para Redis quando a C-1 for microsserviço com múltiplas instâncias. |
vinculado_a resolvido em aplicação (não CTE recursiva) |
Profundidade máxima esperada: 2 (anônimo → Google → gov.br). | Se a cadeia crescer além de 3 níveis. |
Sem criptografia em repouso para provider_data.CPF |
Volume baixo. Schema preparado para adição futura (JSONB). | Adicionar pgcrypto quando houver auditoria de segurança formal. |
| Rate limiting por IP | Endpoints internos, consumidos apenas pelo BFF. | Substituir por autenticação de serviço (mTLS/API key) na extração para microsserviço. |
| JWKS do gov.br em cache de memória | Chaves rotacionam com baixa frequência. TTL de 1 hora cobre o ciclo. | Migrar para Redis se houver múltiplas instâncias da C-1. |
8.4 O que vai para a Fase 3
Seção intitulada “8.4 O que vai para a Fase 3”- Criptografia em repouso de dados sensíveis (
provider_data.CPFcompgcrypto) - Índice GIN em
auth_providers.provider_datapara consulta por CPF - Reconciliação de CPFs duplicados entre providers gov.br
- Endpoint administrativo de busca de cidadão por CPF (restrito a operadores autorizados)
- Deleção de cidadão por requisição LGPD (remoção de chave criptográfica + anonimização)
- Cache Redis compartilhado para
findById()(múltiplas instâncias da C-1) - Métricas Prometheus:
c1_cidadaos_created_total,c1_logins_total{provider},c1_vinculos_validos_total - Rate limiting por serviço (mTLS) em vez de IP
8.5 Verificação de conflitos com outras colônias
Seção intitulada “8.5 Verificação de conflitos com outras colônias”Conflito potencial: cidadão.cadastrado publicado pela C-1 com origem: 'C-1' vs. Fase 1 com origem: 'D-1a'.
Consumidores que registram offset por colônia (D-7, projeções de perfil) precisam consumir de ambas as origens durante a transição. Após a migração completa, apenas origem: 'C-1' é emitida. Solução: handlers registram para o tipo de evento, não para a origem. O campo origem é metadado de auditoria, não chave de roteamento.
Conflito potencial: cidadão.vinculado migra apenas auth_providers, mas outras colônias esperam migração de dados.
No MVP (D-1a), a vinculação migrava demandas (d1a.demandas_recebidas.cidadao_id) dentro do BFF. Na Fase 2, a C-1 migra apenas seus dados (auth_providers). O BFF da D-1a continua responsável por migrar dados de outras colônias que referenciam cidadão_id (demandas, lugares). O evento cidadão.vinculado notifica as demais colônias para que cada uma faça sua própria migração. Sem conflito.
Conflito potencial: vínculo.validado consumido pela C-1 mas a D-9 é Fase 2.
A C-1 implementa o handler e a tabela c1.vinculos desde a Fase 2. Se a D-9 ainda não estiver implementada, o handler existe mas nunca é invocado. A tabela fica vazia, o endpoint GET /:id/vinculos retorna array vazio. Sem conflito.
Conflito potencial: gov.br armazena CPF, mas LGPD exige proteção especial.
O CPF é armazenado em auth_providers.provider_data (JSONB), não em coluna dedicada. Não é retornado nos endpoints REST públicos (GET /:id, GET /:id/vinculos). É acessível apenas internamente pelo GovbrAuthService no momento do login e por endpoints administrativos (Fase 3). O campo provider_data é excluído da serialização padrão. Sem conflito com LGPD no escopo da Fase 2.
Referências
Seção intitulada “Referências”- Ficha técnica da colônia: Apêndice B - Colônias.md, seção “C-1 — Cadastro de Cidadãos”
- Implementação Fase 1 (referência): D-1a - BFF.md, seções 2.2 (Tabela
d1a.cidadaos), 3.3 a 3.5 (eventos de cidadão) e 4.3 a 4.5 (pseudocódigo doCidadaoService) - Schemas de eventos: N-0b - Registry.md, seções 3.4.28 (
cidadão.cadastrado), 3.4.29 (cidadão.perfil_atualizado) e 3.4.30 (cidadão.vinculado); o schema devínculo.validadoentra no catálogo na Fase 2, conforme a seção 8.3 do N-0b - Barramento de eventos: N-0a - Event Bus.md
- D-9 (Validação de Vínculo): Apêndice B - Colônias.md, seção “D-9 — Validação de Vínculo”
- Colônia consumidora dos eventos de cidadão: D-7 - Transparência.md
- Stack de referência e arquitetura do MVP: Apêndice B - Colônias.md, seção “Arquitetura do MVP — Monolito Modular”
- Mapa de dependências de eventos: Apêndice B - Colônias.md, seção “Mapa de Dependências de Eventos entre Colônias”
- Princípios do Formigueiro: Apêndice B - Colônias.md, seção “Princípios herdados do Formigueiro”
- Contexto de gestão: contexto_IA.md, seção 7 (Gestão)
- Contexto de infraestrutura: contexto_IA.md, seção 10 (Infraestrutura cívica digital)
- Contexto do Formigueiro: contexto_IA.md, seção 22 (Arquitetura técnica — O Formigueiro)
Documento de especificação técnica de implementação. Fase 2 — Microsserviço independente.