Skip to content

KeyFlow API em Rust (API-T1..T12 + deploy parcial da T13) - #4

Merged
yan-vidal merged 14 commits into
mainfrom
feature/keyflow-api
Sep 28, 2026
Merged

yan-vidal merged 14 commits into
mainfrom
feature/keyflow-api

Conversation

@yan-vidal

Copy link
Copy Markdown
Contributor

Resumo

Primeira parte do Milestone 7: a KeyFlow API em Rust (axum + tokio + Postgres), na pasta api/ do monorepo, seguindo .specs/features/keyflow-api/. Esse trabalho foi feito entre 02 e 03/07 e estava só numa branch local — este PR o traz para o GitHub e para a main.

Não toca em nada do Android: fora de api/, só .github/workflows/api.yml, .gitignore e os documentos da própria feature.

O que entra

Task Entrega
API-T1 Esqueleto axum, GET /health, workflow api.yml (fmt → clippy → test com Postgres)
API-T2 Config tipada por ambiente + tracing JSON com latência por rota
API-T3 Postgres, migrations do schema núcleo e helpers de ledger
API-T4 Autenticação Google (JWT de service account) + client do Play Integrity decode
API-T5 POST /v1/register + middleware Bearer → conta
API-T6 Rate limiting por IP (register) e por conta
API-T7 GET /v1/account
API-T8 POST /v1/purchases/redeem — validação, idempotência, entitlements
API-T9 Job de voided purchases (reverte reembolsos)
API-T10 GET /v1/config + GET /v1/models com ETag/304
API-T11 POST /v1/transcribe — proxy streaming com débito por bytes, 402/413 e fallback de provedor
API-T12 GET /v1/ssv/admob — verificação ECDSA + cap diário
API-T13 (parcial) api/Dockerfile multi-stage + guia api/docs/deploy.md

Os formatos das APIs do Google (Integrity, Developer API, SSV) foram conferidos na documentação oficial e estão fixados em testes com wiremock.

O que fica pendente

  • API-T13 — staging (depende do Yan): Postgres de staging, domínio/DNS + TLS, service account do Google com Play Integrity e Developer API habilitadas e vinculada no Play Console, chaves dos provedores de transcrição e a unidade rewarded no AdMob. O checklist completo está no fim de api/docs/deploy.md.
  • API-T14 — e2e com o app: fecha junto com a MZ-T15 da monetização.

Merge da main

A branch saiu da main em 02/07; a main foi mesclada nela (commit de merge) para o PR ficar sem conflito. Dois arquivos conflitavam:

  • .gitignore: mantidas as duas entradas (AAR do sherpa-onnx e api/target/).
  • .specs/features/keyflow-api/tasks.md: fica a versão da branch, que é a da main acrescida do status e das notas de execução de cada task.

Verificação

Rodado localmente em api/, depois do merge, contra o Postgres do docker-compose.yml:

  • cargo fmt --check ✅
  • cargo clippy --all-targets -- -D warnings ✅
  • cargo test ✅ — 111 testes (66 unitários + 45 de integração), 0 falhas

yan-vidal added 14 commits July 2, 2026 16:49
Cria api/ na raiz do monorepo (cargo init, edição 2024) com axum+tokio e
GET /health; rustfmt.toml; CI (.github/workflows/api.yml, path filter
api/**) rodando fmt+clippy+test; docker-compose.yml com Postgres de
dev/teste; README com setup e convenções de teste. Sincroniza spec/
design/tasks da keyflow-api nesta worktree (Frente B) e marca API-T1
concluída no tasks.md.
… rota (API-T2)

Config::from_env() tipa DATABASE_URL (obrigatória) e BIND_ADDR (default
0.0.0.0:8080) com erro claro na falta/valor inválido. TraceLayer via
route_layer (não layer, por causa da extensão MatchedPath) loga rota e
latência em JSON; RUST_LOG default já deixa isso visível sem config
extra. Comportamento de log verificado por inspeção documentada
(cargo run real) em vez de unit test, que ficou flaky por causa do
cache global de Interest por callsite do crate tracing.
… (API-T3)

Migration única com as 6 tabelas do design (accounts, ledger,
purchases, rewards, transcribe_requests, documents), sem PII/áudio/
texto. db::ledger::{credit,debit_guarded,revoke} genéricas sobre
sqlx::Acquire — compõem com a tx do chamador via savepoint aninhado.
debit_guarded usa UPDATE...WHERE saldo>=delta RETURNING, serializado
pelo lock de linha do Postgres (testado com duas transações reais
concorrentes). /health passa a fazer SELECT 1 de verdade.

Vira lib+bin (src/lib.rs expõe app/config/db/init_tracing) para os
testes de integração em tests/ acessarem a API pública. Config::from_env
virou wrapper sobre from_map(&HashMap) — os testes antigos mutavam
std::env do processo real e corriam risco de colidir com outros
testes em paralelo tocando DATABASE_URL. CI ganhou serviço Postgres
efêmero para os testes de integração.
…ty decode (API-T4)

google::auth::TokenCache assina JWT RS256 (fluxo oauth2 service
account do Google) e cacheia o access token com margem de 30s antes
de expirar. google::integrity::IntegrityClient chama
decodeIntegrityToken; verify_verdicts (função pura) valida pacote,
appRecognitionVerdict e deviceRecognitionVerdict, com flag pra
relaxar app-recognition em build de teste sem abrir mão do pacote/
device. Contrato das duas APIs do Google conferido nas fontes
oficiais (discovery doc + developer.android.com), fixado em fixtures
wiremock — nada fabricado. Chave RSA de teste descartável em
tests/fixtures/ só para assinar JWT contra o mock.
AppState (pool + IntegrityClient + package_name + integrity_relaxed)
substitui PgPool cru como estado do axum. Registro: decodeIntegrityToken
+ verify_verdicts → 403 se inválido; deviceKey novo cria conta, deviceKey
existente rotaciona o token (fluxo de recuperação, preserva saldo).
access_token: 32 bytes aleatórios em base64url, só o hash SHA-256 é
persistido. Bearer→Account via extractor FromRequestParts (401 sem/
inválido) em vez de tower::Layer — mais idiomático em axum 0.8.

Corrige teste pré-existente de db/accounts que usava token_hash fixo
(vec![1u8;32]) — colidia entre execuções no Postgres de dev/CI, que
acumula linhas e não tem UNIQUE em token_hash; trocado por hash
aleatório por execução.
RateLimiters (governor, GCRA) no AppState com dois limitadores keyed
independentes — IpAddr no /v1/register (ConnectInfo), Uuid da conta
nas rotas autenticadas. Estouro -> 429 + Retry-After (segundos,
calculado de NotUntil::wait_time_from). Limites configuráveis via
REGISTER_RATE_LIMIT_PER_MINUTE/ACCOUNT_RATE_LIMIT_PER_MINUTE.

"Por conta" ainda não tem rota de negócio real (GET /v1/account é a
API-T7); testado com o extractor Account real sobre um router
efêmero, mesmo padrão já usado pro Bearer na T5.
Handler trivial sobre o extractor Account (T5): saldo + entitlements
num payload único, mais o rate limit por conta (T6) — primeira rota
de negócio real a usar os dois. Fecha a Fase B (Identidade), API-T3..T7.
…lements (API-T8)

PublisherClient (purchases.products.get/acknowledge) com contrato
conferido na discovery doc oficial do androidpublisher — purchaseState
é inteiro (0/1/2), não string. Catálogo estático em catalog.rs
(credits_10/20/50, unlock_forever; minutos placeholder até o Yan
calibrar). Fluxo: Integrity -> purchase lookup -> checa
obfuscatedExternalAccountId contra sha256hex(deviceKey) -> PENDING
responde sem creditar -> PURCHASED credita numa tx idempotente por
token_hash (savepoint com credit() da T3) -> liga entitlements se
SKU >= R$20 -> acknowledge só na primeira vez.

db::accounts::Account ganhou device_key (necessário pro elo
compra<->identidade). 10 testes novos.
PublisherClient::list_voided_purchases (contrato conferido na
discovery doc). jobs::voided::run_periodically roda em background
(tokio::spawn no main.rs, intervalo configurável) e reverte cada
compra reembolsada/chargeback conhecida: UPDATE condicional em
purchases.status marca 'voided' (idempotente, mesmo padrão do
debit_guarded), debita o ledger sem guarda (saldo pode negativar) e
recomputa adsOff/skinsUnlocked do zero a partir das compras restantes
válidas — evita drift quando há mais de uma compra concedendo
entitlements. Erros só logam, não travam a próxima janela.

Fecha a Fase C (Compras): API-T8/T9 completas.
Seed em migrations/0002_seed_documents.sql (shape do design: products/
ads/transcription/minAppVersion/killSwitch; manifest de models) —
valores de ads/transcription e o manifest são placeholder explícito
até native-voice-models colher os modelos reais e o Yan calibrar os
caps. ETag calculado em request-time a partir dos mesmos bytes
enviados no corpo (sha256 hex entre aspas); If-None-Match (exato ou
*) -> 304 sem corpo; Cache-Control max-age=300. Rotas sem Bearer
(conteúdo público). 10 testes novos.

Fecha a dependência de T3 que faltava pra Fase E (proxy) rodar em
paralelo.
…API-T11)

Corpo multipart repassado byte a byte pro provedor via
reqwest::Body::wrap_stream (nunca bufferizado, TRX-04) com contador
que corta em 413 ao passar do cap. Saldo <=0 (request nova) -> 402
antes de tocar o provedor; sucesso credita -ceil(bytes/8000)s numa tx
atômica com o registro de idempotência (transcribe_requests por
X-Request-Id) + header X-KeyFlow-Balance-Seconds; timeout -> 504;
provedor caindo (conexão/5xx) -> 502.

TRANSCRIPTION_PROVIDERS_JSON tipa a lista ordenada de provedores do
design (segredo de cada um numa env var à parte, via authValueEnv,
nunca inline). SPEC_DEVIATION documentada: fallback de provedor é
entre requests (circuit breaker com cooldown), não dentro da mesma
request — reenviar o corpo pro provedor 2 depois de já ter
streamado pro 1 exigiria bufferizar tudo, contradizendo TRX-04. Em
regime permanente o efeito é equivalente ao fallback do design.

credit() (T3) passou a aceitar delta negativo (débito sem guarda,
usado aqui). 9 testes de integração + unit de arredondamento +
testes de providers.rs (parsing/circuit breaker).
admob.rs verifica a assinatura ECDSA P-256/SHA-256 (DER) das
callbacks de rewarded SSV do AdMob contra as chaves públicas de
gstatic.com/admob/reward/verifier-keys.json (cache com refetch
completo quando um key_id desconhecido aparece). Conteúdo assinado =
query string crua até "&signature=", nunca reordenada/reserializada,
conforme a doc oficial.

custom_data = accountId (UUID); conta inexistente ou inválida -> 200
sem crédito, só loga (não vazar existência). Crédito de
ADS_REWARD_SECONDS (decisão nossa, não vem do reward_amount do
AdMob) idempotente por transaction_id, sujeito a ADS_DAILY_CAP;
sempre 200 exceto assinatura inválida (403), porque o AdMob reenvia
a callback até 5x sem um 200.

db::accounts::exists evita uma FK do ledger estourando 500 quando
custom_data é um UUID bem-formado de conta inexistente. Testes de
transaction_id fixos trocados por sufixo aleatório (mesma classe do
bug de idempotência "vazando" entre execuções que apareceu na T5).

Fecha a Fase F (Rewarded). 12 testes novos (6 integração + 6 unit).
…e [YAN])

Dockerfile multi-stage (rust:1.96-slim-bookworm -> debian:bookworm-slim,
tags conferidas no Docker Hub) com as migrations embutidas no binário
via sqlx::migrate!() -- nada roda separado no boot. Runtime só com
ca-certificates (rustls/sqlx-postgres são puro-Rust, mas
rustls-platform-verifier usa o trust store do SO). Validado de verdade:
docker build local (imagem final 101MB) + docker run contra o Postgres
do docker-compose.yml -- /health e /v1/config responderam 200,
migrations aplicaram no boot.

docs/deploy.md: guia completo (reverse proxy/TLS com Caddyfile,
tabela de env vars, service account do Google, checklist de
credenciais pendentes).

Não subiu staging real nem tocou infraestrutura fora deste ambiente --
credenciais reais (Postgres gerenciado, domínio, service account,
chaves de provedor, AdMob) são do Yan, conforme DEVELOPMENT-ORDER.md.
API-T14 segue bloqueada até isso e até MZ-T1..T8 no app.
Traz os 102 commits da main (gesture-tour, native-voice-models, CI).
Conflitos: .gitignore (mantidas as duas entradas) e o tasks.md da
keyflow-api (fica a versão da branch, que é a da main com o progresso
das tasks).
@yan-vidal
yan-vidal merged commit f6090b9 into main Sep 28, 2026
3 checks passed
yan-vidal added a commit that referenced this pull request Sep 28, 2026


A tabela de estado, o bloco da API, a posição atual e o roadmap deixam de
dizer "PR #4 aguardando merge"; a Q9 (sugestões) sai de pendente; o
download de modelos passa a constar como fila do app.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant