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

15 KiB

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?)