Repository navigation
KeyFlow API em Rust (API-T1..T12 + deploy parcial da T13) - #4
Merged
Merged
Conversation
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 amain.Não toca em nada do Android: fora de
api/, só.github/workflows/api.yml,.gitignoree os documentos da própria feature.O que entra
GET /health, workflowapi.yml(fmt → clippy → test com Postgres)POST /v1/register+ middleware Bearer → contaGET /v1/accountPOST /v1/purchases/redeem— validação, idempotência, entitlementsGET /v1/config+GET /v1/modelscom ETag/304POST /v1/transcribe— proxy streaming com débito por bytes, 402/413 e fallback de provedorGET /v1/ssv/admob— verificação ECDSA + cap diárioapi/Dockerfilemulti-stage + guiaapi/docs/deploy.mdOs 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/docs/deploy.md.Merge da main
A branch saiu da
mainem 02/07; amainfoi mesclada nela (commit de merge) para o PR ficar sem conflito. Dois arquivos conflitavam:.gitignore: mantidas as duas entradas (AAR do sherpa-onnx eapi/target/)..specs/features/keyflow-api/tasks.md: fica a versão da branch, que é a damainacrescida do status e das notas de execução de cada task.Verificação
Rodado localmente em
api/, depois do merge, contra o Postgres dodocker-compose.yml:cargo fmt --check✅cargo clippy --all-targets -- -D warnings✅cargo test✅ — 111 testes (66 unitários + 45 de integração), 0 falhas