shivao-projeto/ARCHITECTURE.md
PontualTech / Karlão 5b02feae50 chore: initial commit + security hardening (4 runs squad shivao-melhoria)
Importação inicial do projeto Shivão (Diário de Bordo do veleiro) em estado
pronto pra produção, já incluindo as 5 mudanças de hardening implementadas
pela squad shivao-melhoria em 2026-04-27.

Mudanças de hardening (HANDOFF.md seção " Pronto"):

1. **Rate limiting nos 3 endpoints públicos de share**
   - express-rate-limit ^8.4.1, 60 req/min/IP
   - server/src/index.js linhas 38, 262, 271, 279

2. **Tamanho de upload reduzido pra 50MB** (era 200MB)
   - multer linha 84

3. **Validação Zod nos endpoints autenticados** (POST /api/data e /zones)
   - novo arquivo server/src/schemas/index.js
   - middleware validate() retorna 400 com top 5 issues

4. **Audit log de ações sensíveis**
   - nova tabela audit_log + funções db.audit() e db.recentAudit()
   - 6 endpoints instrumentados (state_set, media_insert/delete, share_create/revoke/zones_update)
   - novo endpoint GET /api/audit (autenticado)

5. **Catch silencioso de webhook em zona PROIBIDA tratado**
   - app/diario-bordo.html + server/public/index.html linha 3280
   - agora loga erro + exibe toast ao usuário

Status final do HANDOFF:
- 🔴 Críticos restantes: 1 (CORS — decisão consciente single-tenant, não-acionável)
- 🟡 Importantes: 4 itens (testes, vigia reconnect, refator frontend, demais catches)
- 🟢 Bom-ter: 5 itens

Stack: Node 20 ESM + Express + better-sqlite3 + Docker (Coolify)
Single-tenant pessoal · single-file frontend HTML · offline-first

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:24:08 -03:00

315 lines
15 KiB
Markdown

# ARCHITECTURE
## Visão geral
```
┌─────────────────────────┐ ┌──────────────────────────────────┐
│ APP (celular/web) │ │ SHIVAO CLOUD (VPS Coolify) │
│ │ │ │
│ - HTML standalone │ ─HTTPS─→│ Express + SQLite │
│ - localStorage (state) │ Bearer │ - /api/data (sync) │
│ - IndexedDB (mídia) │ Token │ - /api/media (upload/serve) │
│ - Leaflet + OSM │ │ - /api/anchor/* (vigia) │
│ - Web Audio (alarme) │ │ - /share/:token (público) │
│ - Geolocation API │ │ │
│ │←HTTPS───│ Notificações (fan-out): │
│ │ updates │ - Telegram │
└─────────────────────────┘ │ - ntfy.sh │
│ - SMTP │
↓ webhooks diretos │ - Twilio (SMS/WhatsApp) │
(do app sem servidor) │ - Webhook genérico │
↓ │ │
┌──────────────────┐ │ Dead-man switch: │
│ Telegram bot │ │ - heartbeat a cada 30s │
│ Discord webhook │ │ - timeout 5min → alarme │
│ Webhook genérico │ │ │
└──────────────────┘ │ Compartilhamento público: │
│ - tokens aleatórios │
↓ Windy API │ - posições rotativas (500 max) │
(do app, com chave do dono) │ - cleanup diário │
↓ └──────────────────────────────────┘
┌──────────────────┐ ↓
│ api.windy.com │ ┌──────────────────────────────────┐
│ Point Forecast │ │ Volume persistente /data │
│ - GFS │ │ - shivao.db (SQLite) │
│ - GFS Wave │ │ - media/ (uploads) │
└──────────────────┘ └──────────────────────────────────┘
```
## Decisões técnicas
### Frontend: por que single-file HTML?
**Prós:**
- Distribuição trivial (compartilhar por e-mail, drive, etc)
- Funciona offline (após primeiro carregamento)
- Pode ser "instalado" no celular sem app store (apenas Chrome → Adicionar à tela inicial)
- Zero build step para customizações simples
**Contras:**
- Arquivo monolítico (~3500 linhas, 200 KB)
- Difícil de revisar em PRs
- Sem code splitting
**Alternativas consideradas:**
- React/Vue PWA: maior overhead, build step, mas componentização melhor
- Capacitor/Cordova: app nativo "real", mas complexidade de publicação
### Frontend: armazenamento
**Estado estruturado (`localStorage`)**: travessias, manutenções, pendências,
configurações. Vantagem: sincronização trivial (1 JSON blob). Desvantagem:
limite de ~5MB total — se viagens viram milhares com track points longos,
pode estourar.
**Mídias (`IndexedDB`)**: blobs de fotos, áudios, vídeos. Cada arquivo guardado
como Blob, separado do estado. URLs criadas com `URL.createObjectURL()` sob
demanda. Limites do navegador: ~50% do disco no Android, varia no iOS.
### Backend: por que SQLite + filesystem?
Para single-tenant pessoal:
- **Simplicidade**: zero configuração, sem servidor de DB separado
- **Backup trivial**: copiar o arquivo `.db` + pasta `media/`
- **Performance**: WAL mode + synchronous=NORMAL é mais que rápido o suficiente
- **Coolify-friendly**: 1 container, 1 volume
Se evoluir para multi-tenant: Postgres + S3 (ou MinIO) seria a escolha óbvia.
### Backend: ESM modules
Node 20+ com `"type": "module"`. Imports nativos `import x from 'y'`. Decisão
para ficar moderno e por preferência. Trade-off: algumas libs antigas precisam
de gambiarras de import.
### Auth: Bearer Token simples
Um único `BOAT_TOKEN` configurado via env var. Vai em todo request.
**Por quê não OAuth/JWT/cookies?**
- Single-tenant pessoal — não há "usuários"
- Token simples roda em qualquer dispositivo do dono
- Sem expiração — para revogar, troca o env var
**Limitações:**
- Se o token vazar, intruso tem acesso completo
- Sem audit trail granular (não tem quem fez o quê)
Mitigação: token longo aleatório (32 bytes hex). HTTPS obrigatório
(garantido pelo Coolify).
### Mapas: Leaflet + OSM
**Leaflet**: biblioteca leve (~40 KB), API estável, plugin ecosystem.
**OSM tiles** (`tile.openstreetmap.org`): gratuitos, sem chave, qualidade
boa para uso recreativo. Política de uso permite apps pessoais.
**Limitações:**
- Tiles náuticas específicas não disponíveis (sem batimetria, sem profundidade)
- Para ECDIS-grade, considerar Mapbox + nautical layer ou OpenSeaMap overlay
### Áudio do alarme: Web Audio API
Em vez de tocar mp3 estático:
**Pros:**
- Volume real-time controlável
- Padrão de tons alternados (klaxon 900↔1200 Hz) impossível de gerar com `<audio>`
- Sem latência de carregamento
- Funciona até com o telefone no modo silencioso (em alguns navegadores)
**Cons:**
- Requer interação do usuário primeiro (políticas de autoplay)
- iOS pode silenciar mesmo Web Audio quando em modo silencioso
### Notificações: por que servidor faz fan-out?
**Alternativa A**: cliente fala direto com cada API (Telegram, Twilio, etc).
- ❌ Credenciais Twilio expostas no cliente
- ❌ Cliente precisa estar online quando alarme dispara
- ✅ Já implementado para Telegram/Discord/genérico (bots e webhooks são "públicos")
**Alternativa B (escolhida)**: cliente chama servidor, servidor faz fan-out.
- ✅ Credenciais sensíveis (Twilio, SMTP) só no servidor
- ✅ Servidor pode disparar mesmo se cliente caiu (dead-man switch)
- ✅ Auditável centralmente (`alarm_log`)
Atualmente fazemos os DOIS: cliente envia direto para webhooks que conhece
+ chama servidor para fan-out completo. Redundância intencional.
### Dead-man switch
Por que não usar push notifications (FCM, etc)?
- FCM exige Service Worker + Google Play Services + setup mais complexo
- Push é "do servidor para o cliente" — o que precisamos é o INVERSO
(servidor detectar que cliente sumiu)
- Polling com heartbeat é mais simples e mais robusto para esse caso
### Compartilhamento público
Tokens randômicos de 16 bytes (base64url) — espaço de 2^128 possibilidades,
inviável de adivinhar.
URL completa NÃO está na busca do Google (não há robots.txt mas também
não há link público em lugar nenhum). Depende de "security through
obscurity" do token.
Para mais segurança: adicionar PIN opcional no momento de criar o share
e exigir antes de mostrar o mapa. Não implementado.
### Tipografia
3 famílias (todas Google Fonts):
- **Fraunces** (variável, com eixos `opsz` e `SOFT`) — display, itálico de
caderno editorial
- **Manrope** — corpo, sans serif geométrica e legível
- **JetBrains Mono** — números (instrumentos), labels mono caps
Decisão de design: aparência de "diário antigo de capitão" sem ser excessivamente
nostálgico — paleta marítima e tipografia editorial limpa.
### Cores
- Pergaminho (`#efe5cd`) — fundo
- Tinta marinha (`#0e2a3d`) — texto principal
- Latão envelhecido (`#a07832`) — acentos, GPS, brass-bright em fundos escuros
- Tempestade (`#8c3434`) — proibições, alarmes
- Sol (`#b67025`) — atenção
- Alga (`#3f7768`) — sucesso, em segurança
## Fluxos críticos
### 1. Vigia de fundeio com dead-man switch
```
[App] [Servidor]
│ │
│ startTracking() — usuário toca "Fundear" │
│ │
│ POST /api/anchor/start │
│ { anchor_lat, anchor_lng, radius } │
│ ────────────────→ │
│ │ setAnchor(active=1)
│ │ startedAt = now
│ ←──────────────── │
│ startGPS() + startHeartbeat() │
│ │
├─ a cada 30s ────────────────────────────→ │
│ POST /api/anchor/heartbeat │
│ { lat, lng, distance } │
│ │ updateHeartbeat(...)
│ │
│ GPS detecta deriva (d > radius) │
│ triggerAnchorAlarm() — local │
│ - playAlarmSound() │
│ - vibrate() │
│ - showAlarmScreen() │
│ POST /api/anchor/alarm │
│ ────────────────→ │
│ │ dispatchAlarm()
│ │ → Telegram
│ │ → ntfy
│ │ → SMTP
│ │ → Twilio
│ │ logAlarm()
│ ←──────────────── │
│ │
. .
. APP MORRE / PERDE SINAL / TELA TRAVA .
. .
. │
. │ setInterval (30s)
. │ checkDeadman():
. │ if (now - last_heartbeat
. │ > HEARTBEAT_TIMEOUT)
. │ dispatchAlarm({
. │ reason: "heartbeat_lost"
. │ })
. │ setAlarmFired(true)
```
### 2. Sync de dados
```
App startup
├─ loadState() ← localStorage
├─ if (cloud configured)
│ try cloudPullAll()
│ - GET /api/data → estado da nuvem
│ - GET /api/media/list → mídias remotas
│ - download das mídias faltantes para IndexedDB
├─ usuário usa app, edita coisas
│ - cada saveState() agenda um cloudPushDataOnly() em 5s (debounced)
├─ cloudPushDataOnly()
│ - POST /api/data com {data: {...estado...}}
│ - servidor sobrescreve registro único
```
### 3. Compartilhamento ao vivo
```
[App dono] [Servidor] [Página pública]
│ │ │
│ POST /api/share/create │ │
│ { duration: 360, zones } │ │
│ ─────────────→ │ │
│ │ token = randomBytes(12) │
│ │ insert into shares │
│ ←───────────── │ │
│ { url: ".../share/abc123" } │
│ │ │
│ Compartilha URL com tripulação │
│ │ ─────────────→ │
│ │ │ abre /share/abc123
│ │ ←───────────── │
│ │ HTML com Leaflet │
│ │ │
├─ a cada 30s ──────────────→ │ │
│ POST /api/share/position │ │
│ { lat, lng, speed } │ │
│ │ insert into share_positions
│ │ │
│ │ ←───────────── │
│ │ GET /api/share/abc123/positions
│ │ ─────────────→ │
│ │ │ desenha trilha + marcador
│ │ │
│ │ (auto-refresh a cada 15s) │
```
## Performance considerations
- **localStorage**: limite 5-10 MB, varia por navegador. Travessias com track de
GPS longas pesam: 1 ponto a cada 5s = 720 pts/h. Cada ponto ~70 bytes → 50 KB/h.
Uma viagem de 50h = 2.5 MB. Limite: ~100h de tracking total no app antes de
estourar. Se virar problema, mover tracks para IndexedDB.
- **IndexedDB**: limite muito maior (~50% do storage do dispositivo). Cada foto
3-5 MB, vídeo 30-100 MB. Em 32 GB de Android com bastante coisa, ainda dá pra
ter algumas centenas de fotos e dezenas de vídeos.
- **Mapas Leaflet**: cada modal cria nova instância. Importante chamar `.remove()`
ao fechar para evitar memory leaks. Fizemos isso em todos os modais de mapa.
- **Web Audio context**: criado on-demand quando alarme dispara. Não há vazamento.
- **Watch position**: throttling baseado em bateria reduz drasticamente o consumo.
Eco mode = ~30% menos uso de GPS comparado a normal.
## Segurança — checklist para produção
Ver **HANDOFF.md** para detalhes. Resumo:
- [ ] Rate limiting nos endpoints públicos
- [ ] Validação de schema nos endpoints autenticados
- [ ] Reduzir limite de upload de 200 MB
- [ ] Audit log
- [ ] Backup off-site automático do volume `/data`
- [ ] Monitoramento de uptime (UptimeRobot, Better Stack, etc)
- [ ] Alertas de erro 5xx (Sentry?)