prompt inicial do projeto completo

This commit is contained in:
2026-07-22 18:45:09 -03:00
parent 6fb920d333
commit b9ede753c4
12 changed files with 64 additions and 18 deletions

View File

@@ -202,7 +202,7 @@ Três identidades distintas, três mecanismos:
| **Morador / operador** | OIDC (Spring Security) + refresh token no keystore do dispositivo | Access 15 min, refresh 30 dias | | **Morador / operador** | OIDC (Spring Security) + refresh token no keystore do dispositivo | Access 15 min, refresh 30 dias |
| **Admin** | OIDC + MFA obrigatório | Sessão de 8h | | **Admin** | OIDC + MFA obrigatório | Sessão de 8h |
**Busca cega de unidade:** o endpoint de destino recebe bloco + unidade e **nunca** confirma se existe ou quem mora lá. Resposta idêntica para unidade válida e inválida; se não existir, a chamada simplesmente não é atendida. Isso impede que qualquer pessoa com o QR enumere quem mora onde — um problema de segurança física antes de ser de privacidade. Detalhes em `06-LGPD-E-SEGURANCA.md`. **Busca cega de unidade:** o endpoint de destino recebe bloco + unidade e **nunca** confirma se existe ou quem mora lá. Resposta idêntica para unidade válida e inválida**inclusive na linha do tempo de estados**: a visita-sombra percorre os mesmos estados nos mesmos tempos sem notificar ninguém (`03-FLUXOS-E-CONTRATOS.md` §2). Isso impede que qualquer pessoa com o QR enumere quem mora onde — um problema de segurança física antes de ser de privacidade. Detalhes em `06-LGPD-E-SEGURANCA.md`.
## 7. Multi-tenant preparado, não ativado ## 7. Multi-tenant preparado, não ativado

View File

@@ -56,7 +56,9 @@ CREATE TABLE condominiums (
name text NOT NULL, name text NOT NULL,
address text NOT NULL, address text NOT NULL,
timezone text NOT NULL DEFAULT 'America/Sao_Paulo', timezone text NOT NULL DEFAULT 'America/Sao_Paulo',
quiet_hours int4range, -- janela de silêncio, ex.: [22,7) -- janela de silêncio ("quiet_hours" nos demais docs); start > end cruza a meia-noite (22 → 7)
quiet_start smallint CHECK (quiet_start BETWEEN 0 AND 23),
quiet_end smallint CHECK (quiet_end BETWEEN 0 AND 23),
created_at timestamptz NOT NULL DEFAULT now(), created_at timestamptz NOT NULL DEFAULT now(),
version integer NOT NULL DEFAULT 0 version integer NOT NULL DEFAULT 0
); );
@@ -82,7 +84,7 @@ CREATE TABLE units (
); );
``` ```
`quiet_hours` alimenta a defesa contra "tocar em todos os apartamentos de madrugada": dentro da janela, visitas não-pré-autorizadas vão direto para a fila de operador em vez de acordar moradores. `quiet_start`/`quiet_end` alimentam a defesa contra "tocar em todos os apartamentos de madrugada": dentro da janela, visitas não-pré-autorizadas vão direto para a fila de operador em vez de acordar moradores. Duas colunas em vez de `int4range` de propósito: a janela típica cruza a meia-noite (22 → 7), e `int4range(22, 7)` é um range inválido no Postgres. A verificação "está na janela?" vive em `shared/validation`, testada nos dois sentidos.
## 3. Pessoas e dispositivos ## 3. Pessoas e dispositivos
@@ -258,7 +260,7 @@ CREATE TABLE access_grants (
visit_id uuid NOT NULL REFERENCES visits(id), visit_id uuid NOT NULL REFERENCES visits(id),
gate_id uuid NOT NULL REFERENCES gates(id), gate_id uuid NOT NULL REFERENCES gates(id),
granted_by uuid NOT NULL REFERENCES persons(id), granted_by uuid NOT NULL REFERENCES persons(id),
pin text, -- 6 dígitos, conferência humana pin_hash text, -- hash do PIN de 6 dígitos; o PIN em claro só existe na tela e na notificação
valid_until timestamptz NOT NULL, valid_until timestamptz NOT NULL,
used_at timestamptz, used_at timestamptz,
device_result text, -- resultado do AccessControlDevice (v2) device_result text, -- resultado do AccessControlDevice (v2)
@@ -361,6 +363,8 @@ CREATE INDEX idx_audit_entity ON audit_log (tenant_id, entity_type, entity_id, c
**Append-only.** Nenhum `UPDATE` ou `DELETE`, garantido por permissão do usuário de aplicação no banco. É a defesa jurídica em caso de autorização indevida. **Append-only.** Nenhum `UPDATE` ou `DELETE`, garantido por permissão do usuário de aplicação no banco. É a defesa jurídica em caso de autorização indevida.
**Papéis de banco separados — sem isso o revoke é decorativo.** Dono de tabela ignora `REVOKE` e RLS (salvo `FORCE`). Portanto: o Flyway conecta como `portaria` (dono do schema, roda migrations); a aplicação conecta como **`portaria_app`**, papel sem ownership, criado no init do Postgres (ver `05-INFRA-DOCKER.md` §2). Se o backend conectar como dono, nem o append-only do `audit_log` nem as policies de RLS valem nada — e nenhum teste funcional percebe.
## 9. Row Level Security — preparada, desativada ## 9. Row Level Security — preparada, desativada
```sql ```sql

View File

@@ -42,7 +42,7 @@ Implementada em `shared/state/VisitStateMachine.kt` — a mesma classe roda no a
| Job | Dispara | Ação | | Job | Dispara | Ação |
|---|---|---| |---|---|---|
| `VISIT_RING_TIMEOUT` | 20s após `TOCANDO` | → `ESCALONADA`, toca para os demais `unit_members` por `ring_order` | | `VISIT_RING_TIMEOUT` | 20s após `TOCANDO` (kind `VISITA`) | → `ESCALONADA`, toca para os demais `unit_members` por `ring_order` |
| `VISIT_ESCALATE` | 15s após `ESCALONADA` | → `FILA_OPERADOR` se `operator_queue` ativo, senão → `RECADO_EM_VIDEO` | | `VISIT_ESCALATE` | 15s após `ESCALONADA` | → `FILA_OPERADOR` se `operator_queue` ativo, senão → `RECADO_EM_VIDEO` |
| `VISIT_QUEUE_TIMEOUT` | 120s em `FILA_OPERADOR` | → `RECADO_EM_VIDEO` | | `VISIT_QUEUE_TIMEOUT` | 120s em `FILA_OPERADOR` | → `RECADO_EM_VIDEO` |
| `VISIT_EXPIRE` | `expires_at` (10 min) | → `EXPIRADA` | | `VISIT_EXPIRE` | `expires_at` (10 min) | → `EXPIRADA` |
@@ -66,7 +66,7 @@ VISITANTE BACKEND MORADOR
│ {kind, nome, bloco, │ BUSCA CEGA: │ │ {kind, nome, bloco, │ BUSCA CEGA: │
│ unidade, foto} │ resolve unit_id ou NULL │ │ unidade, foto} │ resolve unit_id ou NULL │
├───────────────────────────►│ resposta idêntica sempre │ ├───────────────────────────►│ resposta idêntica sempre │
│◄─── {visitId, roomToken} ──┤ │ │◄──────── {visitId} ────────┤ │
│ │ ── outbox: VisitaCriada ──┤ │ │ ── outbox: VisitaCriada ──┤
│ │ │ │ │ │
│ │ push FCM / VoIP+CallKit │ │ │ push FCM / VoIP+CallKit │
@@ -74,6 +74,8 @@ VISITANTE BACKEND MORADOR
│ │ │ │ │ │
│ 4. WS /ws/visitor/{id} │ │ 5. atende │ 4. WS /ws/visitor/{id} │ │ 5. atende
│◄══ estado em tempo real ══►│◄═══════ WS /ws/app ═══════►│ │◄══ estado em tempo real ══►│◄═══════ WS /ws/app ═══════►│
│ │ sala criada em EM_CHAMADA │
│◄── ROOM_READY + token ─────┼──── ROOM_READY + token ───►│
│ │ │ │ │ │
│◄────── LiveKit: sala compartilhada ────────────────────►│ │◄────── LiveKit: sala compartilhada ────────────────────►│
│ │ │ │ │ │
@@ -83,7 +85,23 @@ VISITANTE BACKEND MORADOR
│ ├── notifica responsável ────► │ ├── notifica responsável ────►
``` ```
**Passo 3 é o ponto crítico de segurança.** A resposta é byte-a-byte idêntica para unidade existente e inexistente, com o mesmo tempo de resposta (comparação em tempo constante e delay artificial se necessário). Se a unidade não existe, a visita entra em `TOCANDO`, ninguém é notificado, e ela expira normalmente. Quem tem o QR não consegue mapear o prédio. **Passo 3 é o ponto crítico de segurança.** A resposta é byte-a-byte idêntica para unidade existente e inexistente, com o mesmo tempo de resposta (comparação em tempo constante e delay artificial se necessário). Quem tem o QR não consegue mapear o prédio.
### Visita-sombra — a busca cega vale também para a linha do tempo
Não basta a resposta HTTP ser idêntica: se a visita de unidade inexistente ficasse parada em `TOCANDO` até expirar enquanto a real anda `TOCANDO → ESCALONADA → FILA_OPERADOR`, o atacante distinguiria os dois casos **observando a sequência de estados no WebSocket**. A busca cega tem que valer para a linha do tempo inteira.
Por isso, quando `unit_id` é `NULL` — ou quando a unidade existe mas não tem nenhum dispositivo ativo — a visita percorre **exatamente os mesmos estados, nos mesmos tempos**, sem notificar ninguém:
```
visita-sombra: TOCANDO (20s, sem push) → ESCALONADA (15s, sem push)
├─ operator_queue ativo → FILA_OPERADOR (de verdade,
│ marcada para o operador como "unidade não cadastrada")
└─ sem módulo → RECADO_EM_VIDEO
(listado no admin como "recado sem unidade")
```
Com o módulo de operador, a visita-sombra entra na fila **de verdade**: o operador vê a marcação privada "unidade não cadastrada" e trata como um porteiro humano trataria quem errou o número — pergunta, corrige a unidade e redireciona. Efeito colateral desejável: erro de digitação legítimo ("101A" em vez de "101-A") é **recuperado** em vez de perdido, e a indistinguibilidade fica perfeita — nos dois casos alguém atende.
## 3. Fluxo B — Entrega (funil rápido) ## 3. Fluxo B — Entrega (funil rápido)
@@ -161,7 +179,7 @@ Na v1 a abertura é humana: quem abre confere o PIN na tela do visitante contra
| `GET` | `/visits/{id}` | Estado atual (fallback de polling se o WS cair) | | `GET` | `/visits/{id}` | Estado atual (fallback de polling se o WS cair) |
| `POST` | `/visits/{id}/message` | Envia recado em vídeo | | `POST` | `/visits/{id}/message` | Envia recado em vídeo |
| `POST` | `/visits/{id}/cancel` | Visitante desiste | | `POST` | `/visits/{id}/cancel` | Visitante desiste |
| `GET` | `/visits/{id}/room-token` | Token LiveKit com escopo da sala | | `GET` | `/visits/{id}/room-token` | Token LiveKit com escopo da sala — disponível a partir de `EM_CHAMADA`; antes disso, `409` |
### Morador e operador — `/api/v1/app` ### Morador e operador — `/api/v1/app`

View File

@@ -43,8 +43,9 @@ services:
image: postgis/postgis:17-3.5 image: postgis/postgis:17-3.5
environment: environment:
POSTGRES_DB: portaria POSTGRES_DB: portaria
POSTGRES_USER: portaria POSTGRES_USER: portaria # dono do schema — usado só pelo Flyway
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_APP_PASSWORD: ${POSTGRES_APP_PASSWORD} # lido por init/02-roles.sh → cria portaria_app
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
- ./postgres/init:/docker-entrypoint-initdb.d:ro - ./postgres/init:/docker-entrypoint-initdb.d:ro
@@ -60,9 +61,13 @@ services:
environment: environment:
SPRING_PROFILES_ACTIVE: prod SPRING_PROFILES_ACTIVE: prod
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/portaria SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/portaria
SPRING_DATASOURCE_USERNAME: portaria # aplicação NUNCA conecta como dona do schema — senão REVOKE e RLS são inócuos (02 §8)
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD} SPRING_DATASOURCE_USERNAME: portaria_app
LIVEKIT_URL: ws://livekit:7880 SPRING_DATASOURCE_PASSWORD: ${POSTGRES_APP_PASSWORD}
SPRING_FLYWAY_USER: portaria
SPRING_FLYWAY_PASSWORD: ${POSTGRES_PASSWORD}
# livekit está em network_mode:host — o nome de serviço não resolve; alcançado via gateway do host
LIVEKIT_URL: ws://host.docker.internal:7880
LIVEKIT_API_KEY: ${LIVEKIT_API_KEY} LIVEKIT_API_KEY: ${LIVEKIT_API_KEY}
LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET} LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET}
STORAGE_ENDPOINT: http://minio:9000 STORAGE_ENDPOINT: http://minio:9000
@@ -73,6 +78,8 @@ services:
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317 OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317
volumes: volumes:
- ./secrets:/secrets:ro - ./secrets:/secrets:ro
extra_hosts:
- host.docker.internal:host-gateway
depends_on: depends_on:
postgres: { condition: service_healthy } postgres: { condition: service_healthy }
minio: { condition: service_started } minio: { condition: service_started }
@@ -110,6 +117,8 @@ services:
redis: # exclusivo do LiveKit e do rate limit redis: # exclusivo do LiveKit e do rate limit
image: redis:7-alpine # NUNCA para dados que não podem se perder image: redis:7-alpine # NUNCA para dados que não podem se perder
command: redis-server --save "" --appendonly no command: redis-server --save "" --appendonly no
ports:
- 127.0.0.1:6379:6379 # livekit está em host network e alcança via localhost; nunca exponha fora do host
restart: unless-stopped restart: unless-stopped
minio: minio:
@@ -185,6 +194,8 @@ room:
**Redis não é opcional** com múltiplas instâncias do LiveKit: sem ele, o estado das salas se divide silenciosamente entre nós e dois participantes da mesma sala não se enxergam. **Redis não é opcional** com múltiplas instâncias do LiveKit: sem ele, o estado das salas se divide silenciosamente entre nós e dois participantes da mesma sala não se enxergam.
**Capacidade de relay do coturn:** a faixa 4916049200 são 40 portas ≈ **20 chamadas simultâneas via TURN**. Suficiente para o piloto (TURN só entra quando o UDP direto falha, tipicamente 1020% das chamadas), mas amplie a faixa em produção junto com o firewall.
## 4. Dimensionamento de banda ## 4. Dimensionamento de banda
Uma chamada de portaria tem 2 participantes, vídeo em 480p a ~600kbps mais áudio. O SFU recebe e reenvia: Uma chamada de portaria tem 2 participantes, vídeo em 480p a ~600kbps mais áudio. O SFU recebe e reenvia:
@@ -218,6 +229,7 @@ GH_OWNER=sua-org
TAG=latest TAG=latest
POSTGRES_PASSWORD= POSTGRES_PASSWORD=
POSTGRES_APP_PASSWORD=
LIVEKIT_API_KEY= LIVEKIT_API_KEY=
LIVEKIT_API_SECRET= LIVEKIT_API_SECRET=
TURN_SECRET= TURN_SECRET=

View File

@@ -93,6 +93,7 @@ Prazos são configuráveis por tenant, com **teto** definido pela plataforma. Um
- Visita criada mesmo com `unit_id` nulo, preservando `unit_input` - Visita criada mesmo com `unit_id` nulo, preservando `unit_input`
- Resposta idêntica em corpo, código e headers - Resposta idêntica em corpo, código e headers
- **Tempo de resposta constante** (delay artificial iguala os caminhos — sem isso o ataque vira timing attack) - **Tempo de resposta constante** (delay artificial iguala os caminhos — sem isso o ataque vira timing attack)
- **Linha do tempo de estados idêntica** — a visita-sombra percorre os mesmos estados nos mesmos tempos, sem notificar ninguém (`03-FLUXOS-E-CONTRATOS.md` §2). Sem isso, o atacante distingue os casos observando a sequência no WebSocket, e o tempo constante da resposta HTTP não protege nada
- Nome do morador nunca aparece antes do atendimento - Nome do morador nunca aparece antes do atendimento
- Rate limit por IP e por dispositivo, com bloqueio progressivo - Rate limit por IP e por dispositivo, com bloqueio progressivo
@@ -128,9 +129,11 @@ REVOKE UPDATE, DELETE ON audit_log FROM portaria_app;
Nem a aplicação comprometida altera o log. Somado ao `sha256` da mídia, isso é o que dá valor probatório ao registro. Nem a aplicação comprometida altera o log. Somado ao `sha256` da mídia, isso é o que dá valor probatório ao registro.
**Só funciona se a aplicação não for dona do schema** — dono ignora `REVOKE` e RLS. A aplicação conecta como `portaria_app`; o Flyway, como `portaria` (dono). Ver `02-MODELO-DE-DADOS.md` §8.
### A.6 — Roubo de token ### A.6 — Roubo de token
**Mitigação:** JWT de visitante com 15 min, escopo de uma visita, atado ao `gateId` e ao IP de origem. Refresh do morador no Keystore/Keychain com biometria de dispositivo. Rotação de refresh token e revogação por dispositivo no admin. **Mitigação:** JWT de visitante com 15 min, escopo de uma visita, atado ao `gateId` e à sessão — **não ao IP de origem**: IP de celular muda no meio da sessão (troca de torre, CGNAT) e a amarração derrubaria visitante legítimo no meio do fluxo. A defesa é a posse do token somada à validade curta e ao escopo mínimo. Refresh do morador no Keystore/Keychain com biometria de dispositivo. Rotação de refresh token e revogação por dispositivo no admin.
### A.7 — QR vazado ou clonado ### A.7 — QR vazado ou clonado

View File

@@ -22,6 +22,7 @@ Um condomínio só entra em produção com todos os itens verificados **em campo
- [ ] **Wi-Fi aberto de visitante** com cobertura medida em cada portaria - [ ] **Wi-Fi aberto de visitante** com cobertura medida em cada portaria
- [ ] SSID óbvio: `Portaria-<NomeDoCondominio>` - [ ] SSID óbvio: `Portaria-<NomeDoCondominio>`
- [ ] **Captive portal** que abre direto a página do visitante - [ ] **Captive portal** que abre direto a página do visitante
- [ ] **Portal instrui a abrir no navegador padrão** — o mini-navegador do captive portal (CNA do iOS, e vários Androids) **bloqueia câmera e geolocalização**, as duas permissões de que o fluxo depende. O portal exibe botão/instrução "abrir no Safari/Chrome", e o teste de ativação percorre o fluxo completo **a partir do portal**, não de um navegador já aberto
- [ ] Rede **isolada** da rede administrativa (VLAN separada, sem acesso à LAN interna) - [ ] Rede **isolada** da rede administrativa (VLAN separada, sem acesso à LAN interna)
- [ ] Banda mínima 5 Mbps simétricos reservados para o visitante - [ ] Banda mínima 5 Mbps simétricos reservados para o visitante
- [ ] Teste de sinal ≥ -70 dBm no ponto exato onde o visitante fica - [ ] Teste de sinal ≥ -70 dBm no ponto exato onde o visitante fica
@@ -47,6 +48,8 @@ A rede de visitante isolada não é detalhe: uma rede aberta com acesso à LAN d
O botão físico é o que atende quem não tem celular, está sem bateria ou não consegue usar o QR. **Sem ele, o produto exclui uma parcela real de visitantes** — e cria a situação em que alguém legítimo simplesmente não consegue entrar. O botão físico é o que atende quem não tem celular, está sem bateria ou não consegue usar o QR. **Sem ele, o produto exclui uma parcela real de visitantes** — e cria a situação em que alguém legítimo simplesmente não consegue entrar.
**Na v1, o botão é deliberadamente um interfone direto (GSM/4G ou SIP) para o responsável/central — fora da plataforma.** É proposital: ele precisa continuar funcionando exatamente quando a plataforma cai, então não pode depender dela. Integrá-lo como dispositivo da plataforma (disparando o fluxo de visita com operador) é v2, atrás de `AccessControlDevice`.
### 2.4 Aviso legal — obrigatório ### 2.4 Aviso legal — obrigatório
- [ ] **Placa física** com o aviso de tratamento de dados, visível antes da coleta - [ ] **Placa física** com o aviso de tratamento de dados, visível antes da coleta

View File

@@ -40,7 +40,7 @@ Um controller `GET /api/v1/health` devolvendo `{"status":"UP"}`, só para provar
### 6. `infra/` ### 6. `infra/`
`docker-compose.yml` exatamente como em `docs/05-INFRA-DOCKER.md` §2, mais `docker-compose.observability.yml` e `.env.example`. Configs em `livekit/livekit.yaml`, `traefik/`, `postgres/init/01-extensions.sql` (`CREATE EXTENSION postgis; CREATE EXTENSION pgcrypto;`). `docker-compose.yml` exatamente como em `docs/05-INFRA-DOCKER.md` §2, mais `docker-compose.observability.yml` e `.env.example`. Configs em `livekit/livekit.yaml`, `traefik/`, `postgres/init/01-extensions.sql` (`CREATE EXTENSION postgis; CREATE EXTENSION pgcrypto;`) e `postgres/init/02-roles.sh` (cria o papel `portaria_app`, sem ownership, com a senha de `POSTGRES_APP_PASSWORD` — ver `02-MODELO-DE-DADOS.md` §8).
### 7. CI — GitHub Actions ### 7. CI — GitHub Actions

View File

@@ -73,7 +73,9 @@ Filtro/interceptor que lê `Idempotency-Key`, consulta `idempotency_keys`, e:
Spring Security com três cadeias de filtro conforme `06-LGPD-E-SEGURANCA.md` §6: `/api/v1/visitor/**` (JWT efêmero), `/api/v1/app/**` (OIDC), `/api/v1/admin/**` (OIDC + MFA). Spring Security com três cadeias de filtro conforme `06-LGPD-E-SEGURANCA.md` §6: `/api/v1/visitor/**` (JWT efêmero), `/api/v1/app/**` (OIDC), `/api/v1/admin/**` (OIDC + MFA).
Emissão e validação do JWT de visitante: 15 min, escopo de uma visita, atado a `gateId` e IP. Emissão e validação do JWT de visitante: 15 min, escopo de uma visita, atado a `gateId` e à sessão — **não ao IP**: IP de celular muda no meio da sessão (CGNAT, troca de torre) e derrubaria visitante legítimo.
**Conexões ao banco:** Flyway como `portaria` (dono), aplicação como `portaria_app` — sem essa separação, o revoke do `audit_log` e a RLS futura são decorativos (dono ignora ambos). Ver `02-MODELO-DE-DADOS.md` §8.
### 8. Auditoria ### 8. Auditoria

View File

@@ -27,6 +27,7 @@ Localização ausente ou negada → `403 LOCATION_REQUIRED`. Fora do raio → `4
- Resolve `unit_id` a partir de bloco + unidade; **se não existir, cria a visita com `unit_id = NULL`** e preserva `unit_input` - Resolve `unit_id` a partir de bloco + unidade; **se não existir, cria a visita com `unit_id = NULL`** e preserva `unit_input`
- Resposta **idêntica** — mesmo corpo, mesmo status, mesmos headers — nos dois casos - Resposta **idêntica** — mesmo corpo, mesmo status, mesmos headers — nos dois casos
- **Tempo de resposta constante:** meça o caminho mais lento e aplique delay artificial no mais rápido. Sem isso o ataque vira timing attack e a busca cega não serve para nada - **Tempo de resposta constante:** meça o caminho mais lento e aplique delay artificial no mais rápido. Sem isso o ataque vira timing attack e a busca cega não serve para nada
- **Visita-sombra:** com `unit_id` NULL — ou unidade sem nenhum dispositivo ativo — os jobs percorrem a **mesma linha do tempo de estados**, nos mesmos tempos, sem notificar ninguém; com `operator_queue` ativo, entra na fila marcada "unidade não cadastrada" (`03-FLUXOS-E-CONTRATOS.md` §2). Sem isso, a sequência de estados no WebSocket entrega o que o tempo constante escondeu
- Nome de morador nunca aparece na resposta - Nome de morador nunca aparece na resposta
- Rate limit por IP e por dispositivo, com bloqueio progressivo - Rate limit por IP e por dispositivo, com bloqueio progressivo
@@ -91,6 +92,7 @@ Importação CSV de unidades com **dry-run obrigatório** — devolve o que seri
- [ ] Testes de integração com Testcontainers cobrindo os fluxos A, B e C ponta a ponta - [ ] Testes de integração com Testcontainers cobrindo os fluxos A, B e C ponta a ponta
- [ ] **Teste de busca cega:** unidade existente e inexistente produzem resposta idêntica, e a diferença de tempo fica abaixo do ruído de medição - [ ] **Teste de busca cega:** unidade existente e inexistente produzem resposta idêntica, e a diferença de tempo fica abaixo do ruído de medição
- [ ] **Teste de visita-sombra:** unidade inexistente percorre a mesma sequência de estados, nos mesmos tempos, que uma visita real não atendida — e ninguém é notificado
- [ ] Teste: geofence rejeita fora do raio e aceita dentro - [ ] Teste: geofence rejeita fora do raio e aceita dentro
- [ ] Teste: `quiet_hours` desvia do morador - [ ] Teste: `quiet_hours` desvia do morador
- [ ] Teste: dois `resolve` concorrentes — um vence, outro recebe `409` com o autor - [ ] Teste: dois `resolve` concorrentes — um vence, outro recebe `409` com o autor

View File

@@ -22,7 +22,7 @@ Login OIDC com **MFA obrigatório** — este perfil vê fotos de todos os visita
### 2. Auditoria de visitas — a tela mais importante ### 2. Auditoria de visitas — a tela mais importante
Tabela virtualizada com filtros persistidos na URL: período, condomínio, bloco, unidade, tipo, estado, **dentro/fora do geofence**, e quem resolveu. Tabela virtualizada com filtros persistidos na URL: período, condomínio, bloco, unidade, tipo, estado, **dentro/fora do geofence**, quem resolveu, e **"unidade não cadastrada"** (visitas-sombra: erro de digitação legítimo ou tentativa de enumeração — recorrência do mesmo dispositivo em unidades diferentes é padrão de varredura e deve gerar destaque).
Colunas: data/hora, tipo, visitante, unidade, estado, quem resolveu, duração, selo de geofence. Colunas: data/hora, tipo, visitante, unidade, estado, quem resolveu, duração, selo de geofence.

View File

@@ -40,6 +40,8 @@ Mesmo binário Compose Multiplatform, perfil decidido por `GET /me`. Layout mais
**Contexto** à direita, antes de assumir: histórico da unidade, visitas recentes, regra de entrega, nomes dos moradores, telefone. **O operador nunca atende sem contexto** — atender às cegas é o que faz a portaria remota parecer pior que a física. **Contexto** à direita, antes de assumir: histórico da unidade, visitas recentes, regra de entrega, nomes dos moradores, telefone. **O operador nunca atende sem contexto** — atender às cegas é o que faz a portaria remota parecer pior que a física.
**Visitas-sombra** (`unit_id` nulo — busca cega, `03-FLUXOS-E-CONTRATOS.md` §2) chegam à fila marcadas como **"unidade não cadastrada"**, visível só para o operador. Ele trata como um porteiro trataria quem errou o número: pergunta, corrige a unidade (`PATCH` que resolve o `unit_id` e registra em `audit_log`) e redireciona — ou nega. Para o visitante, nada distingue esse atendimento de um normal.
Presença: `DISPONIVEL` / `EM_ATENDIMENTO` / `OFFLINE`, com heartbeat. Operador que perde conexão volta para `OFFLINE` automaticamente e suas visitas retornam à fila. Presença: `DISPONIVEL` / `EM_ATENDIMENTO` / `OFFLINE`, com heartbeat. Operador que perde conexão volta para `OFFLINE` automaticamente e suas visitas retornam à fila.
Ao resolver, o operador registra `resolution_reason` — obrigatório, e é o que alimenta a análise de por que o modelo autônomo falhou naquele caso. Ao resolver, o operador registra `resolution_reason` — obrigatório, e é o que alimenta a análise de por que o modelo autônomo falhou naquele caso.

View File

@@ -18,7 +18,7 @@ Playwright para os webs, Maestro para os apps. Cenários obrigatórios:
2. Entrega em um toque, resolvida pela notificação, em menos de 10s 2. Entrega em um toque, resolvida pela notificação, em menos de 10s
3. Escalonamento completo: ninguém atende → outros moradores → fila → operador → autorizado 3. Escalonamento completo: ninguém atende → outros moradores → fila → operador → autorizado
4. Recado: ninguém atende, sem módulo de operador → grava → morador resolve depois 4. Recado: ninguém atende, sem módulo de operador → grava → morador resolve depois
5. Unidade inexistente: visita criada, ninguém notificado, expira — **resposta indistinguível de unidade válida** 5. Unidade inexistente: visita criada, ninguém notificado, e a **sequência de estados vista pelo visitante é idêntica** à de uma visita real não atendida (visita-sombra) — incluindo os tempos das transições
6. Fora do geofence: bloqueado com mensagem acionável 6. Fora do geofence: bloqueado com mensagem acionável
7. Quiet hours: desvia sem tocar em ninguém 7. Quiet hours: desvia sem tocar em ninguém
8. Dois moradores respondendo juntos: um vence, o outro vê quem resolveu 8. Dois moradores respondendo juntos: um vence, o outro vê quem resolveu
@@ -56,7 +56,7 @@ Derrube cada peça, uma por vez, com uma visita em andamento, e verifique o comp
Percorra o modelo de ameaças de `06-LGPD-E-SEGURANCA.md` §5 e prove cada mitigação: Percorra o modelo de ameaças de `06-LGPD-E-SEGURANCA.md` §5 e prove cada mitigação:
- [ ] **A.1 Busca cega** — tempo de resposta constante medido estatisticamente, não no olho. Este é o teste mais importante da fase - [ ] **A.1 Busca cega** — tempo de resposta constante medido estatisticamente, não no olho, **e** linha do tempo de estados idêntica entre unidade real não atendida e visita-sombra. Este é o teste mais importante da fase
- [ ] **A.2 Geofence** — rejeita fora do raio; GPS falsificado é registrado - [ ] **A.2 Geofence** — rejeita fora do raio; GPS falsificado é registrado
- [ ] **A.3 DoS social** — rate limit e quiet hours funcionam; varredura gera alerta - [ ] **A.3 DoS social** — rate limit e quiet hours funcionam; varredura gera alerta
- [ ] **A.4 Mídia** — nenhuma URL pública; assinada expira; acesso auditado - [ ] **A.4 Mídia** — nenhuma URL pública; assinada expira; acesso auditado