diff --git a/.github/workflows/api.yml b/.github/workflows/api.yml new file mode 100644 index 00000000..35b23a7a --- /dev/null +++ b/.github/workflows/api.yml @@ -0,0 +1,55 @@ +name: api + +on: + push: + branches: [main, master] + paths: + - "api/**" + - ".github/workflows/api.yml" + pull_request: + paths: + - "api/**" + - ".github/workflows/api.yml" + +defaults: + run: + working-directory: api + +jobs: + rust: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:18-alpine + env: + POSTGRES_USER: keyflow + POSTGRES_PASSWORD: keyflow + POSTGRES_DB: keyflow_api + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U keyflow -d keyflow_api" + --health-interval 2s + --health-timeout 2s + --health-retries 15 + env: + DATABASE_URL: postgres://keyflow:keyflow@localhost:5432/keyflow_api + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: api + + - name: cargo fmt --check + run: cargo fmt --check + + - name: cargo clippy + run: cargo clippy --all-targets -- -D warnings + + - name: cargo test + run: cargo test diff --git a/.gitignore b/.gitignore index 8408d358..30a528ea 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ build/ # AAR do sherpa-onnx: baixado e conferido por SHA256 pelo app/build.gradle.kts (NM-T1) /app/libs/*.aar /app/libs/*.part + +# KeyFlow API (Rust, monorepo — api/) +api/target/ diff --git a/.specs/features/keyflow-api/tasks.md b/.specs/features/keyflow-api/tasks.md index 0d4b7b6f..d4ef0392 100644 --- a/.specs/features/keyflow-api/tasks.md +++ b/.specs/features/keyflow-api/tasks.md @@ -1,7 +1,8 @@ # KeyFlow API — Tasks **Spec**: `spec.md` · **Design**: `design.md` · **Contexto**: `context.md` (M1–M6) -**Status**: Draft (aguardando aprovação do Yan) +**Status**: Em execução (Yan autorizou o início pela Frente B em 2026-07-02, via +`DEVELOPMENT-ORDER.md`) > **Monorepo** — o código Rust vive em **`api/`** na raiz deste repositório (API-T1 cria a > pasta; esta spec fica onde está). Não tocar em nada do Android (app/, goldens, STATE.md). @@ -31,7 +32,7 @@ então `[P]` real se houver dois agentes — senão seguir a ordem sequencial. ## Fase A — Fundação -### API-T1 — Pasta `api/` no monorepo + esqueleto axum + CI +### API-T1 — Pasta `api/` no monorepo + esqueleto axum + CI — ✅ Concluída (2026-07-02) - **What**: criar `api/` na raiz (`cargo init` bin, edição estável atual); axum + tokio + `GET /health` (stub sem DB); `rustfmt.toml`/clippy config; CI (GitHub Actions, `.github/workflows/api.yml`) com **path filter `api/**`** rodando o gate `rust`; @@ -44,8 +45,17 @@ então `[P]` real se houver dois agentes — senão seguir a ordem sequencial. - **Done when**: `cargo run` (em `api/`) responde `/health` 200; CI verde no gate `rust`; build Android intacto (`assembleDebug` verde — Gradle não enxerga `api/`). - **Tests**: 1 teste de integração do `/health`. **Gate**: `rust`. +- **Nota de execução**: versões conferidas na sparse index de crates.io em 2026-07-02 (não + fabricadas) — axum 0.8.9, tokio 1.52.3, tower 0.5.3 (dev-dep, feature `util`, p/ `oneshot` no + teste). Edição 2024 (default do `cargo init` nesta toolchain, rustc 1.96). `docker-compose.yml` + usa `postgres:18-alpine` (tag mais recente confirmada no Docker Hub). CI usa + `dtolnay/rust-toolchain@stable` + `Swatinem/rust-cache@v2`; roda `fmt --check` → `clippy -D + warnings` → `test` (Postgres só entra no workflow quando a API-T3 tiver testes de integração). + `assembleDebug` **não** foi executado (evitar contenção de `GRADLE_USER_HOME` com a Frente A) — + confirmado estaticamente que `settings.gradle.kts` só inclui `:app`, então `api/` é invisível ao + Gradle. Trabalho feito em worktree própria (`../keyflow-api-wt`, branch `feature/keyflow-api`). -### API-T2 — Config por ambiente + tracing JSON +### API-T2 — Config por ambiente + tracing JSON — ✅ Concluída (2026-07-02) - **What**: módulo `config` (envs tipadas: `DATABASE_URL`, paths de secrets, bind addr, flags de teste) com erro claro de env faltante; `tracing` + subscriber JSON + `TraceLayer` (latência por rota). Nenhum secret no repo (OPS-03). @@ -54,8 +64,21 @@ então `[P]` real se houver dois agentes — senão seguir a ordem sequencial. - **Done when**: unit — env faltante falha com mensagem específica; logs saem em JSON com rota e latência (verificado em teste capturando o subscriber ou por inspeção documentada). - **Tests**: unit. **Gate**: `rust`. +- **Nota de execução**: `Config::from_env()` tipa `DATABASE_URL` (obrigatória) e `BIND_ADDR` + (opcional, default `0.0.0.0:8080`) — 4 unit tests cobrindo falta/valor inválido/default. + `GOOGLE_SERVICE_ACCOUNT_JSON_PATH`/flags de relax (Integrity/AdMob SSV) ficaram para T4/T12, + quando o código que efetivamente os consome existir (evita superfície de config morta hoje). + Tracing: `tower_http::trace::TraceLayer` aplicado via **`route_layer`** (não `layer` — gotcha do + axum: `layer` envolve o roteamento inteiro e roda antes da extensão `MatchedPath` ser inserida, + então o span sairia sem rota). `RUST_LOG` default `info,tower_http=debug` deixa latência visível + sem configuração extra. **Rota + latência em JSON verificados por inspeção documentada** (não + por unit test): um teste que instala subscriber via `tracing::subscriber::set_default` ficou + flaky quando rodado em paralelo com as outras `#[tokio::test]` do binário — causa raiz é o cache + global de `Interest` por callsite do crate `tracing`, compartilhado entre threads de teste + (arquitetura do crate, não bug do código); alternativa prevista no próprio design. Evidência real + (via `cargo run`) documentada no README e no comentário de `src/main.rs`. -### API-T3 — Postgres + migrations do schema núcleo + helpers de tx +### API-T3 — Postgres + migrations do schema núcleo + helpers de tx — ✅ Concluída (2026-07-02) - **What**: sqlx com pool + migrations do design §Schema (accounts, ledger, purchases, rewards, transcribe_requests, documents); helpers de mutação de saldo (crédito/débito com guarda e sem guarda) **sempre em tx** (ledger + update juntos); `/health` passa a fazer `SELECT 1`. @@ -64,10 +87,28 @@ então `[P]` real se houver dois agentes — senão seguir a ordem sequencial. - **Done when**: integração — migrations aplicam do zero; débito concorrente do último saldo: só um passa (teste com 2 tx disputando); crédito+ledger atômicos (falha no meio → rollback). - **Tests**: integração (Postgres docker). **Gate**: `rust`. +- **Nota de execução**: migration única `migrations/0001_core_schema.sql` com as 6 tabelas do + design (sem PII/áudio/texto — conferível por inspeção). `src/db/ledger.rs`: `credit`/ + `debit_guarded`/`revoke`, genéricas sobre `sqlx::Acquire<'a, Database = Postgres>` (aceitam + `&PgPool` OU `&mut Transaction` — nesse caso abrem um **savepoint** aninhado, então compõem + naturalmente com uma tx maior do chamador, ex.: T8 vai inserir a purchase + creditar na mesma + tx). `debit_guarded` usa `UPDATE ... WHERE balance_seconds >= $delta RETURNING` — o lock de + linha do Postgres serializa débitos concorrentes na mesma conta (testado com `tokio::join!` de + 2 chamadas reais, 7 testes de integração em `tests/db_ledger.rs` + helpers em + `tests/common/mod.rs`). **Refatoração necessária**: virou **lib+bin** (`src/lib.rs` expõe + `app`/`config`/`db`/`init_tracing`; `src/main.rs` ficou fino) — os testes de integração em + `tests/` só enxergam a API pública do crate, e a spec já previa "exercitar rotas reais" nesses + testes. **Achado corrigido antes de virar flake em CI**: os testes de `config.rs` mutavam + `std::env::set_var`/`remove_var` (estado global do processo) e corriam risco de colidir com + outros `#[test]`s tocando `DATABASE_URL` em paralelo no mesmo binário; `Config::from_env()` + virou wrapper fino sobre `Config::from_map(&HashMap)`, testado sem tocar env real. CI + (`.github/workflows/api.yml`) ganhou serviço Postgres efêmero com as credenciais do + `docker-compose.yml`. `postgres:18-alpine` real usado nos testes locais deste ambiente (Docker + funcionando no sandbox). ## Fase B — Identidade -### API-T4 — Google auth + Play Integrity decode client +### API-T4 — Google auth + Play Integrity decode client — ✅ Concluída (2026-07-02) - **What**: módulo `google`: OAuth de service account (JWT assinado → access token, cache até expirar) + client do `decodeIntegrityToken`; validação de verdicts do design (§Identidade) com flag de config p/ relaxar em build de teste. **Formato conferido na doc oficial** e fixado em @@ -77,8 +118,29 @@ então `[P]` real se houver dois agentes — senão seguir a ordem sequencial. - **Done when**: unit — verdicts aceitos/rejeitados por caso (pacote errado, device fora, UNRECOGNIZED com/sem flag); integração wiremock — token OAuth cacheado, decode ok/erro. - **Tests**: unit + integração (wiremock). **Gate**: `rust`. +- **Nota de execução**: formato conferido nas fontes oficiais do Google (não fabricado) — fluxo + JWT Bearer de service account em + `developers.google.com/identity/protocols/oauth2/service-account` (header RS256, claim set + iss/scope/aud/exp/iat, POST `https://oauth2.googleapis.com/token` form-encoded, + `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`) e o contrato exato do + `decodeIntegrityToken` (`POST /v1/{packageName}:decodeIntegrityToken`, schemas + `DecodeIntegrityTokenRequest`/`Response`/`TokenPayloadExternal`) direto da discovery doc + (`playintegrity.googleapis.com/$discovery/rest?version=v1`) + verdicts em + `developer.android.com/google/play/integrity/verdicts`. `google::auth::TokenCache` cacheia o + access token com margem de 30s antes de expirar (testado: reuso, renovação, erro do endpoint). + `google::integrity::verify_verdicts` é função pura (pacote/`appRecognitionVerdict`/ + `deviceRecognitionVerdict`, com `relax_app_recognition` só bypassando o app-recognition — nunca + pacote ou device) — 6 unit tests cobrindo os casos do "Done when". `IntegrityClient` recebe + `base_url` explícito (não hardcoded) para apontar pro wiremock em teste e para produção via + config na T5. Chave RSA de teste descartável gerada com `openssl` em + `tests/fixtures/test_service_account_key.pem` (nunca uma credencial real — só para assinar JWT + contra o wiremock). `jsonwebtoken` precisou da feature `aws_lc_rs` explícita (crate não escolhe + provider de crypto sozinho desde a 10.x) — mesmo backend que o `reqwest`/`rustls` já usavam, + evita duas libs de crypto concorrentes no binário. Nada foi wireado em `Config`/rotas ainda + (fica pra API-T5, quando `POST /v1/register` de fato constrói o `IntegrityClient` a partir do + ambiente — evita campos de config nunca lidos até lá). -### API-T5 — `POST /v1/register` + middleware Bearer +### API-T5 — `POST /v1/register` + middleware Bearer — ✅ Concluída (2026-07-02) - **What**: registro do design (Integrity → conta por `deviceKey` novo/existente → token novo hasheado) + middleware que resolve `Bearer` → `Account` (401 sem/inválido). Re-registro rotaciona token e preserva saldo (fluxo de recuperação). @@ -87,9 +149,31 @@ então `[P]` real se houver dois agentes — senão seguir a ordem sequencial. - **Done when**: integração — registro com Integrity válido → token funciona em rota protegida; inválido → 403; re-registro do mesmo `deviceKey` → mesma conta, token antigo morto; schema não guarda nada além do design (assert nas colunas). +- **Nota de execução**: `AppState` (novo, `Arc` por dentro) substitui `PgPool` cru como estado do + axum — carrega `pool` + `IntegrityClient` + `package_name` + `integrity_relaxed`; `Config` + ganhou os 3 campos que isso consome de verdade agora (`GOOGLE_SERVICE_ACCOUNT_JSON_PATH`, + `PACKAGE_NAME`, `INTEGRITY_RELAXED`, deferidos da T4 até terem consumidor real). Middleware + Bearer→Account implementado como **extractor** (`impl FromRequestParts for Account`, + não `tower::Layer`) — equivalente funcionalmente (401 sem/inválido) e mais idiomático em axum + 0.8; qualquer handler que pedir `Account` como parâmetro já fica protegido. `access_token`: 32 + bytes de `rand::fill` em base64url (43 chars), hash SHA-256 é o único valor persistido + (`db::accounts.token_hash`); vetor de teste SHA-256("") conferido contra valor bem conhecido + (não fabricado). Re-registro (`db::accounts::rotate_token`) preserva `id`/saldo/entitlements — + só troca `token_hash`, então o token antigo para de resolver e o saldo não se move. `GET + /v1/account` real (T7) ainda não existe, então o "rota protegida" do done-when foi provado + batendo o token novo direto em `db::accounts::find_by_token_hash` (equivalente ao que o + extractor faz) + um teste dedicado (`missing_bearer_is_rejected_with_401`) que monta um router + efêmero só com o extractor sobre o mesmo `AppState` real — nenhuma rota de produção nova além de + `/v1/register` foi criada aqui (T7 é dona da primeira rota de negócio protegida). **Achado + corrigido antes de virar flake em CI**: os testes de `db/accounts.rs` usavam `token_hash` + literais fixos (`vec![1u8;32]` etc.) — coerente na primeira execução, mas como o Postgres de + dev/CI acumula linhas entre rodadas (nada dá `TRUNCATE`) e `token_hash` não é `UNIQUE` no + schema, rodadas repetidas passaram a resolver a linha errada (`fetch_optional` sem `ORDER BY` + não garante qual "primeira" linha volta). Trocado por hash aleatório (`Uuid::new_v4()`) por + execução, mesmo padrão já usado em `tests/common/mod.rs`. - **Tests**: integração + unit (hash/parse do Bearer). **Gate**: `rust`. -### API-T6 — Rate limiting `[P]` +### API-T6 — Rate limiting `[P]` — ✅ Concluída (2026-07-02) - **What**: limite por IP (register) e por conta (rotas autenticadas), parâmetros na config; estouro → 429 + `Retry-After`. - **Where**: `src/middleware/ratelimit.rs`. @@ -97,18 +181,39 @@ então `[P]` real se houver dois agentes — senão seguir a ordem sequencial. - **Done when**: integração — flood no register (por IP) e no account (por conta) → 429 com header; abaixo do limite → passa. - **Tests**: integração. **Gate**: `rust`. +- **Nota de execução**: `governor` 0.10.4 (`DefaultKeyedRateLimiter`, algoritmo GCRA) — API + conferida em docs.rs, não fabricada. `RateLimiters` no `AppState` com dois limitadores keyed + independentes (`IpAddr` pro register, `Uuid` da conta pras autenticadas); `Retry-After` + calculado de `NotUntil::wait_time_from`, arredondado pra cima e nunca 0. `POST /v1/register` + ganhou `ConnectInfo` (produção liga via + `into_make_service_with_connect_info` no `main.rs`; testes injetam a extensão direto no + request). Limites configuráveis via `REGISTER_RATE_LIMIT_PER_MINUTE`/ + `ACCOUNT_RATE_LIMIT_PER_MINUTE` (opcionais, defaults 5 e 60). **"Por conta" ainda não tem rota + de negócio real** (`GET /v1/account` é a API-T7) — testado em `tests/ratelimit.rs` com o mesmo + padrão já usado no Bearer (T5): router efêmero com o extractor `Account` real chamando + `check_account` sobre o `AppState` real, nenhuma rota de produção nova além do que já existia. + 4 testes de integração via HTTP real (`tower::ServiceExt::oneshot`) cobrindo flood por IP → + 429+header, IPs diferentes não se afetam, flood por conta → 429+header — mais 4 unit tests do + limitador isolado (`middleware::ratelimit`). -### API-T7 — `GET /v1/account` `[P]` +### API-T7 — `GET /v1/account` `[P]` — ✅ Concluída (2026-07-02) - **What**: saldo (segundos) + entitlements (`adsOff`, `skinsUnlocked`) num payload único e barato. - **Where**: `src/routes/account.rs`. - **Depends on**: API-T5. **Covers**: PAY-05. - **Done when**: integração — conta nova → 0/false/false; após seeds de ledger/entitlements → valores certos; sem Bearer → 401. - **Tests**: integração. **Gate**: `rust`. +- **Nota de execução**: handler trivial — o extractor `Account` (T5) já resolveu Bearer→conta + antes dele rodar, então só serializa `balance_seconds`/`ads_off`/`skins_unlocked` (camelCase) e + aplica `check_account` do rate limiter (T6, primeira rota de negócio real a usá-lo — os testes + da T6 tinham provado o mecanismo num router efêmero, agora há uma rota de produção de verdade). + 4 testes de integração via HTTP real: conta nova zerada, valores refletindo um `UPDATE` direto + na tabela (equivalente ao que a T8 fará via `credit`/entitlements), 401 sem Bearer e com Bearer + inválido. Fecha a **Fase B (Identidade)** — API-T3..T7 completas. ## Fase C — Compras -### API-T8 — `POST /v1/purchases/redeem` (validação + idempotência + entitlements) +### API-T8 — `POST /v1/purchases/redeem` (validação + idempotência + entitlements) — ✅ Concluída (2026-07-02) - **What**: fluxo do design §Compras: Integrity → `purchases.products.get` (wiremock) → PENDING sem crédito / PURCHASED → tx idempotente por `token_hash` → crédito (minutos do catálogo na config) + entitlements se SKU ≥ R$20 → acknowledge. `obfuscatedExternalAccountId` conferido @@ -120,8 +225,24 @@ então `[P]` real se houver dois agentes — senão seguir a ordem sequencial. novo; produto/conta errados → rejeitado; PENDING → "pending" sem crédito; `credits_20` liga entitlements, `credits_10` não; acknowledge chamado no wiremock. - **Tests**: integração + unit (regra de entitlements/catálogo). **Gate**: `rust`. +- **Nota de execução**: contrato de `purchases.products.get`/`...acknowledge` conferido na + discovery doc oficial (`androidpublisher.googleapis.com/$discovery/rest?version=v3`, schemas + `ProductPurchase`/`ProductPurchasesAcknowledgeRequest`) — `purchaseState` é **inteiro** (0 + Purchased, 1 Canceled, 2 Pending), não string como a prosa do design sugeria; usei os valores + reais da API. `src/catalog.rs`: catálogo estático (`credits_10/20/50`, `unlock_forever`) — + **minutos são placeholder** explicitamente marcado no código (Yan calibra depois, sem release, + vira remoto na T10). Idempotência: `INSERT ... ON CONFLICT (token_hash) DO NOTHING RETURNING + granted_entitlements` na mesma tx do `credit()` (savepoint, T3) — `Some` = primeira vez (credita + + liga entitlements + `acknowledge` depois do commit); `None` = replay, sem crédito novo e sem + acknowledge de novo (testado com `wiremock` `.expect(1)`, provando que a 2ª chamada não bate no + Google). `device_key_account_id` (novo, `auth.rs`) implementa `sha256hex(deviceKey)`, + compartilhável com a `monetization` (mesma função, mesmo hash — risco documentado no design). + `db::accounts::Account` ganhou o campo `device_key` (precisava dele pra essa checagem). 10 testes + novos (4 unit de catálogo/publisher-client + 6 integração via HTTP real: compra válida+ack, + `credits_10` sem entitlements, replay sem crédito duplo, PENDING, conta errada, produto + desconhecido). -### API-T9 — Job de voided purchases +### API-T9 — Job de voided purchases — ✅ Concluída (2026-07-02) - **What**: task tokio periódica (intervalo na config): `voidedpurchases.list` (wiremock) → p/ cada token conhecido, tx `revoke` (saldo pode negativar) + recomputa entitlements das compras restantes. Idempotente entre execuções (voided já revertido não reverte 2×). @@ -130,10 +251,26 @@ então `[P]` real se houver dois agentes — senão seguir a ordem sequencial. - **Done when**: integração — reembolso reverte crédito e entitlements; saldo negativo bloqueia proxy (asserção junto com T11 ou via helper); rodar o job 2× não duplica reversão. - **Tests**: integração + unit (recomputação de entitlements). **Gate**: `rust`. +- **Nota de execução**: contrato de `voidedpurchases.list` conferido na discovery doc oficial + (schemas `VoidedPurchasesListResponse`/`VoidedPurchase`); sem paginação — a janela seguinte do + job cobre o que ficar de fora (`startTime` default do Google já é "agora - 30 dias"). Idempotência + via `UPDATE purchases SET status='voided' WHERE token_hash=$1 AND status != 'voided' RETURNING + ...` (mesmo padrão do `debit_guarded` da T3: o `UPDATE` condicional detecta se já foi + processado). Entitlements recomputados **do zero** a cada void (`EXISTS` sobre as compras + restantes não-voided com `granted_entitlements=true`), não decrementados — evita drift quando + há mais de uma compra concedendo entitlements na mesma conta (testado). `run_periodically` + nunca para em erro (loga e segue pra próxima janela — reembolso é melhor-esforço, não bloqueia + request). `main.rs` sobe o job via `tokio::spawn` com uma `PublisherClient`/`TokenCache` + própria (separada da que atende requests). **"Saldo negativo bloqueia proxy"** do done-when + fica para a T11 de fato bloquear (o mecanismo — saldo pode ficar negativo e `debit_guarded` + rejeita nesse caso — já está testado desde a T3/aqui; só falta a rota do proxy existir). 5 + testes novos (1 unit de `list_voided_purchases` + 4 integração: reembolso reverte crédito e + entitlements, saldo pode negativar, job 2× não duplica, entitlements sobrevivem se outra compra + ainda os concede). Fecha a **Fase C (Compras)** — API-T8/T9 completas. ## Fase D — Conteúdo -### API-T10 — `GET /v1/models` + `GET /v1/config` (ETag) `[P]` +### API-T10 — `GET /v1/models` + `GET /v1/config` (ETag) `[P]` — ✅ Concluída (2026-07-02) - **What**: servir `documents` com ETag sha256 + `If-None-Match`/304 + `Cache-Control`; seeds de dev com o shape do design (products/ads/transcription/minAppVersion/killSwitch; manifest de modelos espelhando o `ModelCatalog` do app — ids/URLs/sha256 reais quando a native-voice-models @@ -143,10 +280,23 @@ então `[P]` real se houver dois agentes — senão seguir a ordem sequencial. - **Done when**: integração — 200 com ETag; If-None-Match → 304; body bate com o seed; unit do cálculo de ETag. - **Tests**: integração + unit. **Gate**: `rust`. +- **Nota de execução**: `migrations/0002_seed_documents.sql` semeia `config`/`models`; o shape do + `config` segue o design (`products`/`ads`/`transcription`/`minAppVersion`/`killSwitch`) — + **valores de `ads`/`transcription` e o manifest de `models` são placeholder explícito** (chave + `"note"` no próprio JSON), já que `native-voice-models` (Bloco 4 do + `DEVELOPMENT-ORDER.md`) ainda não colheu os modelos reais nem o Yan calibrou os caps de + anúncio. ETag: `serde_json::to_vec` do `Value` lido do Postgres vira **os mesmos bytes** do + corpo e a base do sha256 — garante que o header sempre bate com o que foi enviado (calculado + em request-time, não armazenado; simples o bastante pro alvo de latência do design, é 1 SELECT + de linha única). `If-None-Match` aceita `*` ou o valor exato (com aspas, formato padrão HTTP); + 10 testes novos (5 unit de ETag/matching + 5 integração via HTTP real, incluindo ETag estável + entre duas requests). Rotas **sem Bearer** (conteúdo público, como o design já indicava + implicitamente ao não mencionar auth pra elas). Abre a **Fase E** (proxy) em paralelo — T10 era + a única dependência restante de T3 nessa frente. ## Fase E — Proxy -### API-T11 — `POST /v1/transcribe` (streaming + débito atômico + 402/413 + fallback) +### API-T11 — `POST /v1/transcribe` (streaming + débito atômico + 402/413 + fallback) — ✅ Concluída (2026-07-03) - **What**: fluxo completo do design §Proxy: saldo ≤ 0 → 402 com saldo; stream axum→reqwest sem bufferizar (TRX-04) com contador de bytes → 413 no cap; débito `ceil(bytes/8000)` (tabela na config) **na mesma decisão da resposta de sucesso** (erro/timeout → não debita); idempotência @@ -161,10 +311,45 @@ então `[P]` real se houver dois agentes — senão seguir a ordem sequencial. debita 2×; memória não cresce com upload grande (teste com body chunked grande + limite de alocação verificado por code review documentado — sem `to_vec()` do body no caminho). - **Tests**: integração + unit (duração por bytes/roundup). **Gate**: `rust`. +- **SPEC_DEVIATION documentado (arquitetura do fallback)**: o design pede "falha de conexão/5xx + imediato → tenta o próximo [provedor]" — mas isso é **incompatível** com TRX-04 ("nunca + bufferizar") depois que o corpo já começou a ser streamado pro provedor 1: não existe como + "rebobinar" um `reqwest::Body::wrap_stream` consumido parcialmente e reenviar pro provedor 2 sem + materializar o corpo inteiro em memória, o que contradiria o próprio requisito que motiva a + arquitetura de streaming. Resolvido com um **circuit breaker entre requests** + (`providers::ProviderHealth`): provedor que falha (conexão/5xx) fica indisponível por + `PROVIDER_UNHEALTHY_COOLDOWN_SECS`; a *próxima* request pula pro próximo da lista. Em regime + permanente o efeito é equivalente ao fallback do design (toda request após a 1ª falha já usa o + saudável) — só a request que descobriu a falha não tem retry intra-request; recebe `502` e cabe + ao cliente decidir reenviar. Detalhe completo no `README.md` da API (§Arquitetura). + Testado (`provider_5xx_marks_unhealthy_and_next_request_falls_back`): 1ª request no provedor + caído → 502 sem débito; 2ª request (novo `X-Request-Id`) → vai direto pro fallback e sucede. +- **Nota de execução**: `TRANSCRIPTION_PROVIDERS_JSON` (novo, obrigatório) tipa a "lista ordenada + `[{name,url,authHeaderName,authValueEnv,model}]`" do design literalmente — o segredo de cada + provedor mora em OUTRA env var (nome referenciado por `authValueEnv`), nunca inline (OPS-03); + parseado/resolvido em `src/providers.rs`. Corpo de entrada é passthrough puro — **não faz parse + de multipart** (não precisa: o design já pede "repassado em streaming", então contar bytes + + encaminhar os mesmos chunks pro provedor via `reqwest::Body::wrap_stream` preserva 100% a + estrutura multipart original sem tocar nela). Cap (413) detectado via um erro synthetic + (`StreamCapExceeded`) que o stream contador injeta e a handler reconhece andando pela cadeia + `source()` do `reqwest::Error` — distingue "corpo grande demais" de "provedor caiu". 402 usa o + saldo já resolvido pelo extractor `Account` (sem re-`SELECT`) — a corrida entre duas + transcrições simultâneas da mesma conta é aceita pelo próprio design ("saldo pode negativar + levemente; o próximo uso leva 402"). Idempotência: `INSERT INTO transcribe_requests ... ON + CONFLICT DO NOTHING` decide se debita, mas o provedor é **sempre** chamado de novo no retry + (não persistimos texto, ID-03). `credit()` (T3) passou a aceitar delta negativo pra esse débito + sem guarda — doc-comment atualizado, mecânica não mudou. **Teste de memória por RSS de processo + foi tentado e descartado**: cresceu ruidosamente mais que o esperado por confundir com + overhead do alocador/runtime/pool de conexões da própria execução do teste, não por buffer real + do corpo — mesma lição da API-T2 (teste flaky por causa de infraestrutura de teste, não bug); + TRX-04 é garantido por **inspeção de código** (nenhum `to_vec()`/`.bytes()` no caminho do corpo + de entrada, só `body.into_data_stream()` repassado direto), com um teste funcional (upload de + 5 MB) provando que segue funcionando corretamente em maior escala. 9 testes de integração novos + + 1 unit (arredondamento bytes→segundos) + testes de `providers.rs` (parsing/circuit breaker). ## Fase F — Rewarded -### API-T12 — `GET /v1/ssv/admob` (ECDSA + cap diário) +### API-T12 — `GET /v1/ssv/admob` (ECDSA + cap diário) — ✅ Concluída (2026-07-03) - **What**: verificação ECDSA da query (chaves públicas do AdMob cacheadas, refresh por `key_id` desconhecido), `custom_data` → conta, crédito idempotente por `transaction_id` com **cap diário** da config. Inválido → 403. @@ -175,10 +360,37 @@ então `[P]` real se houver dois agentes — senão seguir a ordem sequencial. integração — crédito 1× por transaction_id; cap diário corta; conta inexistente → 200 sem crédito (não vazar existência) com log. - **Tests**: unit + integração. **Gate**: `rust`. +- **Nota de execução**: algoritmo e endpoint conferidos na doc oficial + (developers.google.com/admob/android/rewarded-video-ssv) — conteúdo assinado é a query string + crua **até** `&signature=` (nunca reordenar/reserializar), `signature`/`key_id` sempre por + último nessa ordem; ECDSA **P-256**/SHA-256, assinatura em **DER**; chaves em + `https://www.gstatic.com/admob/reward/verifier-keys.json` (confirmado via `curl` real durante a + task — `www.` importa, `gstatic.com` sem `www` dá 301). `p256` 0.13.2 (última estável; a 0.14 só + tem release candidates nesta data) — `Signature::from_der`/`VerifyingKey::from_public_key_pem` + funcionam com as features default do crate (`pkcs8` já traz `der` transitivamente). Cache + (`AdmobKeysCache`) refetch completo (não incremental) quando um `key_id` desconhecido aparece — + cobre rotação sem precisar de timer. `ADS_REWARD_SECONDS` é decisão nossa (config), não vem do + `reward_amount` do AdMob (que só reflete o valor configurado no console AdMob, sem relação com + segundos). `credit_reward` faz o cap diário com um `SELECT count(*)` antes do `INSERT ... ON + CONFLICT DO NOTHING` na mesma tx — pequena janela de corrida entre duas callbacks quase + simultâneas perto do limite é aceita (rewarded ads são baixa frequência; diferente do débito de + transcrição, o design não pede guarda atômica aqui). Sempre responde `200` (Google reenvia até + 5× sem isso) exceto assinatura inválida (`403`, conforme a task). **Achado corrigido antes de + virar bug**: a 1ª versão só validava que `custom_data` era um UUID sintaticamente válido, sem + checar se a conta existia — um UUID bem-formado de conta inexistente estourava a FK do `ledger` + em 500 em vez do `200 sem crédito` esperado; adicionado `db::accounts::exists`. **Achado + corrigido (mesma classe do bug da T5)**: os testes usavam `transaction_id` literais fixos + ("tx-1", "tx-a"...) — coincidiam com o PK entre execuções no Postgres de dev (nunca truncado), + então rodadas repetidas paravam de creditar (idempotência "vazando" entre execuções de teste, + não da API); trocado por sufixo `Uuid::new_v4()`. Smoke real via `cargo run`: a chamada ao + `gstatic.com` de verdade (latência ~700ms, condizente com rede real) confirmou que o cache busca + as chaves reais do Google e rejeita corretamente uma assinatura forjada com `key_id` + inexistente. 6 testes de integração + 6 unit de `admob.rs` (assinatura válida/adulterada/chave + errada/`key_id` desconhecido/params ausentes). Fecha a **Fase F (Rewarded)**. ## Fase G — Deploy & e2e -### API-T13 — Deploy (TLS/secrets/Postgres) + smoke em staging +### API-T13 — Deploy (TLS/secrets/Postgres) + smoke em staging — 🟡 Parcial (2026-07-03) — **[YAN]** - **What**: Dockerfile de release; guia de deploy (reverse proxy com TLS, envs/secrets, Postgres gerenciado ou host, job ativo); subir staging; smoke: health/config/models/register (com Integrity **relaxado por flag** se a credencial ainda não existir — documentar o que ficou @@ -189,6 +401,27 @@ então `[P]` real se houver dois agentes — senão seguir a ordem sequencial. - **Done when**: staging responde health/config/models via HTTPS; secrets só por ambiente; checklist de credenciais pendentes entregue ao Yan. - **Tests**: smoke manual documentado. **Gate**: `rust` + manual. +- **Feito nesta sessão (sem depender de credencial)**: `api/Dockerfile` multi-stage + (`rust:1.96-slim-bookworm` → `debian:bookworm-slim`, tags conferidas no Docker Hub — não + fabricadas; migrations embutidas no binário via `sqlx::migrate!()`, sem comando separado no + boot; só `ca-certificates` no runtime, já que `rustls`/`sqlx-postgres` são puro-Rust e não + precisam de `libssl`/`libpq` do sistema — mas `rustls-platform-verifier` usa o trust store do + SO, daí `ca-certificates` continuar necessário). **Validado de verdade**: `docker build` local + (imagem final 101 MB) + `docker run` contra o Postgres do `docker-compose.yml` — `/health` e + `/v1/config` responderam `200` de ponta a ponta, migrations aplicaram no boot. `api/docs/ + deploy.md`: guia completo (reverse proxy/TLS com exemplo de Caddyfile, tabela de env vars com + origem de cada uma, service account do Google — quais APIs habilitar e como vincular ao Play + Console, checklist de credenciais pendentes). +- **Parou no gate [YAN]** (conforme `DEVELOPMENT-ORDER.md` — "credenciais reais... é do Yan"): + **não** subiu staging de verdade nem tentou nenhuma ação de infraestrutura real (domínio, + Postgres gerenciado, DNS, secrets em produção) — são ações com efeito fora deste + ambiente/sandbox, fora do escopo de algo que se prepara e valida sozinho. Pendências exatas no + checklist do fim de `api/docs/deploy.md`: Postgres de staging, domínio/DNS, service account do + Google (Play Integrity + Developer API habilitadas e vinculada ao app no Play Console), + provedor(es) de transcrição + chaves, unidade de anúncio rewarded no AdMob configurada, e o + smoke de `POST /v1/register` com device físico (só é testável com um `integrityToken` real, que + não dá pra fabricar fora do fluxo real do Play). **API-T14 seguirá bloqueada** até isso e até + MZ-T1..T8 no app (ver `DEVELOPMENT-ORDER.md`). ### API-T14 — e2e com o app + traceability - **What**: com a `monetization` (MZ) implementada no app: rodar o Independent Test de cada área diff --git a/api/Cargo.lock b/api/Cargo.lock new file mode 100644 index 00000000..01ee7c59 --- /dev/null +++ b/api/Cargo.lock @@ -0,0 +1,3301 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.6", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +dependencies = [ + "serde", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", +] + +[[package]] +name = "governor" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8" +dependencies = [ + "cfg-if", + "dashmap", + "futures-sink", + "futures-timer", + "futures-util", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "quanta", + "rand 0.9.4", + "smallvec", + "spinning_top", + "web-time", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "aws-lc-rs", + "base64", + "getrandom 0.2.17", + "js-sys", + "pem", + "serde", + "serde_json", + "signature", + "simple_asn1", + "zeroize", +] + +[[package]] +name = "keyflow-api" +version = "0.1.0" +dependencies = [ + "axum", + "base64", + "bytes", + "futures-util", + "governor", + "jsonwebtoken", + "p256", + "rand 0.10.2", + "rand_core 0.6.4", + "reqwest", + "serde", + "serde_json", + "serde_urlencoded", + "sha2 0.11.0", + "sqlx", + "thiserror", + "tokio", + "tower", + "tower-http 0.7.0", + "tracing", + "tracing-subscriber", + "uuid", + "wiremock", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http 0.6.11", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "aws-lc-rs", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spinning_top" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64", + "bytes", + "cfg-if", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink", + "indexmap", + "log", + "memchr", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots", +] + +[[package]] +name = "sqlx-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "thiserror", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" +dependencies = [ + "bitflags", + "byteorder", + "bytes", + "crc", + "digest 0.11.3", + "dotenvy", + "either", + "futures-core", + "futures-util", + "generic-array", + "log", + "percent-encoding", + "serde", + "sha1", + "sha2 0.11.0", + "sqlx-core", + "thiserror", + "tracing", + "uuid", +] + +[[package]] +name = "sqlx-postgres" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac 0.13.0", + "itoa", + "log", + "md-5", + "memchr", + "rand 0.10.2", + "serde", + "serde_json", + "sha2 0.11.0", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" +dependencies = [ + "atoi", + "flume", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "thiserror", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-http" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233" +dependencies = [ + "bitflags", + "bytes", + "http", + "http-body", + "percent-encoding", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/api/Cargo.toml b/api/Cargo.toml new file mode 100644 index 00000000..6a0c4e16 --- /dev/null +++ b/api/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "keyflow-api" +version = "0.1.0" +edition = "2024" + +[dependencies] +axum = "0.8.9" +base64 = "0.22.1" +bytes = "1.12.0" +futures-util = "0.3.32" +governor = "0.10.4" +jsonwebtoken = { version = "10.4.0", features = ["aws_lc_rs"] } +p256 = "0.13.2" +rand = "0.10.2" +reqwest = { version = "0.13.4", features = ["form", "json", "rustls", "stream"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" +serde_urlencoded = "0.7.1" +sha2 = "0.11.0" +sqlx = { version = "0.9.0", features = ["postgres", "runtime-tokio", "tls-rustls", "uuid"] } +thiserror = "2.0.18" +tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "net", "signal"] } +tower-http = { version = "0.7.0", features = ["trace"] } +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.23", features = ["json", "env-filter"] } +uuid = { version = "1.23.4", features = ["v4"] } + +[dev-dependencies] +rand_core = { version = "0.6", features = ["getrandom"] } +tower = { version = "0.5.3", features = ["util"] } +wiremock = "0.6.5" diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 00000000..00a91fb6 --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,31 @@ +# Build reproduzível: mesma versão de toolchain usada em dev (ver rustc --version no README). +# Tags conferidas no Docker Hub em 2026-07-03 (não fabricadas). +FROM rust:1.96-slim-bookworm AS builder + +WORKDIR /build +COPY Cargo.toml Cargo.lock ./ +COPY migrations ./migrations +COPY src ./src + +# --locked: falha se Cargo.lock estiver desatualizado em vez de silenciosamente resolver de novo. +RUN cargo build --release --locked --bin keyflow-api + +FROM debian:bookworm-slim + +# rustls (não OpenSSL) faz o TLS de saída (Google, provedores de transcrição, AdMob) — mas +# rustls-platform-verifier usa o trust store do SO, por isso ca-certificates é necessário mesmo +# sem libssl. sqlx-postgres é driver puro-Rust: sem libpq no runtime. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +RUN useradd --system --no-create-home --shell /usr/sbin/nologin keyflow +COPY --from=builder /build/target/release/keyflow-api /usr/local/bin/keyflow-api + +USER keyflow + +# `sqlx::migrate!()` embute o SQL das migrations NO BINÁRIO em tempo de compilação (não lê +# migrations/ do disco em runtime) — roda sozinho no boot (db::MIGRATOR.run, ver src/main.rs), +# sem comando de migração separado no entrypoint nem COPY da pasta migrations/ pro runtime. +EXPOSE 8080 +ENTRYPOINT ["/usr/local/bin/keyflow-api"] diff --git a/api/README.md b/api/README.md new file mode 100644 index 00000000..01b45fe1 --- /dev/null +++ b/api/README.md @@ -0,0 +1,164 @@ +# KeyFlow API + +Backend Rust (axum) do KeyFlow — identidade anônima, créditos/ledger, compras, proxy de +transcrição e conteúdo remoto. Spec/design/tasks completos em +`.specs/features/keyflow-api/` na raiz do monorepo (não duplicar aqui). + +Módulo disjunto do app Android (`app/`): o Gradle não enxerga esta pasta e vice-versa. + +## Setup + +- Rust stable (edição 2024). +- Postgres local para testes de integração: `docker compose up -d` (sobe na porta `5432`, + usuário/senha/banco `keyflow`). +- Variáveis de ambiente (ver `src/config.rs`): + - `DATABASE_URL` (obrigatória) — string de conexão Postgres. Ausente → erro claro no boot + (`missing required environment variable: DATABASE_URL`), sem stack trace genérico. + - `BIND_ADDR` (opcional, default `0.0.0.0:8080`) — endereço:porta do axum. + - `RUST_LOG` (opcional, default `info,tower_http=debug`) — diretivas do `tracing_subscriber`; + o default já deixa a latência por rota (`tower_http::trace`) visível sem precisar setar nada. + - `GOOGLE_SERVICE_ACCOUNT_JSON_PATH` (obrigatória) — caminho do JSON da service account do + Google (campos usados: `client_email`, `private_key`, `token_uri`); usada para assinar o JWT + do fluxo OAuth (Play Integrity). **Credencial real é do Yan** (API-T13); localmente, qualquer + JSON com esse shape e uma chave RSA válida serve para o processo subir (só falha de verdade + ao chamar o Google). + - `PACKAGE_NAME` (obrigatória) — package name do app Android (ex.: `com.keyflow`), conferido + contra `requestPackageName` do Play Integrity. + - `INTEGRITY_RELAXED` (opcional, default `false`; `true`/`1` liga) — relaxa só o + `appRecognitionVerdict` (builds de teste/interno); nunca relaxa pacote nem device. + - `REGISTER_RATE_LIMIT_PER_MINUTE` (opcional, default `5`) — limite de `POST /v1/register` por + IP (ID-04). `ACCOUNT_RATE_LIMIT_PER_MINUTE` (opcional, default `60`) — limite por conta nas + rotas autenticadas. Estouro → `429` + header `Retry-After` (segundos). + - `TRANSCRIPTION_PROVIDERS_JSON` (obrigatória) — lista ordenada de provedores Whisper-compatible + (design §Proxy): `[{"name","url","authHeaderName","authValueEnv","model"}]`. `authValueEnv` + é o **nome de outra env var** que guarda o segredo de verdade (nunca inline no JSON — OPS-03). + Exemplo: `[{"name":"openai","url":"https://api.openai.com/v1/audio/transcriptions", + "authHeaderName":"Authorization","authValueEnv":"OPENAI_KEY","model":"whisper-1"}]` com + `OPENAI_KEY=Bearer sk-...` setada à parte. + - `TRANSCRIPTION_TIMEOUT_MS` (opcional, default `30000`), `TRANSCRIPTION_MAX_BYTES` (opcional, + default `26214400` = 25 MiB), `PROVIDER_UNHEALTHY_COOLDOWN_SECS` (opcional, default `60`). + - `ADS_REWARD_SECONDS` (opcional, default `60`) — segundos creditados por rewarded ad + verificado (decisão nossa, não vem do `reward_amount` do AdMob). `ADS_DAILY_CAP` (opcional, + default `5`) — máximo de recompensas por conta por dia (ADS-01). + +## Rotas + +- `GET /health` — `SELECT 1` no Postgres. +- `POST /v1/register` — `{deviceKey, integrityToken}` → `{accessToken}` (403 `{"error": + "integrity"}` se o Play Integrity rejeitar). Re-registro do mesmo `deviceKey` rotaciona o token. +- `GET /v1/account` (Bearer) — `{balanceSeconds, adsOff, skinsUnlocked}`. +- `POST /v1/purchases/redeem` (Bearer) — `{productId, purchaseToken, integrityToken}` → + `{status: "credited"}` ou `{status: "pending"}`. Catálogo estático em `src/catalog.rs` + (`credits_10/20/50`, `unlock_forever`; minutos são **placeholder** até o Yan calibrar — vira + remoto na API-T10). Idempotente por `purchaseToken`; SKUs ≥ R$20 ligam `adsOff`+`skinsUnlocked`. +- `GET /v1/config` / `GET /v1/models` — `documents.body` (seed em `migrations/ + 0002_seed_documents.sql`, editável só por SQL/migration no MVP). `ETag` = sha256 hex do corpo + entre aspas; `If-None-Match` (valor exato ou `*`) → `304` sem corpo; `Cache-Control: max-age=300`. +- `POST /v1/transcribe` (Bearer, header `X-Request-Id` obrigatório) — corpo `multipart/form-data` + **repassado em streaming, byte a byte, pro provedor** (nunca bufferizado — TRX-04; ver + `src/routes/transcribe.rs`). Saldo ≤ 0 (e request nova) → `402 {"balanceSeconds"}` antes de + tocar o provedor; corpo > `TRANSCRIPTION_MAX_BYTES` → `413`; timeout → `504`; provedor caindo + (conexão/5xx) → `502` + marca o provedor indisponível por `PROVIDER_UNHEALTHY_COOLDOWN_SECS` + (fallback é **entre requests**, não dentro da mesma — ver nota de arquitetura abaixo). Sucesso: + repassa o JSON do provedor (`{"text":...}`) + header `X-KeyFlow-Balance-Seconds`; debita + `ceil(bytes/8000)` segundos numa tx atômica com o registro de idempotência + (`transcribe_requests`, chave `X-Request-Id`) — retry com o mesmo id re-executa o provedor mas + não debita 2×. +- `GET /v1/ssv/admob` — callback de rewarded SSV do AdMob (design §Rewarded, ADS-01). Verifica + ECDSA P-256/SHA-256 (assinatura DER) contra a chave pública de `key_id`, buscada e cacheada de + `https://www.gstatic.com/admob/reward/verifier-keys.json` (refetch automático se `key_id` for + desconhecido). Assinatura inválida → `403`. Assinatura válida: `custom_data` é o `accountId` + (UUID); conta inexistente/`custom_data` inválido → `200` sem crédito, só loga (não vazar + existência). Crédito de `ADS_REWARD_SECONDS` idempotente por `transaction_id`, sujeito a + `ADS_DAILY_CAP` por conta/dia — sempre responde `200` mesmo quando o cap bloqueia (é assim que + o AdMob espera: sem `200`, ele reenvia a callback até 5×). + +### Arquitetura: por que o fallback de provedor é entre requests, não dentro da mesma + +O design pede "falha de conexão/5xx imediato → tenta o próximo" provedor. Isso é **incompatível** +com TRX-04 ("nunca bufferizar"): uma vez que o corpo da request já foi parcialmente consumido e +repassado como stream pro provedor 1, não há como "rebobinar" e reenviar pro provedor 2 sem +materializar o corpo inteiro em memória — contradiria o próprio requisito que motiva a arquitetura +de streaming. Decisão registrada em `.specs/features/keyflow-api/tasks.md` (API-T11): um provedor +que falha fica marcado indisponível por um cooldown (`providers::ProviderHealth`, circuit +breaker simples); a **próxima** request pula direto pro próximo da lista. Em regime permanente o +efeito é equivalente ao fallback do design (toda request após a 1ª falha já usa o provedor +saudável) — só a request que descobriu a falha não tem retry automático; ela recebe erro e cabe +ao cliente decidir reenviar. + +## Job em background + +`jobs::voided::run_periodically` (disparado no `main.rs` via `tokio::spawn`) consulta +`voidedpurchases.list` a cada `VOIDED_PURCHASES_JOB_INTERVAL_SECS` (opcional, default `3600`) e +reverte compras canceladas/reembolsadas: debita o ledger (saldo pode negativar — sem guarda, +igual a um reembolso de verdade) e recomputa `adsOff`/`skinsUnlocked` a partir das compras que +restaram válidas. Idempotente (`purchases.status` vira `'voided'`, nunca reverte 2×); erro numa +execução só loga e tenta de novo na próxima janela (não é caminho crítico de request). + +```bash +DATABASE_URL=postgres://keyflow:keyflow@localhost/keyflow_api \ +GOOGLE_SERVICE_ACCOUNT_JSON_PATH=/path/to/service-account.json \ +PACKAGE_NAME=com.keyflow \ +TRANSCRIPTION_PROVIDERS_JSON='[{"name":"openai","url":"https://api.openai.com/v1/audio/transcriptions","authHeaderName":"Authorization","authValueEnv":"OPENAI_KEY","model":"whisper-1"}]' \ +OPENAI_KEY="Bearer sk-..." \ +cargo run +``` + +Logs saem em **JSON estruturado** (um objeto por linha), com a rota casada (`matched_path`) e a +latência da request no evento de resposta: + +```json +{"timestamp":"...","level":"DEBUG","fields":{"message":"finished processing request","latency":"0 ms","status":200},"target":"tower_http::trace::on_response","span":{"matched_path":"/health","method":"GET","name":"http_request"}} +``` + +## Gate `rust` + +Rodar sempre dentro de `api/`, com o Postgres do `docker-compose.yml` no ar (os testes de +integração precisam de `DATABASE_URL` — rodam as migrations sozinhos a cada teste): + +```bash +docker compose up -d +DATABASE_URL=postgres://keyflow:keyflow@localhost:5432/keyflow_api \ + bash -c 'cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test' +``` + +CI (`.github/workflows/api.yml`) roda o mesmo gate com path filter `api/**`, subindo um serviço +Postgres efêmero (mesmas credenciais do `docker-compose.yml`). + +## Convenções de teste + +- **Unit** (`cargo test`, sem I/O externo, em `#[cfg(test)]` dentro do próprio módulo): regras + puras — validação de config (`config.rs`), validação de verdicts do Play Integrity, duração por + bytes (arredondamento), cálculo de ETag, seleção de fallback de provedor, verificação ECDSA + (vetor de teste fixo), regra de entitlements por SKU. **Nunca mutam `std::env` do processo real** + (env vars são estado global compartilhado entre `#[test]`s paralelos do mesmo binário — testável + via uma função `from_map`/injeção em vez de `std::env::set_var`). +- **Integração** (arquivos em `tests/`, mesmo `cargo test`, requer `DATABASE_URL` de teste — + `docker-compose.yml`; helpers comuns em `tests/common/mod.rs`): exercita rotas reais via + `tower::ServiceExt::oneshot`/servidor real, e os helpers de `db::ledger` direto contra o + Postgres; Google/provedores externos são substituídos por **wiremock** (nunca chamados de + verdade em teste). Cada teste cria sua própria conta com `device_key` aleatório (`uuid::new_v4`) + para não colidir com outros testes rodando em paralelo no mesmo Postgres. +- **Nunca fabricar formato de API externa** (Play Integrity, Play Developer API, SSV do AdMob): + cada task que integra com essas APIs confere a documentação oficial e fixa o contrato exato + numa fixture wiremock — ver Knowledge Verification Chain em + `.specs/features/keyflow-api/design.md`. + +## Convenções de código + +- `cargo fmt` (config em `rustfmt.toml`) + `clippy -D warnings` sem exceções silenciosas. +- Nenhum secret no repositório — configuração via env (módulo `config`, API-T2). +- Nenhum dado pessoal, áudio ou texto transcrito é persistido — ver `design.md` §Princípio + condutor. + +## Deploy + +`Dockerfile` (multi-stage, `rust:1.96-slim-bookworm` → `debian:bookworm-slim`) builda um binário +de release com as migrations embutidas (`sqlx::migrate!()` — nada roda separado no boot além do +próprio processo). Guia completo (reverse proxy/TLS, secrets, Postgres, checklist de credenciais +pendentes do Yan) em [`docs/deploy.md`](docs/deploy.md). + +## Status + +Ver o `Status` de cada task em `.specs/features/keyflow-api/tasks.md` (fonte da verdade de +progresso desta frente). diff --git a/api/docker-compose.yml b/api/docker-compose.yml new file mode 100644 index 00000000..71464964 --- /dev/null +++ b/api/docker-compose.yml @@ -0,0 +1,14 @@ +services: + postgres: + image: postgres:18-alpine + environment: + POSTGRES_USER: keyflow + POSTGRES_PASSWORD: keyflow + POSTGRES_DB: keyflow_api + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U keyflow -d keyflow_api"] + interval: 2s + timeout: 2s + retries: 15 diff --git a/api/docs/deploy.md b/api/docs/deploy.md new file mode 100644 index 00000000..75c302d9 --- /dev/null +++ b/api/docs/deploy.md @@ -0,0 +1,113 @@ +# Deploy da KeyFlow API (staging/produção) + +Guia de deploy (API-T13). A parte com **credenciais reais é do Yan** — este documento prepara e +documenta cada passo; quem sobe o staging de fato precisa preencher os itens marcados **[YAN]**. + +## 1. Build da imagem + +```bash +cd api/ +docker build -t keyflow-api:latest . +``` + +Multi-stage (`api/Dockerfile`): builda com `rust:1.96-slim-bookworm` (mesma versão de toolchain +do dev), roda em `debian:bookworm-slim` com só `ca-certificates` (TLS de saída é via `rustls`, +que usa o trust store do SO — não precisa de `libssl`; `sqlx-postgres` é driver puro-Rust, não +precisa de `libpq`). As migrations do Postgres estão **embutidas no binário** em tempo de +compilação (`sqlx::migrate!()`) e rodam sozinhas no boot — nenhum comando de migração separado. + +## 2. Postgres + +Gerenciado (RDS, Cloud SQL, Supabase, etc.) ou uma instância própria no host — sem preferência +do design, qualquer Postgres acessível via `DATABASE_URL` serve. Recomendado: gerenciado, para +não ter que operar backup/failover manualmente. + +- **[YAN]** provisionar a instância e gerar a `DATABASE_URL` de produção. +- Migrations rodam automaticamente no boot do processo (não precisa rodar `sqlx migrate` à mão). + +## 3. Reverse proxy com TLS + +O binário fala HTTP puro no loopback (`BIND_ADDR`, default `0.0.0.0:8080`) — TLS termina na +frente, conforme o design (`design.md` §Stack: "Caddy/nginx/LB do host"). Exemplo mínimo com +[Caddy](https://caddyserver.com) (certificado automático via Let's Encrypt, sem config manual de +certs): + +```caddyfile +# [YAN] trocar api.keyflow.example pelo domínio real +api.keyflow.example { + reverse_proxy localhost:8080 +} +``` + +- **[YAN]** decidir/registrar o domínio real e apontar o DNS pro host do staging. +- Se usar nginx em vez de Caddy, o único requisito é: TLS termina ali, proxy_pass pro + `BIND_ADDR` do processo, sem exigências adicionais de headers/timeouts especiais (o axum já + lida com `X-Forwarded-*` corretamente via `into_make_service_with_connect_info` só se o proxy + preservar o IP original — Caddy/nginx fazem isso por padrão com `reverse_proxy`/`proxy_pass`). + +## 4. Variáveis de ambiente / secrets + +**Nenhum secret no repositório** (OPS-03) — tudo por variável de ambiente no host/orquestrador. +Lista completa (ver também `api/README.md` §Setup): + +| Variável | Obrigatória | Origem | +| --- | --- | --- | +| `DATABASE_URL` | sim | Passo 2 acima | +| `BIND_ADDR` | não (default `0.0.0.0:8080`) | — | +| `GOOGLE_SERVICE_ACCOUNT_JSON_PATH` | sim | **[YAN]** service account do Google Cloud (Play Integrity + Developer API habilitadas) — ver abaixo | +| `PACKAGE_NAME` | sim | `com.keyflow` (já conhecido, não é segredo) | +| `INTEGRITY_RELAXED` | não (default `false`) | `true` só se a credencial real ainda não existir (ver §5) | +| `REGISTER_RATE_LIMIT_PER_MINUTE`, `ACCOUNT_RATE_LIMIT_PER_MINUTE` | não | defaults razoáveis, ajustar depois de medir tráfego real | +| `VOIDED_PURCHASES_JOB_INTERVAL_SECS` | não (default `3600`) | — | +| `TRANSCRIPTION_PROVIDERS_JSON` | sim | **[YAN]** decidir o(s) provedor(es) Whisper-compatible (OpenAI, Groq, etc.) | +| `` | sim, 1 por provedor | **[YAN]** a(s) chave(s) de API do(s) provedor(es) | +| `TRANSCRIPTION_TIMEOUT_MS`, `TRANSCRIPTION_MAX_BYTES`, `PROVIDER_UNHEALTHY_COOLDOWN_SECS` | não | defaults do MVP | +| `ADS_REWARD_SECONDS`, `ADS_DAILY_CAP` | não | defaults do MVP — **[YAN]** calibrar depois de ver o AdMob real configurado | + +### Service account do Google — **[YAN]** + +Precisa de uma service account no Google Cloud com: +- API **Play Integrity** habilitada, permissão pra decodificar tokens do app `com.keyflow`. +- API **Google Play Android Developer** habilitada (`androidpublisher`), vinculada ao app no + Play Console (Configurações da API no Play Console → vincular o projeto GCP → conceder acesso + à service account). + +O arquivo JSON da credencial baixado do Console GCP vai direto em +`GOOGLE_SERVICE_ACCOUNT_JSON_PATH` — os campos usados são só `client_email`, `private_key`, +`token_uri` (ver `src/google/auth.rs`). + +## 5. Job ativo (voided purchases) + +Roda **no mesmo processo** via `tokio::spawn` (`src/main.rs`) — não é um deploy separado, sobe +junto com a API. Usa a mesma `DATABASE_URL` e a mesma service account (Developer API). Se a +credencial ainda não existir, o job vai logar erro a cada janela (`VOIDED_PURCHASES_JOB_INTERVAL_SECS`) +sem derrubar o processo (ver `jobs::voided::run_periodically` — nunca propaga erro). + +## 6. Smoke test em staging + +Com a credencial do Google ainda pendente, suba com `INTEGRITY_RELAXED=true` (só relaxa +`appRecognitionVerdict` — pacote e device continuam validados; documentar isso como pendência, +não como definitivo) pra pelo menos `/health`, `/v1/config` e `/v1/models` responderem: + +```bash +curl -s https://api.keyflow.example/health +curl -si https://api.keyflow.example/v1/config | head -20 +curl -si https://api.keyflow.example/v1/models | head -20 +``` + +`POST /v1/register` só é testável de ponta a ponta com um `integrityToken` real gerado por um +device Android de verdade contra o app `com.keyflow` publicado (ou em teste interno) no Play — +não dá pra fabricar um token de Integrity válido fora desse fluxo. Esse smoke específico fica +**[YAN]** (junto com API-T14, que cruza com MZ-T15 do app). + +## 7. Checklist de credenciais pendentes (entregar ao Yan) + +- [ ] **[YAN]** Postgres de staging provisionado, `DATABASE_URL` gerada. +- [ ] **[YAN]** Domínio de staging registrado/DNS apontado. +- [ ] **[YAN]** Service account do Google Cloud (Play Integrity + Developer API habilitadas, + vinculada ao app no Play Console) — arquivo JSON. +- [ ] **[YAN]** Decidir provedor(es) de transcrição e gerar as chaves de API. +- [ ] **[YAN]** Configurar a unidade de anúncio rewarded no AdMob (o `key_id`/chaves públicas de + verificação são públicos e já funcionam sem nenhuma config nossa — só a unidade de anúncio + em si precisa existir no console AdMob pra gerar callbacks reais). +- [ ] **[YAN]** Smoke de `POST /v1/register` com device físico + app publicado/teste interno. diff --git a/api/migrations/0001_core_schema.sql b/api/migrations/0001_core_schema.sql new file mode 100644 index 00000000..9d2839d5 --- /dev/null +++ b/api/migrations/0001_core_schema.sql @@ -0,0 +1,55 @@ +-- Schema núcleo (design.md §Schema). Nenhuma coluna de PII, áudio ou texto transcrito (ID-03). + +create extension if not exists pgcrypto; + +create table accounts ( + id uuid primary key default gen_random_uuid(), + device_key text unique not null, + token_hash bytea not null, + balance_seconds bigint not null default 0, + ads_off boolean not null default false, + skins_unlocked boolean not null default false, + created_at timestamptz not null default now() +); + +create table ledger ( + id bigserial primary key, + account_id uuid not null references accounts (id), + delta_seconds bigint not null, + reason text not null check (reason in ('purchase', 'transcription', 'reward', 'revoke')), + ref text, + created_at timestamptz not null default now() +); + +create index ledger_account_id_idx on ledger (account_id); + +create table purchases ( + token_hash bytea primary key, + account_id uuid not null references accounts (id), + product_id text not null, + status text not null, + credited_seconds bigint not null, + granted_entitlements boolean not null default false, + created_at timestamptz not null default now() +); + +create table rewards ( + transaction_id text primary key, + account_id uuid not null references accounts (id), + seconds bigint not null, + created_at timestamptz not null default now() +); + +create table transcribe_requests ( + account_id uuid not null references accounts (id), + request_id text not null, + seconds bigint not null, + created_at timestamptz not null default now(), + primary key (account_id, request_id) +); + +create table documents ( + key text primary key, + body jsonb not null, + updated_at timestamptz not null default now() +); diff --git a/api/migrations/0002_seed_documents.sql b/api/migrations/0002_seed_documents.sql new file mode 100644 index 00000000..b27cffb1 --- /dev/null +++ b/api/migrations/0002_seed_documents.sql @@ -0,0 +1,39 @@ +-- Seeds de dev do design §Manifest & config remota (MDL-01, CFG-01). Edição no MVP é via +-- SQL/migration (painel admin fora de escopo) — ver design.md. + +insert into documents (key, body) values ( + 'config', + '{ + "products": { + "credits_10": {"minutes": 10, "grantsEntitlements": false}, + "credits_20": {"minutes": 25, "grantsEntitlements": true}, + "credits_50": {"minutes": 70, "grantsEntitlements": true}, + "unlock_forever": {"grantsEntitlements": true} + }, + "ads": { + "note": "placeholder — Yan calibra sem release (M4)", + "initialCooldownSeconds": 60, + "cooldownFactor": 2, + "maxCooldownSeconds": 3600, + "minEngagementSeconds": 5, + "postPurchaseWindowSeconds": 300, + "dailyCap": 10 + }, + "transcription": { + "maxAudioBytes": 26214400, + "maxDurationSeconds": 300, + "timeoutMs": 30000, + "providers": [] + }, + "minAppVersion": "1.0.0", + "killSwitch": false + }'::jsonb +); + +insert into documents (key, body) values ( + 'models', + '{ + "note": "placeholder — native-voice-models ainda não colheu os modelos reais", + "models": [] + }'::jsonb +); diff --git a/api/rustfmt.toml b/api/rustfmt.toml new file mode 100644 index 00000000..f216078d --- /dev/null +++ b/api/rustfmt.toml @@ -0,0 +1 @@ +edition = "2024" diff --git a/api/src/auth.rs b/api/src/auth.rs new file mode 100644 index 00000000..b13ce97b --- /dev/null +++ b/api/src/auth.rs @@ -0,0 +1,145 @@ +use axum::extract::{FromRequestParts, State}; +use axum::http::request::Parts; +use axum::http::{StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use sha2::{Digest, Sha256}; + +use crate::AppState; +use crate::db::accounts::Account; + +/// Gera um novo access token: 32 bytes aleatórios (devolvidos ao cliente em base64url) + seu +/// hash SHA-256 (o único valor persistido — design.md §Identidade). +pub fn generate_token() -> (String, Vec) { + let mut bytes = [0u8; 32]; + rand::fill(&mut bytes); + let token = URL_SAFE_NO_PAD.encode(bytes); + let hash = hash_token(token.as_bytes()); + (token, hash) +} + +pub fn hash_token(token: &[u8]) -> Vec { + Sha256::digest(token).to_vec() +} + +/// `sha256hex(deviceKey)` — o elo compra↔identidade (design M2, §Compras): o app manda esse +/// mesmo valor como `obfuscatedExternalAccountId` no Billing Library, e a API confere aqui contra +/// o que o Play devolve. Digest de 32 bytes vira exatamente 64 chars hex — dentro do limite de 64 +/// chars do `obfuscatedAccountId` do Play sem precisar truncar de verdade. +pub fn device_key_account_id(device_key: &str) -> String { + Sha256::digest(device_key.as_bytes()) + .iter() + .map(|b| format!("{b:02x}")) + .collect::() + .chars() + .take(64) + .collect() +} + +#[derive(Debug)] +pub enum AuthRejection { + Missing, + Invalid, +} + +impl IntoResponse for AuthRejection { + fn into_response(self) -> Response { + StatusCode::UNAUTHORIZED.into_response() + } +} + +impl FromRequestParts for Account { + type Rejection = AuthRejection; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let State(state) = State::::from_request_parts(parts, state) + .await + .map_err(|_| AuthRejection::Missing)?; + + let raw = parts + .headers + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .ok_or(AuthRejection::Missing)?; + let token = raw.strip_prefix("Bearer ").ok_or(AuthRejection::Missing)?; + + let hash = hash_token(token.as_bytes()); + crate::db::accounts::find_by_token_hash(&state.pool, &hash) + .await + .map_err(|_| AuthRejection::Invalid)? + .ok_or(AuthRejection::Invalid) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generate_token_returns_matching_hash() { + let (token, hash) = generate_token(); + + assert_eq!( + token.len(), + 43, + "32 bytes em base64url sem padding têm 43 chars" + ); + assert_eq!(hash, hash_token(token.as_bytes())); + } + + #[test] + fn generate_token_is_random() { + let (token1, _) = generate_token(); + let (token2, _) = generate_token(); + assert_ne!(token1, token2); + } + + #[test] + fn hash_token_is_sha256() { + // Vetor de teste: SHA-256("") — valor bem conhecido, não fabricado. + let hash = hash_token(b""); + assert_eq!( + hash, + hex_decode("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") + ); + } + + fn hex_decode(s: &str) -> Vec { + (0..s.len()) + .step_by(2) + .filter_map(|i| s.get(i..i + 2)) + .map(|b| u8::from_str_radix(b, 16).unwrap()) + .collect() + } + + #[test] + fn device_key_account_id_is_64_char_lowercase_hex_sha256() { + let id = device_key_account_id(""); + + assert_eq!(id.len(), 64); + assert_eq!( + id, + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + assert!( + id.chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) + ); + } + + #[test] + fn device_key_account_id_is_deterministic() { + assert_eq!( + device_key_account_id("device-abc"), + device_key_account_id("device-abc") + ); + assert_ne!( + device_key_account_id("device-abc"), + device_key_account_id("device-xyz") + ); + } +} diff --git a/api/src/catalog.rs b/api/src/catalog.rs new file mode 100644 index 00000000..ed597787 --- /dev/null +++ b/api/src/catalog.rs @@ -0,0 +1,56 @@ +//! Catálogo de produtos (design §Compras). Estático até a API-T10 servir `documents.config` de +//! verdade via Postgres — "shape mínimo pode ser embutido até T10" (tasks.md, API-T8). + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Product { + pub id: &'static str, + /// Minutos calibrados pelo Yan sem release (design M4) — placeholders até a calibração real. + pub credited_seconds: i64, + /// SKUs ≥ R$20 ligam `ads_off`+`skins_unlocked` (design §Compras). + pub grants_entitlements: bool, +} + +pub const PRODUCTS: &[Product] = &[ + Product { + id: "credits_10", + credited_seconds: 10 * 60, + grants_entitlements: false, + }, + Product { + id: "credits_20", + credited_seconds: 25 * 60, + grants_entitlements: true, + }, + Product { + id: "credits_50", + credited_seconds: 70 * 60, + grants_entitlements: true, + }, + Product { + id: "unlock_forever", + credited_seconds: 0, + grants_entitlements: true, + }, +]; + +pub fn find_product(product_id: &str) -> Option<&'static Product> { + PRODUCTS.iter().find(|p| p.id == product_id) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn credits_20_and_above_grant_entitlements_credits_10_does_not() { + assert!(!find_product("credits_10").unwrap().grants_entitlements); + assert!(find_product("credits_20").unwrap().grants_entitlements); + assert!(find_product("credits_50").unwrap().grants_entitlements); + assert!(find_product("unlock_forever").unwrap().grants_entitlements); + } + + #[test] + fn unknown_product_is_none() { + assert!(find_product("does_not_exist").is_none()); + } +} diff --git a/api/src/config.rs b/api/src/config.rs new file mode 100644 index 00000000..02280030 --- /dev/null +++ b/api/src/config.rs @@ -0,0 +1,457 @@ +use std::collections::HashMap; +use std::fmt; +use std::net::SocketAddr; +use std::num::NonZeroU32; + +const DEFAULT_BIND_ADDR: &str = "0.0.0.0:8080"; +const DEFAULT_REGISTER_RATE_LIMIT_PER_MINUTE: u32 = 5; +const DEFAULT_ACCOUNT_RATE_LIMIT_PER_MINUTE: u32 = 60; +const DEFAULT_VOIDED_PURCHASES_JOB_INTERVAL_SECS: u32 = 3600; +const DEFAULT_TRANSCRIPTION_TIMEOUT_MS: u32 = 30_000; +const DEFAULT_TRANSCRIPTION_MAX_BYTES: u32 = 26_214_400; // 25 MiB — mesmo placeholder da T10. +const DEFAULT_PROVIDER_UNHEALTHY_COOLDOWN_SECS: u32 = 60; +const DEFAULT_ADS_REWARD_SECONDS: u32 = 60; +const DEFAULT_ADS_DAILY_CAP: u32 = 5; + +#[derive(Debug, Clone)] +pub struct Config { + pub database_url: String, + pub bind_addr: SocketAddr, + /// Caminho do JSON de credencial da service account do Google (Play Integrity/Developer API). + pub google_service_account_json_path: String, + /// Package name do app Android (`com.keyflow`) — conferido contra `requestPackageName`. + pub package_name: String, + /// Relaxa `appRecognitionVerdict` != PLAY_RECOGNIZED (builds de teste/interno, design + /// §Identidade) — nunca relaxa pacote nem device. + pub integrity_relaxed: bool, + /// Limite de `POST /v1/register` por IP (ID-04). + pub register_rate_limit_per_minute: NonZeroU32, + /// Limite das rotas autenticadas por conta (ID-04). + pub account_rate_limit_per_minute: NonZeroU32, + /// Intervalo do job de `voidedpurchases.list` (PAY-06). + pub voided_purchases_job_interval_secs: NonZeroU32, + /// Provedores de transcrição, na ordem de tentativa (design §Proxy, TRX-05). + pub providers: Vec, + pub transcription_timeout_ms: NonZeroU32, + pub transcription_max_bytes: NonZeroU32, + /// Cooldown do circuit breaker entre requests (ver `providers::ProviderHealth`). + pub provider_unhealthy_cooldown_secs: NonZeroU32, + /// Segundos creditados por rewarded ad verificado (design §Rewarded) — decisão nossa, não vem + /// do `reward_amount` do AdMob (que é só o valor configurado no console, sem unidade fixa). + pub ads_reward_seconds: NonZeroU32, + /// Cap diário de recompensas por conta (ADS-01). + pub ads_daily_cap: NonZeroU32, +} + +#[derive(Debug)] +pub enum ConfigError { + MissingEnv(&'static str), + InvalidEnv { + var: &'static str, + value: String, + reason: String, + }, +} + +impl fmt::Display for ConfigError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ConfigError::MissingEnv(var) => { + write!(f, "missing required environment variable: {var}") + } + ConfigError::InvalidEnv { var, value, reason } => { + write!(f, "invalid value for {var} ({value:?}): {reason}") + } + } + } +} + +impl std::error::Error for ConfigError {} + +impl Config { + pub fn from_env() -> Result { + Self::from_map(&std::env::vars().collect()) + } + + /// Lê a config de um mapa em vez do processo real — isola os testes de env vars globais + /// (mutar `std::env` é estado compartilhado do processo e corre risco de vazar entre + /// `#[test]`s executando em paralelo no mesmo binário). + fn from_map(vars: &HashMap) -> Result { + let database_url = vars + .get("DATABASE_URL") + .cloned() + .ok_or(ConfigError::MissingEnv("DATABASE_URL"))?; + + let bind_addr = match vars.get("BIND_ADDR") { + Some(value) => value + .parse::() + .map_err(|e| ConfigError::InvalidEnv { + var: "BIND_ADDR", + value: value.clone(), + reason: e.to_string(), + })?, + None => DEFAULT_BIND_ADDR + .parse() + .expect("default bind addr is valid"), + }; + + let google_service_account_json_path = vars + .get("GOOGLE_SERVICE_ACCOUNT_JSON_PATH") + .cloned() + .ok_or(ConfigError::MissingEnv("GOOGLE_SERVICE_ACCOUNT_JSON_PATH"))?; + + let package_name = vars + .get("PACKAGE_NAME") + .cloned() + .ok_or(ConfigError::MissingEnv("PACKAGE_NAME"))?; + + let integrity_relaxed = vars + .get("INTEGRITY_RELAXED") + .map(|v| v == "true" || v == "1") + .unwrap_or(false); + + let register_rate_limit_per_minute = parse_nonzero_u32( + vars, + "REGISTER_RATE_LIMIT_PER_MINUTE", + DEFAULT_REGISTER_RATE_LIMIT_PER_MINUTE, + )?; + let account_rate_limit_per_minute = parse_nonzero_u32( + vars, + "ACCOUNT_RATE_LIMIT_PER_MINUTE", + DEFAULT_ACCOUNT_RATE_LIMIT_PER_MINUTE, + )?; + let voided_purchases_job_interval_secs = parse_nonzero_u32( + vars, + "VOIDED_PURCHASES_JOB_INTERVAL_SECS", + DEFAULT_VOIDED_PURCHASES_JOB_INTERVAL_SECS, + )?; + + let providers_json = vars + .get("TRANSCRIPTION_PROVIDERS_JSON") + .cloned() + .ok_or(ConfigError::MissingEnv("TRANSCRIPTION_PROVIDERS_JSON"))?; + let providers = + crate::providers::parse_and_resolve(&providers_json, vars).map_err(|reason| { + ConfigError::InvalidEnv { + var: "TRANSCRIPTION_PROVIDERS_JSON", + value: providers_json.clone(), + reason, + } + })?; + + let transcription_timeout_ms = parse_nonzero_u32( + vars, + "TRANSCRIPTION_TIMEOUT_MS", + DEFAULT_TRANSCRIPTION_TIMEOUT_MS, + )?; + let transcription_max_bytes = parse_nonzero_u32( + vars, + "TRANSCRIPTION_MAX_BYTES", + DEFAULT_TRANSCRIPTION_MAX_BYTES, + )?; + let provider_unhealthy_cooldown_secs = parse_nonzero_u32( + vars, + "PROVIDER_UNHEALTHY_COOLDOWN_SECS", + DEFAULT_PROVIDER_UNHEALTHY_COOLDOWN_SECS, + )?; + + let ads_reward_seconds = + parse_nonzero_u32(vars, "ADS_REWARD_SECONDS", DEFAULT_ADS_REWARD_SECONDS)?; + let ads_daily_cap = parse_nonzero_u32(vars, "ADS_DAILY_CAP", DEFAULT_ADS_DAILY_CAP)?; + + Ok(Config { + database_url, + bind_addr, + google_service_account_json_path, + package_name, + integrity_relaxed, + register_rate_limit_per_minute, + account_rate_limit_per_minute, + voided_purchases_job_interval_secs, + providers, + transcription_timeout_ms, + transcription_max_bytes, + provider_unhealthy_cooldown_secs, + ads_reward_seconds, + ads_daily_cap, + }) + } +} + +fn parse_nonzero_u32( + vars: &HashMap, + var: &'static str, + default: u32, +) -> Result { + let raw = match vars.get(var) { + Some(value) => value, + None => { + return Ok(NonZeroU32::new(default).expect("default rate limit is non-zero")); + } + }; + + let parsed: u32 = + raw.parse() + .map_err(|e: std::num::ParseIntError| ConfigError::InvalidEnv { + var, + value: raw.clone(), + reason: e.to_string(), + })?; + + NonZeroU32::new(parsed).ok_or(ConfigError::InvalidEnv { + var, + value: raw.clone(), + reason: "must be greater than zero".to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn vars(pairs: &[(&str, &str)]) -> HashMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + } + + #[test] + fn missing_database_url_fails_with_specific_message() { + let err = Config::from_map(&vars(&[])).unwrap_err(); + + assert_eq!( + err.to_string(), + "missing required environment variable: DATABASE_URL" + ); + } + + const REQUIRED: &[(&str, &str)] = &[ + ( + "DATABASE_URL", + "postgres://keyflow:keyflow@localhost/keyflow_api", + ), + ( + "GOOGLE_SERVICE_ACCOUNT_JSON_PATH", + "/secrets/google-sa.json", + ), + ("PACKAGE_NAME", "com.keyflow"), + ( + "TRANSCRIPTION_PROVIDERS_JSON", + r#"[{"name":"primary","url":"https://p1.example/transcribe","authHeaderName":"Authorization","authValueEnv":"TEST_PROVIDER_KEY","model":"whisper-1"}]"#, + ), + ("TEST_PROVIDER_KEY", "test-secret"), + ]; + + fn with_required(extra: &[(&str, &str)]) -> HashMap { + let mut all = REQUIRED.to_vec(); + all.extend_from_slice(extra); + vars(&all) + } + + #[test] + fn valid_env_produces_config() { + let config = Config::from_map(&with_required(&[("BIND_ADDR", "127.0.0.1:9090")])).unwrap(); + + assert_eq!( + config.database_url, + "postgres://keyflow:keyflow@localhost/keyflow_api" + ); + assert_eq!(config.bind_addr, "127.0.0.1:9090".parse().unwrap()); + assert_eq!( + config.google_service_account_json_path, + "/secrets/google-sa.json" + ); + assert_eq!(config.package_name, "com.keyflow"); + assert!(!config.integrity_relaxed); + assert_eq!( + config.register_rate_limit_per_minute.get(), + DEFAULT_REGISTER_RATE_LIMIT_PER_MINUTE + ); + assert_eq!( + config.account_rate_limit_per_minute.get(), + DEFAULT_ACCOUNT_RATE_LIMIT_PER_MINUTE + ); + assert_eq!( + config.voided_purchases_job_interval_secs.get(), + DEFAULT_VOIDED_PURCHASES_JOB_INTERVAL_SECS + ); + assert_eq!(config.providers.len(), 1); + assert_eq!(config.providers[0].name, "primary"); + assert_eq!(config.providers[0].auth_value, "test-secret"); + assert_eq!( + config.transcription_timeout_ms.get(), + DEFAULT_TRANSCRIPTION_TIMEOUT_MS + ); + assert_eq!( + config.transcription_max_bytes.get(), + DEFAULT_TRANSCRIPTION_MAX_BYTES + ); + assert_eq!(config.ads_reward_seconds.get(), DEFAULT_ADS_REWARD_SECONDS); + assert_eq!(config.ads_daily_cap.get(), DEFAULT_ADS_DAILY_CAP); + } + + #[test] + fn ads_reward_and_cap_are_configurable() { + let config = Config::from_map(&with_required(&[ + ("ADS_REWARD_SECONDS", "30"), + ("ADS_DAILY_CAP", "3"), + ])) + .unwrap(); + + assert_eq!(config.ads_reward_seconds.get(), 30); + assert_eq!(config.ads_daily_cap.get(), 3); + } + + #[test] + fn voided_purchases_job_interval_is_configurable() { + let config = Config::from_map(&with_required(&[( + "VOIDED_PURCHASES_JOB_INTERVAL_SECS", + "60", + )])) + .unwrap(); + + assert_eq!(config.voided_purchases_job_interval_secs.get(), 60); + } + + #[test] + fn rate_limits_are_configurable() { + let config = Config::from_map(&with_required(&[ + ("REGISTER_RATE_LIMIT_PER_MINUTE", "10"), + ("ACCOUNT_RATE_LIMIT_PER_MINUTE", "120"), + ])) + .unwrap(); + + assert_eq!(config.register_rate_limit_per_minute.get(), 10); + assert_eq!(config.account_rate_limit_per_minute.get(), 120); + } + + #[test] + fn zero_rate_limit_fails_with_specific_message() { + let err = Config::from_map(&with_required(&[("REGISTER_RATE_LIMIT_PER_MINUTE", "0")])) + .unwrap_err(); + + assert!(err.to_string().contains("REGISTER_RATE_LIMIT_PER_MINUTE")); + assert!(err.to_string().contains("greater than zero")); + } + + #[test] + fn non_numeric_rate_limit_fails_with_specific_message() { + let err = Config::from_map(&with_required(&[("ACCOUNT_RATE_LIMIT_PER_MINUTE", "many")])) + .unwrap_err(); + + assert!(err.to_string().contains("ACCOUNT_RATE_LIMIT_PER_MINUTE")); + assert!(err.to_string().contains("many")); + } + + #[test] + fn missing_bind_addr_falls_back_to_default() { + let config = Config::from_map(&with_required(&[])).unwrap(); + + assert_eq!(config.bind_addr, DEFAULT_BIND_ADDR.parse().unwrap()); + } + + #[test] + fn invalid_bind_addr_fails_with_specific_message() { + let err = Config::from_map(&with_required(&[("BIND_ADDR", "not-an-addr")])).unwrap_err(); + + assert!(err.to_string().contains("BIND_ADDR")); + assert!(err.to_string().contains("not-an-addr")); + } + + #[test] + fn missing_google_service_account_path_fails_with_specific_message() { + let err = Config::from_map(&vars(&[ + ( + "DATABASE_URL", + "postgres://keyflow:keyflow@localhost/keyflow_api", + ), + ("PACKAGE_NAME", "com.keyflow"), + ])) + .unwrap_err(); + + assert_eq!( + err.to_string(), + "missing required environment variable: GOOGLE_SERVICE_ACCOUNT_JSON_PATH" + ); + } + + #[test] + fn missing_package_name_fails_with_specific_message() { + let err = Config::from_map(&vars(&[ + ( + "DATABASE_URL", + "postgres://keyflow:keyflow@localhost/keyflow_api", + ), + ( + "GOOGLE_SERVICE_ACCOUNT_JSON_PATH", + "/secrets/google-sa.json", + ), + ])) + .unwrap_err(); + + assert_eq!( + err.to_string(), + "missing required environment variable: PACKAGE_NAME" + ); + } + + #[test] + fn integrity_relaxed_parses_true_and_1_as_true() { + for value in ["true", "1"] { + let config = Config::from_map(&with_required(&[("INTEGRITY_RELAXED", value)])).unwrap(); + assert!(config.integrity_relaxed, "esperava true para {value:?}"); + } + } + + #[test] + fn integrity_relaxed_defaults_to_false() { + let config = Config::from_map(&with_required(&[])).unwrap(); + assert!(!config.integrity_relaxed); + } + + #[test] + fn missing_transcription_providers_json_fails_with_specific_message() { + let err = Config::from_map(&vars(&[ + ( + "DATABASE_URL", + "postgres://keyflow:keyflow@localhost/keyflow_api", + ), + ( + "GOOGLE_SERVICE_ACCOUNT_JSON_PATH", + "/secrets/google-sa.json", + ), + ("PACKAGE_NAME", "com.keyflow"), + ])) + .unwrap_err(); + + assert_eq!( + err.to_string(), + "missing required environment variable: TRANSCRIPTION_PROVIDERS_JSON" + ); + } + + #[test] + fn invalid_providers_json_fails_with_specific_message() { + let mut map = with_required(&[]); + map.insert( + "TRANSCRIPTION_PROVIDERS_JSON".to_string(), + "not json".to_string(), + ); + let err = Config::from_map(&map).unwrap_err(); + + assert!(err.to_string().contains("TRANSCRIPTION_PROVIDERS_JSON")); + } + + #[test] + fn transcription_timeout_and_max_bytes_are_configurable() { + let config = Config::from_map(&with_required(&[ + ("TRANSCRIPTION_TIMEOUT_MS", "5000"), + ("TRANSCRIPTION_MAX_BYTES", "1000"), + ])) + .unwrap(); + + assert_eq!(config.transcription_timeout_ms.get(), 5000); + assert_eq!(config.transcription_max_bytes.get(), 1000); + } +} diff --git a/api/src/db/accounts.rs b/api/src/db/accounts.rs new file mode 100644 index 00000000..911483e3 --- /dev/null +++ b/api/src/db/accounts.rs @@ -0,0 +1,128 @@ +use sqlx::PgPool; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)] +pub struct Account { + pub id: Uuid, + pub device_key: String, + pub balance_seconds: i64, + pub ads_off: bool, + pub skins_unlocked: bool, +} + +pub async fn exists(pool: &PgPool, account_id: Uuid) -> sqlx::Result { + sqlx::query_scalar("select exists(select 1 from accounts where id = $1)") + .bind(account_id) + .fetch_one(pool) + .await +} + +pub async fn find_by_token_hash(pool: &PgPool, token_hash: &[u8]) -> sqlx::Result> { + sqlx::query_as( + "select id, device_key, balance_seconds, ads_off, skins_unlocked from accounts \ + where token_hash = $1", + ) + .bind(token_hash) + .fetch_optional(pool) + .await +} + +pub async fn find_by_device_key(pool: &PgPool, device_key: &str) -> sqlx::Result> { + sqlx::query_as( + "select id, device_key, balance_seconds, ads_off, skins_unlocked from accounts \ + where device_key = $1", + ) + .bind(device_key) + .fetch_optional(pool) + .await +} + +pub async fn create(pool: &PgPool, device_key: &str, token_hash: &[u8]) -> sqlx::Result { + sqlx::query_as( + "insert into accounts (device_key, token_hash) values ($1, $2) \ + returning id, device_key, balance_seconds, ads_off, skins_unlocked", + ) + .bind(device_key) + .bind(token_hash) + .fetch_one(pool) + .await +} + +/// Re-registro: rotaciona o token da conta existente, preservando `id`/saldo/entitlements — é o +/// fluxo de recuperação do design (deviceKey existente, "conta corrompida" no device). +pub async fn rotate_token( + pool: &PgPool, + account_id: Uuid, + new_token_hash: &[u8], +) -> sqlx::Result<()> { + sqlx::query("update accounts set token_hash = $1 where id = $2") + .bind(new_token_hash) + .bind(account_id) + .execute(pool) + .await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + async fn test_pool() -> PgPool { + let database_url = std::env::var("DATABASE_URL").expect( + "DATABASE_URL deve estar setada para os testes de integração (ver api/docker-compose.yml)", + ); + let pool = crate::db::connect(&database_url) + .await + .expect("falha ao conectar no Postgres de teste"); + crate::db::MIGRATOR + .run(&pool) + .await + .expect("falha ao rodar migrations"); + pool + } + + #[tokio::test] + async fn create_then_find_by_device_key_and_token_hash() { + let pool = test_pool().await; + let device_key = format!("test-{}", Uuid::new_v4()); + let token_hash = Uuid::new_v4().into_bytes().to_vec(); + + let created = create(&pool, &device_key, &token_hash).await.unwrap(); + + assert_eq!(created.balance_seconds, 0); + assert!(!created.ads_off); + assert!(!created.skins_unlocked); + + let by_device = find_by_device_key(&pool, &device_key) + .await + .unwrap() + .unwrap(); + assert_eq!(by_device, created); + + let by_token = find_by_token_hash(&pool, &token_hash) + .await + .unwrap() + .unwrap(); + assert_eq!(by_token, created); + } + + #[tokio::test] + async fn rotate_token_kills_old_hash_keeps_same_account() { + let pool = test_pool().await; + let device_key = format!("test-{}", Uuid::new_v4()); + let old_hash = Uuid::new_v4().into_bytes().to_vec(); + let new_hash = Uuid::new_v4().into_bytes().to_vec(); + + let created = create(&pool, &device_key, &old_hash).await.unwrap(); + rotate_token(&pool, created.id, &new_hash).await.unwrap(); + + assert!( + find_by_token_hash(&pool, &old_hash) + .await + .unwrap() + .is_none() + ); + let by_new = find_by_token_hash(&pool, &new_hash).await.unwrap().unwrap(); + assert_eq!(by_new.id, created.id); + } +} diff --git a/api/src/db/ledger.rs b/api/src/db/ledger.rs new file mode 100644 index 00000000..7959525b --- /dev/null +++ b/api/src/db/ledger.rs @@ -0,0 +1,133 @@ +use sqlx::{Acquire, Postgres}; +use uuid::Uuid; + +/// Motivo do lançamento no ledger — espelha o `check` da coluna `reason` (migração 0001). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LedgerReason { + Purchase, + Transcription, + Reward, +} + +impl LedgerReason { + fn as_str(self) -> &'static str { + match self { + LedgerReason::Purchase => "purchase", + LedgerReason::Transcription => "transcription", + LedgerReason::Reward => "reward", + } + } +} + +/// Ajusta `balance_seconds` por `delta_seconds` (positivo credita, negativo debita **sem +/// guarda** — usado pelo proxy de transcrição, TRX-02, onde o saldo pode negativar levemente numa +/// corrida rara e o design aceita isso) + registra no `ledger`, na mesma tx (savepoint se `conn` +/// já estiver dentro de uma transação do chamador — nunca parcial). +pub async fn credit<'a, A>( + conn: A, + account_id: Uuid, + delta_seconds: i64, + reason: LedgerReason, + reference: Option<&str>, +) -> sqlx::Result<()> +where + A: Acquire<'a, Database = Postgres>, +{ + let mut tx = conn.begin().await?; + + sqlx::query("update accounts set balance_seconds = balance_seconds + $1 where id = $2") + .bind(delta_seconds) + .bind(account_id) + .execute(&mut *tx) + .await?; + + sqlx::query( + "insert into ledger (account_id, delta_seconds, reason, ref) values ($1, $2, $3, $4)", + ) + .bind(account_id) + .bind(delta_seconds) + .bind(reason.as_str()) + .bind(reference) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(()) +} + +/// Debita `delta_seconds` (> 0) **com guarda**: só aplica se `balance_seconds >= delta_seconds`. +/// A guarda é a própria `UPDATE ... WHERE` (lock de linha do Postgres serializa débitos +/// concorrentes na mesma conta — só um vence a corrida pelo último saldo). Retorna `false` sem +/// mutar nada se o saldo for insuficiente. +pub async fn debit_guarded<'a, A>( + conn: A, + account_id: Uuid, + delta_seconds: i64, + reason: LedgerReason, + reference: Option<&str>, +) -> sqlx::Result +where + A: Acquire<'a, Database = Postgres>, +{ + let mut tx = conn.begin().await?; + + let updated: Option<(i64,)> = sqlx::query_as( + "update accounts set balance_seconds = balance_seconds - $1 \ + where id = $2 and balance_seconds >= $1 \ + returning balance_seconds", + ) + .bind(delta_seconds) + .bind(account_id) + .fetch_optional(&mut *tx) + .await?; + + if updated.is_none() { + tx.rollback().await?; + return Ok(false); + } + + sqlx::query( + "insert into ledger (account_id, delta_seconds, reason, ref) values ($1, $2, $3, $4)", + ) + .bind(account_id) + .bind(-delta_seconds) + .bind(reason.as_str()) + .bind(reference) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(true) +} + +/// Reverte `delta_seconds` (normalmente negativo) **sem guarda** — o saldo pode negativar +/// (reembolso de compra voided, PAY-06). Reason sempre `'revoke'`. +pub async fn revoke<'a, A>( + conn: A, + account_id: Uuid, + delta_seconds: i64, + reference: Option<&str>, +) -> sqlx::Result<()> +where + A: Acquire<'a, Database = Postgres>, +{ + let mut tx = conn.begin().await?; + + sqlx::query("update accounts set balance_seconds = balance_seconds + $1 where id = $2") + .bind(delta_seconds) + .bind(account_id) + .execute(&mut *tx) + .await?; + + sqlx::query( + "insert into ledger (account_id, delta_seconds, reason, ref) values ($1, $2, 'revoke', $3)", + ) + .bind(account_id) + .bind(delta_seconds) + .bind(reference) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(()) +} diff --git a/api/src/db/mod.rs b/api/src/db/mod.rs new file mode 100644 index 00000000..26576591 --- /dev/null +++ b/api/src/db/mod.rs @@ -0,0 +1,14 @@ +pub mod accounts; +pub mod ledger; + +use sqlx::PgPool; +use sqlx::postgres::PgPoolOptions; + +pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!(); + +pub async fn connect(database_url: &str) -> sqlx::Result { + PgPoolOptions::new() + .max_connections(10) + .connect(database_url) + .await +} diff --git a/api/src/google/admob.rs b/api/src/google/admob.rs new file mode 100644 index 00000000..ea9d7eb8 --- /dev/null +++ b/api/src/google/admob.rs @@ -0,0 +1,254 @@ +//! Verificação ECDSA das callbacks de rewarded SSV do AdMob (design §Rewarded, ADS-01). +//! Algoritmo e endpoint conferidos na doc oficial +//! (developers.google.com/admob/android/rewarded-video-ssv, 2026-07-03): +//! - Conteúdo assinado = a query string inteira **até** (exclusive) `&signature=`. +//! - `signature` (base64url) + `key_id` (inteiro) vêm depois, nessa ordem, sempre por último. +//! - ECDSA P-256, SHA-256, assinatura em DER — chave pública buscada em +//! `https://www.gstatic.com/admob/reward/verifier-keys.json` (JSON `{"keys":[{"keyId","pem"}]}`), +//! cacheável, mas nunca por mais de 24h (rotação regular). + +use std::collections::HashMap; +use std::sync::Mutex; + +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use p256::ecdsa::signature::Verifier; +use p256::ecdsa::{Signature, VerifyingKey}; +use p256::pkcs8::DecodePublicKey; +use serde::Deserialize; + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum SsvVerifyError { + #[error("missing signature query parameter")] + MissingSignature, + #[error("missing key_id query parameter")] + MissingKeyId, + #[error("key_id is not a valid integer")] + InvalidKeyId, + #[error("signature is not valid base64url")] + InvalidSignatureEncoding, + #[error("signature is not a valid DER ECDSA signature")] + InvalidSignatureFormat, + #[error("no public key known for key_id {0}")] + UnknownKeyId(u64), + #[error("signature does not match the content")] + SignatureMismatch, +} + +/// Extrai `(conteúdo assinado, key_id, signature bytes)` de uma query string crua de callback SSV +/// — nunca reordena/reserializa (a doc é explícita: "Content ... should not be modified in any +/// way, including keeping the order of query parameters"). +fn split_signed_content(raw_query: &str) -> Result<(&str, u64, Vec), SsvVerifyError> { + const SIGNATURE_PARAM: &str = "signature="; + const KEY_ID_PARAM: &str = "key_id="; + + let sig_start = raw_query + .find(SIGNATURE_PARAM) + .ok_or(SsvVerifyError::MissingSignature)?; + // -1 porque tem um '&' antes de "signature=" (exceto se fosse o único param, que nunca é o + // caso real de uma callback do AdMob). + let content = &raw_query[..sig_start.saturating_sub(1)]; + + let after_sig = &raw_query[sig_start + SIGNATURE_PARAM.len()..]; + let key_id_marker = after_sig + .find(KEY_ID_PARAM) + .ok_or(SsvVerifyError::MissingKeyId)?; + let sig_b64 = &after_sig[..key_id_marker.saturating_sub(1)]; + let key_id_str = after_sig[key_id_marker + KEY_ID_PARAM.len()..] + .split('&') + .next() + .unwrap_or_default(); + + let key_id: u64 = key_id_str + .parse() + .map_err(|_| SsvVerifyError::InvalidKeyId)?; + let signature_bytes = URL_SAFE_NO_PAD + .decode(sig_b64) + .map_err(|_| SsvVerifyError::InvalidSignatureEncoding)?; + + Ok((content, key_id, signature_bytes)) +} + +/// Verifica a assinatura ECDSA de uma callback SSV crua contra o mapa de chaves públicas +/// conhecidas (indexado por `key_id`). Retorna o conteúdo assinado (pra extrair os parâmetros de +/// negócio depois) só se a assinatura bater. +pub fn verify<'a>( + raw_query: &'a str, + known_keys: &HashMap, +) -> Result<&'a str, SsvVerifyError> { + let (content, key_id, signature_bytes) = split_signed_content(raw_query)?; + + let public_key = known_keys + .get(&key_id) + .ok_or(SsvVerifyError::UnknownKeyId(key_id))?; + let signature = Signature::from_der(&signature_bytes) + .map_err(|_| SsvVerifyError::InvalidSignatureFormat)?; + + public_key + .verify(content.as_bytes(), &signature) + .map_err(|_| SsvVerifyError::SignatureMismatch)?; + + Ok(content) +} + +#[derive(Debug, Deserialize)] +struct VerifierKeysResponse { + keys: Vec, +} + +#[derive(Debug, Deserialize)] +struct VerifierKeyEntry { + #[serde(rename = "keyId")] + key_id: u64, + pem: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum AdmobKeysError { + #[error("failed to fetch verifier keys: {0}")] + Http(#[from] reqwest::Error), + #[error("verifier keys endpoint returned status {0}")] + RequestFailed(u16), + #[error("failed to parse a public key from the verifier keys response")] + InvalidKey, +} + +/// Cache das chaves públicas do AdMob — refetch completo quando um `key_id` desconhecido aparece +/// (design: "cacheadas e re-buscadas quando key_id desconhecido"; nunca por mais de 24h segundo a +/// doc oficial, mas o refresh-on-miss já cobre rotação na prática sem precisar de um timer). +pub struct AdmobKeysCache { + http: reqwest::Client, + url: String, + keys: Mutex>, +} + +impl AdmobKeysCache { + pub fn new(http: reqwest::Client, url: String) -> Self { + Self { + http, + url, + keys: Mutex::new(HashMap::new()), + } + } + + /// Mapa de chaves conhecidas *neste instante*; chame `refresh` antes se `key_id` não estiver + /// aqui — não faz a busca sozinho pra manter esta função sync e barata de clonar. + pub fn snapshot(&self) -> HashMap { + self.keys.lock().unwrap().clone() + } + + pub async fn refresh(&self) -> Result<(), AdmobKeysError> { + let response = self.http.get(&self.url).send().await?; + if !response.status().is_success() { + return Err(AdmobKeysError::RequestFailed(response.status().as_u16())); + } + let body: VerifierKeysResponse = response.json().await?; + + let mut parsed = HashMap::with_capacity(body.keys.len()); + for entry in body.keys { + let key = VerifyingKey::from_public_key_pem(&entry.pem) + .map_err(|_| AdmobKeysError::InvalidKey)?; + parsed.insert(entry.key_id, key); + } + + *self.keys.lock().unwrap() = parsed; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use p256::ecdsa::SigningKey; + use p256::ecdsa::signature::Signer; + use rand_core::OsRng; + + fn sign(signing_key: &SigningKey, content: &str) -> String { + let signature: Signature = signing_key.sign(content.as_bytes()); + URL_SAFE_NO_PAD.encode(signature.to_der().as_bytes()) + } + + #[test] + fn valid_signature_verifies_and_returns_content() { + let signing_key = SigningKey::random(&mut OsRng); + let verifying_key = VerifyingKey::from(&signing_key); + let content = "ad_network=1&custom_data=abc&transaction_id=tx1"; + let sig = sign(&signing_key, content); + let raw_query = format!("{content}&signature={sig}&key_id=42"); + + let mut keys = HashMap::new(); + keys.insert(42, verifying_key); + + let verified_content = verify(&raw_query, &keys).unwrap(); + assert_eq!(verified_content, content); + } + + #[test] + fn tampered_content_fails_verification() { + let signing_key = SigningKey::random(&mut OsRng); + let verifying_key = VerifyingKey::from(&signing_key); + let content = "ad_network=1&custom_data=abc&transaction_id=tx1"; + let sig = sign(&signing_key, content); + // Adultera um parâmetro depois de assinar. + let raw_query = + format!("ad_network=1&custom_data=abc&transaction_id=tx2&signature={sig}&key_id=42"); + + let mut keys = HashMap::new(); + keys.insert(42, verifying_key); + + assert_eq!( + verify(&raw_query, &keys), + Err(SsvVerifyError::SignatureMismatch) + ); + } + + #[test] + fn unknown_key_id_is_rejected() { + let signing_key = SigningKey::random(&mut OsRng); + let content = "transaction_id=tx1"; + let sig = sign(&signing_key, content); + let raw_query = format!("{content}&signature={sig}&key_id=999"); + + let keys = HashMap::new(); // vazio — key_id 999 nunca visto. + + assert_eq!( + verify(&raw_query, &keys), + Err(SsvVerifyError::UnknownKeyId(999)) + ); + } + + #[test] + fn wrong_key_fails_verification() { + let signing_key = SigningKey::random(&mut OsRng); + let other_key = SigningKey::random(&mut OsRng); + let content = "transaction_id=tx1"; + let sig = sign(&signing_key, content); + let raw_query = format!("{content}&signature={sig}&key_id=1"); + + let mut keys = HashMap::new(); + keys.insert(1, VerifyingKey::from(&other_key)); + + assert_eq!( + verify(&raw_query, &keys), + Err(SsvVerifyError::SignatureMismatch) + ); + } + + #[test] + fn missing_signature_param_is_rejected() { + let keys = HashMap::new(); + assert_eq!( + verify("transaction_id=tx1&key_id=1", &keys), + Err(SsvVerifyError::MissingSignature) + ); + } + + #[test] + fn missing_key_id_param_is_rejected() { + let keys = HashMap::new(); + assert_eq!( + verify("transaction_id=tx1&signature=abc", &keys), + Err(SsvVerifyError::MissingKeyId) + ); + } +} diff --git a/api/src/google/auth.rs b/api/src/google/auth.rs new file mode 100644 index 00000000..67f52d50 --- /dev/null +++ b/api/src/google/auth.rs @@ -0,0 +1,237 @@ +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; + +use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; +use serde::{Deserialize, Serialize}; + +/// Margem de segurança: renova o token um pouco antes de expirar de fato, para não devolver um +/// token que expire no meio de uma requisição em voo. +const EXPIRY_SKEW_SECONDS: u64 = 30; +const JWT_LIFETIME_SECONDS: u64 = 3600; + +/// Formato padrão do JSON de chave de service account do Google Cloud (campos que usamos). +#[derive(Debug, Clone, Deserialize)] +pub struct ServiceAccountKey { + pub client_email: String, + pub private_key: String, + pub token_uri: String, +} + +impl ServiceAccountKey { + pub fn from_json_file(path: &str) -> std::io::Result { + let raw = std::fs::read_to_string(path)?; + serde_json::from_str(&raw) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e)) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum AuthError { + #[error("failed to sign JWT: {0}")] + Jwt(#[from] jsonwebtoken::errors::Error), + #[error("token request failed: {0}")] + Http(#[from] reqwest::Error), + #[error("token endpoint returned status {0}")] + TokenRequestFailed(u16), +} + +#[derive(Serialize)] +struct Claims<'a> { + iss: &'a str, + scope: &'a str, + aud: &'a str, + exp: u64, + iat: u64, +} + +#[derive(Deserialize)] +struct TokenResponse { + access_token: String, + expires_in: u64, +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock before unix epoch") + .as_secs() +} + +/// Token OAuth de service account, cacheado até perto de expirar (RFC do fluxo JWT Bearer: +/// developers.google.com/identity/protocols/oauth2/service-account). +pub struct TokenCache { + cached: Mutex>, +} + +impl Default for TokenCache { + fn default() -> Self { + Self::new() + } +} + +impl TokenCache { + pub fn new() -> Self { + Self { + cached: Mutex::new(None), + } + } + + pub async fn get_token( + &self, + http: &reqwest::Client, + key: &ServiceAccountKey, + scope: &str, + ) -> Result { + let now = now_unix(); + + if let Some((token, expires_at)) = self.cached.lock().unwrap().as_ref() + && *expires_at > now + EXPIRY_SKEW_SECONDS + { + return Ok(token.clone()); + } + + let (token, expires_in) = fetch_token(http, key, scope).await?; + let expires_at = now + expires_in; + *self.cached.lock().unwrap() = Some((token.clone(), expires_at)); + Ok(token) + } +} + +async fn fetch_token( + http: &reqwest::Client, + key: &ServiceAccountKey, + scope: &str, +) -> Result<(String, u64), AuthError> { + let now = now_unix(); + let claims = Claims { + iss: &key.client_email, + scope, + aud: &key.token_uri, + exp: now + JWT_LIFETIME_SECONDS, + iat: now, + }; + let encoding_key = EncodingKey::from_rsa_pem(key.private_key.as_bytes())?; + let jwt = encode(&Header::new(Algorithm::RS256), &claims, &encoding_key)?; + + let response = http + .post(&key.token_uri) + .form(&[ + ("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"), + ("assertion", jwt.as_str()), + ]) + .send() + .await?; + + if !response.status().is_success() { + return Err(AuthError::TokenRequestFailed(response.status().as_u16())); + } + + let body: TokenResponse = response.json().await?; + Ok((body.access_token, body.expires_in)) +} + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::matchers::{body_string_contains, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn test_key(token_uri: String) -> ServiceAccountKey { + ServiceAccountKey { + client_email: "test@keyflow-test.iam.gserviceaccount.com".to_string(), + private_key: include_str!("../../tests/fixtures/test_service_account_key.pem") + .to_string(), + token_uri, + } + } + + #[tokio::test] + async fn fetches_and_caches_token_until_near_expiry() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .and(body_string_contains( + "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "fake-token-1", + "scope": "https://www.googleapis.com/auth/playintegrity", + "token_type": "Bearer", + "expires_in": 3600 + }))) + .expect(1) + .mount(&server) + .await; + + let key = test_key(format!("{}/token", server.uri())); + let http = reqwest::Client::new(); + let cache = TokenCache::new(); + + let token1 = cache + .get_token(&http, &key, "https://www.googleapis.com/auth/playintegrity") + .await + .unwrap(); + let token2 = cache + .get_token(&http, &key, "https://www.googleapis.com/auth/playintegrity") + .await + .unwrap(); + + assert_eq!(token1, "fake-token-1"); + assert_eq!(token2, "fake-token-1"); + // `.expect(1)` no mock: a 2ª chamada teria falhado o mock se tivesse batido de novo no + // servidor — prova o cache. + } + + #[tokio::test] + async fn refetches_token_after_expiry() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "fake-token-short", + "token_type": "Bearer", + "expires_in": 1 + }))) + .up_to_n_times(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "fake-token-renewed", + "token_type": "Bearer", + "expires_in": 3600 + }))) + .mount(&server) + .await; + + let key = test_key(format!("{}/token", server.uri())); + let http = reqwest::Client::new(); + let cache = TokenCache::new(); + + let token1 = cache.get_token(&http, &key, "scope").await.unwrap(); + // expires_in=1 já está dentro da margem de segurança (30s) — a próxima chamada renova. + let token2 = cache.get_token(&http, &key, "scope").await.unwrap(); + + assert_eq!(token1, "fake-token-short"); + assert_eq!(token2, "fake-token-renewed"); + } + + #[tokio::test] + async fn token_endpoint_error_status_is_reported() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(401)) + .mount(&server) + .await; + + let key = test_key(format!("{}/token", server.uri())); + let http = reqwest::Client::new(); + let cache = TokenCache::new(); + + let err = cache.get_token(&http, &key, "scope").await.unwrap_err(); + + assert!(matches!(err, AuthError::TokenRequestFailed(401))); + } +} diff --git a/api/src/google/integrity.rs b/api/src/google/integrity.rs new file mode 100644 index 00000000..a55e8725 --- /dev/null +++ b/api/src/google/integrity.rs @@ -0,0 +1,238 @@ +use serde::{Deserialize, Serialize}; + +use super::auth::{AuthError, ServiceAccountKey, TokenCache}; + +const INTEGRITY_SCOPE: &str = "https://www.googleapis.com/auth/playintegrity"; + +/// Contrato exato conferido na discovery doc oficial (`playintegrity.googleapis.com/$discovery/ +/// rest?version=v1`, 2026-07-02) — schemas `DecodeIntegrityTokenRequest`/`...Response`/ +/// `TokenPayloadExternal` — e em developer.android.com/google/play/integrity/verdicts. +#[derive(Debug, Clone, Serialize)] +struct DecodeIntegrityTokenRequest<'a> { + integrity_token: &'a str, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DecodeIntegrityTokenResponse { + token_payload_external: TokenPayloadExternal, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TokenPayloadExternal { + pub request_details: RequestDetails, + pub app_integrity: AppIntegrity, + #[serde(default)] + pub device_integrity: DeviceIntegrity, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RequestDetails { + pub request_package_name: String, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AppIntegrity { + pub app_recognition_verdict: String, +} + +/// `deviceRecognitionVerdict` é omitido pelo Google quando o device não passa em nenhum label — +/// por isso `#[serde(default)]` (payload vira lista vazia, não erro de parse). +#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub struct DeviceIntegrity { + #[serde(default)] + pub device_recognition_verdict: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub enum IntegrityError { + #[error("failed to obtain access token: {0}")] + Auth(#[from] AuthError), + #[error("request to Play Integrity failed: {0}")] + Http(#[from] reqwest::Error), + #[error("Play Integrity returned status {0}")] + RequestFailed(u16), +} + +pub struct IntegrityClient { + http: reqwest::Client, + base_url: String, + package_name: String, + service_account: ServiceAccountKey, + tokens: TokenCache, +} + +impl IntegrityClient { + pub fn new( + http: reqwest::Client, + base_url: String, + package_name: String, + service_account: ServiceAccountKey, + ) -> Self { + Self { + http, + base_url, + package_name, + service_account, + tokens: TokenCache::new(), + } + } + + pub async fn decode( + &self, + integrity_token: &str, + ) -> Result { + let token = self + .tokens + .get_token(&self.http, &self.service_account, INTEGRITY_SCOPE) + .await?; + + let url = format!( + "{}/v1/{}:decodeIntegrityToken", + self.base_url, self.package_name + ); + let response = self + .http + .post(&url) + .bearer_auth(token) + .json(&DecodeIntegrityTokenRequest { integrity_token }) + .send() + .await?; + + if !response.status().is_success() { + return Err(IntegrityError::RequestFailed(response.status().as_u16())); + } + + let body: DecodeIntegrityTokenResponse = response.json().await?; + Ok(body.token_payload_external) + } +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum VerdictError { + #[error("unexpected package name: {0}")] + WrongPackage(String), + #[error("app recognition verdict rejected: {0}")] + AppNotRecognized(String), + #[error("device does not meet integrity requirements")] + DeviceNotTrusted, +} + +/// Valida os verdicts exigidos pelo design (§Identidade): pacote correto, app reconhecido pelo +/// Play (`relax_app_recognition` permite builds de teste/interno sem `PLAY_RECOGNIZED`) e device +/// íntegro (`MEETS_DEVICE_INTEGRITY` presente). +pub fn verify_verdicts( + payload: &TokenPayloadExternal, + expected_package: &str, + relax_app_recognition: bool, +) -> Result<(), VerdictError> { + if payload.request_details.request_package_name != expected_package { + return Err(VerdictError::WrongPackage( + payload.request_details.request_package_name.clone(), + )); + } + + if !relax_app_recognition && payload.app_integrity.app_recognition_verdict != "PLAY_RECOGNIZED" + { + return Err(VerdictError::AppNotRecognized( + payload.app_integrity.app_recognition_verdict.clone(), + )); + } + + if !payload + .device_integrity + .device_recognition_verdict + .iter() + .any(|v| v == "MEETS_DEVICE_INTEGRITY") + { + return Err(VerdictError::DeviceNotTrusted); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn payload(package: &str, app_verdict: &str, device_verdicts: &[&str]) -> TokenPayloadExternal { + TokenPayloadExternal { + request_details: RequestDetails { + request_package_name: package.to_string(), + }, + app_integrity: AppIntegrity { + app_recognition_verdict: app_verdict.to_string(), + }, + device_integrity: DeviceIntegrity { + device_recognition_verdict: device_verdicts.iter().map(|s| s.to_string()).collect(), + }, + } + } + + #[test] + fn accepts_valid_verdicts() { + let p = payload( + "com.keyflow", + "PLAY_RECOGNIZED", + &["MEETS_DEVICE_INTEGRITY"], + ); + assert_eq!(verify_verdicts(&p, "com.keyflow", false), Ok(())); + } + + #[test] + fn rejects_wrong_package() { + let p = payload("com.evil", "PLAY_RECOGNIZED", &["MEETS_DEVICE_INTEGRITY"]); + assert_eq!( + verify_verdicts(&p, "com.keyflow", false), + Err(VerdictError::WrongPackage("com.evil".to_string())) + ); + } + + #[test] + fn rejects_device_out_of_integrity() { + // deviceRecognitionVerdict vazio = "sinais de ataque/root/emulador" (doc oficial). + let p = payload("com.keyflow", "PLAY_RECOGNIZED", &[]); + assert_eq!( + verify_verdicts(&p, "com.keyflow", false), + Err(VerdictError::DeviceNotTrusted) + ); + } + + #[test] + fn rejects_unrecognized_app_without_relax_flag() { + let p = payload( + "com.keyflow", + "UNRECOGNIZED_VERSION", + &["MEETS_DEVICE_INTEGRITY"], + ); + assert_eq!( + verify_verdicts(&p, "com.keyflow", false), + Err(VerdictError::AppNotRecognized( + "UNRECOGNIZED_VERSION".to_string() + )) + ); + } + + #[test] + fn accepts_unrecognized_app_with_relax_flag() { + let p = payload( + "com.keyflow", + "UNRECOGNIZED_VERSION", + &["MEETS_DEVICE_INTEGRITY"], + ); + assert_eq!(verify_verdicts(&p, "com.keyflow", true), Ok(())); + } + + #[test] + fn relax_flag_does_not_bypass_package_or_device_checks() { + let p = payload("com.evil", "UNRECOGNIZED_VERSION", &[]); + assert_eq!( + verify_verdicts(&p, "com.keyflow", true), + Err(VerdictError::WrongPackage("com.evil".to_string())) + ); + } +} diff --git a/api/src/google/mod.rs b/api/src/google/mod.rs new file mode 100644 index 00000000..0f936ff1 --- /dev/null +++ b/api/src/google/mod.rs @@ -0,0 +1,4 @@ +pub mod admob; +pub mod auth; +pub mod integrity; +pub mod publisher; diff --git a/api/src/google/publisher.rs b/api/src/google/publisher.rs new file mode 100644 index 00000000..11c4aedb --- /dev/null +++ b/api/src/google/publisher.rs @@ -0,0 +1,256 @@ +use serde::Deserialize; + +use super::auth::{AuthError, ServiceAccountKey, TokenCache}; + +const PUBLISHER_SCOPE: &str = "https://www.googleapis.com/auth/androidpublisher"; + +/// `purchaseState` (design conferido na discovery doc oficial, `androidpublisher.googleapis.com/ +/// $discovery/rest?version=v3`, schema `ProductPurchase`, 2026-07-02): 0 Purchased, 1 Canceled, +/// 2 Pending. +pub const PURCHASE_STATE_PURCHASED: i32 = 0; +pub const PURCHASE_STATE_CANCELED: i32 = 1; +pub const PURCHASE_STATE_PENDING: i32 = 2; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProductPurchase { + pub purchase_state: i32, + #[serde(default)] + pub obfuscated_external_account_id: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VoidedPurchase { + pub purchase_token: String, +} + +#[derive(Debug, Clone, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct VoidedPurchasesListResponse { + #[serde(default)] + pub voided_purchases: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub enum PublisherError { + #[error("failed to obtain access token: {0}")] + Auth(#[from] AuthError), + #[error("request to Play Developer API failed: {0}")] + Http(#[from] reqwest::Error), + #[error("Play Developer API returned status {0}")] + RequestFailed(u16), +} + +pub struct PublisherClient { + http: reqwest::Client, + base_url: String, + package_name: String, + service_account: ServiceAccountKey, + tokens: TokenCache, +} + +impl PublisherClient { + pub fn new( + http: reqwest::Client, + base_url: String, + package_name: String, + service_account: ServiceAccountKey, + ) -> Self { + Self { + http, + base_url, + package_name, + service_account, + tokens: TokenCache::new(), + } + } + + async fn access_token(&self) -> Result { + Ok(self + .tokens + .get_token(&self.http, &self.service_account, PUBLISHER_SCOPE) + .await?) + } + + /// `GET .../purchases/products/{productId}/tokens/{token}` — checa status/dono da compra. + pub async fn get_product_purchase( + &self, + product_id: &str, + purchase_token: &str, + ) -> Result { + let access_token = self.access_token().await?; + let url = format!( + "{}/androidpublisher/v3/applications/{}/purchases/products/{}/tokens/{}", + self.base_url, self.package_name, product_id, purchase_token + ); + + let response = self.http.get(&url).bearer_auth(access_token).send().await?; + if !response.status().is_success() { + return Err(PublisherError::RequestFailed(response.status().as_u16())); + } + Ok(response.json().await?) + } + + /// `POST .../tokens/{token}:acknowledge` — server-side, depois de creditar (design §Compras). + pub async fn acknowledge_product_purchase( + &self, + product_id: &str, + purchase_token: &str, + ) -> Result<(), PublisherError> { + let access_token = self.access_token().await?; + let url = format!( + "{}/androidpublisher/v3/applications/{}/purchases/products/{}/tokens/{}:acknowledge", + self.base_url, self.package_name, product_id, purchase_token + ); + + let response = self + .http + .post(&url) + .bearer_auth(access_token) + .json(&serde_json::json!({})) + .send() + .await?; + if !response.status().is_success() { + return Err(PublisherError::RequestFailed(response.status().as_u16())); + } + Ok(()) + } + + /// `GET .../purchases/voidedpurchases` — compras canceladas/reembolsadas/chargeback (design + /// §Compras, job PAY-06). Sem paginação: o job roda periodicamente e a próxima janela cobre o + /// que ficou de fora (`startTime` default do Google já é "agora - 30 dias"). + pub async fn list_voided_purchases( + &self, + ) -> Result { + let access_token = self.access_token().await?; + let url = format!( + "{}/androidpublisher/v3/applications/{}/purchases/voidedpurchases", + self.base_url, self.package_name + ); + + let response = self.http.get(&url).bearer_auth(access_token).send().await?; + if !response.status().is_success() { + return Err(PublisherError::RequestFailed(response.status().as_u16())); + } + Ok(response.json().await?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn test_key(token_uri: String) -> ServiceAccountKey { + ServiceAccountKey { + client_email: "test@keyflow-test.iam.gserviceaccount.com".to_string(), + private_key: include_str!("../../tests/fixtures/test_service_account_key.pem") + .to_string(), + token_uri, + } + } + + async fn mock_token(google: &MockServer) { + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "fake-access-token", + "token_type": "Bearer", + "expires_in": 3600 + }))) + .mount(google) + .await; + } + + #[tokio::test] + async fn get_product_purchase_parses_response() { + let google = MockServer::start().await; + mock_token(&google).await; + Mock::given(method("GET")) + .and(path( + "/androidpublisher/v3/applications/com.keyflow/purchases/products/credits_20/tokens/tok-1", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "purchaseState": 0, + "obfuscatedExternalAccountId": "abc123" + }))) + .mount(&google) + .await; + + let client = PublisherClient::new( + reqwest::Client::new(), + google.uri(), + "com.keyflow".to_string(), + test_key(format!("{}/token", google.uri())), + ); + + let purchase = client + .get_product_purchase("credits_20", "tok-1") + .await + .unwrap(); + + assert_eq!(purchase.purchase_state, PURCHASE_STATE_PURCHASED); + assert_eq!( + purchase.obfuscated_external_account_id.as_deref(), + Some("abc123") + ); + } + + #[tokio::test] + async fn acknowledge_sends_expected_request() { + let google = MockServer::start().await; + mock_token(&google).await; + Mock::given(method("POST")) + .and(path( + "/androidpublisher/v3/applications/com.keyflow/purchases/products/credits_20/tokens/tok-1:acknowledge", + )) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&google) + .await; + + let client = PublisherClient::new( + reqwest::Client::new(), + google.uri(), + "com.keyflow".to_string(), + test_key(format!("{}/token", google.uri())), + ); + + client + .acknowledge_product_purchase("credits_20", "tok-1") + .await + .unwrap(); + } + + #[tokio::test] + async fn list_voided_purchases_parses_response() { + let google = MockServer::start().await; + mock_token(&google).await; + Mock::given(method("GET")) + .and(path( + "/androidpublisher/v3/applications/com.keyflow/purchases/voidedpurchases", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "voidedPurchases": [ + { "purchaseToken": "voided-tok-1" }, + { "purchaseToken": "voided-tok-2" } + ] + }))) + .mount(&google) + .await; + + let client = PublisherClient::new( + reqwest::Client::new(), + google.uri(), + "com.keyflow".to_string(), + test_key(format!("{}/token", google.uri())), + ); + + let response = client.list_voided_purchases().await.unwrap(); + + assert_eq!(response.voided_purchases.len(), 2); + assert_eq!(response.voided_purchases[0].purchase_token, "voided-tok-1"); + } +} diff --git a/api/src/jobs/mod.rs b/api/src/jobs/mod.rs new file mode 100644 index 00000000..712d7ccd --- /dev/null +++ b/api/src/jobs/mod.rs @@ -0,0 +1 @@ +pub mod voided; diff --git a/api/src/jobs/voided.rs b/api/src/jobs/voided.rs new file mode 100644 index 00000000..0bf010c9 --- /dev/null +++ b/api/src/jobs/voided.rs @@ -0,0 +1,323 @@ +use std::time::Duration; + +use sqlx::PgPool; +use uuid::Uuid; + +use crate::auth::hash_token; +use crate::db::ledger::revoke; +use crate::google::publisher::PublisherClient; + +/// Roda `process_once` a cada `interval` — nunca para em erro (loga e tenta de novo na próxima +/// janela); é assim que o design trata reembolsos: melhor esforço, não é caminho crítico de +/// request. +pub async fn run_periodically(pool: PgPool, publisher: PublisherClient, interval: Duration) { + let mut ticker = tokio::time::interval(interval); + loop { + ticker.tick().await; + match process_once(&pool, &publisher).await { + Ok(reverted) => { + if reverted > 0 { + tracing::info!(reverted, "voided purchases job reverted purchases"); + } + } + Err(e) => tracing::error!(error = %e, "voided purchases job failed"), + } + } +} + +/// Busca `voidedpurchases.list` e reverte cada token conhecido (PAY-06). Retorna quantos foram +/// de fato revertidos nesta execução (idempotente: já revertidos antes não contam de novo). +pub async fn process_once( + pool: &PgPool, + publisher: &PublisherClient, +) -> Result { + let voided = publisher.list_voided_purchases().await?; + + let mut reverted = 0; + for purchase in &voided.voided_purchases { + let token_hash = hash_token(purchase.purchase_token.as_bytes()); + if revoke_purchase(pool, &token_hash).await { + reverted += 1; + } + } + Ok(reverted) +} + +/// `true` se esta chamada de fato reverteu algo (primeira vez que vemos este token voided); +/// `false` se o token é desconhecido ou já tinha sido revertido antes (idempotência). +async fn revoke_purchase(pool: &PgPool, token_hash: &[u8]) -> bool { + let mut tx = match pool.begin().await { + Ok(tx) => tx, + Err(e) => { + tracing::error!(error = %e, "failed to begin tx for voided purchase"); + return false; + } + }; + + let row: Option<(Uuid, i64)> = match sqlx::query_as( + "update purchases set status = 'voided' where token_hash = $1 and status != 'voided' \ + returning account_id, credited_seconds", + ) + .bind(token_hash) + .fetch_optional(&mut *tx) + .await + { + Ok(row) => row, + Err(e) => { + tracing::error!(error = %e, "failed to mark purchase as voided"); + return false; + } + }; + + let Some((account_id, credited_seconds)) = row else { + return false; + }; + + if let Err(e) = revoke( + &mut tx, + account_id, + -credited_seconds, + Some("voided_purchase"), + ) + .await + { + tracing::error!(error = %e, "failed to revoke ledger for voided purchase"); + return false; + } + + if let Err(e) = recompute_entitlements(&mut tx, account_id).await { + tracing::error!(error = %e, "failed to recompute entitlements after void"); + return false; + } + + if let Err(e) = tx.commit().await { + tracing::error!(error = %e, "failed to commit voided purchase revert"); + return false; + } + + true +} + +/// Entitlements ligados se AINDA existir alguma compra não-voided que os concede — recomputado do +/// zero a cada void (não decrementado), evitando drift entre múltiplas compras que concedem +/// entitlements na mesma conta. +async fn recompute_entitlements( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + account_id: Uuid, +) -> sqlx::Result<()> { + let (still_entitled,): (bool,) = sqlx::query_as( + "select exists( \ + select 1 from purchases \ + where account_id = $1 and status != 'voided' and granted_entitlements = true \ + )", + ) + .bind(account_id) + .fetch_one(&mut **tx) + .await?; + + sqlx::query("update accounts set ads_off = $1, skins_unlocked = $1 where id = $2") + .bind(still_entitled) + .bind(account_id) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::accounts; + use crate::db::ledger::{LedgerReason, credit}; + use crate::google::auth::ServiceAccountKey; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + async fn test_pool() -> PgPool { + let database_url = std::env::var("DATABASE_URL").expect( + "DATABASE_URL deve estar setada para os testes de integração (ver api/docker-compose.yml)", + ); + let pool = crate::db::connect(&database_url) + .await + .expect("falha ao conectar no Postgres de teste"); + crate::db::MIGRATOR + .run(&pool) + .await + .expect("falha ao rodar migrations"); + pool + } + + async fn mock_voided(tokens: &[&str]) -> (MockServer, PublisherClient) { + let google = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "fake-access-token", + "token_type": "Bearer", + "expires_in": 3600 + }))) + .mount(&google) + .await; + Mock::given(method("GET")) + .and(path("/androidpublisher/v3/applications/com.keyflow/purchases/voidedpurchases")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "voidedPurchases": tokens.iter().map(|t| serde_json::json!({"purchaseToken": t})).collect::>() + }))) + .mount(&google) + .await; + + let publisher = PublisherClient::new( + reqwest::Client::new(), + google.uri(), + "com.keyflow".to_string(), + ServiceAccountKey { + client_email: "test@keyflow-test.iam.gserviceaccount.com".to_string(), + private_key: include_str!("../../tests/fixtures/test_service_account_key.pem") + .to_string(), + token_uri: format!("{}/token", google.uri()), + }, + ); + (google, publisher) + } + + /// Simula uma compra já "redeemed" (como a T8 deixaria) para o job reverter. + async fn seed_redeemed_purchase( + pool: &PgPool, + account_id: Uuid, + purchase_token: &str, + credited_seconds: i64, + grants_entitlements: bool, + ) { + let token_hash = hash_token(purchase_token.as_bytes()); + sqlx::query( + "insert into purchases \ + (token_hash, account_id, product_id, status, credited_seconds, granted_entitlements) \ + values ($1, $2, 'credits_20', 'redeemed', $3, $4)", + ) + .bind(&token_hash) + .bind(account_id) + .bind(credited_seconds) + .bind(grants_entitlements) + .execute(pool) + .await + .unwrap(); + credit( + pool, + account_id, + credited_seconds, + LedgerReason::Purchase, + Some(purchase_token), + ) + .await + .unwrap(); + if grants_entitlements { + sqlx::query("update accounts set ads_off = true, skins_unlocked = true where id = $1") + .bind(account_id) + .execute(pool) + .await + .unwrap(); + } + } + + #[tokio::test] + async fn refund_reverts_credit_and_entitlements() { + let pool = test_pool().await; + let device_key = format!("device-{}", Uuid::new_v4()); + let account = accounts::create(&pool, &device_key, Uuid::new_v4().into_bytes().as_ref()) + .await + .unwrap(); + let purchase_token = format!("tok-{}", Uuid::new_v4()); + seed_redeemed_purchase(&pool, account.id, &purchase_token, 1500, true).await; + + let (_google, publisher) = mock_voided(&[&purchase_token]).await; + + let reverted = process_once(&pool, &publisher).await.unwrap(); + + assert_eq!(reverted, 1); + let after = accounts::find_by_device_key(&pool, &device_key) + .await + .unwrap() + .unwrap(); + assert_eq!(after.balance_seconds, 0); + assert!( + !after.ads_off, + "sem outras compras válidas, entitlements devem cair" + ); + assert!(!after.skins_unlocked); + } + + #[tokio::test] + async fn negative_balance_after_void_is_allowed() { + let pool = test_pool().await; + let device_key = format!("device-{}", Uuid::new_v4()); + let account = accounts::create(&pool, &device_key, Uuid::new_v4().into_bytes().as_ref()) + .await + .unwrap(); + let purchase_token = format!("tok-{}", Uuid::new_v4()); + seed_redeemed_purchase(&pool, account.id, &purchase_token, 600, false).await; + + // Gasta o saldo antes do reembolso (equivalente ao app já ter usado os minutos). + crate::db::ledger::debit_guarded(&pool, account.id, 600, LedgerReason::Transcription, None) + .await + .unwrap(); + + let (_google, publisher) = mock_voided(&[&purchase_token]).await; + process_once(&pool, &publisher).await.unwrap(); + + let after = accounts::find_by_device_key(&pool, &device_key) + .await + .unwrap() + .unwrap(); + assert_eq!( + after.balance_seconds, -600, + "revoke não tem guarda — saldo pode negativar" + ); + } + + #[tokio::test] + async fn running_job_twice_does_not_double_revert() { + let pool = test_pool().await; + let device_key = format!("device-{}", Uuid::new_v4()); + let account = accounts::create(&pool, &device_key, Uuid::new_v4().into_bytes().as_ref()) + .await + .unwrap(); + let purchase_token = format!("tok-{}", Uuid::new_v4()); + seed_redeemed_purchase(&pool, account.id, &purchase_token, 900, false).await; + + let (_google, publisher) = mock_voided(&[&purchase_token]).await; + + let first = process_once(&pool, &publisher).await.unwrap(); + let second = process_once(&pool, &publisher).await.unwrap(); + + assert_eq!(first, 1); + assert_eq!(second, 0, "2ª execução não deve reverter de novo"); + let after = accounts::find_by_device_key(&pool, &device_key) + .await + .unwrap() + .unwrap(); + assert_eq!(after.balance_seconds, 0, "saldo não deve cair duas vezes"); + } + + #[tokio::test] + async fn entitlements_survive_if_another_purchase_still_grants_them() { + let pool = test_pool().await; + let device_key = format!("device-{}", Uuid::new_v4()); + let account = accounts::create(&pool, &device_key, Uuid::new_v4().into_bytes().as_ref()) + .await + .unwrap(); + let voided_token = format!("tok-{}", Uuid::new_v4()); + let kept_token = format!("tok-{}", Uuid::new_v4()); + seed_redeemed_purchase(&pool, account.id, &voided_token, 1500, true).await; + seed_redeemed_purchase(&pool, account.id, &kept_token, 4200, true).await; + + let (_google, publisher) = mock_voided(&[&voided_token]).await; + process_once(&pool, &publisher).await.unwrap(); + + let after = accounts::find_by_device_key(&pool, &device_key) + .await + .unwrap() + .unwrap(); + assert!(after.ads_off, "a 2ª compra ainda concede entitlements"); + assert!(after.skins_unlocked); + } +} diff --git a/api/src/lib.rs b/api/src/lib.rs new file mode 100644 index 00000000..7612cc41 --- /dev/null +++ b/api/src/lib.rs @@ -0,0 +1,272 @@ +pub mod auth; +pub mod catalog; +pub mod config; +pub mod db; +pub mod google; +pub mod jobs; +pub mod middleware; +pub mod providers; +pub mod routes; + +use std::num::NonZeroU32; +use std::sync::Arc; +use std::time::Duration; + +use axum::extract::{MatchedPath, State}; +use axum::http::{Request, StatusCode}; +use axum::routing::post; +use axum::{Router, routing::get}; +use sqlx::PgPool; +use tower_http::trace::TraceLayer; + +use google::admob::AdmobKeysCache; +use google::integrity::IntegrityClient; +use google::publisher::PublisherClient; +use middleware::ratelimit::RateLimiters; +use providers::{ProviderHealth, ResolvedProvider}; + +/// Config do proxy de transcrição (design §Proxy, API-T11) — agrupado à parte pra não inchar +/// ainda mais `AppState::new`. +pub struct TranscriptionConfig { + pub providers: Vec, + pub timeout: Duration, + pub max_bytes: u64, + pub unhealthy_cooldown: Duration, +} + +/// Config do rewarded SSV (design §Rewarded, API-T12). +pub struct AdsConfig { + pub verifier_keys_url: String, + pub reward_seconds: i64, + pub daily_cap: i64, +} + +pub struct AppStateInner { + pub pool: PgPool, + pub integrity: IntegrityClient, + pub publisher: PublisherClient, + pub http: reqwest::Client, + pub package_name: String, + pub integrity_relaxed: bool, + pub rate_limiters: RateLimiters, + pub providers: Vec, + pub provider_health: ProviderHealth, + pub transcription_timeout: Duration, + pub transcription_max_bytes: u64, + pub provider_unhealthy_cooldown: Duration, + pub admob_keys: AdmobKeysCache, + pub ads_reward_seconds: i64, + pub ads_daily_cap: i64, +} + +/// Estado compartilhado entre handlers — `Clone` barato (`Arc` por dentro; `PgPool` também já é +/// `Arc` por dentro, mas o `IntegrityClient`/`TokenCache` não são `Clone`, daí o wrapper). +#[derive(Clone)] +pub struct AppState(Arc); + +impl AppState { + #[allow(clippy::too_many_arguments)] + pub fn new( + pool: PgPool, + integrity: IntegrityClient, + publisher: PublisherClient, + http: reqwest::Client, + package_name: String, + integrity_relaxed: bool, + register_rate_limit_per_minute: NonZeroU32, + account_rate_limit_per_minute: NonZeroU32, + transcription: TranscriptionConfig, + ads: AdsConfig, + ) -> Self { + let provider_health = ProviderHealth::new(transcription.providers.len()); + let admob_keys = AdmobKeysCache::new(reqwest::Client::new(), ads.verifier_keys_url); + Self(Arc::new(AppStateInner { + pool, + integrity, + publisher, + http, + package_name, + integrity_relaxed, + rate_limiters: RateLimiters::new( + register_rate_limit_per_minute, + account_rate_limit_per_minute, + ), + providers: transcription.providers, + provider_health, + transcription_timeout: transcription.timeout, + transcription_max_bytes: transcription.max_bytes, + provider_unhealthy_cooldown: transcription.unhealthy_cooldown, + admob_keys, + ads_reward_seconds: ads.reward_seconds, + ads_daily_cap: ads.daily_cap, + })) + } +} + +impl std::ops::Deref for AppState { + type Target = AppStateInner; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +async fn health(State(state): State) -> Result<&'static str, StatusCode> { + sqlx::query("select 1") + .execute(&state.pool) + .await + .map(|_| "ok") + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +pub fn app(state: AppState) -> Router { + // `route_layer` (não `layer`): só assim a extensão `MatchedPath` já está no request quando + // `make_span_with` roda — `layer` envolve o roteamento inteiro e roda ANTES da extensão ser + // inserida (gotcha documentado do axum). + Router::new() + .route("/health", get(health)) + .route("/v1/register", post(routes::register::register)) + .route("/v1/account", get(routes::account::get_account)) + .route("/v1/purchases/redeem", post(routes::redeem::redeem)) + .route("/v1/config", get(routes::content::get_config)) + .route("/v1/models", get(routes::content::get_models)) + .route("/v1/transcribe", post(routes::transcribe::transcribe)) + .route("/v1/ssv/admob", get(routes::ssv::ssv_admob)) + .route_layer( + TraceLayer::new_for_http().make_span_with(|request: &Request<_>| { + let matched_path = request + .extensions() + .get::() + .map(MatchedPath::as_str); + + tracing::info_span!( + "http_request", + method = %request.method(), + matched_path, + ) + }), + ) + .with_state(state) +} + +pub fn init_tracing() { + // Sem RUST_LOG, mantém a latência por rota visível (tower_http::trace roda em DEBUG). + let filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,tower_http=debug")); + + tracing_subscriber::fmt() + .json() + .with_env_filter(filter) + .init(); +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use reqwest::Client; + use tower::ServiceExt; + + async fn test_pool() -> PgPool { + let database_url = std::env::var("DATABASE_URL").expect( + "DATABASE_URL deve estar setada para os testes de integração (ver api/docker-compose.yml)", + ); + let pool = crate::db::connect(&database_url) + .await + .expect("falha ao conectar no Postgres de teste"); + crate::db::MIGRATOR + .run(&pool) + .await + .expect("falha ao rodar migrations"); + pool + } + + async fn test_state() -> AppState { + let pool = test_pool().await; + // Nenhum teste deste módulo bate de verdade no Google — a IntegrityClient aponta pra um + // endereço que nunca é chamado (health não usa integrity). + let unused_key = google::auth::ServiceAccountKey { + client_email: "unused@example.com".to_string(), + private_key: String::new(), + token_uri: "http://127.0.0.1:0/token".to_string(), + }; + let integrity = IntegrityClient::new( + Client::new(), + "http://127.0.0.1:0".to_string(), + "com.keyflow".to_string(), + unused_key.clone(), + ); + let publisher = PublisherClient::new( + Client::new(), + "http://127.0.0.1:0".to_string(), + "com.keyflow".to_string(), + unused_key, + ); + AppState::new( + pool, + integrity, + publisher, + Client::new(), + "com.keyflow".to_string(), + false, + std::num::NonZeroU32::new(1000).unwrap(), + std::num::NonZeroU32::new(1000).unwrap(), + test_transcription_config(), + test_ads_config(), + ) + } + + fn test_transcription_config() -> TranscriptionConfig { + TranscriptionConfig { + providers: vec![ResolvedProvider { + name: "unused".to_string(), + url: "http://127.0.0.1:0".to_string(), + auth_header_name: "Authorization".to_string(), + auth_value: "unused".to_string(), + model: "unused".to_string(), + }], + timeout: Duration::from_secs(30), + max_bytes: 26_214_400, + unhealthy_cooldown: Duration::from_secs(60), + } + } + + fn test_ads_config() -> AdsConfig { + AdsConfig { + verifier_keys_url: "http://127.0.0.1:0".to_string(), + reward_seconds: 60, + daily_cap: 5, + } + } + + #[tokio::test] + async fn health_returns_200_with_real_select_1() { + let state = test_state().await; + + let response = app(state) + .oneshot( + Request::builder() + .uri("/health") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + } + + // Log JSON com rota (`matched_path`) e latência: verificado por inspeção documentada em vez + // de unit test (alternativa prevista no design §Plano de testes). Um teste que instala um + // subscriber via `tracing::subscriber::set_default`/`with_default` fica flaky quando roda em + // paralelo com as outras `#[tokio::test]` deste binário — o cache global de `Interest` por + // callsite do crate `tracing` é compartilhado entre threads, e o resultado depende de qual + // thread pergunta "enabled?" primeiro. Evidência real capturada com `cargo run` (RUST_LOG= + // info,tower_http=debug), request real em GET /health: + // {"timestamp":"...","level":"DEBUG","fields":{"message":"started processing request"}, + // "target":"tower_http::trace::on_request","span":{"matched_path":"/health","method":"GET", + // "name":"http_request"},"spans":[...]} + // {"timestamp":"...","level":"DEBUG","fields":{"message":"finished processing request", + // "latency":"0 ms","status":200},"target":"tower_http::trace::on_response", + // "span":{"matched_path":"/health",...},"spans":[...]} +} diff --git a/api/src/main.rs b/api/src/main.rs new file mode 100644 index 00000000..7f7d8b0d --- /dev/null +++ b/api/src/main.rs @@ -0,0 +1,89 @@ +use keyflow_api::google::auth::ServiceAccountKey; +use keyflow_api::google::integrity::IntegrityClient; +use keyflow_api::google::publisher::PublisherClient; +use keyflow_api::{AppState, app, config::Config, db, init_tracing, jobs}; +use std::time::Duration; +use tokio::net::TcpListener; + +const PLAY_INTEGRITY_BASE_URL: &str = "https://playintegrity.googleapis.com"; +const ANDROID_PUBLISHER_BASE_URL: &str = "https://androidpublisher.googleapis.com"; +const ADMOB_VERIFIER_KEYS_URL: &str = "https://www.gstatic.com/admob/reward/verifier-keys.json"; + +#[tokio::main] +async fn main() { + init_tracing(); + + let config = Config::from_env().unwrap_or_else(|e| panic!("config error: {e}")); + + let pool = db::connect(&config.database_url) + .await + .unwrap_or_else(|e| panic!("failed to connect to database: {e}")); + db::MIGRATOR + .run(&pool) + .await + .unwrap_or_else(|e| panic!("failed to run migrations: {e}")); + + let service_account = + ServiceAccountKey::from_json_file(&config.google_service_account_json_path) + .unwrap_or_else(|e| panic!("failed to load Google service account key: {e}")); + let integrity = IntegrityClient::new( + reqwest::Client::new(), + PLAY_INTEGRITY_BASE_URL.to_string(), + config.package_name.clone(), + service_account.clone(), + ); + let publisher = PublisherClient::new( + reqwest::Client::new(), + ANDROID_PUBLISHER_BASE_URL.to_string(), + config.package_name.clone(), + service_account.clone(), + ); + + // Cliente próprio pro job (TokenCache separada do que atende requests) — mesma credencial. + let job_publisher = PublisherClient::new( + reqwest::Client::new(), + ANDROID_PUBLISHER_BASE_URL.to_string(), + config.package_name.clone(), + service_account, + ); + tokio::spawn(jobs::voided::run_periodically( + pool.clone(), + job_publisher, + Duration::from_secs(config.voided_purchases_job_interval_secs.get() as u64), + )); + + let state = AppState::new( + pool, + integrity, + publisher, + reqwest::Client::new(), + config.package_name, + config.integrity_relaxed, + config.register_rate_limit_per_minute, + config.account_rate_limit_per_minute, + keyflow_api::TranscriptionConfig { + providers: config.providers, + timeout: Duration::from_millis(config.transcription_timeout_ms.get() as u64), + max_bytes: config.transcription_max_bytes.get() as u64, + unhealthy_cooldown: Duration::from_secs( + config.provider_unhealthy_cooldown_secs.get() as u64 + ), + }, + keyflow_api::AdsConfig { + verifier_keys_url: ADMOB_VERIFIER_KEYS_URL.to_string(), + reward_seconds: config.ads_reward_seconds.get() as i64, + daily_cap: config.ads_daily_cap.get() as i64, + }, + ); + + let listener = TcpListener::bind(config.bind_addr) + .await + .unwrap_or_else(|e| panic!("failed to bind {}: {e}", config.bind_addr)); + tracing::info!(addr = %config.bind_addr, "keyflow-api listening"); + axum::serve( + listener, + app(state).into_make_service_with_connect_info::(), + ) + .await + .expect("server error"); +} diff --git a/api/src/middleware/mod.rs b/api/src/middleware/mod.rs new file mode 100644 index 00000000..8616d152 --- /dev/null +++ b/api/src/middleware/mod.rs @@ -0,0 +1 @@ +pub mod ratelimit; diff --git a/api/src/middleware/ratelimit.rs b/api/src/middleware/ratelimit.rs new file mode 100644 index 00000000..1f95e09f --- /dev/null +++ b/api/src/middleware/ratelimit.rs @@ -0,0 +1,130 @@ +use std::net::IpAddr; +use std::num::NonZeroU32; + +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use governor::clock::{Clock, DefaultClock}; +use governor::{DefaultKeyedRateLimiter, Quota}; +use uuid::Uuid; + +/// Limitadores keyed do design (ID-04): por IP no `/v1/register` (rota sem auth — só o IP +/// identifica quem está batendo) e por conta nas rotas autenticadas. +pub struct RateLimiters { + register_by_ip: DefaultKeyedRateLimiter, + account: DefaultKeyedRateLimiter, + clock: DefaultClock, +} + +pub struct RateLimitRejection { + retry_after_secs: u64, +} + +impl IntoResponse for RateLimitRejection { + fn into_response(self) -> Response { + ( + StatusCode::TOO_MANY_REQUESTS, + [("retry-after", self.retry_after_secs.to_string())], + ) + .into_response() + } +} + +impl RateLimiters { + pub fn new(register_per_minute: NonZeroU32, account_per_minute: NonZeroU32) -> Self { + Self { + register_by_ip: DefaultKeyedRateLimiter::keyed(Quota::per_minute(register_per_minute)), + account: DefaultKeyedRateLimiter::keyed(Quota::per_minute(account_per_minute)), + clock: DefaultClock::default(), + } + } + + pub fn check_register(&self, ip: IpAddr) -> Result<(), RateLimitRejection> { + self.check(&self.register_by_ip, ip) + } + + pub fn check_account(&self, account_id: Uuid) -> Result<(), RateLimitRejection> { + self.check(&self.account, account_id) + } + + fn check( + &self, + limiter: &DefaultKeyedRateLimiter, + key: K, + ) -> Result<(), RateLimitRejection> { + limiter + .check_key(&key) + .map_err(|not_until| RateLimitRejection { + // Arredonda pra cima e nunca devolve 0 — um cliente que respeita o header não deve + // martelar de novo no mesmo instante. + retry_after_secs: not_until + .wait_time_from(self.clock.now()) + .as_secs_f64() + .ceil() + .max(1.0) as u64, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::{IpAddr, Ipv4Addr}; + + fn one() -> NonZeroU32 { + NonZeroU32::new(1).unwrap() + } + + #[test] + fn register_allows_up_to_quota_then_429_with_retry_after() { + let limiters = RateLimiters::new(one(), one()); + let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)); + + assert!( + limiters.check_register(ip).is_ok(), + "1ª request deve passar" + ); + + let rejection = limiters.check_register(ip).unwrap_err(); + assert!(rejection.retry_after_secs >= 1); + } + + #[test] + fn register_limit_is_per_ip() { + let limiters = RateLimiters::new(one(), one()); + let ip_a = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)); + let ip_b = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)); + + assert!(limiters.check_register(ip_a).is_ok()); + assert!( + limiters.check_register(ip_a).is_err(), + "ip_a estourou a quota" + ); + assert!( + limiters.check_register(ip_b).is_ok(), + "ip_b não deveria ser afetado por ip_a" + ); + } + + #[test] + fn account_limit_is_independent_from_register_limit() { + let limiters = RateLimiters::new(one(), one()); + let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)); + let account_id = Uuid::new_v4(); + + assert!(limiters.check_register(ip).is_ok()); + assert!(limiters.check_register(ip).is_err()); + // A quota de register estourada não deveria afetar a quota (separada) por conta. + assert!(limiters.check_account(account_id).is_ok()); + } + + #[test] + fn account_limit_is_per_account() { + let limiters = RateLimiters::new(one(), one()); + let account_a = Uuid::new_v4(); + let account_b = Uuid::new_v4(); + + assert!(limiters.check_account(account_a).is_ok()); + assert!(limiters.check_account(account_a).is_err()); + assert!(limiters.check_account(account_b).is_ok()); + } +} diff --git a/api/src/providers.rs b/api/src/providers.rs new file mode 100644 index 00000000..05fc720e --- /dev/null +++ b/api/src/providers.rs @@ -0,0 +1,179 @@ +//! Provedores de transcrição (design §Proxy): "lista ordenada `[{name, url, authHeaderName, +//! authValueEnv, model}]`" — a chave (segredo real) mora em OUTRA env var, referenciada por +//! nome, nunca inline no JSON da lista (OPS-03, nenhum secret no repo/config direta). + +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProviderConfigRaw { + name: String, + url: String, + auth_header_name: String, + auth_value_env: String, + model: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedProvider { + pub name: String, + pub url: String, + pub auth_header_name: String, + pub auth_value: String, + pub model: String, +} + +/// Circuit breaker simples entre requests (design TRX-05, decisão registrada em +/// `.specs/features/keyflow-api/tasks.md` API-T11): dado que o corpo da request é +/// verdadeiramente streamado (TRX-04, sem buffer), **não é possível reenviar o mesmo corpo pra um +/// 2º provedor dentro da mesma request** sem materializá-lo — contradiria "nunca bufferizar". +/// Em vez de fallback intra-request, um provedor que falhar (conexão/5xx) fica marcado indisponível +/// por um cooldown; a PRÓXIMA request pula direto pro próximo da lista. Efeito prático equivalente +/// ao fallback do design em regime permanente (todo request após a 1ª falha já usa o saudável), +/// só a request que descobriu a falha não tem retry — ela erra e o cliente decide reenviar. +pub struct ProviderHealth { + unhealthy_until: Vec>>, +} + +impl ProviderHealth { + pub fn new(provider_count: usize) -> Self { + Self { + unhealthy_until: (0..provider_count).map(|_| Mutex::new(None)).collect(), + } + } + + pub fn is_healthy(&self, index: usize) -> bool { + match self.unhealthy_until[index].lock().unwrap().as_ref() { + Some(until) => Instant::now() >= *until, + None => true, + } + } + + pub fn mark_unhealthy(&self, index: usize, cooldown: Duration) { + *self.unhealthy_until[index].lock().unwrap() = Some(Instant::now() + cooldown); + } + + /// Primeiro provedor saudável na ordem da config; se todos estiverem em cooldown, tenta o + /// primeiro mesmo assim (melhor uma tentativa fadada a falhar rápido do que recusar de cara). + pub fn pick(&self) -> usize { + (0..self.unhealthy_until.len()) + .find(|&i| self.is_healthy(i)) + .unwrap_or(0) + } +} + +pub fn parse_and_resolve( + json: &str, + vars: &std::collections::HashMap, +) -> Result, String> { + let raw: Vec = + serde_json::from_str(json).map_err(|e| format!("invalid JSON: {e}"))?; + + if raw.is_empty() { + return Err("must list at least one provider".to_string()); + } + + raw.into_iter() + .map(|p| { + let auth_value = vars + .get(&p.auth_value_env) + .cloned() + .ok_or_else(|| format!("authValueEnv {} is not set", p.auth_value_env))?; + Ok(ResolvedProvider { + name: p.name, + url: p.url, + auth_header_name: p.auth_header_name, + auth_value, + model: p.model, + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn vars(pairs: &[(&str, &str)]) -> std::collections::HashMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + } + + #[test] + fn parses_and_resolves_auth_value_from_env() { + let json = r#"[{"name":"primary","url":"https://p1.example/transcribe","authHeaderName":"Authorization","authValueEnv":"P1_KEY","model":"whisper-1"}]"#; + let providers = parse_and_resolve(json, &vars(&[("P1_KEY", "secret-123")])).unwrap(); + + assert_eq!(providers.len(), 1); + assert_eq!(providers[0].name, "primary"); + assert_eq!(providers[0].auth_value, "secret-123"); + } + + #[test] + fn missing_auth_value_env_fails_with_specific_message() { + let json = r#"[{"name":"primary","url":"https://p1.example","authHeaderName":"Authorization","authValueEnv":"P1_KEY","model":"whisper-1"}]"#; + let err = parse_and_resolve(json, &vars(&[])).unwrap_err(); + + assert!(err.contains("P1_KEY")); + } + + #[test] + fn empty_list_is_rejected() { + let err = parse_and_resolve("[]", &vars(&[])).unwrap_err(); + assert!(err.contains("at least one")); + } + + #[test] + fn preserves_order() { + let json = r#"[ + {"name":"primary","url":"https://p1.example","authHeaderName":"Authorization","authValueEnv":"P1_KEY","model":"m1"}, + {"name":"fallback","url":"https://p2.example","authHeaderName":"Authorization","authValueEnv":"P2_KEY","model":"m2"} + ]"#; + let providers = + parse_and_resolve(json, &vars(&[("P1_KEY", "a"), ("P2_KEY", "b")])).unwrap(); + + assert_eq!(providers[0].name, "primary"); + assert_eq!(providers[1].name, "fallback"); + } + + #[test] + fn provider_health_starts_all_healthy_and_picks_index_0() { + let health = ProviderHealth::new(2); + assert!(health.is_healthy(0)); + assert!(health.is_healthy(1)); + assert_eq!(health.pick(), 0); + } + + #[test] + fn marking_unhealthy_makes_pick_skip_to_next() { + let health = ProviderHealth::new(2); + health.mark_unhealthy(0, Duration::from_secs(60)); + + assert!(!health.is_healthy(0)); + assert!(health.is_healthy(1)); + assert_eq!(health.pick(), 1); + } + + #[test] + fn all_unhealthy_still_picks_the_first() { + let health = ProviderHealth::new(2); + health.mark_unhealthy(0, Duration::from_secs(60)); + health.mark_unhealthy(1, Duration::from_secs(60)); + + assert_eq!(health.pick(), 0); + } + + #[test] + fn cooldown_expires_and_provider_becomes_healthy_again() { + let health = ProviderHealth::new(1); + health.mark_unhealthy(0, Duration::from_millis(1)); + std::thread::sleep(Duration::from_millis(20)); + + assert!(health.is_healthy(0)); + } +} diff --git a/api/src/routes/account.rs b/api/src/routes/account.rs new file mode 100644 index 00000000..875c0957 --- /dev/null +++ b/api/src/routes/account.rs @@ -0,0 +1,49 @@ +use axum::Json; +use axum::extract::State; +use axum::response::{IntoResponse, Response}; +use serde::Serialize; + +use crate::AppState; +use crate::db::accounts::Account; +use crate::middleware::ratelimit::RateLimitRejection; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountResponse { + balance_seconds: i64, + ads_off: bool, + skins_unlocked: bool, +} + +pub enum AccountError { + RateLimited(RateLimitRejection), +} + +impl From for AccountError { + fn from(rejection: RateLimitRejection) -> Self { + AccountError::RateLimited(rejection) + } +} + +impl IntoResponse for AccountError { + fn into_response(self) -> Response { + match self { + AccountError::RateLimited(rejection) => rejection.into_response(), + } + } +} + +/// `Account` (extractor) já resolveu o Bearer antes deste handler rodar — 401 sem/inválido +/// acontece antes de chegar aqui (design ID-01..03). +pub async fn get_account( + State(state): State, + account: Account, +) -> Result, AccountError> { + state.rate_limiters.check_account(account.id)?; + + Ok(Json(AccountResponse { + balance_seconds: account.balance_seconds, + ads_off: account.ads_off, + skins_unlocked: account.skins_unlocked, + })) +} diff --git a/api/src/routes/content.rs b/api/src/routes/content.rs new file mode 100644 index 00000000..563e4acb --- /dev/null +++ b/api/src/routes/content.rs @@ -0,0 +1,123 @@ +use axum::extract::State; +use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use sha2::{Digest, Sha256}; + +use crate::AppState; + +const CACHE_CONTROL: &str = "max-age=300"; + +pub async fn get_config(state: State, headers: HeaderMap) -> Response { + serve_document(state, headers, "config").await +} + +pub async fn get_models(state: State, headers: HeaderMap) -> Response { + serve_document(state, headers, "models").await +} + +async fn serve_document(State(state): State, headers: HeaderMap, key: &str) -> Response { + let body: Option<(serde_json::Value,)> = + match sqlx::query_as("select body from documents where key = $1") + .bind(key) + .fetch_optional(&state.pool) + .await + { + Ok(row) => row, + Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }; + + let Some((body,)) = body else { + return StatusCode::NOT_FOUND.into_response(); + }; + + // Serializado uma única vez: os mesmos bytes viram tanto o corpo quanto a base do ETag — + // garante que o header sempre corresponde ao que foi de fato enviado. + let bytes = match serde_json::to_vec(&body) { + Ok(bytes) => bytes, + Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(), + }; + let etag = etag_for(&bytes); + + if if_none_match_matches(&headers, &etag) { + let mut response = StatusCode::NOT_MODIFIED.into_response(); + set_cache_headers(response.headers_mut(), &etag); + return response; + } + + let mut response = (StatusCode::OK, bytes).into_response(); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + set_cache_headers(response.headers_mut(), &etag); + response +} + +fn etag_for(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect(); + format!("\"{hex}\"") +} + +fn set_cache_headers(headers: &mut HeaderMap, etag: &str) { + if let Ok(value) = HeaderValue::from_str(etag) { + headers.insert(header::ETAG, value); + } + headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_static(CACHE_CONTROL), + ); +} + +fn if_none_match_matches(headers: &HeaderMap, etag: &str) -> bool { + let Some(value) = headers + .get(header::IF_NONE_MATCH) + .and_then(|v| v.to_str().ok()) + else { + return false; + }; + value + .split(',') + .map(str::trim) + .any(|candidate| candidate == "*" || candidate == etag) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn etag_is_stable_sha256_hex_wrapped_in_quotes() { + let etag = etag_for(b"hello"); + // SHA-256("hello") — valor bem conhecido, não fabricado. + assert_eq!( + etag, + "\"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\"" + ); + } + + #[test] + fn etag_changes_with_content() { + assert_ne!(etag_for(b"a"), etag_for(b"b")); + } + + #[test] + fn if_none_match_matches_exact_etag() { + let mut headers = HeaderMap::new(); + headers.insert(header::IF_NONE_MATCH, HeaderValue::from_static("\"abc\"")); + assert!(if_none_match_matches(&headers, "\"abc\"")); + assert!(!if_none_match_matches(&headers, "\"xyz\"")); + } + + #[test] + fn if_none_match_wildcard_always_matches() { + let mut headers = HeaderMap::new(); + headers.insert(header::IF_NONE_MATCH, HeaderValue::from_static("*")); + assert!(if_none_match_matches(&headers, "\"anything\"")); + } + + #[test] + fn missing_if_none_match_never_matches() { + assert!(!if_none_match_matches(&HeaderMap::new(), "\"abc\"")); + } +} diff --git a/api/src/routes/mod.rs b/api/src/routes/mod.rs new file mode 100644 index 00000000..2a3436b2 --- /dev/null +++ b/api/src/routes/mod.rs @@ -0,0 +1,6 @@ +pub mod account; +pub mod content; +pub mod redeem; +pub mod register; +pub mod ssv; +pub mod transcribe; diff --git a/api/src/routes/redeem.rs b/api/src/routes/redeem.rs new file mode 100644 index 00000000..c74f6cf1 --- /dev/null +++ b/api/src/routes/redeem.rs @@ -0,0 +1,174 @@ +use axum::Json; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::{Deserialize, Serialize}; + +use crate::AppState; +use crate::auth::device_key_account_id; +use crate::catalog; +use crate::db::accounts::Account; +use crate::db::ledger::{LedgerReason, credit}; +use crate::google::integrity::verify_verdicts; +use crate::google::publisher::{PURCHASE_STATE_PENDING, PURCHASE_STATE_PURCHASED}; +use crate::middleware::ratelimit::RateLimitRejection; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RedeemRequest { + product_id: String, + purchase_token: String, + integrity_token: String, +} + +#[derive(Debug, Serialize)] +#[serde(tag = "status", rename_all = "camelCase")] +pub enum RedeemResponse { + #[serde(rename = "pending")] + Pending, + #[serde(rename = "credited")] + Credited, +} + +pub enum RedeemError { + RateLimited(RateLimitRejection), + Integrity, + UnknownProduct, + AccountMismatch, + PurchaseInvalid, + Internal, +} + +impl From for RedeemError { + fn from(rejection: RateLimitRejection) -> Self { + RedeemError::RateLimited(rejection) + } +} + +impl IntoResponse for RedeemError { + fn into_response(self) -> Response { + match self { + RedeemError::RateLimited(rejection) => rejection.into_response(), + RedeemError::Integrity => ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ "error": "integrity" })), + ) + .into_response(), + RedeemError::UnknownProduct => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "unknown_product" })), + ) + .into_response(), + RedeemError::AccountMismatch => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "account_mismatch" })), + ) + .into_response(), + RedeemError::PurchaseInvalid => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "purchase_invalid" })), + ) + .into_response(), + RedeemError::Internal => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } + } +} + +pub async fn redeem( + State(state): State, + account: Account, + Json(body): Json, +) -> Result, RedeemError> { + state.rate_limiters.check_account(account.id)?; + + let product = catalog::find_product(&body.product_id).ok_or(RedeemError::UnknownProduct)?; + + let integrity_payload = state + .integrity + .decode(&body.integrity_token) + .await + .map_err(|_| RedeemError::Integrity)?; + verify_verdicts( + &integrity_payload, + &state.package_name, + state.integrity_relaxed, + ) + .map_err(|_| RedeemError::Integrity)?; + + let purchase = state + .publisher + .get_product_purchase(&body.product_id, &body.purchase_token) + .await + .map_err(|_| RedeemError::PurchaseInvalid)?; + + let expected_account_id = device_key_account_id(&account.device_key); + if purchase.obfuscated_external_account_id.as_deref() != Some(expected_account_id.as_str()) { + return Err(RedeemError::AccountMismatch); + } + + if purchase.purchase_state == PURCHASE_STATE_PENDING { + return Ok(Json(RedeemResponse::Pending)); + } + if purchase.purchase_state != PURCHASE_STATE_PURCHASED { + return Err(RedeemError::PurchaseInvalid); + } + + let token_hash = crate::auth::hash_token(body.purchase_token.as_bytes()); + + let mut tx = state + .pool + .begin() + .await + .map_err(|_| RedeemError::Internal)?; + + let inserted: Option<(bool,)> = sqlx::query_as( + "insert into purchases \ + (token_hash, account_id, product_id, status, credited_seconds, granted_entitlements) \ + values ($1, $2, $3, 'redeemed', $4, $5) \ + on conflict (token_hash) do nothing \ + returning granted_entitlements", + ) + .bind(&token_hash) + .bind(account.id) + .bind(&body.product_id) + .bind(product.credited_seconds) + .bind(product.grants_entitlements) + .fetch_optional(&mut *tx) + .await + .map_err(|_| RedeemError::Internal)?; + + let Some((granted_entitlements,)) = inserted else { + // Replay: já processado antes (idempotência PAY-04) — nenhum crédito novo. + tx.commit().await.map_err(|_| RedeemError::Internal)?; + return Ok(Json(RedeemResponse::Credited)); + }; + + credit( + &mut tx, + account.id, + product.credited_seconds, + LedgerReason::Purchase, + Some(&body.product_id), + ) + .await + .map_err(|_| RedeemError::Internal)?; + + if granted_entitlements { + sqlx::query("update accounts set ads_off = true, skins_unlocked = true where id = $1") + .bind(account.id) + .execute(&mut *tx) + .await + .map_err(|_| RedeemError::Internal)?; + } + + tx.commit().await.map_err(|_| RedeemError::Internal)?; + + // Acknowledge server-side só na primeira vez (design §Compras) — falha aqui não desfaz o + // crédito já commitado; o job de voided purchases (T9) reconcilia se o Google discordar. + let _ = state + .publisher + .acknowledge_product_purchase(&body.product_id, &body.purchase_token) + .await; + + Ok(Json(RedeemResponse::Credited)) +} diff --git a/api/src/routes/register.rs b/api/src/routes/register.rs new file mode 100644 index 00000000..c133ab81 --- /dev/null +++ b/api/src/routes/register.rs @@ -0,0 +1,90 @@ +use std::net::SocketAddr; + +use axum::Json; +use axum::extract::{ConnectInfo, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::{Deserialize, Serialize}; + +use crate::AppState; +use crate::auth::generate_token; +use crate::db::accounts; +use crate::google::integrity::verify_verdicts; +use crate::middleware::ratelimit::RateLimitRejection; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RegisterRequest { + device_key: String, + integrity_token: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RegisterResponse { + access_token: String, +} + +pub enum RegisterError { + RateLimited(RateLimitRejection), + Integrity, + Internal, +} + +impl From for RegisterError { + fn from(rejection: RateLimitRejection) -> Self { + RegisterError::RateLimited(rejection) + } +} + +impl IntoResponse for RegisterError { + fn into_response(self) -> Response { + match self { + RegisterError::RateLimited(rejection) => rejection.into_response(), + RegisterError::Integrity => ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ "error": "integrity" })), + ) + .into_response(), + RegisterError::Internal => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } + } +} + +pub async fn register( + State(state): State, + ConnectInfo(addr): ConnectInfo, + Json(body): Json, +) -> Result, RegisterError> { + state.rate_limiters.check_register(addr.ip())?; + + let payload = state + .integrity + .decode(&body.integrity_token) + .await + .map_err(|_| RegisterError::Integrity)?; + + verify_verdicts(&payload, &state.package_name, state.integrity_relaxed) + .map_err(|_| RegisterError::Integrity)?; + + let (access_token, token_hash) = generate_token(); + + let existing = accounts::find_by_device_key(&state.pool, &body.device_key) + .await + .map_err(|_| RegisterError::Internal)?; + + match existing { + Some(account) => { + accounts::rotate_token(&state.pool, account.id, &token_hash) + .await + .map_err(|_| RegisterError::Internal)?; + } + None => { + accounts::create(&state.pool, &body.device_key, &token_hash) + .await + .map_err(|_| RegisterError::Internal)?; + } + } + + Ok(Json(RegisterResponse { access_token })) +} diff --git a/api/src/routes/ssv.rs b/api/src/routes/ssv.rs new file mode 100644 index 00000000..98c084ae --- /dev/null +++ b/api/src/routes/ssv.rs @@ -0,0 +1,131 @@ +use axum::extract::{RawQuery, State}; +use axum::http::StatusCode; +use serde::Deserialize; +use uuid::Uuid; + +use crate::AppState; +use crate::db::ledger::{LedgerReason, credit}; +use crate::google::admob::{self, SsvVerifyError}; + +#[derive(Debug, Deserialize)] +struct SsvContentParams { + custom_data: Option, + transaction_id: String, +} + +/// `GET /v1/ssv/admob` — callback de rewarded SSV do AdMob (design §Rewarded, ADS-01). Sempre +/// `200` pra Google não reenviar (ela reenvia até 5x se não vir 200), **exceto** assinatura +/// inválida (`403`, conforme a task) — nesse caso não é um evento legítimo do AdMob mesmo. +pub async fn ssv_admob(State(state): State, RawQuery(raw_query): RawQuery) -> StatusCode { + let Some(raw_query) = raw_query else { + return StatusCode::FORBIDDEN; + }; + + let content = match verify_with_refresh(&state, &raw_query).await { + Ok(content) => content, + Err(e) => { + tracing::warn!(error = %e, "SSV signature verification failed"); + return StatusCode::FORBIDDEN; + } + }; + + let params: SsvContentParams = match serde_urlencoded::from_str(content) { + Ok(params) => params, + Err(e) => { + tracing::warn!(error = %e, "SSV callback missing required params after verification"); + return StatusCode::FORBIDDEN; + } + }; + + let parsed_account_id = params + .custom_data + .as_deref() + .and_then(|s| Uuid::parse_str(s).ok()); + + let account_id = match parsed_account_id { + Some(id) => match crate::db::accounts::exists(&state.pool, id).await { + Ok(true) => Some(id), + Ok(false) => None, + Err(e) => { + tracing::error!(error = %e, "failed to check account existence for SSV callback"); + return StatusCode::INTERNAL_SERVER_ERROR; + } + }, + None => None, + }; + + let Some(account_id) = account_id else { + // "Não vazar existência": mesma resposta (200, sem crédito) tanto pra custom_data + // ausente/malformado quanto pra um UUID válido que não é conta de ninguém. + tracing::info!(custom_data = ?params.custom_data, "SSV callback: unknown/invalid account, no credit"); + return StatusCode::OK; + }; + + if let Err(e) = credit_reward(&state, account_id, ¶ms.transaction_id).await { + tracing::error!(error = %e, "failed to credit SSV reward"); + return StatusCode::INTERNAL_SERVER_ERROR; + } + + StatusCode::OK +} + +async fn verify_with_refresh<'a>( + state: &AppState, + raw_query: &'a str, +) -> Result<&'a str, SsvVerifyError> { + let snapshot = state.admob_keys.snapshot(); + match admob::verify(raw_query, &snapshot) { + Err(SsvVerifyError::UnknownKeyId(_)) => { + let _ = state.admob_keys.refresh().await; + let snapshot = state.admob_keys.snapshot(); + admob::verify(raw_query, &snapshot) + } + other => other, + } +} + +async fn credit_reward( + state: &AppState, + account_id: Uuid, + transaction_id: &str, +) -> Result<(), sqlx::Error> { + let mut tx = state.pool.begin().await?; + + let today_count: i64 = sqlx::query_scalar( + "select count(*) from rewards where account_id = $1 and created_at >= date_trunc('day', now())", + ) + .bind(account_id) + .fetch_one(&mut *tx) + .await?; + + if today_count >= state.ads_daily_cap { + tracing::info!(%account_id, today_count, "SSV reward blocked by daily cap"); + tx.commit().await?; + return Ok(()); + } + + let inserted: Option<(String,)> = sqlx::query_as( + "insert into rewards (transaction_id, account_id, seconds) values ($1, $2, $3) \ + on conflict (transaction_id) do nothing \ + returning transaction_id", + ) + .bind(transaction_id) + .bind(account_id) + .bind(state.ads_reward_seconds) + .fetch_optional(&mut *tx) + .await?; + + if inserted.is_some() { + credit( + &mut tx, + account_id, + state.ads_reward_seconds, + LedgerReason::Reward, + Some(transaction_id), + ) + .await?; + } + + tx.commit().await?; + Ok(()) +} diff --git a/api/src/routes/transcribe.rs b/api/src/routes/transcribe.rs new file mode 100644 index 00000000..8cc795fa --- /dev/null +++ b/api/src/routes/transcribe.rs @@ -0,0 +1,250 @@ +use std::error::Error as StdError; +use std::fmt; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use axum::body::Body; +use axum::extract::State; +use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use bytes::Bytes; +use futures_util::StreamExt; +use serde::Serialize; + +use crate::AppState; +use crate::db::accounts::Account; +use crate::db::ledger::{LedgerReason, credit}; +use crate::middleware::ratelimit::RateLimitRejection; + +/// `AudioRecorder` do app grava AAC CBR 64 kbps → `segundos = ceil(bytes / 8000)` (design +/// §Proxy). Autoritativo: o header opcional `X-Audio-Seconds` do app é só cross-check/telemetria. +const BYTES_PER_SECOND: u64 = 8000; + +/// Erro synthetic yielded pelo stream contador quando o corpo passa do cap — nunca chega a sair +/// do processo; existe só pra reconhecermos essa causa específica na cadeia de `source()` do +/// `reqwest::Error` (distingue 413 de erro genérico de conexão/provedor). +#[derive(Debug)] +struct StreamCapExceeded; + +impl fmt::Display for StreamCapExceeded { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "request body exceeded the configured cap") + } +} + +impl StdError for StreamCapExceeded {} + +pub enum TranscribeError { + RateLimited(RateLimitRejection), + MissingRequestId, + InsufficientBalance { balance_seconds: i64 }, + PayloadTooLarge { max_bytes: u64 }, + UpstreamTimeout, + UpstreamError, + Internal, +} + +impl From for TranscribeError { + fn from(rejection: RateLimitRejection) -> Self { + TranscribeError::RateLimited(rejection) + } +} + +#[derive(Serialize)] +struct BalanceBody { + #[serde(rename = "balanceSeconds")] + balance_seconds: i64, +} + +impl IntoResponse for TranscribeError { + fn into_response(self) -> Response { + match self { + TranscribeError::RateLimited(rejection) => rejection.into_response(), + TranscribeError::MissingRequestId => ( + StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({ "error": "missing_request_id" })), + ) + .into_response(), + TranscribeError::InsufficientBalance { balance_seconds } => ( + StatusCode::PAYMENT_REQUIRED, + axum::Json(BalanceBody { balance_seconds }), + ) + .into_response(), + TranscribeError::PayloadTooLarge { max_bytes } => ( + StatusCode::PAYLOAD_TOO_LARGE, + axum::Json( + serde_json::json!({ "error": "payload_too_large", "maxBytes": max_bytes }), + ), + ) + .into_response(), + TranscribeError::UpstreamTimeout => StatusCode::GATEWAY_TIMEOUT.into_response(), + TranscribeError::UpstreamError => StatusCode::BAD_GATEWAY.into_response(), + TranscribeError::Internal => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } + } +} + +fn is_cap_exceeded(err: &reqwest::Error) -> bool { + let mut source: Option<&(dyn StdError + 'static)> = StdError::source(err); + while let Some(e) = source { + if e.downcast_ref::().is_some() { + return true; + } + source = e.source(); + } + false +} + +pub async fn transcribe( + State(state): State, + account: Account, + headers: HeaderMap, + body: Body, +) -> Result { + state.rate_limiters.check_account(account.id)?; + + let request_id = headers + .get("x-request-id") + .and_then(|v| v.to_str().ok()) + .map(str::to_string) + .ok_or(TranscribeError::MissingRequestId)?; + + let already_known: bool = sqlx::query_scalar( + "select exists(select 1 from transcribe_requests where account_id = $1 and request_id = $2)", + ) + .bind(account.id) + .bind(&request_id) + .fetch_one(&state.pool) + .await + .map_err(|_| TranscribeError::Internal)?; + + if !already_known && account.balance_seconds <= 0 { + return Err(TranscribeError::InsufficientBalance { + balance_seconds: account.balance_seconds, + }); + } + + let content_type = headers.get(header::CONTENT_TYPE).cloned(); + + let counted = Arc::new(AtomicU64::new(0)); + let counted_for_stream = counted.clone(); + let max_bytes = state.transcription_max_bytes; + let data_stream = body.into_data_stream().map(move |chunk| { + let chunk = chunk.map_err(|e| Box::new(e) as Box)?; + let total = counted_for_stream.fetch_add(chunk.len() as u64, Ordering::Relaxed) + + chunk.len() as u64; + if total > max_bytes { + return Err(Box::new(StreamCapExceeded) as Box); + } + Ok::>(chunk) + }); + let reqwest_body = reqwest::Body::wrap_stream(data_stream); + + let provider_index = state.provider_health.pick(); + let provider = &state.providers[provider_index]; + + let mut request = state + .http + .post(&provider.url) + .header( + provider.auth_header_name.as_str(), + provider.auth_value.as_str(), + ) + .timeout(state.transcription_timeout) + .body(reqwest_body); + if let Some(content_type) = content_type { + request = request.header(header::CONTENT_TYPE, content_type); + } + + let response = match request.send().await { + Ok(response) => response, + Err(e) if is_cap_exceeded(&e) => { + return Err(TranscribeError::PayloadTooLarge { max_bytes }); + } + Err(e) if e.is_timeout() => return Err(TranscribeError::UpstreamTimeout), + Err(_) => { + state + .provider_health + .mark_unhealthy(provider_index, state.provider_unhealthy_cooldown); + return Err(TranscribeError::UpstreamError); + } + }; + + if response.status().is_server_error() { + state + .provider_health + .mark_unhealthy(provider_index, state.provider_unhealthy_cooldown); + return Err(TranscribeError::UpstreamError); + } + if !response.status().is_success() { + return Err(TranscribeError::UpstreamError); + } + + let provider_body = response + .bytes() + .await + .map_err(|_| TranscribeError::UpstreamError)?; + let total_bytes = counted.load(Ordering::Relaxed); + let seconds = total_bytes.div_ceil(BYTES_PER_SECOND) as i64; + + let mut tx = state + .pool + .begin() + .await + .map_err(|_| TranscribeError::Internal)?; + let inserted: Option<(i32,)> = sqlx::query_as( + "insert into transcribe_requests (account_id, request_id, seconds) values ($1, $2, $3) \ + on conflict (account_id, request_id) do nothing \ + returning 1", + ) + .bind(account.id) + .bind(&request_id) + .bind(seconds) + .fetch_optional(&mut *tx) + .await + .map_err(|_| TranscribeError::Internal)?; + + // Idempotência (edge de retry, design §Proxy): já processado antes → re-executa upstream (não + // persistimos texto, ID-03) mas não debita de novo. + if inserted.is_some() { + credit( + &mut tx, + account.id, + -seconds, + LedgerReason::Transcription, + Some(&request_id), + ) + .await + .map_err(|_| TranscribeError::Internal)?; + } + tx.commit().await.map_err(|_| TranscribeError::Internal)?; + + let fresh_balance: (i64,) = + sqlx::query_as("select balance_seconds from accounts where id = $1") + .bind(account.id) + .fetch_one(&state.pool) + .await + .map_err(|_| TranscribeError::Internal)?; + + let mut response = (StatusCode::OK, provider_body).into_response(); + response.headers_mut().insert( + "x-keyflow-balance-seconds", + HeaderValue::from_str(&fresh_balance.0.to_string()) + .map_err(|_| TranscribeError::Internal)?, + ); + Ok(response) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bytes_per_second_rounds_up() { + assert_eq!(0u64.div_ceil(BYTES_PER_SECOND), 0); + assert_eq!(1u64.div_ceil(BYTES_PER_SECOND), 1); + assert_eq!(8000u64.div_ceil(BYTES_PER_SECOND), 1); + assert_eq!(8001u64.div_ceil(BYTES_PER_SECOND), 2); + assert_eq!(80_000u64.div_ceil(BYTES_PER_SECOND), 10); + } +} diff --git a/api/tests/account.rs b/api/tests/account.rs new file mode 100644 index 00000000..2b1272a9 --- /dev/null +++ b/api/tests/account.rs @@ -0,0 +1,131 @@ +mod common; + +use axum::body::Body; +use axum::http::{Request, StatusCode, header}; +use common::{test_pool, unused_ads_config, unused_transcription_config}; +use keyflow_api::app; +use keyflow_api::google::auth::ServiceAccountKey; +use keyflow_api::google::integrity::IntegrityClient; +use keyflow_api::google::publisher::PublisherClient; +use keyflow_api::{AppState, auth::generate_token, db::accounts}; +use serde_json::Value; +use tower::ServiceExt; + +const PACKAGE_NAME: &str = "com.keyflow"; + +async fn test_state() -> AppState { + let pool = test_pool().await; + // Nenhum teste deste arquivo bate no Google (só GET /v1/account, autenticado por Bearer já + // emitido direto no banco). + let unused_key = ServiceAccountKey { + client_email: "unused@example.com".to_string(), + private_key: String::new(), + token_uri: "http://127.0.0.1:0/token".to_string(), + }; + let integrity = IntegrityClient::new( + reqwest::Client::new(), + "http://127.0.0.1:0".to_string(), + PACKAGE_NAME.to_string(), + unused_key.clone(), + ); + let publisher = PublisherClient::new( + reqwest::Client::new(), + "http://127.0.0.1:0".to_string(), + PACKAGE_NAME.to_string(), + unused_key, + ); + AppState::new( + pool, + integrity, + publisher, + reqwest::Client::new(), + PACKAGE_NAME.to_string(), + false, + std::num::NonZeroU32::new(1000).unwrap(), + std::num::NonZeroU32::new(1000).unwrap(), + unused_transcription_config(), + unused_ads_config(), + ) +} + +async fn get_account(state: AppState, bearer: Option<&str>) -> (StatusCode, Value) { + let mut builder = Request::builder().uri("/v1/account"); + if let Some(token) = bearer { + builder = builder.header(header::AUTHORIZATION, format!("Bearer {token}")); + } + let response = app(state) + .oneshot(builder.body(Body::empty()).unwrap()) + .await + .unwrap(); + + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let body: Value = if bytes.is_empty() { + Value::Null + } else { + serde_json::from_slice(&bytes).unwrap() + }; + (status, body) +} + +#[tokio::test] +async fn new_account_has_zero_balance_and_no_entitlements() { + let state = test_state().await; + let device_key = format!("device-{}", uuid::Uuid::new_v4()); + let (access_token, token_hash) = generate_token(); + accounts::create(&state.pool, &device_key, &token_hash) + .await + .unwrap(); + + let (status, body) = get_account(state, Some(&access_token)).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["balanceSeconds"], 0); + assert_eq!(body["adsOff"], false); + assert_eq!(body["skinsUnlocked"], false); +} + +#[tokio::test] +async fn account_after_ledger_and_entitlements_seed_reflects_real_values() { + let state = test_state().await; + let device_key = format!("device-{}", uuid::Uuid::new_v4()); + let (access_token, token_hash) = generate_token(); + let account = accounts::create(&state.pool, &device_key, &token_hash) + .await + .unwrap(); + + // Seed direto via SQL: crédito de compra + entitlements ligados (equivalente ao que a T8 vai + // automatizar; aqui só provamos que GET /v1/account lê o estado real da conta). + sqlx::query("update accounts set balance_seconds = 1200, ads_off = true, skins_unlocked = true where id = $1") + .bind(account.id) + .execute(&state.pool) + .await + .unwrap(); + + let (status, body) = get_account(state, Some(&access_token)).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["balanceSeconds"], 1200); + assert_eq!(body["adsOff"], true); + assert_eq!(body["skinsUnlocked"], true); +} + +#[tokio::test] +async fn missing_bearer_returns_401() { + let state = test_state().await; + + let (status, _) = get_account(state, None).await; + + assert_eq!(status, StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn invalid_bearer_returns_401() { + let state = test_state().await; + + let (status, _) = get_account(state, Some("not-a-real-token")).await; + + assert_eq!(status, StatusCode::UNAUTHORIZED); +} diff --git a/api/tests/common/mod.rs b/api/tests/common/mod.rs new file mode 100644 index 00000000..bf06eae9 --- /dev/null +++ b/api/tests/common/mod.rs @@ -0,0 +1,81 @@ +use keyflow_api::providers::ResolvedProvider; +use keyflow_api::{AdsConfig, TranscriptionConfig}; +use sqlx::PgPool; +use std::time::Duration; + +pub async fn test_pool() -> PgPool { + let database_url = std::env::var("DATABASE_URL").expect( + "DATABASE_URL deve estar setada para os testes de integração (ver api/docker-compose.yml)", + ); + let pool = keyflow_api::db::connect(&database_url) + .await + .expect("falha ao conectar no Postgres de teste"); + keyflow_api::db::MIGRATOR + .run(&pool) + .await + .expect("falha ao rodar migrations"); + pool +} + +/// Cria uma conta de teste com `device_key`/`token_hash` únicos e o saldo inicial dado. +/// `token_hash` não precisa ser um hash real aqui — os testes de ledger não validam auth. +/// `tests/common/mod.rs` é compilado por cada binário de teste em `tests/` separadamente; nem +/// todos usam todos os helpers — sem `allow`, o clippy (`-D warnings`) reclamaria de dead_code +/// num binário específico mesmo com uso real em outro. +#[allow(dead_code)] +pub async fn create_test_account(pool: &PgPool, balance_seconds: i64) -> uuid::Uuid { + let device_key = format!("test-{}", uuid::Uuid::new_v4()); + let token_hash = uuid::Uuid::new_v4().into_bytes().to_vec(); + + let (id,): (uuid::Uuid,) = sqlx::query_as( + "insert into accounts (device_key, token_hash, balance_seconds) values ($1, $2, $3) \ + returning id", + ) + .bind(device_key) + .bind(token_hash) + .bind(balance_seconds) + .fetch_one(pool) + .await + .expect("falha ao criar conta de teste"); + + id +} + +#[allow(dead_code)] +pub async fn balance_of(pool: &PgPool, account_id: uuid::Uuid) -> i64 { + let (balance,): (i64,) = sqlx::query_as("select balance_seconds from accounts where id = $1") + .bind(account_id) + .fetch_one(pool) + .await + .expect("conta de teste deveria existir"); + balance +} + +/// `TranscriptionConfig` de placeholder pra testes que não exercitam `POST /v1/transcribe` (ver +/// `tests/transcribe.rs` para os que exercitam de verdade, com wiremock no lugar do provedor). +#[allow(dead_code)] +pub fn unused_transcription_config() -> TranscriptionConfig { + TranscriptionConfig { + providers: vec![ResolvedProvider { + name: "unused".to_string(), + url: "http://127.0.0.1:0".to_string(), + auth_header_name: "Authorization".to_string(), + auth_value: "unused".to_string(), + model: "unused".to_string(), + }], + timeout: Duration::from_secs(30), + max_bytes: 26_214_400, + unhealthy_cooldown: Duration::from_secs(60), + } +} + +/// `AdsConfig` de placeholder pra testes que não exercitam `GET /v1/ssv/admob` (ver +/// `tests/ssv.rs` para os que exercitam de verdade, com chaves ECDSA reais geradas no teste). +#[allow(dead_code)] +pub fn unused_ads_config() -> AdsConfig { + AdsConfig { + verifier_keys_url: "http://127.0.0.1:0".to_string(), + reward_seconds: 60, + daily_cap: 5, + } +} diff --git a/api/tests/content.rs b/api/tests/content.rs new file mode 100644 index 00000000..1cb6a084 --- /dev/null +++ b/api/tests/content.rs @@ -0,0 +1,146 @@ +mod common; + +use axum::body::Body; +use axum::http::{Request, StatusCode, header}; +use common::{test_pool, unused_ads_config, unused_transcription_config}; +use keyflow_api::AppState; +use keyflow_api::app; +use keyflow_api::google::auth::ServiceAccountKey; +use keyflow_api::google::integrity::IntegrityClient; +use keyflow_api::google::publisher::PublisherClient; +use tower::ServiceExt; + +const PACKAGE_NAME: &str = "com.keyflow"; + +async fn test_state() -> AppState { + let pool = test_pool().await; + let unused_key = ServiceAccountKey { + client_email: "unused@example.com".to_string(), + private_key: String::new(), + token_uri: "http://127.0.0.1:0/token".to_string(), + }; + let integrity = IntegrityClient::new( + reqwest::Client::new(), + "http://127.0.0.1:0".to_string(), + PACKAGE_NAME.to_string(), + unused_key.clone(), + ); + let publisher = PublisherClient::new( + reqwest::Client::new(), + "http://127.0.0.1:0".to_string(), + PACKAGE_NAME.to_string(), + unused_key, + ); + AppState::new( + pool, + integrity, + publisher, + reqwest::Client::new(), + PACKAGE_NAME.to_string(), + false, + std::num::NonZeroU32::new(1000).unwrap(), + std::num::NonZeroU32::new(1000).unwrap(), + unused_transcription_config(), + unused_ads_config(), + ) +} + +async fn get( + state: AppState, + path: &str, + if_none_match: Option<&str>, +) -> axum::http::Response { + let mut builder = Request::builder().uri(path); + if let Some(etag) = if_none_match { + builder = builder.header(header::IF_NONE_MATCH, etag); + } + app(state) + .oneshot(builder.body(Body::empty()).unwrap()) + .await + .unwrap() +} + +#[tokio::test] +async fn config_returns_200_with_etag_and_seed_body() { + let state = test_state().await; + + let response = get(state, "/v1/config", None).await; + + assert_eq!(response.status(), StatusCode::OK); + let etag = response + .headers() + .get(header::ETAG) + .expect("ETag presente") + .clone(); + assert!(etag.to_str().unwrap().starts_with('"')); + assert_eq!( + response.headers().get(header::CACHE_CONTROL).unwrap(), + "max-age=300" + ); + + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(body["minAppVersion"], "1.0.0"); + assert_eq!(body["killSwitch"], false); + assert_eq!(body["products"]["credits_20"]["grantsEntitlements"], true); + assert_eq!(body["products"]["credits_10"]["grantsEntitlements"], false); +} + +#[tokio::test] +async fn models_returns_200_with_seed_body() { + let state = test_state().await; + + let response = get(state, "/v1/models", None).await; + + assert_eq!(response.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert!(body["models"].is_array()); +} + +#[tokio::test] +async fn matching_if_none_match_returns_304() { + let state = test_state().await; + + let first = get(state.clone(), "/v1/config", None).await; + let etag = first + .headers() + .get(header::ETAG) + .unwrap() + .to_str() + .unwrap() + .to_string(); + + let second = get(state, "/v1/config", Some(&etag)).await; + + assert_eq!(second.status(), StatusCode::NOT_MODIFIED); + let bytes = axum::body::to_bytes(second.into_body(), usize::MAX) + .await + .unwrap(); + assert!(bytes.is_empty(), "304 não deve ter corpo"); +} + +#[tokio::test] +async fn stale_if_none_match_returns_200_with_fresh_body() { + let state = test_state().await; + + let response = get(state, "/v1/config", Some("\"stale-etag\"")).await; + + assert_eq!(response.status(), StatusCode::OK); +} + +#[tokio::test] +async fn same_content_produces_same_etag_across_requests() { + let state = test_state().await; + + let a = get(state.clone(), "/v1/config", None).await; + let etag_a = a.headers().get(header::ETAG).unwrap().clone(); + let b = get(state, "/v1/config", None).await; + let etag_b = b.headers().get(header::ETAG).unwrap().clone(); + + assert_eq!(etag_a, etag_b); +} diff --git a/api/tests/db_ledger.rs b/api/tests/db_ledger.rs new file mode 100644 index 00000000..bc1dacda --- /dev/null +++ b/api/tests/db_ledger.rs @@ -0,0 +1,195 @@ +mod common; + +use common::{balance_of, create_test_account, test_pool}; +use keyflow_api::db::ledger::{LedgerReason, credit, debit_guarded, revoke}; + +#[tokio::test] +async fn migrations_apply_from_scratch_and_health_tables_exist() { + let pool = test_pool().await; + + // As 6 tabelas do schema núcleo (design.md §Schema) devem existir após as migrations. + let tables: Vec<(String,)> = sqlx::query_as( + "select table_name from information_schema.tables \ + where table_schema = 'public' order by table_name", + ) + .fetch_all(&pool) + .await + .unwrap(); + let names: Vec = tables.into_iter().map(|(n,)| n).collect(); + + for expected in [ + "accounts", + "documents", + "ledger", + "purchases", + "rewards", + "transcribe_requests", + ] { + assert!( + names.contains(&expected.to_string()), + "faltando tabela {expected}: {names:?}" + ); + } +} + +#[tokio::test] +async fn credit_updates_balance_and_inserts_ledger_row() { + let pool = test_pool().await; + let account_id = create_test_account(&pool, 0).await; + + credit( + &pool, + account_id, + 300, + LedgerReason::Purchase, + Some("credits_10"), + ) + .await + .unwrap(); + + assert_eq!(balance_of(&pool, account_id).await, 300); + + let (delta, reason): (i64, String) = + sqlx::query_as("select delta_seconds, reason from ledger where account_id = $1") + .bind(account_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(delta, 300); + assert_eq!(reason, "purchase"); +} + +#[tokio::test] +async fn debit_guarded_succeeds_when_balance_is_enough() { + let pool = test_pool().await; + let account_id = create_test_account(&pool, 60).await; + + let ok = debit_guarded( + &pool, + account_id, + 45, + LedgerReason::Transcription, + Some("req-1"), + ) + .await + .unwrap(); + + assert!(ok); + assert_eq!(balance_of(&pool, account_id).await, 15); +} + +#[tokio::test] +async fn debit_guarded_fails_without_mutating_when_balance_is_insufficient() { + let pool = test_pool().await; + let account_id = create_test_account(&pool, 10).await; + + let ok = debit_guarded( + &pool, + account_id, + 11, + LedgerReason::Transcription, + Some("req-2"), + ) + .await + .unwrap(); + + assert!(!ok); + assert_eq!(balance_of(&pool, account_id).await, 10); + + let ledger_rows: i64 = sqlx::query_scalar("select count(*) from ledger where account_id = $1") + .bind(account_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + ledger_rows, 0, + "débito recusado não deve deixar linha no ledger" + ); +} + +/// Duas transações concorrentes disputando o último saldo: só uma pode vencer (guarda via +/// `UPDATE ... WHERE balance_seconds >= $delta`, serializada pelo lock de linha do Postgres). +#[tokio::test] +async fn debit_guarded_only_one_of_two_concurrent_debits_wins_the_last_balance() { + let pool = test_pool().await; + let account_id = create_test_account(&pool, 10).await; + + let (r1, r2) = tokio::join!( + debit_guarded( + &pool, + account_id, + 10, + LedgerReason::Transcription, + Some("race-a") + ), + debit_guarded( + &pool, + account_id, + 10, + LedgerReason::Transcription, + Some("race-b") + ), + ); + + let results = [r1.unwrap(), r2.unwrap()]; + let wins = results.iter().filter(|ok| **ok).count(); + assert_eq!( + wins, 1, + "exatamente uma das duas corridas deveria vencer: {results:?}" + ); + assert_eq!(balance_of(&pool, account_id).await, 0); +} + +#[tokio::test] +async fn credit_and_ledger_are_atomic_rollback_leaves_no_partial_state() { + let pool = test_pool().await; + let account_id = create_test_account(&pool, 0).await; + + // Simula a falha "no meio" de uma operação maior: credita dentro de uma tx externa e depois + // força um erro antes do commit — como `credit` já commitou seu próprio savepoint, o teste + // teria saldo parcial se `credit` não fosse de fato atômico com o ledger; aqui validamos que + // rollback da tx EXTERNA desfaz o savepoint interno de `credit` por completo. + let mut tx = pool.begin().await.unwrap(); + credit( + &mut tx, + account_id, + 120, + LedgerReason::Purchase, + Some("credits_20"), + ) + .await + .unwrap(); + tx.rollback().await.unwrap(); + + assert_eq!(balance_of(&pool, account_id).await, 0); + let ledger_rows: i64 = sqlx::query_scalar("select count(*) from ledger where account_id = $1") + .bind(account_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + ledger_rows, 0, + "rollback da tx externa deve desfazer o savepoint de credit()" + ); +} + +#[tokio::test] +async fn revoke_allows_negative_balance() { + let pool = test_pool().await; + let account_id = create_test_account(&pool, 5).await; + + revoke(&pool, account_id, -20, Some("voided-purchase")) + .await + .unwrap(); + + assert_eq!(balance_of(&pool, account_id).await, -15); + + let (delta, reason): (i64, String) = + sqlx::query_as("select delta_seconds, reason from ledger where account_id = $1") + .bind(account_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(delta, -20); + assert_eq!(reason, "revoke"); +} diff --git a/api/tests/fixtures/test_service_account_key.pem b/api/tests/fixtures/test_service_account_key.pem new file mode 100644 index 00000000..f409cdb3 --- /dev/null +++ b/api/tests/fixtures/test_service_account_key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQD6kosxRGbuSBrG +muww5GshKjFU4+l3CRjONFNKQeFDjGoOYqMV7b+fU+VlCJuM6tE7tB9wsWnFi5wt +wXEgguC5y6dR21Ac0AFK7a4tzXZJpeYDTYe6okJQvXuFkXl4TZjH9OTGGnDVa3lP +2/62/+adqWs05S1Y45hgMUFvbB4SAddTj4hId0flId2x5x250fhdRB2E2/TrU0ul +1drC0bOH/WTdOW6l84AHnpt5qAleYSkVjTknUni43aZi43paO8dpcLpTPzdQ0GB6 +HG9FL4k1cNtZ3GSKM7dWRwFNBhuIYJlpYQ+5vAjeMlXuwIuRWk/NMzHuf04ad21W +O/c40GRRAgMBAAECggEAH3kaLitFEX9xi4ooiiCauyzxStQgvkCggsAMSSq6uHER +3jIyAV5eQA055kWdQic5djoS6+UA390AFjgytS5zOysTHmNBbgkLbIWrp9mjlq8V +k8sgdgcM9NbEBdwmqX93pgAC30Yxb4EjGO6NUR+V8C44RsnVdxGJUWXGbhAiu8iL +Sjg3DIKPpLbZHUn6m5sNCA0OZkxmwHMJ3cS2lUInB8BOOJyzYgDzqle1uqXud1rq +CQHnLnIFBpPrxmnhuuXteNpWFC2KctOZmSYHU9fzN2M+0HmR7EE6jL6u00iwgTNz +mgVDp4W8FY8C9AwygaKW8pdKhRoVSN6gzpTYQSMv5QKBgQD/1a1wm3CO0aVQA6cR +Gq9+uo9My7+OZbXEJ8tTTmQl7Dfkrh8jJbeyVEyJuC4i//1Bb7mwczbYHOJMf2Ic +RXCO/iKJCE5ebY0iju4jKPfJsN4BZY3/gWloeYpYZs767u2ui7iogLuysiIqZfwh +os34E2KODdF0Duu1dGtsGu4svQKBgQD6u/7lvz81roAQsGqck6N8SJoKYKfe30/J +iYuaTzvfA2wPgbpIzfKXSo01t/B2LXWkfd+oIkcqwZkNLB/n/zy8cw4ZdHBXgQrc +jiLcgd9A9xLtPpUOFDTqADyzSZOVcS9IdZ613QV67g5tmjmGBv022zNCfINznycP +gUs6CL7xJQKBgC4vLeNRPE2cDSLXtfZBggL3vNzZP1h+hgTGRk9grHWoIiL3SAm5 +SmM4EqUeCb4pxldkyvnOosz60eOLb3O5GWvG9wcT58nmLv9ZHCZew/S2KC3KXosM +mrHQlygET0M/lMrY0Foi+z90JvTOemi8EkDBhx4r4m74LpcQUEAw0GRlAoGBAOad +Pwclbgf75gJzoLs+9+F6DWR6HizZjFM71VEqi1DFnwBsO2B3PCd3BIZiFiM8ELYo +qCvGy1pCEnmRGg+SLNTPpio3dJSnNcxCV0IlmafxGFFpfEMkxr5rVWk5xSpwniKx +PDbdgQreJhsc/MePahqoZm9Cgs03lv+l1gEcxjKZAoGBAOv4ZasGcjO/acDSX6vc +W6BU8Il6Ng4LrCYx0+5KBkTYrTrWOmeZOFpERyiBVgbN9S7OGulddpTeFWcsX/Pc +vcNXkVFmCcVdZsf1nCOiQ2Js7dOMCRi8EKdd13xvMzZxWKXtseZjQAKKVxMkhR89 +3bbW6wudWN0NsgR7yDvkZHWt +-----END PRIVATE KEY----- diff --git a/api/tests/google_integrity.rs b/api/tests/google_integrity.rs new file mode 100644 index 00000000..4fdb0917 --- /dev/null +++ b/api/tests/google_integrity.rs @@ -0,0 +1,94 @@ +use keyflow_api::google::auth::ServiceAccountKey; +use keyflow_api::google::integrity::IntegrityClient; +use wiremock::matchers::{header, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +fn test_key(token_uri: String) -> ServiceAccountKey { + ServiceAccountKey { + client_email: "test@keyflow-test.iam.gserviceaccount.com".to_string(), + private_key: include_str!("fixtures/test_service_account_key.pem").to_string(), + token_uri, + } +} + +#[tokio::test] +async fn decode_returns_parsed_payload_on_success() { + let google = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "fake-access-token", + "token_type": "Bearer", + "expires_in": 3600 + }))) + .mount(&google) + .await; + + Mock::given(method("POST")) + .and(path("/v1/com.keyflow:decodeIntegrityToken")) + .and(header("authorization", "Bearer fake-access-token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "tokenPayloadExternal": { + "requestDetails": { "requestPackageName": "com.keyflow" }, + "appIntegrity": { "appRecognitionVerdict": "PLAY_RECOGNIZED" }, + "deviceIntegrity": { "deviceRecognitionVerdict": ["MEETS_DEVICE_INTEGRITY"] } + } + }))) + .mount(&google) + .await; + + let client = IntegrityClient::new( + reqwest::Client::new(), + google.uri(), + "com.keyflow".to_string(), + test_key(format!("{}/token", google.uri())), + ); + + let payload = client.decode("fake-integrity-token").await.unwrap(); + + assert_eq!(payload.request_details.request_package_name, "com.keyflow"); + assert_eq!( + payload.app_integrity.app_recognition_verdict, + "PLAY_RECOGNIZED" + ); + assert_eq!( + payload.device_integrity.device_recognition_verdict, + vec!["MEETS_DEVICE_INTEGRITY".to_string()] + ); +} + +#[tokio::test] +async fn decode_propagates_error_status_from_google() { + let google = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "fake-access-token", + "token_type": "Bearer", + "expires_in": 3600 + }))) + .mount(&google) + .await; + + Mock::given(method("POST")) + .and(path("/v1/com.keyflow:decodeIntegrityToken")) + .respond_with(ResponseTemplate::new(400)) + .mount(&google) + .await; + + let client = IntegrityClient::new( + reqwest::Client::new(), + google.uri(), + "com.keyflow".to_string(), + test_key(format!("{}/token", google.uri())), + ); + + let err = client.decode("bad-token").await.unwrap_err(); + + assert!(matches!( + err, + keyflow_api::google::integrity::IntegrityError::RequestFailed(400) + )); +} diff --git a/api/tests/ratelimit.rs b/api/tests/ratelimit.rs new file mode 100644 index 00000000..5180de92 --- /dev/null +++ b/api/tests/ratelimit.rs @@ -0,0 +1,199 @@ +mod common; + +use axum::body::Body; +use axum::http::{Request, StatusCode, header}; +use common::{test_pool, unused_ads_config, unused_transcription_config}; +use keyflow_api::db::accounts::Account; +use keyflow_api::google::auth::ServiceAccountKey; +use keyflow_api::google::integrity::IntegrityClient; +use keyflow_api::google::publisher::PublisherClient; +use keyflow_api::{AppState, app}; +use serde_json::json; +use std::net::SocketAddr; +use tower::ServiceExt; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const PACKAGE_NAME: &str = "com.keyflow"; + +async fn mock_google_ok() -> MockServer { + let google = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "fake-access-token", + "token_type": "Bearer", + "expires_in": 3600 + }))) + .mount(&google) + .await; + + Mock::given(method("POST")) + .and(path(format!("/v1/{PACKAGE_NAME}:decodeIntegrityToken"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "tokenPayloadExternal": { + "requestDetails": { "requestPackageName": PACKAGE_NAME }, + "appIntegrity": { "appRecognitionVerdict": "PLAY_RECOGNIZED" }, + "deviceIntegrity": { "deviceRecognitionVerdict": ["MEETS_DEVICE_INTEGRITY"] } + } + }))) + .mount(&google) + .await; + + google +} + +async fn state_with_limits( + google_uri: String, + register_per_minute: u32, + account_per_minute: u32, +) -> AppState { + let pool = test_pool().await; + let key = ServiceAccountKey { + client_email: "test@keyflow-test.iam.gserviceaccount.com".to_string(), + private_key: include_str!("fixtures/test_service_account_key.pem").to_string(), + token_uri: format!("{google_uri}/token"), + }; + let integrity = IntegrityClient::new( + reqwest::Client::new(), + google_uri.clone(), + PACKAGE_NAME.to_string(), + key.clone(), + ); + let publisher = PublisherClient::new( + reqwest::Client::new(), + google_uri, + PACKAGE_NAME.to_string(), + key, + ); + AppState::new( + pool, + integrity, + publisher, + reqwest::Client::new(), + PACKAGE_NAME.to_string(), + false, + std::num::NonZeroU32::new(register_per_minute).unwrap(), + std::num::NonZeroU32::new(account_per_minute).unwrap(), + unused_transcription_config(), + unused_ads_config(), + ) +} + +async fn register_once(state: AppState, addr: SocketAddr) -> axum::http::Response { + let device_key = format!("device-{}", uuid::Uuid::new_v4()); + app(state) + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/register") + .header(header::CONTENT_TYPE, "application/json") + .extension(axum::extract::ConnectInfo(addr)) + .body(Body::from( + json!({ "deviceKey": device_key, "integrityToken": "fake-integrity-token" }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap() +} + +#[tokio::test] +async fn register_flood_from_same_ip_gets_429_with_retry_after_header() { + let google = mock_google_ok().await; + // Quota 1/min: a 1ª passa, a 2ª (mesmo IP, mesmo minuto) estoura. + let state = state_with_limits(google.uri(), 1, 1000).await; + let addr: SocketAddr = "203.0.113.7:0".parse().unwrap(); + + let first = register_once(state.clone(), addr).await; + assert_eq!(first.status(), StatusCode::OK); + + let second = register_once(state, addr).await; + assert_eq!(second.status(), StatusCode::TOO_MANY_REQUESTS); + let retry_after = second + .headers() + .get("retry-after") + .expect("header Retry-After presente") + .to_str() + .unwrap() + .parse::() + .unwrap(); + assert!(retry_after >= 1); +} + +#[tokio::test] +async fn register_below_quota_from_different_ips_is_not_limited() { + let google = mock_google_ok().await; + let state = state_with_limits(google.uri(), 1, 1000).await; + let addr_a: SocketAddr = "203.0.113.10:0".parse().unwrap(); + let addr_b: SocketAddr = "203.0.113.11:0".parse().unwrap(); + + let a = register_once(state.clone(), addr_a).await; + let b = register_once(state, addr_b).await; + + assert_eq!(a.status(), StatusCode::OK); + assert_eq!( + b.status(), + StatusCode::OK, + "IP diferente não deveria ser afetado pela quota do outro" + ); +} + +/// "Por conta" ainda não tem rota de negócio autenticada real (`GET /v1/account` é a API-T7); +/// prova a mesma checagem (`AppState::rate_limiters.check_account`) batendo num router efêmero +/// com o extractor `Account` real sobre o `AppState` real — mesmo padrão usado em +/// `tests/register.rs::missing_bearer_is_rejected_with_401` para o Bearer. +#[tokio::test] +async fn account_flood_gets_429_with_retry_after_header() { + let google = mock_google_ok().await; + let state = state_with_limits(google.uri(), 1000, 1).await; + let addr: SocketAddr = "203.0.113.20:0".parse().unwrap(); + + let response = register_once(state.clone(), addr).await; + assert_eq!(response.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + let access_token = body["accessToken"].as_str().unwrap().to_string(); + + use axum::routing::get; + async fn probe_handler( + axum::extract::State(state): axum::extract::State, + account: Account, + ) -> Result { + state.rate_limiters.check_account(account.id)?; + Ok(StatusCode::OK) + } + let probe = axum::Router::new() + .route("/probe", get(probe_handler)) + .with_state(state); + + let bearer = format!("Bearer {access_token}"); + let request = |probe: axum::Router| { + probe.oneshot( + Request::builder() + .uri("/probe") + .header(header::AUTHORIZATION, &bearer) + .body(Body::empty()) + .unwrap(), + ) + }; + + let first = request(probe.clone()).await.unwrap(); + assert_eq!(first.status(), StatusCode::OK); + + let second = request(probe).await.unwrap(); + assert_eq!(second.status(), StatusCode::TOO_MANY_REQUESTS); + let retry_after = second + .headers() + .get("retry-after") + .expect("header Retry-After presente") + .to_str() + .unwrap() + .parse::() + .unwrap(); + assert!(retry_after >= 1); +} diff --git a/api/tests/redeem.rs b/api/tests/redeem.rs new file mode 100644 index 00000000..6535bf48 --- /dev/null +++ b/api/tests/redeem.rs @@ -0,0 +1,324 @@ +mod common; + +use axum::body::Body; +use axum::http::{Request, StatusCode, header}; +use common::{test_pool, unused_ads_config, unused_transcription_config}; +use keyflow_api::auth::{device_key_account_id, generate_token}; +use keyflow_api::db::accounts; +use keyflow_api::google::auth::ServiceAccountKey; +use keyflow_api::google::integrity::IntegrityClient; +use keyflow_api::google::publisher::PublisherClient; +use keyflow_api::{AppState, app}; +use serde_json::{Value, json}; +use tower::ServiceExt; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const PACKAGE_NAME: &str = "com.keyflow"; + +async fn mock_integrity_ok(google: &MockServer) { + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "fake-access-token", + "token_type": "Bearer", + "expires_in": 3600 + }))) + .mount(google) + .await; + + Mock::given(method("POST")) + .and(path(format!("/v1/{PACKAGE_NAME}:decodeIntegrityToken"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "tokenPayloadExternal": { + "requestDetails": { "requestPackageName": PACKAGE_NAME }, + "appIntegrity": { "appRecognitionVerdict": "PLAY_RECOGNIZED" }, + "deviceIntegrity": { "deviceRecognitionVerdict": ["MEETS_DEVICE_INTEGRITY"] } + } + }))) + .mount(google) + .await; +} + +async fn mock_purchase( + google: &MockServer, + product_id: &str, + token: &str, + purchase_state: i32, + obfuscated_account_id: &str, +) { + Mock::given(method("GET")) + .and(path(format!( + "/androidpublisher/v3/applications/{PACKAGE_NAME}/purchases/products/{product_id}/tokens/{token}" + ))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "purchaseState": purchase_state, + "obfuscatedExternalAccountId": obfuscated_account_id + }))) + .mount(google) + .await; +} + +async fn mock_acknowledge_expect_n(google: &MockServer, product_id: &str, token: &str, n: u64) { + Mock::given(method("POST")) + .and(path(format!( + "/androidpublisher/v3/applications/{PACKAGE_NAME}/purchases/products/{product_id}/tokens/{token}:acknowledge" + ))) + .respond_with(ResponseTemplate::new(200)) + .expect(n) + .mount(google) + .await; +} + +async fn state_with_google(google_uri: String) -> AppState { + let pool = test_pool().await; + let key = ServiceAccountKey { + client_email: "test@keyflow-test.iam.gserviceaccount.com".to_string(), + private_key: include_str!("fixtures/test_service_account_key.pem").to_string(), + token_uri: format!("{google_uri}/token"), + }; + let integrity = IntegrityClient::new( + reqwest::Client::new(), + google_uri.clone(), + PACKAGE_NAME.to_string(), + key.clone(), + ); + let publisher = PublisherClient::new( + reqwest::Client::new(), + google_uri, + PACKAGE_NAME.to_string(), + key, + ); + AppState::new( + pool, + integrity, + publisher, + reqwest::Client::new(), + PACKAGE_NAME.to_string(), + false, + std::num::NonZeroU32::new(1000).unwrap(), + std::num::NonZeroU32::new(1000).unwrap(), + unused_transcription_config(), + unused_ads_config(), + ) +} + +async fn create_account(state: &AppState) -> (String, uuid::Uuid, String) { + let device_key = format!("device-{}", uuid::Uuid::new_v4()); + let (access_token, token_hash) = generate_token(); + let account = accounts::create(&state.pool, &device_key, &token_hash) + .await + .unwrap(); + (access_token, account.id, device_key) +} + +async fn redeem_request( + state: AppState, + bearer: &str, + product_id: &str, + purchase_token: &str, +) -> (StatusCode, Value) { + let response = app(state) + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/purchases/redeem") + .header(header::CONTENT_TYPE, "application/json") + .header(header::AUTHORIZATION, format!("Bearer {bearer}")) + .body(Body::from( + json!({ + "productId": product_id, + "purchaseToken": purchase_token, + "integrityToken": "fake-integrity-token" + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let body: Value = if bytes.is_empty() { + Value::Null + } else { + serde_json::from_slice(&bytes).unwrap() + }; + (status, body) +} + +#[tokio::test] +async fn valid_purchase_credits_once_and_acknowledges() { + let google = MockServer::start().await; + mock_integrity_ok(&google).await; + let state = state_with_google(google.uri()).await; + let (bearer, _account_id, device_key) = create_account(&state).await; + let purchase_token = format!("tok-{}", uuid::Uuid::new_v4()); + mock_purchase( + &google, + "credits_20", + &purchase_token, + keyflow_api::google::publisher::PURCHASE_STATE_PURCHASED, + &device_key_account_id(&device_key), + ) + .await; + mock_acknowledge_expect_n(&google, "credits_20", &purchase_token, 1).await; + + let (status, body) = + redeem_request(state.clone(), &bearer, "credits_20", &purchase_token).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "credited"); + + let account = accounts::find_by_device_key(&state.pool, &device_key) + .await + .unwrap() + .unwrap(); + assert_eq!(account.balance_seconds, 25 * 60); + assert!( + account.ads_off, + "credits_20 (>= R$20) deve ligar entitlements" + ); + assert!(account.skins_unlocked); +} + +#[tokio::test] +async fn credits_10_does_not_grant_entitlements() { + let google = MockServer::start().await; + mock_integrity_ok(&google).await; + let state = state_with_google(google.uri()).await; + let (bearer, _account_id, device_key) = create_account(&state).await; + let purchase_token = format!("tok-{}", uuid::Uuid::new_v4()); + mock_purchase( + &google, + "credits_10", + &purchase_token, + keyflow_api::google::publisher::PURCHASE_STATE_PURCHASED, + &device_key_account_id(&device_key), + ) + .await; + mock_acknowledge_expect_n(&google, "credits_10", &purchase_token, 1).await; + + let (status, body) = + redeem_request(state.clone(), &bearer, "credits_10", &purchase_token).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "credited"); + + let account = accounts::find_by_device_key(&state.pool, &device_key) + .await + .unwrap() + .unwrap(); + assert_eq!(account.balance_seconds, 10 * 60); + assert!( + !account.ads_off, + "credits_10 (< R$20) não deve ligar entitlements" + ); + assert!(!account.skins_unlocked); +} + +#[tokio::test] +async fn replay_of_same_token_does_not_credit_twice() { + let google = MockServer::start().await; + mock_integrity_ok(&google).await; + let state = state_with_google(google.uri()).await; + let (bearer, _account_id, device_key) = create_account(&state).await; + let purchase_token = format!("tok-{}", uuid::Uuid::new_v4()); + mock_purchase( + &google, + "credits_10", + &purchase_token, + keyflow_api::google::publisher::PURCHASE_STATE_PURCHASED, + &device_key_account_id(&device_key), + ) + .await; + // Só 1 acknowledge esperado — a 2ª chamada (replay) não deve tentar de novo. + mock_acknowledge_expect_n(&google, "credits_10", &purchase_token, 1).await; + + let (status1, body1) = + redeem_request(state.clone(), &bearer, "credits_10", &purchase_token).await; + let (status2, body2) = + redeem_request(state.clone(), &bearer, "credits_10", &purchase_token).await; + + assert_eq!(status1, StatusCode::OK); + assert_eq!(status2, StatusCode::OK); + assert_eq!(body1["status"], "credited"); + assert_eq!(body2["status"], "credited"); + + let account = accounts::find_by_device_key(&state.pool, &device_key) + .await + .unwrap() + .unwrap(); + assert_eq!( + account.balance_seconds, + 10 * 60, + "replay não deve creditar de novo" + ); +} + +#[tokio::test] +async fn pending_purchase_returns_pending_without_crediting() { + let google = MockServer::start().await; + mock_integrity_ok(&google).await; + let state = state_with_google(google.uri()).await; + let (bearer, _account_id, device_key) = create_account(&state).await; + let purchase_token = format!("tok-{}", uuid::Uuid::new_v4()); + mock_purchase( + &google, + "credits_10", + &purchase_token, + keyflow_api::google::publisher::PURCHASE_STATE_PENDING, + &device_key_account_id(&device_key), + ) + .await; + + let (status, body) = + redeem_request(state.clone(), &bearer, "credits_10", &purchase_token).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "pending"); + + let account = accounts::find_by_device_key(&state.pool, &device_key) + .await + .unwrap() + .unwrap(); + assert_eq!(account.balance_seconds, 0); +} + +#[tokio::test] +async fn wrong_account_id_is_rejected() { + let google = MockServer::start().await; + mock_integrity_ok(&google).await; + let state = state_with_google(google.uri()).await; + let (bearer, _account_id, _device_key) = create_account(&state).await; + let purchase_token = format!("tok-{}", uuid::Uuid::new_v4()); + mock_purchase( + &google, + "credits_10", + &purchase_token, + keyflow_api::google::publisher::PURCHASE_STATE_PURCHASED, + "conta-de-outro-device-completamente-diferente", + ) + .await; + + let (status, body) = redeem_request(state, &bearer, "credits_10", &purchase_token).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["error"], "account_mismatch"); +} + +#[tokio::test] +async fn unknown_product_is_rejected() { + let google = MockServer::start().await; + mock_integrity_ok(&google).await; + let state = state_with_google(google.uri()).await; + let (bearer, _account_id, _device_key) = create_account(&state).await; + + let (status, body) = redeem_request(state, &bearer, "does_not_exist", "tok-anything").await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["error"], "unknown_product"); +} diff --git a/api/tests/register.rs b/api/tests/register.rs new file mode 100644 index 00000000..fafeed9c --- /dev/null +++ b/api/tests/register.rs @@ -0,0 +1,255 @@ +mod common; + +use axum::body::Body; +use axum::http::{Request, StatusCode, header}; +use common::{test_pool, unused_ads_config, unused_transcription_config}; +use keyflow_api::db::accounts::Account; +use keyflow_api::google::auth::ServiceAccountKey; +use keyflow_api::google::integrity::IntegrityClient; +use keyflow_api::google::publisher::PublisherClient; +use keyflow_api::{AppState, app}; +use serde_json::{Value, json}; +use tower::ServiceExt; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +async fn mock_google_ok(package_name: &str) -> MockServer { + let google = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "fake-access-token", + "token_type": "Bearer", + "expires_in": 3600 + }))) + .mount(&google) + .await; + + Mock::given(method("POST")) + .and(path(format!("/v1/{package_name}:decodeIntegrityToken"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "tokenPayloadExternal": { + "requestDetails": { "requestPackageName": package_name }, + "appIntegrity": { "appRecognitionVerdict": "PLAY_RECOGNIZED" }, + "deviceIntegrity": { "deviceRecognitionVerdict": ["MEETS_DEVICE_INTEGRITY"] } + } + }))) + .mount(&google) + .await; + + google +} + +async fn mock_google_bad_device(package_name: &str) -> MockServer { + let google = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": "fake-access-token", + "token_type": "Bearer", + "expires_in": 3600 + }))) + .mount(&google) + .await; + + Mock::given(method("POST")) + .and(path(format!("/v1/{package_name}:decodeIntegrityToken"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "tokenPayloadExternal": { + "requestDetails": { "requestPackageName": package_name }, + "appIntegrity": { "appRecognitionVerdict": "PLAY_RECOGNIZED" }, + "deviceIntegrity": { "deviceRecognitionVerdict": [] } + } + }))) + .mount(&google) + .await; + + google +} + +async fn state_with_google(google_uri: String, package_name: &str) -> AppState { + let pool = test_pool().await; + let key = ServiceAccountKey { + client_email: "test@keyflow-test.iam.gserviceaccount.com".to_string(), + private_key: include_str!("fixtures/test_service_account_key.pem").to_string(), + token_uri: format!("{google_uri}/token"), + }; + let integrity = IntegrityClient::new( + reqwest::Client::new(), + google_uri.clone(), + package_name.to_string(), + key.clone(), + ); + let publisher = PublisherClient::new( + reqwest::Client::new(), + google_uri, + package_name.to_string(), + key, + ); + // Limite generoso nos testes deste arquivo: o rate limit em si é coberto isoladamente em + // src/middleware/ratelimit.rs; aqui só não pode atrapalhar os cenários de negócio. + AppState::new( + pool, + integrity, + publisher, + reqwest::Client::new(), + package_name.to_string(), + false, + std::num::NonZeroU32::new(1000).unwrap(), + std::num::NonZeroU32::new(1000).unwrap(), + unused_transcription_config(), + unused_ads_config(), + ) +} + +async fn register_request(state: AppState, device_key: &str) -> (StatusCode, Value) { + let addr: std::net::SocketAddr = "127.0.0.1:0".parse().unwrap(); + let response = app(state) + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/register") + .header(header::CONTENT_TYPE, "application/json") + .extension(axum::extract::ConnectInfo(addr)) + .body(Body::from( + json!({ "deviceKey": device_key, "integrityToken": "fake-integrity-token" }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let body: Value = if bytes.is_empty() { + Value::Null + } else { + serde_json::from_slice(&bytes).unwrap() + }; + (status, body) +} + +#[tokio::test] +async fn register_with_valid_integrity_returns_token_that_works_on_protected_extractor() { + let package_name = "com.keyflow"; + let google = mock_google_ok(package_name).await; + let state = state_with_google(google.uri(), package_name).await; + let device_key = format!("device-{}", uuid::Uuid::new_v4()); + + let (status, body) = register_request(state.clone(), &device_key).await; + + assert_eq!(status, StatusCode::OK); + let access_token = body["accessToken"] + .as_str() + .expect("accessToken presente") + .to_string(); + assert!(!access_token.is_empty()); + + // O token devolvido deve resolver a conta via o extractor `Account` — exercitado batendo + // numa rota protegida real (o `/health` atual não exige Bearer; usamos o extractor + // diretamente para provar a resolução Bearer→Account sem acoplar a uma rota de negócio + // que ainda não existe nesta task — GET /v1/account é a API-T7). + let hash = keyflow_api::auth::hash_token(access_token.as_bytes()); + let account = keyflow_api::db::accounts::find_by_token_hash(&state.pool, &hash) + .await + .unwrap() + .expect("token deveria resolver a uma conta"); + assert_eq!(account.balance_seconds, 0); +} + +#[tokio::test] +async fn register_with_invalid_integrity_returns_403() { + let package_name = "com.keyflow"; + let google = mock_google_bad_device(package_name).await; + let state = state_with_google(google.uri(), package_name).await; + let device_key = format!("device-{}", uuid::Uuid::new_v4()); + + let (status, body) = register_request(state, &device_key).await; + + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(body["error"], "integrity"); +} + +#[tokio::test] +async fn re_register_same_device_key_keeps_account_rotates_token() { + let package_name = "com.keyflow"; + let google = mock_google_ok(package_name).await; + let state = state_with_google(google.uri(), package_name).await; + let device_key = format!("device-{}", uuid::Uuid::new_v4()); + + let (_, body1) = register_request(state.clone(), &device_key).await; + let token1 = body1["accessToken"].as_str().unwrap().to_string(); + + let (status2, body2) = register_request(state.clone(), &device_key).await; + let token2 = body2["accessToken"].as_str().unwrap().to_string(); + + assert_eq!(status2, StatusCode::OK); + assert_ne!(token1, token2, "re-registro deve rotacionar o token"); + + let account1: Option = { + let hash = keyflow_api::auth::hash_token(token1.as_bytes()); + keyflow_api::db::accounts::find_by_token_hash(&state.pool, &hash) + .await + .unwrap() + }; + assert!( + account1.is_none(), + "token antigo deve estar morto após re-registro" + ); + + let account2 = keyflow_api::db::accounts::find_by_device_key(&state.pool, &device_key) + .await + .unwrap() + .expect("conta deveria existir"); + let by_new_token = { + let hash = keyflow_api::auth::hash_token(token2.as_bytes()); + keyflow_api::db::accounts::find_by_token_hash(&state.pool, &hash) + .await + .unwrap() + .expect("token novo deveria resolver a conta") + }; + assert_eq!(account2.id, by_new_token.id, "mesma conta, não uma nova"); +} + +#[tokio::test] +async fn missing_bearer_is_rejected_with_401() { + let package_name = "com.keyflow"; + let google = mock_google_ok(package_name).await; + let state = state_with_google(google.uri(), package_name).await; + + // `/health` não usa o extractor `Account`; testamos a resolução Bearer→401 diretamente via + // uma rota crua que só existe neste teste, montada com o mesmo `AppState` da app real. + use axum::routing::get; + let probe = axum::Router::new() + .route("/probe", get(async |_: Account| StatusCode::OK)) + .with_state(state); + + let response = probe + .clone() + .oneshot( + Request::builder() + .uri("/probe") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + + let response = probe + .oneshot( + Request::builder() + .uri("/probe") + .header(header::AUTHORIZATION, "Bearer not-a-real-token") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} diff --git a/api/tests/ssv.rs b/api/tests/ssv.rs new file mode 100644 index 00000000..f2cc0eb0 --- /dev/null +++ b/api/tests/ssv.rs @@ -0,0 +1,310 @@ +mod common; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use common::{test_pool, unused_transcription_config}; +use keyflow_api::google::auth::ServiceAccountKey; +use keyflow_api::google::integrity::IntegrityClient; +use keyflow_api::google::publisher::PublisherClient; +use keyflow_api::{AdsConfig, AppState, app}; +use p256::ecdsa::signature::Signer; +use p256::ecdsa::{Signature, SigningKey, VerifyingKey}; +use p256::pkcs8::EncodePublicKey; +use rand_core::OsRng; +use sqlx::PgPool; +use tower::ServiceExt; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const PACKAGE_NAME: &str = "com.keyflow"; +const KEY_ID: u64 = 42; + +async fn mock_verifier_keys(signing_key: &SigningKey) -> MockServer { + let verifying_key = VerifyingKey::from(signing_key); + let pem = verifying_key.to_public_key_pem(Default::default()).unwrap(); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/verifier-keys.json")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "keys": [{ "keyId": KEY_ID, "pem": pem }] + }))) + .mount(&server) + .await; + server +} + +async fn state_with_admob( + keys_url: String, + pool: PgPool, + reward_seconds: i64, + daily_cap: i64, +) -> AppState { + let unused_key = ServiceAccountKey { + client_email: "unused@example.com".to_string(), + private_key: String::new(), + token_uri: "http://127.0.0.1:0/token".to_string(), + }; + let integrity = IntegrityClient::new( + reqwest::Client::new(), + "http://127.0.0.1:0".to_string(), + PACKAGE_NAME.to_string(), + unused_key.clone(), + ); + let publisher = PublisherClient::new( + reqwest::Client::new(), + "http://127.0.0.1:0".to_string(), + PACKAGE_NAME.to_string(), + unused_key, + ); + AppState::new( + pool, + integrity, + publisher, + reqwest::Client::new(), + PACKAGE_NAME.to_string(), + false, + std::num::NonZeroU32::new(1000).unwrap(), + std::num::NonZeroU32::new(1000).unwrap(), + unused_transcription_config(), + AdsConfig { + verifier_keys_url: keys_url, + reward_seconds, + daily_cap, + }, + ) +} + +/// Monta uma query de callback SSV assinada de verdade — mesma regra da doc oficial (conteúdo +/// assinado = tudo antes de `&signature=`; `signature`+`key_id` sempre por último, nessa ordem). +fn build_signed_query(signing_key: &SigningKey, key_id: u64, content: &str) -> String { + let signature: Signature = signing_key.sign(content.as_bytes()); + let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_der().as_bytes()); + format!("{content}&signature={sig_b64}&key_id={key_id}") +} + +async fn ssv_request(state: AppState, query: &str) -> StatusCode { + app(state) + .oneshot( + Request::builder() + .uri(format!("/v1/ssv/admob?{query}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap() + .status() +} + +#[tokio::test] +async fn valid_callback_credits_account_once() { + let signing_key = SigningKey::random(&mut OsRng); + let keys_server = mock_verifier_keys(&signing_key).await; + let pool = test_pool().await; + let device_key = format!("device-{}", uuid::Uuid::new_v4()); + let account = keyflow_api::db::accounts::create( + &pool, + &device_key, + uuid::Uuid::new_v4().into_bytes().as_ref(), + ) + .await + .unwrap(); + + let state = state_with_admob( + format!("{}/verifier-keys.json", keys_server.uri()), + pool.clone(), + 60, + 5, + ) + .await; + + let content = format!( + "ad_network=1&custom_data={}&reward_amount=5&transaction_id=tx-{}", + account.id, + uuid::Uuid::new_v4() + ); + let query = build_signed_query(&signing_key, KEY_ID, &content); + + let status = ssv_request(state, &query).await; + + assert_eq!(status, StatusCode::OK); + let after = keyflow_api::db::accounts::find_by_device_key(&pool, &device_key) + .await + .unwrap() + .unwrap(); + assert_eq!(after.balance_seconds, 60); +} + +#[tokio::test] +async fn replay_of_same_transaction_id_does_not_credit_twice() { + let signing_key = SigningKey::random(&mut OsRng); + let keys_server = mock_verifier_keys(&signing_key).await; + let pool = test_pool().await; + let device_key = format!("device-{}", uuid::Uuid::new_v4()); + let account = keyflow_api::db::accounts::create( + &pool, + &device_key, + uuid::Uuid::new_v4().into_bytes().as_ref(), + ) + .await + .unwrap(); + + let state = state_with_admob( + format!("{}/verifier-keys.json", keys_server.uri()), + pool.clone(), + 60, + 5, + ) + .await; + + let content = format!( + "custom_data={}&transaction_id=same-tx-{}", + account.id, + uuid::Uuid::new_v4() + ); + let query = build_signed_query(&signing_key, KEY_ID, &content); + + assert_eq!(ssv_request(state.clone(), &query).await, StatusCode::OK); + assert_eq!(ssv_request(state, &query).await, StatusCode::OK); + + let after = keyflow_api::db::accounts::find_by_device_key(&pool, &device_key) + .await + .unwrap() + .unwrap(); + assert_eq!( + after.balance_seconds, 60, + "replay não deve creditar de novo" + ); +} + +#[tokio::test] +async fn daily_cap_blocks_further_credits() { + let signing_key = SigningKey::random(&mut OsRng); + let keys_server = mock_verifier_keys(&signing_key).await; + let pool = test_pool().await; + let device_key = format!("device-{}", uuid::Uuid::new_v4()); + let account = keyflow_api::db::accounts::create( + &pool, + &device_key, + uuid::Uuid::new_v4().into_bytes().as_ref(), + ) + .await + .unwrap(); + + // Cap de 1: a 1ª recompensa do dia passa, a 2ª (transaction_id diferente) é bloqueada. + let state = state_with_admob( + format!("{}/verifier-keys.json", keys_server.uri()), + pool.clone(), + 60, + 1, + ) + .await; + + let content1 = format!( + "custom_data={}&transaction_id=tx-a-{}", + account.id, + uuid::Uuid::new_v4() + ); + let query1 = build_signed_query(&signing_key, KEY_ID, &content1); + assert_eq!(ssv_request(state.clone(), &query1).await, StatusCode::OK); + + let content2 = format!( + "custom_data={}&transaction_id=tx-b-{}", + account.id, + uuid::Uuid::new_v4() + ); + let query2 = build_signed_query(&signing_key, KEY_ID, &content2); + assert_eq!( + ssv_request(state, &query2).await, + StatusCode::OK, + "cap bloqueia mas ainda responde 200" + ); + + let after = keyflow_api::db::accounts::find_by_device_key(&pool, &device_key) + .await + .unwrap() + .unwrap(); + assert_eq!( + after.balance_seconds, 60, + "só a 1ª recompensa do dia deveria contar" + ); +} + +#[tokio::test] +async fn unknown_account_returns_200_without_crediting_anything() { + let signing_key = SigningKey::random(&mut OsRng); + let keys_server = mock_verifier_keys(&signing_key).await; + let pool = test_pool().await; + + let state = state_with_admob( + format!("{}/verifier-keys.json", keys_server.uri()), + pool, + 60, + 5, + ) + .await; + + let random_account_id = uuid::Uuid::new_v4(); + let content = format!("custom_data={random_account_id}&transaction_id=tx-ghost"); + let query = build_signed_query(&signing_key, KEY_ID, &content); + + let status = ssv_request(state, &query).await; + + assert_eq!( + status, + StatusCode::OK, + "não deve vazar se a conta existe ou não" + ); +} + +#[tokio::test] +async fn invalid_signature_returns_403() { + let signing_key = SigningKey::random(&mut OsRng); + let wrong_key = SigningKey::random(&mut OsRng); + let keys_server = mock_verifier_keys(&signing_key).await; + let pool = test_pool().await; + + let state = state_with_admob( + format!("{}/verifier-keys.json", keys_server.uri()), + pool, + 60, + 5, + ) + .await; + + let content = "custom_data=whatever&transaction_id=tx-1"; + // Assinado com uma chave que NÃO é a que o servidor de verifier-keys anuncia pro key_id 42. + let query = build_signed_query(&wrong_key, KEY_ID, content); + + let status = ssv_request(state, &query).await; + + assert_eq!(status, StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn missing_query_returns_403() { + let signing_key = SigningKey::random(&mut OsRng); + let keys_server = mock_verifier_keys(&signing_key).await; + let pool = test_pool().await; + let state = state_with_admob( + format!("{}/verifier-keys.json", keys_server.uri()), + pool, + 60, + 5, + ) + .await; + + let response = app(state) + .oneshot( + Request::builder() + .uri("/v1/ssv/admob") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} diff --git a/api/tests/transcribe.rs b/api/tests/transcribe.rs new file mode 100644 index 00000000..1474d218 --- /dev/null +++ b/api/tests/transcribe.rs @@ -0,0 +1,395 @@ +mod common; + +use axum::body::Body; +use axum::http::{Request, StatusCode, header}; +use common::{test_pool, unused_ads_config}; +use keyflow_api::auth::generate_token; +use keyflow_api::db::accounts; +use keyflow_api::google::auth::ServiceAccountKey; +use keyflow_api::google::integrity::IntegrityClient; +use keyflow_api::google::publisher::PublisherClient; +use keyflow_api::providers::ResolvedProvider; +use keyflow_api::{AppState, TranscriptionConfig, app}; +use serde_json::{Value, json}; +use std::time::Duration; +use tower::ServiceExt; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +const PACKAGE_NAME: &str = "com.keyflow"; +const BYTES_PER_SECOND: usize = 8000; + +fn provider(url: String, name: &str) -> ResolvedProvider { + ResolvedProvider { + name: name.to_string(), + url, + auth_header_name: "Authorization".to_string(), + auth_value: "provider-secret".to_string(), + model: "whisper-1".to_string(), + } +} + +async fn state_with_providers(providers: Vec, max_bytes: u64) -> AppState { + let pool = test_pool().await; + let unused_key = ServiceAccountKey { + client_email: "unused@example.com".to_string(), + private_key: String::new(), + token_uri: "http://127.0.0.1:0/token".to_string(), + }; + let integrity = IntegrityClient::new( + reqwest::Client::new(), + "http://127.0.0.1:0".to_string(), + PACKAGE_NAME.to_string(), + unused_key.clone(), + ); + let publisher = PublisherClient::new( + reqwest::Client::new(), + "http://127.0.0.1:0".to_string(), + PACKAGE_NAME.to_string(), + unused_key, + ); + AppState::new( + pool, + integrity, + publisher, + reqwest::Client::new(), + PACKAGE_NAME.to_string(), + false, + std::num::NonZeroU32::new(1000).unwrap(), + std::num::NonZeroU32::new(1000).unwrap(), + TranscriptionConfig { + providers, + timeout: Duration::from_secs(5), + max_bytes, + unhealthy_cooldown: Duration::from_secs(60), + }, + unused_ads_config(), + ) +} + +async fn create_account(state: &AppState, balance_seconds: i64) -> (String, String) { + let device_key = format!("device-{}", uuid::Uuid::new_v4()); + let (access_token, token_hash) = generate_token(); + accounts::create(&state.pool, &device_key, &token_hash) + .await + .unwrap(); + sqlx::query("update accounts set balance_seconds = $1 where device_key = $2") + .bind(balance_seconds) + .bind(&device_key) + .execute(&state.pool) + .await + .unwrap(); + (access_token, device_key) +} + +async fn transcribe_request( + state: AppState, + bearer: &str, + request_id: &str, + body_bytes: usize, +) -> axum::http::Response { + app(state) + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/transcribe") + .header(header::AUTHORIZATION, format!("Bearer {bearer}")) + .header(header::CONTENT_TYPE, "multipart/form-data; boundary=test") + .header("x-request-id", request_id) + .body(Body::from(vec![0u8; body_bytes])) + .unwrap(), + ) + .await + .unwrap() +} + +async fn body_json(response: axum::http::Response) -> Value { + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + if bytes.is_empty() { + Value::Null + } else { + serde_json::from_slice(&bytes).unwrap() + } +} + +#[tokio::test] +async fn successful_transcription_debits_by_bytes_and_returns_balance_header() { + let provider_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/transcribe")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "text": "hello world" }))) + .mount(&provider_server) + .await; + + let state = state_with_providers( + vec![provider( + format!("{}/transcribe", provider_server.uri()), + "primary", + )], + 1_000_000, + ) + .await; + let (bearer, device_key) = create_account(&state, 60).await; + + let response = transcribe_request(state.clone(), &bearer, "req-1", 10 * BYTES_PER_SECOND).await; + + assert_eq!(response.status(), StatusCode::OK); + let balance_header = response + .headers() + .get("x-keyflow-balance-seconds") + .unwrap() + .to_str() + .unwrap() + .to_string(); + let body = body_json(response).await; + + assert_eq!(body["text"], "hello world"); + assert_eq!(balance_header, "50"); + + let account = accounts::find_by_device_key(&state.pool, &device_key) + .await + .unwrap() + .unwrap(); + assert_eq!(account.balance_seconds, 50); +} + +#[tokio::test] +async fn zero_balance_returns_402_without_calling_provider() { + let provider_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/transcribe")) + .respond_with( + ResponseTemplate::new(200).set_body_json(json!({ "text": "should not happen" })), + ) + .expect(0) + .mount(&provider_server) + .await; + + let state = state_with_providers( + vec![provider( + format!("{}/transcribe", provider_server.uri()), + "primary", + )], + 1_000_000, + ) + .await; + let (bearer, _device_key) = create_account(&state, 0).await; + + let response = transcribe_request(state, &bearer, "req-1", 1000).await; + + assert_eq!(response.status(), StatusCode::PAYMENT_REQUIRED); + let body = body_json(response).await; + assert_eq!(body["balanceSeconds"], 0); +} + +#[tokio::test] +async fn payload_over_cap_returns_413_without_debiting() { + let provider_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/transcribe")) + .respond_with( + ResponseTemplate::new(200).set_body_json(json!({ "text": "should not happen" })), + ) + .expect(0) + .mount(&provider_server) + .await; + + let state = state_with_providers( + vec![provider( + format!("{}/transcribe", provider_server.uri()), + "primary", + )], + // Cap bem menor que o corpo enviado. + 100, + ) + .await; + let (bearer, device_key) = create_account(&state, 1000).await; + + let response = transcribe_request(state.clone(), &bearer, "req-1", 10_000).await; + + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + + let account = accounts::find_by_device_key(&state.pool, &device_key) + .await + .unwrap() + .unwrap(); + assert_eq!(account.balance_seconds, 1000, "413 não deve debitar"); +} + +#[tokio::test] +async fn provider_5xx_marks_unhealthy_and_next_request_falls_back() { + let primary = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/transcribe")) + .respond_with(ResponseTemplate::new(503)) + .mount(&primary) + .await; + + let fallback = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/transcribe")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "text": "from fallback" }))) + .mount(&fallback) + .await; + + let state = state_with_providers( + vec![ + provider(format!("{}/transcribe", primary.uri()), "primary"), + provider(format!("{}/transcribe", fallback.uri()), "fallback"), + ], + 1_000_000, + ) + .await; + let (bearer, device_key) = create_account(&state, 60).await; + + let first = transcribe_request(state.clone(), &bearer, "req-1", BYTES_PER_SECOND).await; + assert_eq!(first.status(), StatusCode::BAD_GATEWAY); + let account = accounts::find_by_device_key(&state.pool, &device_key) + .await + .unwrap() + .unwrap(); + assert_eq!(account.balance_seconds, 60, "provedor caindo não debita"); + + // 2ª request (novo request-id): o circuit breaker já marcou "primary" indisponível — deve + // ir direto pro fallback e ter sucesso. + let second = transcribe_request(state.clone(), &bearer, "req-2", BYTES_PER_SECOND).await; + assert_eq!(second.status(), StatusCode::OK); + let body = body_json(second).await; + assert_eq!(body["text"], "from fallback"); +} + +#[tokio::test] +async fn only_provider_failing_returns_error_without_debit() { + let primary = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/transcribe")) + .respond_with(ResponseTemplate::new(500)) + .mount(&primary) + .await; + + let state = state_with_providers( + vec![provider(format!("{}/transcribe", primary.uri()), "primary")], + 1_000_000, + ) + .await; + let (bearer, device_key) = create_account(&state, 60).await; + + let response = transcribe_request(state.clone(), &bearer, "req-1", BYTES_PER_SECOND).await; + + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + let account = accounts::find_by_device_key(&state.pool, &device_key) + .await + .unwrap() + .unwrap(); + assert_eq!(account.balance_seconds, 60); +} + +#[tokio::test] +async fn retry_with_same_request_id_does_not_debit_twice() { + let provider_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/transcribe")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "text": "hello" }))) + .mount(&provider_server) + .await; + + let state = state_with_providers( + vec![provider( + format!("{}/transcribe", provider_server.uri()), + "primary", + )], + 1_000_000, + ) + .await; + let (bearer, device_key) = create_account(&state, 60).await; + + let first = transcribe_request(state.clone(), &bearer, "same-id", 10 * BYTES_PER_SECOND).await; + assert_eq!(first.status(), StatusCode::OK); + + let second = transcribe_request(state.clone(), &bearer, "same-id", 10 * BYTES_PER_SECOND).await; + assert_eq!( + second.status(), + StatusCode::OK, + "retry deve re-executar upstream com sucesso" + ); + + let account = accounts::find_by_device_key(&state.pool, &device_key) + .await + .unwrap() + .unwrap(); + assert_eq!( + account.balance_seconds, 50, + "retry não deve debitar de novo" + ); +} + +/// TRX-04 ("nunca bufferizar"): a garantia é por **inspeção de código** (nenhum `to_vec()`/ +/// `.bytes()` no caminho do corpo de ENTRADA — só `body.into_data_stream()` repassado direto pro +/// `reqwest::Body::wrap_stream`, ver `src/routes/transcribe.rs`), não por medir memória de +/// processo em teste automatizado. Tentei medir RSS (`/proc/self/status`) antes/depois de um +/// upload de 5 MB e descartei: o crescimento observado (~17 MB) vem majoritariamente de ruído do +/// alocador/runtime tokio/pool de conexões nesta única execução de teste, não do corpo em si — +/// sinal não confiável o bastante pra virar gate de CI (mesma lição da API-T2 com o teste de +/// tracing flaky). Este teste fica só como prova funcional de que um upload maior que o típico +/// (10s de áudio) continua funcionando corretamente fim-a-fim. +#[tokio::test] +async fn large_upload_completes_successfully() { + let provider_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/transcribe")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "text": "ok" }))) + .mount(&provider_server) + .await; + + let state = state_with_providers( + vec![provider( + format!("{}/transcribe", provider_server.uri()), + "primary", + )], + 50_000_000, + ) + .await; + let (bearer, device_key) = create_account(&state, 100_000).await; + + let upload_bytes = 5_000_000usize; + let response = transcribe_request(state.clone(), &bearer, "req-large", upload_bytes).await; + + assert_eq!(response.status(), StatusCode::OK); + let expected_seconds = (upload_bytes as u64).div_ceil(BYTES_PER_SECOND as u64) as i64; + let account = accounts::find_by_device_key(&state.pool, &device_key) + .await + .unwrap() + .unwrap(); + assert_eq!(account.balance_seconds, 100_000 - expected_seconds); +} + +#[tokio::test] +async fn missing_request_id_is_rejected() { + let provider_server = MockServer::start().await; + let state = state_with_providers( + vec![provider( + format!("{}/transcribe", provider_server.uri()), + "primary", + )], + 1_000_000, + ) + .await; + let (bearer, _device_key) = create_account(&state, 60).await; + + let response = app(state) + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/transcribe") + .header(header::AUTHORIZATION, format!("Bearer {bearer}")) + .header(header::CONTENT_TYPE, "multipart/form-data; boundary=test") + .body(Body::from(vec![0u8; 100])) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +}