From fc145b8a4edfc4eb0b6fea98eb65335a4ac8b751 Mon Sep 17 00:00:00 2001 From: Yan Lucas Date: Thu, 2 Jul 2026 16:49:37 -0300 Subject: [PATCH 01/13] feat(api): esqueleto axum + GET /health + CI (API-T1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cria api/ na raiz do monorepo (cargo init, edição 2024) com axum+tokio e GET /health; rustfmt.toml; CI (.github/workflows/api.yml, path filter api/**) rodando fmt+clippy+test; docker-compose.yml com Postgres de dev/teste; README com setup e convenções de teste. Sincroniza spec/ design/tasks da keyflow-api nesta worktree (Frente B) e marca API-T1 concluída no tasks.md. --- .github/workflows/api.yml | 39 ++ .gitignore | 3 + .specs/features/keyflow-api/context.md | 8 +- .specs/features/keyflow-api/design.md | 193 +++++++++ .specs/features/keyflow-api/spec.md | 8 +- .specs/features/keyflow-api/tasks.md | 249 ++++++++++++ api/Cargo.lock | 518 +++++++++++++++++++++++++ api/Cargo.toml | 11 + api/README.md | 53 +++ api/docker-compose.yml | 14 + api/rustfmt.toml | 1 + api/src/main.rs | 43 ++ 12 files changed, 1133 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/api.yml create mode 100644 .specs/features/keyflow-api/design.md create mode 100644 .specs/features/keyflow-api/tasks.md create mode 100644 api/Cargo.lock create mode 100644 api/Cargo.toml create mode 100644 api/README.md create mode 100644 api/docker-compose.yml create mode 100644 api/rustfmt.toml create mode 100644 api/src/main.rs diff --git a/.github/workflows/api.yml b/.github/workflows/api.yml new file mode 100644 index 00000000..4b6b053a --- /dev/null +++ b/.github/workflows/api.yml @@ -0,0 +1,39 @@ +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 + 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 e6001f9e..4962f3e8 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ build/ # Saída regenerável do harness de verificação (o golden versionado vem em verification/golden/, T26) /verification/*.png + +# KeyFlow API (Rust, monorepo — api/) +api/target/ diff --git a/.specs/features/keyflow-api/context.md b/.specs/features/keyflow-api/context.md index b32b13fd..f0df33ea 100644 --- a/.specs/features/keyflow-api/context.md +++ b/.specs/features/keyflow-api/context.md @@ -1,12 +1,14 @@ # KeyFlow API — Context (decisões da discussão, 2026-07-02) Gray areas resolvidas com o Yan no brainstorm de monetização. A spec do app consumidor é -`.specs/features/monetization/`. **Esta spec migra para o repositório da API (Rust) quando ele -existir** (decisão do Yan). +`.specs/features/monetization/`. **Atualização 2026-07-02: monorepo** — o Yan optou por manter a +API neste repositório (código em `api/` na raiz); esta spec permanece aqui. Supersede a decisão +anterior de migrar para repo próprio. ## M1 — Duas specs, API primeiro A API é fundação (identidade, validação de compra, ledger, proxy, manifest, config remota); a -monetização no app é consumidora. Specs separadas também facilitam mover esta para o repo Rust. +monetização no app é consumidora. Specs separadas mantêm a fronteira clara (backend × app +consumidor) — agora dentro do mesmo monorepo. ## M2 — Sem login, com segurança server-side (pergunta central do Yan) "Tem como ser seguro sem login?" — Sim, com o trio: diff --git a/.specs/features/keyflow-api/design.md b/.specs/features/keyflow-api/design.md new file mode 100644 index 00000000..77dedf5d --- /dev/null +++ b/.specs/features/keyflow-api/design.md @@ -0,0 +1,193 @@ +# KeyFlow API — Design + +Arquitetura do backend Rust. Decisões em `context.md` (M1–M6); requisitos em `spec.md`. +**Status**: Draft (aguardando aprovação do Yan). + +> **Monorepo (decisão do Yan, 2026-07-02)**: o código vive em **`api/`** na raiz deste +> repositório, irmã de `app/` — Cargo/CI/docker próprios; o Gradle não enxerga a pasta e +> vice-versa. Esta spec permanece em `.specs/features/keyflow-api/`. O módulo Android só é +> tocado pela feature `monetization` (consumidora). + +## Princípio condutor + +**O ledger no Postgres é a única fonte de verdade; ninguém é confiado sem prova.** O app nunca +decide crédito (só apresenta tokens); o Google nunca é aceito sem validação criptográfica ou +server-to-server (Play Developer API, Play Integrity decode, ECDSA do AdMob). Nenhum dado pessoal, +áudio ou texto transcrito persiste (ID-03) — o schema inteiro é auditável por inspeção. + +``` +app (Bearer token) ──► axum router + ├─ POST /v1/register ──────► Play Integrity decode (Google) ──► accounts (+ token) + ├─ GET /v1/account ───────► saldo + entitlements (barato, sem Integrity) + ├─ POST /v1/purchases/redeem ► Play Developer API ──► tx: purchases+ledger+entitlements + ├─ POST /v1/transcribe ────► stream ──► provedor (chave do servidor) ──► débito atômico + ├─ GET /v1/models · GET /v1/config ──► documents (ETag/304) + ├─ GET /v1/ssv/admob ─────► ECDSA AdMob ──► tx: rewards+ledger (cap diário) + └─ GET /health +job em background: voided purchases (reverte reembolsos — PAY-06) +Postgres: accounts · ledger · purchases · rewards · transcribe_requests · documents +``` + +## Stack (OPS-01) + +- **Rust stable**; **axum** + **tokio**; **sqlx** (Postgres, migrations versionadas); **reqwest** + (rustls) para Google/provedores; **serde**; **tracing** + subscriber JSON (OPS-02); rate limit + keyed (crate tipo `governor` ou tower middleware próprio — decidir na task com a chain de + verificação, não fabricar API de crate). +- Versões exatas dos crates conferidas na API-T1 (docs.rs/crates.io) — nunca de memória. +- TLS termina num reverse proxy na frente (Caddy/nginx/LB do host); o binário fala HTTP no + loopback. OPS-03 é atendido no deploy (API-T13). + +## Identidade & auth (ID-01..04) + +- `POST /v1/register` `{deviceKey, integrityToken}`: + 1. Decodifica o token via `playintegrity.googleapis.com/v1/{pkg}:decodeIntegrityToken` (OAuth de + service account). Verdicts exigidos: `requestPackageName` correto, + `appRecognitionVerdict = PLAY_RECOGNIZED` (config permite relaxar p/ build de teste/interno) e + `deviceRecognitionVerdict ⊇ MEETS_DEVICE_INTEGRITY`. Falha → **403** `{"error":"integrity"}`. + 2. `deviceKey` (UUID gerado no device, M2) inédito → cria `account`; existente → **rotaciona o + token de acesso e devolve a mesma conta** (re-registro É o fluxo de recuperação do edge + "conta não existe/estado corrompido"). + 3. `accessToken`: 32 bytes aleatórios (base64url), devolvido **uma única vez**; armazenado só + como SHA-256 (`token_hash`). Middleware Bearer resolve `Account` p/ as demais rotas. +- **Onde o Integrity é exigido**: `register` e `redeem` (operações que criam valor). O `transcribe` + **não** exige Integrity por chamada (Bearer basta) → **indisponibilidade do Google não trava quem + já tem saldo** (edge da spec decidido aqui; SSV é assinado pelo AdMob, não pelo app). +- **Rate limit (ID-04)**: por IP no `register`; por conta nas demais; estouro → **429** + + `Retry-After`. + +## Schema (Postgres) + +```sql +accounts(id uuid pk default gen_random_uuid(), + device_key text unique not null, + token_hash bytea not null, + balance_seconds bigint not null default 0, -- materializado; ledger é a auditoria + ads_off bool not null default false, + skins_unlocked bool not null default false, + created_at timestamptz not null default now()) + +ledger(id bigserial pk, account_id uuid references accounts, + delta_seconds bigint not null, + reason text not null check (reason in ('purchase','transcription','reward','revoke')), + ref text, -- ex.: product_id, request_id (sem PII) + created_at timestamptz not null default now()) + +purchases(token_hash bytea pk, -- sha256(purchaseToken): idempotência PAY-04 + account_id uuid, product_id text, status text, + credited_seconds bigint, granted_entitlements bool, + created_at timestamptz) + +rewards(transaction_id text pk, -- idempotência ADS-01 + account_id uuid, seconds bigint, created_at timestamptz) + +transcribe_requests(account_id uuid, request_id text, -- idempotência de débito (edge de retry) + seconds bigint, created_at timestamptz, + primary key (account_id, request_id)) + +documents(key text pk, body jsonb not null, updated_at timestamptz) -- 'models' e 'config' +``` + +- **Toda mutação de saldo é uma transação**: insert no `ledger` + `UPDATE accounts SET + balance_seconds = balance_seconds + $delta ...` na mesma tx (crédito/débito nunca parcial, + OPS-01; corrida de débito resolvida pelo banco). +- Reembolso (`revoke`) atualiza **sem guarda** — saldo pode negativar (spec: bloqueia o proxy até + nova recarga; consulta continua funcionando). + +## Compras (PAY-01..06) + +- **Catálogo** em `documents.config.products`: `credits_10/20/50` (minutos por SKU — Yan calibra + sem release, M4) e `unlock_forever`; SKUs ≥ R$20 marcam `grantsEntitlements: true` (PAY-03). +- `POST /v1/purchases/redeem` `{productId, purchaseToken, integrityToken}`: + 1. Integrity (acima) → 403 se inválido. + 2. `purchases.products.get` na Play Developer API (service account, escopo `androidpublisher`): + exige `purchaseState = PURCHASED`, `productId` confere e `obfuscatedExternalAccountId == + sha256hex(deviceKey)[..64]` (elo compra↔identidade, M2; limite de 64 chars do Play). + 3. `PENDING` → responde `{"status":"pending"}` **sem creditar** (app re-tenta depois). + 4. Sucesso → tx: `INSERT INTO purchases ... ON CONFLICT (token_hash) DO NOTHING`; conflito → + devolve o **resultado original** (idempotência PAY-04); senão credita ledger+saldo e, se + `grantsEntitlements`, liga `ads_off`+`skins_unlocked`. Depois **acknowledge** server-side. + O **consume** do consumível é do app (Billing client), só após o redeem OK — contrato com + `monetization` CRD-03. +- **Voided purchases job** (PAY-06): task tokio periódica (intervalo na config) chama + `voidedpurchases.list`; para cada token conhecido → tx `revoke` (ledger negativo) e recomputa + entitlements a partir das purchases restantes válidas. + +## Proxy de transcrição (TRX-01..05) + +- `POST /v1/transcribe`: multipart OpenAI-style **repassado em streaming** — body stream do axum → + `reqwest::Body::wrap_stream` (nunca o arquivo inteiro em memória, TRX-04). Contador de bytes no + caminho: estourou o cap da config → **413** com os limites (TRX-03). +- **Débito por duração derivada de bytes**: o `AudioRecorder` do app grava AAC CBR 64 kbps → + `segundos = ceil(bytes / 8000)` (tabela bitrate×content-type na config). Header opcional + `X-Audio-Seconds` do app serve de cross-check/telemetria; o valor **autoritativo é o derivado de + bytes** (não dá pra mentir pra baixo em CBR sem cortar áudio). *Decisão registrada: suficiente + p/ MVP; revisitar se entrar VBR/outros formatos.* +- **Fluxo**: saldo ≤ 0 → **402** `{"balanceSeconds": n}` antes de tocar o provedor; senão stream → + provedor → sucesso → **tx de débito atômica com a resposta** (TRX-02; erro/timeout upstream → + não debita). Corrida entre duas transcrições simultâneas da mesma conta (raro — o fluxo do app é + serial): débito do sucesso pode negativar levemente; o próximo uso leva 402. Simples e justo. +- **Idempotência** (edge de retry): header `X-Request-Id` (UUID por gravação, gerado no app) → + `INSERT INTO transcribe_requests ... ON CONFLICT DO NOTHING`; conflito → re-executa upstream mas + **não debita de novo** (não persistimos texto, ID-03 — trabalho duplicado é o custo aceito). +- **Resposta**: repassa o JSON do provedor (`{"text": ...}` — contrato que o `TranscriptionClient` + parseia) + header **`X-KeyFlow-Balance-Seconds`** com o saldo restante (CRD-05 do app lê daqui; + o corpo fica 100% OpenAI-compatible). +- **Timeout/fallback (TRX-05)**: timeout upstream do config (teto); expira → **504** distinguível + (o app mapeia p/ `ErrorKind.TIMEOUT`). `providers` na config é uma lista ordenada + `[{name, url, authHeaderName, authValueEnv, model}]`: falha de conexão/5xx imediato → tenta o + próximo; timeout não faz fallback (responde logo). + +## Manifest & config remota (MDL-01, CFG-01) + +- `GET /v1/models` e `GET /v1/config` servem `documents[key].body`; **ETag** = sha256 do body + (`If-None-Match` → 304); `Cache-Control: max-age=300`. Latência alvo < 100 ms (é um SELECT de + linha única). +- Edição no MVP: SQL direto/migration + deploy (painel admin fora de escopo). O shape do + `config` inclui: `products` (minutos/SKU), `ads` (cooldown inicial, fator, caps, engajamento + mínimo, janela pós-compra), `transcription` (caps de tamanho/duração, providers, timeout), + `minAppVersion`, `killSwitch`. + +## Rewarded SSV (ADS-01) + +- `GET /v1/ssv/admob?...&signature=...&key_id=...`: verifica **ECDSA** da query string conforme a + doc do AdMob (chaves públicas de `https://www.gstatic.com/admob/reward/verifier-keys.json`, + cacheadas e re-buscadas quando `key_id` desconhecido). `custom_data` = accountId anônimo. + Válido → tx `rewards` (idempotente por `transaction_id`) + crédito com **cap diário** por conta + (config). Assinatura inválida → 403 sem crédito. + +## Operacional (OPS-02..04) + +- `tracing` JSON estruturado + `TraceLayer` (latência por rota); eventos de negócio contados por + log (débitos, rejeições de Integrity, erros de provedor) — métricas Prometheus são futuro. +- Secrets **só por ambiente**: `DATABASE_URL`, `GOOGLE_SERVICE_ACCOUNT_JSON_PATH`, + `PROVIDER_KEY_*`, `ADMOB_SSV_RELAXED` (test) etc. Nada no repositório (OPS-03). +- `GET /health`: `SELECT 1` + versão do binário (OPS-04). + +## Plano de testes + +Sem TESTING.md do app aqui — convenção própria do repo Rust (fica no README da T1): + +- **Unit** (`cargo test`): validação de verdicts, duração por bytes (arredondamento), ETag, + seleção de fallback, verificação ECDSA (vetor de teste fixo), regra de entitlements por SKU, + recomputação de entitlements pós-revoke. +- **Integração** (`cargo test` com Postgres via Docker + **wiremock** p/ Google/provedor): + register (integrity ok/fail) → account → redeem (credita 1×; replay idempotente; PENDING; + conta errada) → transcribe (402/413/débito/idempotência/timeout) → SSV (cap diário, replay). +- **Contrato**: resposta do `/v1/transcribe` parseável pelo parser mínimo do app (`{"text":...}`). +- **Gate `rust`** (rodar dentro de `api/`): `cargo fmt --check && cargo clippy --all-targets + -- -D warnings && cargo test` (CI desde a T1 com path filter `api/**`; testes de integração + exigem `DATABASE_URL` de teste — `api/docker-compose.yml`). + +## Riscos / mitigação + +- **Quota do Play Integrity** (classic requests): registro/resgate são raros — cabe no free tier; + standard requests se o volume crescer. +- **APIs Google (Developer API/Integrity/SSV)** têm formatos exatos que NÃO devem ser fabricados — + cada task que as toca confere a doc oficial (Knowledge Verification Chain) e cobre com wiremock. +- **`obfuscatedAccountId` ≤ 64 chars** → sha256 hex truncado (documentado no design da + `monetization` também — os dois lados usam a MESMA função). +- **Duração por bytes** subestima/superestima se o app mudar bitrate → tabela na config + teste de + contrato no app (monetization) fixa o bitrate esperado. +- **Monorepo**: CI da API com path filter (`api/**`) p/ não rodar cargo em commits só-Android + (e o CI Android, se existir, ignora `api/**`); `api/target/` no `.gitignore` da raiz. diff --git a/.specs/features/keyflow-api/spec.md b/.specs/features/keyflow-api/spec.md index 8d3d4b12..3a1b5622 100644 --- a/.specs/features/keyflow-api/spec.md +++ b/.specs/features/keyflow-api/spec.md @@ -6,8 +6,8 @@ > manifest de modelos para download e config remota (preços, parâmetros de anúncios, kill-switch). > Decisões em `context.md` (M1–M6). A spec do app que consome isto é `.specs/features/monetization/`. > -> *Nota do Yan (2026-07-02): esta spec será movida para o repositório da API (Rust) quando ele -> existir — está aqui para nascer junto da spec de monetização que a consome.* +> *Nota (2026-07-02, atualizada): o plano inicial era repo próprio, mas o Yan optou por +> **monorepo** — o código vive em `api/` na raiz deste repositório e esta spec permanece aqui.* ## Problem Statement @@ -173,8 +173,8 @@ sem saldo → 402; provedor derrubado → erro sem débito. | ADS-01 | Rewarded SSV | Pending | | OPS-01..04 | Operacional | Pending | -**Coverage:** 22 total, 0 mapeados a tasks (fases Design/Tasks pendentes — e a spec migra para o -repositório da API). +**Coverage:** 22 total, mapeados em `tasks.md` (API-T1..T14 — código em `api/` no monorepo; +implementação pendente). --- diff --git a/.specs/features/keyflow-api/tasks.md b/.specs/features/keyflow-api/tasks.md new file mode 100644 index 00000000..6f512fc4 --- /dev/null +++ b/.specs/features/keyflow-api/tasks.md @@ -0,0 +1,249 @@ +# KeyFlow API — Tasks + +**Spec**: `spec.md` · **Design**: `design.md` · **Contexto**: `context.md` (M1–M6) +**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). +> Gate `rust` (rodar dentro de `api/`): `cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test`. +> Integração exige Postgres local (`api/docker-compose.yml`) + `wiremock` p/ Google/provedor. +> **Nunca fabricar formato de API do Google** (Integrity/Developer API/SSV) — conferir a doc +> oficial na task e fixar o formato num teste wiremock. +> Credenciais reais (service account, chaves de provedor) são do Yan — tasks que dependem delas +> têm a parte "com credencial" marcada como **manual/Yan**. + +## Execution Plan + +``` +Fase A (fundação): API-T1 → API-T2 → API-T3 +Fase B (identidade): API-T3 → API-T4 → API-T5 → API-T6 [P] · API-T7 [P] +Fase C (compras): (T4,T5) → API-T8 → API-T9 +Fase D (conteúdo): T3 → API-T10 [P] +Fase E (proxy): (T5,T10) → API-T11 +Fase F (rewarded): (T5,T10) → API-T12 +Fase G (deploy/e2e): (T6..T12) → API-T13 → API-T14 +``` +Ordem prática sequencial: T1 → T2 → T3 → T4 → T5 → T6 → T7 → T8 → T9 → T10 → T11 → T12 → T13 → T14. +**`[P]`**: T6/T7/T10 são independentes entre si após suas deps; tudo aqui é JVM-free (sem device), +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 — ✅ 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`; + `api/docker-compose.yml` com Postgres de dev/teste; `api/README.md` com setup + convenções de + teste (design §Plano de testes); `.gitignore` da raiz ganha `api/target/`. A spec permanece em + `.specs/features/keyflow-api/`. +- **Where**: `api/Cargo.toml`, `api/src/main.rs`, `.github/workflows/api.yml`, + `api/docker-compose.yml`, `api/README.md`, `.gitignore`. +- **Depends on**: —. **Covers**: OPS-04 (parcial). +- **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 +- **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). +- **Where**: `src/config.rs`, `src/main.rs`. +- **Depends on**: API-T1. **Covers**: OPS-02, OPS-03 (parcial). +- **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`. + +### API-T3 — Postgres + migrations do schema núcleo + helpers de tx +- **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`. +- **Where**: `migrations/`, `src/db/`. +- **Depends on**: API-T2. **Covers**: OPS-01, ID-03 (schema sem PII — verificável), OPS-04. +- **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`. + +## Fase B — Identidade + +### API-T4 — Google auth + Play Integrity decode client +- **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 + fixtures wiremock. +- **Where**: `src/google/{auth,integrity}.rs`. +- **Depends on**: API-T3 (só pelo esqueleto; lógica é pura+HTTP). **Covers**: ID-02 (parcial). +- **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`. + +### API-T5 — `POST /v1/register` + middleware Bearer +- **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). +- **Where**: `src/routes/register.rs`, `src/auth.rs`. +- **Depends on**: API-T3, API-T4. **Covers**: ID-01, ID-02, ID-03. +- **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). +- **Tests**: integração + unit (hash/parse do Bearer). **Gate**: `rust`. + +### API-T6 — Rate limiting `[P]` +- **What**: limite por IP (register) e por conta (rotas autenticadas), parâmetros na config; + estouro → 429 + `Retry-After`. +- **Where**: `src/middleware/ratelimit.rs`. +- **Depends on**: API-T5. **Covers**: ID-04. +- **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`. + +### API-T7 — `GET /v1/account` `[P]` +- **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`. + +## Fase C — Compras + +### API-T8 — `POST /v1/purchases/redeem` (validação + idempotência + entitlements) +- **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 + contra `sha256hex(deviceKey)[..64]`. +- **Where**: `src/routes/redeem.rs`, `src/google/publisher.rs`. +- **Depends on**: API-T4, API-T5 (+catálogo da config; shape mínimo pode ser embutido até T10). +- **Covers**: PAY-01, PAY-02, PAY-03, PAY-04. +- **Done when**: integração — compra válida credita 1×; replay → resultado original sem crédito + 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`. + +### API-T9 — Job de voided purchases +- **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×). +- **Where**: `src/jobs/voided.rs`. +- **Depends on**: API-T8. **Covers**: PAY-06. +- **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`. + +## Fase D — Conteúdo + +### API-T10 — `GET /v1/models` + `GET /v1/config` (ETag) `[P]` +- **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 + já tiver colhido, senão placeholder marcado). +- **Where**: `src/routes/content.rs`, `migrations/seed*`. +- **Depends on**: API-T3. **Covers**: MDL-01, CFG-01. +- **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`. + +## Fase E — Proxy + +### API-T11 — `POST /v1/transcribe` (streaming + débito atômico + 402/413 + fallback) +- **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 + por `X-Request-Id`; header `X-KeyFlow-Balance-Seconds` na resposta; timeout → 504 distinguível; + fallback de provedor em falha de conexão/5xx. +- **Where**: `src/routes/transcribe.rs`. +- **Depends on**: API-T5, API-T10 (config de providers/caps). +- **Covers**: TRX-01, TRX-02, TRX-03, TRX-04, TRX-05. +- **Done when**: integração (wiremock como "provedor") — 60s de saldo + upload de "10s" → saldo + 50s + `{"text":...}` repassado + header de saldo; sem saldo → 402; cap → 413; provedor caindo → + fallback usado; provedor final falhando → erro sem débito; retry com mesmo `X-Request-Id` não + 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`. + +## Fase F — Rewarded + +### API-T12 — `GET /v1/ssv/admob` (ECDSA + cap diário) +- **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. +- **Where**: `src/routes/ssv.rs`. +- **Depends on**: API-T5, API-T10. +- **Covers**: ADS-01. +- **Done when**: unit — assinatura com vetor de teste (chave própria gerada no teste) passa/falha; + 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`. + +## Fase G — Deploy & e2e + +### API-T13 — Deploy (TLS/secrets/Postgres) + smoke em staging +- **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 + pendente de credencial real). **Parte com credenciais reais (service account, chave de + provedor, domínio/TLS) é do Yan** — a task prepara e documenta cada passo. +- **Where**: `api/Dockerfile`, `api/docs/deploy.md`, infra do Yan. +- **Depends on**: API-T6..T12. **Covers**: OPS-03, OPS-04. +- **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. + +### API-T14 — e2e com o app + traceability +- **What**: com a `monetization` (MZ) implementada no app: rodar o Independent Test de cada área + da spec contra o staging (registro/compras de teste do Play/transcrição/débito/402/reembolso/ + SSV de teste); ajustar traceability do `spec.md` → Verified. Cruza com MZ-T15 (mesma sessão de + validação; ver `DEVELOPMENT-ORDER.md`). +- **Where**: staging + device físico do Yan; `spec.md`. +- **Depends on**: API-T13 + monetization MZ-T1..T8 no app. +- **Covers**: success criteria da spec. +- **Done when**: fluxos e2e documentados; requisitos → Verified (os que dependem de credencial/ + produção anotados como tal). +- **Tests**: manual. **Gate**: manual. + +--- + +## Validações (pré-aprovação) + +### Diagram ↔ Depends-on +| Task | Depends on (corpo) | Diagrama | ✓ | +| ---- | ------------------ | -------- | - | +| T1 | — | raiz A | ✅ | +| T2 | T1 | T1→T2 | ✅ | +| T3 | T2 | T2→T3 | ✅ | +| T4 | T3 | T3→T4 | ✅ | +| T5 | T3, T4 | T4→T5 | ✅ | +| T6 | T5 | T5→T6 `[P]` | ✅ | +| T7 | T5 | T5→T7 `[P]` | ✅ | +| T8 | T4, T5 | (T4,T5)→T8 | ✅ | +| T9 | T8 | T8→T9 | ✅ | +| T10 | T3 | T3→T10 `[P]` | ✅ | +| T11 | T5, T10 | (T5,T10)→T11 | ✅ | +| T12 | T5, T10 | (T5,T10)→T12 | ✅ | +| T13 | T6..T12 | →T13 | ✅ | +| T14 | T13 (+MZ do app) | T13→T14 | ✅ | + +### Cobertura de requisitos +| Req | Tasks | +| --- | ----- | +| ID-01..04 | T5 (01/02/03), T6 (04), T4 (02) | +| PAY-01..06 | T8 (01..04), T7 (05), T9 (06) | +| TRX-01..05 | T11 | +| MDL-01, CFG-01 | T10 | +| ADS-01 | T12 | +| OPS-01..04 | T3 (01), T2 (02/03), T13 (03/04), T1 (04) | + +**Saída**: backend Rust em staging com identidade anônima segura, compras validadas e +idempotentes, ledger transacional, proxy de transcrição streaming com débito por segundo, +manifest/config remota cacheáveis e SSV de rewarded — pronto p/ o app (`monetization`) consumir. diff --git a/api/Cargo.lock b/api/Cargo.lock new file mode 100644 index 00000000..5470af9c --- /dev/null +++ b/api/Cargo.lock @@ -0,0 +1,518 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[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 = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[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 = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[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 = "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", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "keyflow-api" +version = "0.1.0" +dependencies = [ + "axum", + "tokio", + "tower", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[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", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[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 = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[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 = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[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" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys", +] + +[[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" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[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 = "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-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-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[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 = "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..295291e8 --- /dev/null +++ b/api/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "keyflow-api" +version = "0.1.0" +edition = "2024" + +[dependencies] +axum = "0.8.9" +tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "net", "signal"] } + +[dev-dependencies] +tower = { version = "0.5.3", features = ["util"] } diff --git a/api/README.md b/api/README.md new file mode 100644 index 00000000..548eefeb --- /dev/null +++ b/api/README.md @@ -0,0 +1,53 @@ +# 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`). + +```bash +cargo run # sobe em 0.0.0.0:8080, GET /health +``` + +## Gate `rust` + +Rodar sempre dentro de `api/`: + +```bash +cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test +``` + +CI (`.github/workflows/api.yml`) roda o mesmo gate com path filter `api/**`. + +## Convenções de teste + +- **Unit** (`cargo test`, sem I/O externo): regras puras — 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. +- **Integração** (mesmo `cargo test`, requer `DATABASE_URL` de teste — `docker-compose.yml`): + exercita rotas reais via `tower::ServiceExt::oneshot`/servidor real; Google/provedores externos + são substituídos por **wiremock** (nunca chamados de verdade em teste). Cada teste de + integração usa um schema/transação isolada para não vazar estado entre testes. +- **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. + +## 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/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/main.rs b/api/src/main.rs new file mode 100644 index 00000000..5dfccfba --- /dev/null +++ b/api/src/main.rs @@ -0,0 +1,43 @@ +use axum::{Router, routing::get}; +use tokio::net::TcpListener; + +async fn health() -> &'static str { + "ok" +} + +fn app() -> Router { + Router::new().route("/health", get(health)) +} + +#[tokio::main] +async fn main() { + let addr = "0.0.0.0:8080"; + let listener = TcpListener::bind(addr) + .await + .unwrap_or_else(|e| panic!("failed to bind {addr}: {e}")); + println!("keyflow-api listening on {addr}"); + axum::serve(listener, app()).await.expect("server error"); +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use tower::ServiceExt; + + #[tokio::test] + async fn health_returns_200() { + let response = app() + .oneshot( + Request::builder() + .uri("/health") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + } +} From 88380296e482453eac37171625d559cc97e87a32 Mon Sep 17 00:00:00 2001 From: Yan Lucas Date: Thu, 2 Jul 2026 17:09:14 -0300 Subject: [PATCH 02/13] =?UTF-8?q?feat(api):=20config=20tipada=20por=20ambi?= =?UTF-8?q?ente=20+=20tracing=20JSON=20com=20lat=C3=AAncia=20por=20rota=20?= =?UTF-8?q?(API-T2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Config::from_env() tipa DATABASE_URL (obrigatória) e BIND_ADDR (default 0.0.0.0:8080) com erro claro na falta/valor inválido. TraceLayer via route_layer (não layer, por causa da extensão MatchedPath) loga rota e latência em JSON; RUST_LOG default já deixa isso visível sem config extra. Comportamento de log verificado por inspeção documentada (cargo run real) em vez de unit test, que ficou flaky por causa do cache global de Interest por callsite do crate tracing. --- .specs/features/keyflow-api/tasks.md | 15 ++- api/Cargo.lock | 161 ++++++++++++++++++++++++++ api/Cargo.toml | 3 + api/README.md | 15 ++- api/src/config.rs | 162 +++++++++++++++++++++++++++ api/src/main.rs | 60 +++++++++- 6 files changed, 409 insertions(+), 7 deletions(-) create mode 100644 api/src/config.rs diff --git a/.specs/features/keyflow-api/tasks.md b/.specs/features/keyflow-api/tasks.md index 6f512fc4..9a1cd018 100644 --- a/.specs/features/keyflow-api/tasks.md +++ b/.specs/features/keyflow-api/tasks.md @@ -55,7 +55,7 @@ então `[P]` real se houver dois agentes — senão seguir a ordem sequencial. 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). @@ -64,6 +64,19 @@ 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 - **What**: sqlx com pool + migrations do design §Schema (accounts, ledger, purchases, rewards, diff --git a/api/Cargo.lock b/api/Cargo.lock index 5470af9c..1fbe40d2 100644 --- a/api/Cargo.lock +++ b/api/Cargo.lock @@ -2,6 +2,15 @@ # 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 = "atomic-waker" version = "1.1.2" @@ -60,12 +69,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + [[package]] name = "bytes" version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + [[package]] name = "errno" version = "0.3.14" @@ -211,8 +232,17 @@ dependencies = [ "axum", "tokio", "tower", + "tower-http", + "tracing", + "tracing-subscriber", ] +[[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" @@ -225,6 +255,15 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[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" @@ -254,6 +293,15 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -290,6 +338,23 @@ dependencies = [ "proc-macro2", ] +[[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 = "ryu" version = "1.0.23" @@ -361,6 +426,15 @@ dependencies = [ "serde", ] +[[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 = "signal-hook-registry" version = "1.4.8" @@ -410,6 +484,15 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +[[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 = "tokio" version = "1.52.3" @@ -452,6 +535,23 @@ dependencies = [ "tracing", ] +[[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" @@ -472,9 +572,21 @@ 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" @@ -482,6 +594,49 @@ 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]] @@ -490,6 +645,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/api/Cargo.toml b/api/Cargo.toml index 295291e8..80139202 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -6,6 +6,9 @@ edition = "2024" [dependencies] axum = "0.8.9" 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"] } [dev-dependencies] tower = { version = "0.5.3", features = ["util"] } diff --git a/api/README.md b/api/README.md index 548eefeb..0ad07e47 100644 --- a/api/README.md +++ b/api/README.md @@ -11,9 +11,22 @@ Módulo disjunto do app Android (`app/`): o Gradle não enxerga esta pasta e vic - 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. ```bash -cargo run # sobe em 0.0.0.0:8080, GET /health +DATABASE_URL=postgres://keyflow:keyflow@localhost/keyflow_api cargo run # GET /health +``` + +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` diff --git a/api/src/config.rs b/api/src/config.rs new file mode 100644 index 00000000..26d41043 --- /dev/null +++ b/api/src/config.rs @@ -0,0 +1,162 @@ +use std::fmt; +use std::net::SocketAddr; + +const DEFAULT_BIND_ADDR: &str = "0.0.0.0:8080"; + +#[derive(Debug, Clone)] +pub struct Config { + // Consumido pelo pool sqlx da API-T3; validado (presença) já aqui na T2. + #[allow(dead_code)] + pub database_url: String, + pub bind_addr: SocketAddr, +} + +#[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 { + let database_url = + std::env::var("DATABASE_URL").map_err(|_| ConfigError::MissingEnv("DATABASE_URL"))?; + + let bind_addr = match std::env::var("BIND_ADDR") { + Ok(value) => value + .parse::() + .map_err(|e| ConfigError::InvalidEnv { + var: "BIND_ADDR", + value, + reason: e.to_string(), + })?, + Err(_) => DEFAULT_BIND_ADDR + .parse() + .expect("default bind addr is valid"), + }; + + Ok(Config { + database_url, + bind_addr, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + // Env vars são estado global do processo: serializa os testes deste módulo para não correr + // um contra o outro (cada teste seta/remove as vars antes de rodar). + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn missing_database_url_fails_with_specific_message() { + let _guard = ENV_LOCK.lock().unwrap(); + // SAFETY: testes deste módulo seguram ENV_LOCK antes de tocar env vars de processo. + unsafe { + std::env::remove_var("DATABASE_URL"); + std::env::remove_var("BIND_ADDR"); + } + + let err = Config::from_env().unwrap_err(); + + assert_eq!( + err.to_string(), + "missing required environment variable: DATABASE_URL" + ); + } + + #[test] + fn valid_env_produces_config() { + let _guard = ENV_LOCK.lock().unwrap(); + // SAFETY: guarda por ENV_LOCK. + unsafe { + std::env::set_var( + "DATABASE_URL", + "postgres://keyflow:keyflow@localhost/keyflow_api", + ); + std::env::set_var("BIND_ADDR", "127.0.0.1:9090"); + } + + let config = Config::from_env().unwrap(); + + assert_eq!( + config.database_url, + "postgres://keyflow:keyflow@localhost/keyflow_api" + ); + assert_eq!(config.bind_addr, "127.0.0.1:9090".parse().unwrap()); + + // SAFETY: guarda por ENV_LOCK. + unsafe { + std::env::remove_var("DATABASE_URL"); + std::env::remove_var("BIND_ADDR"); + } + } + + #[test] + fn missing_bind_addr_falls_back_to_default() { + let _guard = ENV_LOCK.lock().unwrap(); + // SAFETY: guarda por ENV_LOCK. + unsafe { + std::env::set_var( + "DATABASE_URL", + "postgres://keyflow:keyflow@localhost/keyflow_api", + ); + std::env::remove_var("BIND_ADDR"); + } + + let config = Config::from_env().unwrap(); + + assert_eq!(config.bind_addr, DEFAULT_BIND_ADDR.parse().unwrap()); + + // SAFETY: guarda por ENV_LOCK. + unsafe { + std::env::remove_var("DATABASE_URL"); + } + } + + #[test] + fn invalid_bind_addr_fails_with_specific_message() { + let _guard = ENV_LOCK.lock().unwrap(); + // SAFETY: guarda por ENV_LOCK. + unsafe { + std::env::set_var( + "DATABASE_URL", + "postgres://keyflow:keyflow@localhost/keyflow_api", + ); + std::env::set_var("BIND_ADDR", "not-an-addr"); + } + + let err = Config::from_env().unwrap_err(); + + assert!(err.to_string().contains("BIND_ADDR")); + assert!(err.to_string().contains("not-an-addr")); + + // SAFETY: guarda por ENV_LOCK. + unsafe { + std::env::remove_var("DATABASE_URL"); + std::env::remove_var("BIND_ADDR"); + } + } +} diff --git a/api/src/main.rs b/api/src/main.rs index 5dfccfba..9a5b754b 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -1,21 +1,57 @@ +mod config; + +use axum::extract::MatchedPath; +use axum::http::Request; use axum::{Router, routing::get}; +use config::Config; use tokio::net::TcpListener; +use tower_http::trace::TraceLayer; async fn health() -> &'static str { "ok" } fn app() -> Router { - Router::new().route("/health", get(health)) + // `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_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, + ) + }), + ) +} + +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(); } #[tokio::main] async fn main() { - let addr = "0.0.0.0:8080"; - let listener = TcpListener::bind(addr) + init_tracing(); + + let config = Config::from_env().unwrap_or_else(|e| panic!("config error: {e}")); + + let listener = TcpListener::bind(config.bind_addr) .await - .unwrap_or_else(|e| panic!("failed to bind {addr}: {e}")); - println!("keyflow-api listening on {addr}"); + .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()).await.expect("server error"); } @@ -40,4 +76,18 @@ mod tests { 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":[...]} } From 43c5397434d6a2fae0d99e5cce0c880cec9883b5 Mon Sep 17 00:00:00 2001 From: Yan Lucas Date: Thu, 2 Jul 2026 17:39:31 -0300 Subject: [PATCH 03/13] =?UTF-8?q?feat(api):=20Postgres=20+=20migrations=20?= =?UTF-8?q?do=20schema=20n=C3=BAcleo=20+=20helpers=20de=20ledger=20(API-T3?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration única com as 6 tabelas do design (accounts, ledger, purchases, rewards, transcribe_requests, documents), sem PII/áudio/ texto. db::ledger::{credit,debit_guarded,revoke} genéricas sobre sqlx::Acquire — compõem com a tx do chamador via savepoint aninhado. debit_guarded usa UPDATE...WHERE saldo>=delta RETURNING, serializado pelo lock de linha do Postgres (testado com duas transações reais concorrentes). /health passa a fazer SELECT 1 de verdade. Vira lib+bin (src/lib.rs expõe app/config/db/init_tracing) para os testes de integração em tests/ acessarem a API pública. Config::from_env virou wrapper sobre from_map(&HashMap) — os testes antigos mutavam std::env do processo real e corriam risco de colidir com outros testes em paralelo tocando DATABASE_URL. CI ganhou serviço Postgres efêmero para os testes de integração. --- .github/workflows/api.yml | 16 + .specs/features/keyflow-api/tasks.md | 20 +- api/Cargo.lock | 1435 +++++++++++++++++++++++++- api/Cargo.toml | 2 + api/README.md | 29 +- api/migrations/0001_core_schema.sql | 55 + api/src/config.rs | 106 +- api/src/db/ledger.rs | 132 +++ api/src/db/mod.rs | 13 + api/src/lib.rs | 103 ++ api/src/main.rs | 92 +- api/tests/common/mod.rs | 44 + api/tests/db_ledger.rs | 195 ++++ 13 files changed, 2031 insertions(+), 211 deletions(-) create mode 100644 api/migrations/0001_core_schema.sql create mode 100644 api/src/db/ledger.rs create mode 100644 api/src/db/mod.rs create mode 100644 api/src/lib.rs create mode 100644 api/tests/common/mod.rs create mode 100644 api/tests/db_ledger.rs diff --git a/.github/workflows/api.yml b/.github/workflows/api.yml index 4b6b053a..35b23a7a 100644 --- a/.github/workflows/api.yml +++ b/.github/workflows/api.yml @@ -18,6 +18,22 @@ defaults: 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 diff --git a/.specs/features/keyflow-api/tasks.md b/.specs/features/keyflow-api/tasks.md index 9a1cd018..b92e70a9 100644 --- a/.specs/features/keyflow-api/tasks.md +++ b/.specs/features/keyflow-api/tasks.md @@ -78,7 +78,7 @@ então `[P]` real se houver dois agentes — senão seguir a ordem sequencial. (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`. @@ -87,6 +87,24 @@ 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 diff --git a/api/Cargo.lock b/api/Cargo.lock index 1fbe40d2..945d1b55 100644 --- a/api/Cargo.lock +++ b/api/Cargo.lock @@ -11,12 +11,33 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[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 = "axum" version = "0.8.9" @@ -69,11 +90,50 @@ dependencies = [ "tracing", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[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" @@ -81,12 +141,177 @@ 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", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[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", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[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 = "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-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 = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.6", +] + +[[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", + "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 = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +dependencies = [ + "serde", +] + +[[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" @@ -94,9 +319,53 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "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 = "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 = "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" @@ -113,6 +382,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -121,6 +391,40 @@ 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-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" @@ -134,11 +438,103 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", + "futures-io", + "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", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[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", + "rand_core", +] + +[[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 = "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", +] + +[[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" @@ -184,6 +580,15 @@ 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" @@ -220,37 +625,188 @@ dependencies = [ ] [[package]] -name = "itoa" -version = "1.0.18" +name = "icu_collections" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] [[package]] -name = "keyflow-api" -version = "0.1.0" +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ - "axum", - "tokio", - "tower", - "tower-http", - "tracing", - "tracing-subscriber", + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", ] [[package]] -name = "lazy_static" -version = "1.5.0" +name = "icu_normalizer" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] [[package]] -name = "libc" -version = "0.2.186" +name = "icu_normalizer_data" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] -name = "log" +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 = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[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 = "keyflow-api" +version = "0.1.0" +dependencies = [ + "axum", + "sqlx", + "tokio", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[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" @@ -270,6 +826,16 @@ 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" @@ -290,7 +856,7 @@ checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -299,7 +865,16 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", ] [[package]] @@ -308,6 +883,35 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[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 = "percent-encoding" version = "2.3.2" @@ -320,6 +924,21 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -338,6 +957,38 @@ dependencies = [ "proc-macro2", ] +[[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.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[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-automata" version = "0.4.14" @@ -355,12 +1006,72 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[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", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[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 = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "serde" version = "1.0.228" @@ -368,6 +1079,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -415,57 +1127,308 @@ dependencies = [ ] [[package]] -name = "serde_urlencoded" -version = "0.7.1" +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 = "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 = "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 = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ - "form_urlencoded", + "atoi", + "base64", + "bitflags", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", "itoa", - "ryu", + "log", + "md-5", + "memchr", + "rand", "serde", + "serde_json", + "sha2 0.11.0", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", ] [[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 = "signal-hook-registry" -version = "1.4.8" +name = "sqlx-sqlite" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" dependencies = [ - "errno", - "libc", + "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 = "slab" -version = "0.4.12" +name = "stable_deref_trait" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] -name = "smallvec" -version = "1.15.2" +name = "stringprep" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] [[package]] -name = "socket2" -version = "0.6.4" +name = "subtle" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" -dependencies = [ - "libc", - "windows-sys", -] +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" @@ -484,6 +1447,37 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +[[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 = "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" @@ -493,19 +1487,45 @@ dependencies = [ "cfg-if", ] +[[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", + "windows-sys 0.61.2", ] [[package]] @@ -519,6 +1539,17 @@ dependencies = [ "syn", ] +[[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 = "tower" version = "0.5.3" @@ -639,30 +1670,173 @@ dependencies = [ "tracing-serde", ] +[[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.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 = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[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-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 = "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 = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[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" @@ -672,6 +1846,159 @@ 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 = "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 = "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" + +[[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" diff --git a/api/Cargo.toml b/api/Cargo.toml index 80139202..869168f0 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -5,10 +5,12 @@ edition = "2024" [dependencies] axum = "0.8.9" +sqlx = { version = "0.9.0", features = ["postgres", "runtime-tokio", "tls-rustls", "uuid"] } 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] tower = { version = "0.5.3", features = ["util"] } diff --git a/api/README.md b/api/README.md index 0ad07e47..a0cd1854 100644 --- a/api/README.md +++ b/api/README.md @@ -31,23 +31,32 @@ latência da request no evento de resposta: ## Gate `rust` -Rodar sempre dentro de `api/`: +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 -cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test +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/**`. +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): regras puras — 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. -- **Integração** (mesmo `cargo test`, requer `DATABASE_URL` de teste — `docker-compose.yml`): - exercita rotas reais via `tower::ServiceExt::oneshot`/servidor real; Google/provedores externos - são substituídos por **wiremock** (nunca chamados de verdade em teste). Cada teste de - integração usa um schema/transação isolada para não vazar estado entre testes. +- **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 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/src/config.rs b/api/src/config.rs index 26d41043..bfe670c0 100644 --- a/api/src/config.rs +++ b/api/src/config.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::fmt; use std::net::SocketAddr; @@ -5,8 +6,6 @@ const DEFAULT_BIND_ADDR: &str = "0.0.0.0:8080"; #[derive(Debug, Clone)] pub struct Config { - // Consumido pelo pool sqlx da API-T3; validado (presença) já aqui na T2. - #[allow(dead_code)] pub database_url: String, pub bind_addr: SocketAddr, } @@ -38,18 +37,27 @@ impl std::error::Error for ConfigError {} impl Config { pub fn from_env() -> Result { - let database_url = - std::env::var("DATABASE_URL").map_err(|_| ConfigError::MissingEnv("DATABASE_URL"))?; + Self::from_map(&std::env::vars().collect()) + } - let bind_addr = match std::env::var("BIND_ADDR") { - Ok(value) => value + /// 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: value.clone(), reason: e.to_string(), })?, - Err(_) => DEFAULT_BIND_ADDR + None => DEFAULT_BIND_ADDR .parse() .expect("default bind addr is valid"), }; @@ -64,22 +72,17 @@ impl Config { #[cfg(test)] mod tests { use super::*; - use std::sync::Mutex; - // Env vars são estado global do processo: serializa os testes deste módulo para não correr - // um contra o outro (cada teste seta/remove as vars antes de rodar). - static ENV_LOCK: Mutex<()> = Mutex::new(()); + 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 _guard = ENV_LOCK.lock().unwrap(); - // SAFETY: testes deste módulo seguram ENV_LOCK antes de tocar env vars de processo. - unsafe { - std::env::remove_var("DATABASE_URL"); - std::env::remove_var("BIND_ADDR"); - } - - let err = Config::from_env().unwrap_err(); + let err = Config::from_map(&vars(&[])).unwrap_err(); assert_eq!( err.to_string(), @@ -89,74 +92,45 @@ mod tests { #[test] fn valid_env_produces_config() { - let _guard = ENV_LOCK.lock().unwrap(); - // SAFETY: guarda por ENV_LOCK. - unsafe { - std::env::set_var( + let config = Config::from_map(&vars(&[ + ( "DATABASE_URL", "postgres://keyflow:keyflow@localhost/keyflow_api", - ); - std::env::set_var("BIND_ADDR", "127.0.0.1:9090"); - } - - let config = Config::from_env().unwrap(); + ), + ("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()); - - // SAFETY: guarda por ENV_LOCK. - unsafe { - std::env::remove_var("DATABASE_URL"); - std::env::remove_var("BIND_ADDR"); - } } #[test] fn missing_bind_addr_falls_back_to_default() { - let _guard = ENV_LOCK.lock().unwrap(); - // SAFETY: guarda por ENV_LOCK. - unsafe { - std::env::set_var( - "DATABASE_URL", - "postgres://keyflow:keyflow@localhost/keyflow_api", - ); - std::env::remove_var("BIND_ADDR"); - } - - let config = Config::from_env().unwrap(); + let config = Config::from_map(&vars(&[( + "DATABASE_URL", + "postgres://keyflow:keyflow@localhost/keyflow_api", + )])) + .unwrap(); assert_eq!(config.bind_addr, DEFAULT_BIND_ADDR.parse().unwrap()); - - // SAFETY: guarda por ENV_LOCK. - unsafe { - std::env::remove_var("DATABASE_URL"); - } } #[test] fn invalid_bind_addr_fails_with_specific_message() { - let _guard = ENV_LOCK.lock().unwrap(); - // SAFETY: guarda por ENV_LOCK. - unsafe { - std::env::set_var( + let err = Config::from_map(&vars(&[ + ( "DATABASE_URL", "postgres://keyflow:keyflow@localhost/keyflow_api", - ); - std::env::set_var("BIND_ADDR", "not-an-addr"); - } - - let err = Config::from_env().unwrap_err(); + ), + ("BIND_ADDR", "not-an-addr"), + ])) + .unwrap_err(); assert!(err.to_string().contains("BIND_ADDR")); assert!(err.to_string().contains("not-an-addr")); - - // SAFETY: guarda por ENV_LOCK. - unsafe { - std::env::remove_var("DATABASE_URL"); - std::env::remove_var("BIND_ADDR"); - } } } diff --git a/api/src/db/ledger.rs b/api/src/db/ledger.rs new file mode 100644 index 00000000..2b396c90 --- /dev/null +++ b/api/src/db/ledger.rs @@ -0,0 +1,132 @@ +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, + #[allow(dead_code, reason = "consumido pela API-T12 (crédito de rewarded ad)")] + Reward, +} + +impl LedgerReason { + fn as_str(self) -> &'static str { + match self { + LedgerReason::Purchase => "purchase", + LedgerReason::Transcription => "transcription", + LedgerReason::Reward => "reward", + } + } +} + +/// Credita `delta_seconds` (> 0) na conta: `ledger` + `accounts.balance_seconds` 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..3a345ca1 --- /dev/null +++ b/api/src/db/mod.rs @@ -0,0 +1,13 @@ +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/lib.rs b/api/src/lib.rs new file mode 100644 index 00000000..8743743c --- /dev/null +++ b/api/src/lib.rs @@ -0,0 +1,103 @@ +pub mod config; +pub mod db; + +use axum::extract::{MatchedPath, State}; +use axum::http::{Request, StatusCode}; +use axum::{Router, routing::get}; +use sqlx::PgPool; +use tower_http::trace::TraceLayer; + +async fn health(State(pool): State) -> Result<&'static str, StatusCode> { + sqlx::query("select 1") + .execute(&pool) + .await + .map(|_| "ok") + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +pub fn app(pool: PgPool) -> 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_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(pool) +} + +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 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 + } + + #[tokio::test] + async fn health_returns_200_with_real_select_1() { + let pool = test_pool().await; + + let response = app(pool) + .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 index 9a5b754b..e92b57e3 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -1,46 +1,5 @@ -mod config; - -use axum::extract::MatchedPath; -use axum::http::Request; -use axum::{Router, routing::get}; -use config::Config; +use keyflow_api::{app, config::Config, db, init_tracing}; use tokio::net::TcpListener; -use tower_http::trace::TraceLayer; - -async fn health() -> &'static str { - "ok" -} - -fn app() -> 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_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, - ) - }), - ) -} - -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(); -} #[tokio::main] async fn main() { @@ -48,46 +7,19 @@ async fn main() { 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 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()).await.expect("server error"); -} - -#[cfg(test)] -mod tests { - use super::*; - use axum::body::Body; - use axum::http::{Request, StatusCode}; - use tower::ServiceExt; - - #[tokio::test] - async fn health_returns_200() { - let response = app() - .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":[...]} + axum::serve(listener, app(pool)) + .await + .expect("server error"); } diff --git a/api/tests/common/mod.rs b/api/tests/common/mod.rs new file mode 100644 index 00000000..e7309e21 --- /dev/null +++ b/api/tests/common/mod.rs @@ -0,0 +1,44 @@ +use sqlx::PgPool; + +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. +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 +} + +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 +} 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"); +} From 96b70bd2fece67720d82696669278e28e2672d86 Mon Sep 17 00:00:00 2001 From: Yan Lucas Date: Thu, 2 Jul 2026 18:03:27 -0300 Subject: [PATCH 04/13] feat(api): Google auth (JWT service account) + client do Play Integrity decode (API-T4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit google::auth::TokenCache assina JWT RS256 (fluxo oauth2 service account do Google) e cacheia o access token com margem de 30s antes de expirar. google::integrity::IntegrityClient chama decodeIntegrityToken; verify_verdicts (função pura) valida pacote, appRecognitionVerdict e deviceRecognitionVerdict, com flag pra relaxar app-recognition em build de teste sem abrir mão do pacote/ device. Contrato das duas APIs do Google conferido nas fontes oficiais (discovery doc + developer.android.com), fixado em fixtures wiremock — nada fabricado. Chave RSA de teste descartável em tests/fixtures/ só para assinar JWT contra o mock. --- .specs/features/keyflow-api/tasks.md | 23 +- api/Cargo.lock | 992 +++++++++++++++++- api/Cargo.toml | 6 + api/src/google/auth.rs | 237 +++++ api/src/google/integrity.rs | 238 +++++ api/src/google/mod.rs | 2 + api/src/lib.rs | 1 + .../fixtures/test_service_account_key.pem | 28 + api/tests/google_integrity.rs | 94 ++ 9 files changed, 1606 insertions(+), 15 deletions(-) create mode 100644 api/src/google/auth.rs create mode 100644 api/src/google/integrity.rs create mode 100644 api/src/google/mod.rs create mode 100644 api/tests/fixtures/test_service_account_key.pem create mode 100644 api/tests/google_integrity.rs diff --git a/.specs/features/keyflow-api/tasks.md b/.specs/features/keyflow-api/tasks.md index b92e70a9..82a3a5a5 100644 --- a/.specs/features/keyflow-api/tasks.md +++ b/.specs/features/keyflow-api/tasks.md @@ -108,7 +108,7 @@ então `[P]` real se houver dois agentes — senão seguir a ordem sequencial. ## 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 @@ -118,6 +118,27 @@ 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 - **What**: registro do design (Integrity → conta por `deviceKey` novo/existente → token novo diff --git a/api/Cargo.lock b/api/Cargo.lock index 945d1b55..f57934a6 100644 --- a/api/Cargo.lock +++ b/api/Cargo.lock @@ -17,6 +17,16 @@ 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" @@ -38,6 +48,30 @@ 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" @@ -148,6 +182,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -157,6 +193,12 @@ 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" @@ -165,7 +207,16 @@ checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core", + "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]] @@ -174,6 +225,16 @@ 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" @@ -183,6 +244,32 @@ dependencies = [ "crossbeam-utils", ] +[[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" @@ -259,6 +346,30 @@ dependencies = [ "cmov", ] +[[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 = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "digest" version = "0.10.7" @@ -297,6 +408,12 @@ 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 = "either" version = "1.16.0" @@ -306,6 +423,15 @@ dependencies = [ "serde", ] +[[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" @@ -360,6 +486,12 @@ dependencies = [ "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" @@ -375,6 +507,27 @@ 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" @@ -419,6 +572,17 @@ 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" @@ -437,8 +601,10 @@ 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", @@ -463,8 +629,24 @@ 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]] @@ -475,8 +657,27 @@ checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "r-efi", - "rand_core", + "r-efi 6.0.0", + "rand_core 0.10.1", +] + +[[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]] @@ -511,6 +712,12 @@ 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" @@ -599,6 +806,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -607,6 +815,22 @@ dependencies = [ "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]] @@ -615,13 +839,23 @@ 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]] @@ -737,12 +971,77 @@ dependencies = [ "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" @@ -754,18 +1053,42 @@ dependencies = [ "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", + "jsonwebtoken", + "reqwest", + "serde", + "serde_json", "sqlx", + "thiserror", "tokio", "tower", - "tower-http", + "tower-http 0.7.0", "tracing", "tracing-subscriber", "uuid", + "wiremock", ] [[package]] @@ -811,6 +1134,12 @@ 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" @@ -868,6 +1197,31 @@ 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" @@ -877,12 +1231,28 @@ 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 = "parking" version = "2.2.1" @@ -912,6 +1282,16 @@ dependencies = [ "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 = "percent-encoding" version = "2.3.2" @@ -939,6 +1319,21 @@ 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 = "proc-macro2" version = "1.0.106" @@ -948,6 +1343,62 @@ dependencies = [ "unicode-ident", ] +[[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" @@ -957,12 +1408,28 @@ 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" @@ -971,7 +1438,35 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core", + "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]] @@ -989,6 +1484,18 @@ 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" @@ -1006,6 +1513,47 @@ 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", + "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", + "tower", + "tower-http 0.6.11", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "ring" version = "0.17.14" @@ -1016,22 +1564,50 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "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" +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 = [ - "once_cell", - "ring", + "openssl-probe", "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", + "schannel", + "security-framework", ] [[package]] @@ -1040,18 +1616,47 @@ 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", + "untrusted 0.9.0", ] [[package]] @@ -1066,12 +1671,59 @@ 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 = "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" @@ -1196,6 +1848,43 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "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" @@ -1369,7 +2058,7 @@ dependencies = [ "log", "md-5", "memchr", - "rand", + "rand 0.10.2", "serde", "serde_json", "sha2 0.11.0", @@ -1446,6 +2135,9 @@ 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" @@ -1458,6 +2150,27 @@ dependencies = [ "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" @@ -1487,6 +2200,36 @@ 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" @@ -1539,6 +2282,16 @@ dependencies = [ "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" @@ -1550,6 +2303,19 @@ dependencies = [ "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" @@ -1566,6 +2332,24 @@ dependencies = [ "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" @@ -1670,6 +2454,12 @@ dependencies = [ "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" @@ -1703,6 +2493,12 @@ 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" @@ -1756,12 +2552,40 @@ 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" @@ -1775,6 +2599,16 @@ dependencies = [ "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" @@ -1807,6 +2641,35 @@ dependencies = [ "unicode-ident", ] +[[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" @@ -1822,12 +2685,50 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +[[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 = "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" @@ -1910,6 +2811,35 @@ 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" @@ -1939,6 +2869,26 @@ dependencies = [ "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" @@ -1965,6 +2915,20 @@ 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" diff --git a/api/Cargo.toml b/api/Cargo.toml index 869168f0..e7c5f1e2 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -5,7 +5,12 @@ edition = "2024" [dependencies] axum = "0.8.9" +jsonwebtoken = { version = "10.4.0", features = ["aws_lc_rs"] } +reqwest = { version = "0.13.4", features = ["form", "json", "rustls"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" 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" @@ -14,3 +19,4 @@ uuid = { version = "1.23.4", features = ["v4"] } [dev-dependencies] tower = { version = "0.5.3", features = ["util"] } +wiremock = "0.6.5" 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..44fb9f92 --- /dev/null +++ b/api/src/google/mod.rs @@ -0,0 +1,2 @@ +pub mod auth; +pub mod integrity; diff --git a/api/src/lib.rs b/api/src/lib.rs index 8743743c..a786f726 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -1,5 +1,6 @@ pub mod config; pub mod db; +pub mod google; use axum::extract::{MatchedPath, State}; use axum::http::{Request, StatusCode}; 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) + )); +} From 176d560c1d76b42e45704a5eb95a48d67e35f545 Mon Sep 17 00:00:00 2001 From: Yan Lucas Date: Thu, 2 Jul 2026 18:24:25 -0300 Subject: [PATCH 05/13] =?UTF-8?q?feat(api):=20POST=20/v1/register=20+=20mi?= =?UTF-8?q?ddleware=20Bearer=E2=86=92Account=20(API-T5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppState (pool + IntegrityClient + package_name + integrity_relaxed) substitui PgPool cru como estado do axum. Registro: decodeIntegrityToken + verify_verdicts → 403 se inválido; deviceKey novo cria conta, deviceKey existente rotaciona o token (fluxo de recuperação, preserva saldo). access_token: 32 bytes aleatórios em base64url, só o hash SHA-256 é persistido. Bearer→Account via extractor FromRequestParts (401 sem/ inválido) em vez de tower::Layer — mais idiomático em axum 0.8. Corrige teste pré-existente de db/accounts que usava token_hash fixo (vec![1u8;32]) — colidia entre execuções no Postgres de dev/CI, que acumula linhas e não tem UNIQUE em token_hash; trocado por hash aleatório por execução. --- .specs/features/keyflow-api/tasks.md | 24 ++- api/Cargo.lock | 10 ++ api/Cargo.toml | 3 + api/README.md | 14 +- api/src/auth.rs | 104 ++++++++++++ api/src/config.rs | 114 +++++++++++-- api/src/db/accounts.rs | 118 ++++++++++++++ api/src/db/mod.rs | 1 + api/src/lib.rs | 73 ++++++++- api/src/main.rs | 24 ++- api/src/routes/mod.rs | 1 + api/src/routes/register.rs | 76 +++++++++ api/tests/common/mod.rs | 5 + api/tests/register.rs | 232 +++++++++++++++++++++++++++ 14 files changed, 773 insertions(+), 26 deletions(-) create mode 100644 api/src/auth.rs create mode 100644 api/src/db/accounts.rs create mode 100644 api/src/routes/mod.rs create mode 100644 api/src/routes/register.rs create mode 100644 api/tests/register.rs diff --git a/.specs/features/keyflow-api/tasks.md b/.specs/features/keyflow-api/tasks.md index 82a3a5a5..989e8170 100644 --- a/.specs/features/keyflow-api/tasks.md +++ b/.specs/features/keyflow-api/tasks.md @@ -140,7 +140,7 @@ então `[P]` real se houver dois agentes — senão seguir a ordem sequencial. (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). @@ -149,6 +149,28 @@ 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]` diff --git a/api/Cargo.lock b/api/Cargo.lock index f57934a6..8c105e98 100644 --- a/api/Cargo.lock +++ b/api/Cargo.lock @@ -244,6 +244,12 @@ dependencies = [ "crossbeam-utils", ] +[[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" @@ -387,6 +393,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", + "const-oid", "crypto-common 0.2.2", "ctutils", ] @@ -1076,10 +1083,13 @@ name = "keyflow-api" version = "0.1.0" dependencies = [ "axum", + "base64", "jsonwebtoken", + "rand 0.10.2", "reqwest", "serde", "serde_json", + "sha2 0.11.0", "sqlx", "thiserror", "tokio", diff --git a/api/Cargo.toml b/api/Cargo.toml index e7c5f1e2..548a2a3b 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -5,10 +5,13 @@ edition = "2024" [dependencies] axum = "0.8.9" +base64 = "0.22.1" jsonwebtoken = { version = "10.4.0", features = ["aws_lc_rs"] } +rand = "0.10.2" reqwest = { version = "0.13.4", features = ["form", "json", "rustls"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" +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"] } diff --git a/api/README.md b/api/README.md index a0cd1854..aafbffbf 100644 --- a/api/README.md +++ b/api/README.md @@ -17,9 +17,21 @@ Módulo disjunto do app Android (`app/`): o Gradle não enxerga esta pasta e vic - `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. ```bash -DATABASE_URL=postgres://keyflow:keyflow@localhost/keyflow_api cargo run # GET /health +DATABASE_URL=postgres://keyflow:keyflow@localhost/keyflow_api \ +GOOGLE_SERVICE_ACCOUNT_JSON_PATH=/path/to/service-account.json \ +PACKAGE_NAME=com.keyflow \ +cargo run # GET /health, POST /v1/register ``` Logs saem em **JSON estruturado** (um objeto por linha), com a rota casada (`matched_path`) e a diff --git a/api/src/auth.rs b/api/src/auth.rs new file mode 100644 index 00000000..48e94f5d --- /dev/null +++ b/api/src/auth.rs @@ -0,0 +1,104 @@ +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() +} + +#[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() + } +} diff --git a/api/src/config.rs b/api/src/config.rs index bfe670c0..737eafe1 100644 --- a/api/src/config.rs +++ b/api/src/config.rs @@ -8,6 +8,13 @@ const DEFAULT_BIND_ADDR: &str = "0.0.0.0:8080"; 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, } #[derive(Debug)] @@ -62,9 +69,27 @@ impl Config { .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); + Ok(Config { database_url, bind_addr, + google_service_account_json_path, + package_name, + integrity_relaxed, }) } } @@ -90,47 +115,104 @@ mod tests { ); } + 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"), + ]; + + 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(&vars(&[ - ( - "DATABASE_URL", - "postgres://keyflow:keyflow@localhost/keyflow_api", - ), - ("BIND_ADDR", "127.0.0.1:9090"), - ])) - .unwrap(); + 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); } #[test] fn missing_bind_addr_falls_back_to_default() { - let config = Config::from_map(&vars(&[( - "DATABASE_URL", - "postgres://keyflow:keyflow@localhost/keyflow_api", - )])) - .unwrap(); + 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", ), - ("BIND_ADDR", "not-an-addr"), + ("PACKAGE_NAME", "com.keyflow"), ])) .unwrap_err(); - assert!(err.to_string().contains("BIND_ADDR")); - assert!(err.to_string().contains("not-an-addr")); + 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); } } diff --git a/api/src/db/accounts.rs b/api/src/db/accounts.rs new file mode 100644 index 00000000..0a60f684 --- /dev/null +++ b/api/src/db/accounts.rs @@ -0,0 +1,118 @@ +use sqlx::PgPool; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, sqlx::FromRow)] +pub struct Account { + pub id: Uuid, + pub balance_seconds: i64, + pub ads_off: bool, + pub skins_unlocked: bool, +} + +pub async fn find_by_token_hash(pool: &PgPool, token_hash: &[u8]) -> sqlx::Result> { + sqlx::query_as( + "select id, 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, 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, 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/mod.rs b/api/src/db/mod.rs index 3a345ca1..26576591 100644 --- a/api/src/db/mod.rs +++ b/api/src/db/mod.rs @@ -1,3 +1,4 @@ +pub mod accounts; pub mod ledger; use sqlx::PgPool; diff --git a/api/src/lib.rs b/api/src/lib.rs index a786f726..cae3547c 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -1,27 +1,70 @@ +pub mod auth; pub mod config; pub mod db; pub mod google; +pub mod routes; + +use std::sync::Arc; 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; -async fn health(State(pool): State) -> Result<&'static str, StatusCode> { +use google::integrity::IntegrityClient; + +pub struct AppStateInner { + pub pool: PgPool, + pub integrity: IntegrityClient, + pub package_name: String, + pub integrity_relaxed: bool, +} + +/// 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 { + pub fn new( + pool: PgPool, + integrity: IntegrityClient, + package_name: String, + integrity_relaxed: bool, + ) -> Self { + Self(Arc::new(AppStateInner { + pool, + integrity, + package_name, + integrity_relaxed, + })) + } +} + +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(&pool) + .execute(&state.pool) .await .map(|_| "ok") .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) } -pub fn app(pool: PgPool) -> Router { +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_layer( TraceLayer::new_for_http().make_span_with(|request: &Request<_>| { let matched_path = request @@ -36,7 +79,7 @@ pub fn app(pool: PgPool) -> Router { ) }), ) - .with_state(pool) + .with_state(state) } pub fn init_tracing() { @@ -55,6 +98,7 @@ mod tests { use super::*; use axum::body::Body; use axum::http::{Request, StatusCode}; + use reqwest::Client; use tower::ServiceExt; async fn test_pool() -> PgPool { @@ -71,11 +115,28 @@ mod tests { 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 integrity = IntegrityClient::new( + Client::new(), + "http://127.0.0.1:0".to_string(), + "com.keyflow".to_string(), + 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(), + }, + ); + AppState::new(pool, integrity, "com.keyflow".to_string(), false) + } + #[tokio::test] async fn health_returns_200_with_real_select_1() { - let pool = test_pool().await; + let state = test_state().await; - let response = app(pool) + let response = app(state) .oneshot( Request::builder() .uri("/health") diff --git a/api/src/main.rs b/api/src/main.rs index e92b57e3..78eb8241 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -1,6 +1,10 @@ -use keyflow_api::{app, config::Config, db, init_tracing}; +use keyflow_api::google::auth::ServiceAccountKey; +use keyflow_api::google::integrity::IntegrityClient; +use keyflow_api::{AppState, app, config::Config, db, init_tracing}; use tokio::net::TcpListener; +const PLAY_INTEGRITY_BASE_URL: &str = "https://playintegrity.googleapis.com"; + #[tokio::main] async fn main() { init_tracing(); @@ -15,11 +19,27 @@ async fn main() { .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, + ); + let state = AppState::new( + pool, + integrity, + config.package_name, + config.integrity_relaxed, + ); + 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(pool)) + axum::serve(listener, app(state)) .await .expect("server error"); } diff --git a/api/src/routes/mod.rs b/api/src/routes/mod.rs new file mode 100644 index 00000000..f862bee2 --- /dev/null +++ b/api/src/routes/mod.rs @@ -0,0 +1 @@ +pub mod register; diff --git a/api/src/routes/register.rs b/api/src/routes/register.rs new file mode 100644 index 00000000..c948c3d8 --- /dev/null +++ b/api/src/routes/register.rs @@ -0,0 +1,76 @@ +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::generate_token; +use crate::db::accounts; +use crate::google::integrity::verify_verdicts; + +#[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 { + Integrity, + Internal, +} + +impl IntoResponse for RegisterError { + fn into_response(self) -> Response { + match self { + 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, + Json(body): Json, +) -> Result, RegisterError> { + 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/tests/common/mod.rs b/api/tests/common/mod.rs index e7309e21..f35cf8fc 100644 --- a/api/tests/common/mod.rs +++ b/api/tests/common/mod.rs @@ -16,6 +16,10 @@ pub async fn test_pool() -> PgPool { /// 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(); @@ -34,6 +38,7 @@ pub async fn create_test_account(pool: &PgPool, balance_seconds: i64) -> uuid::U 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) diff --git a/api/tests/register.rs b/api/tests/register.rs new file mode 100644 index 00000000..e8a00c15 --- /dev/null +++ b/api/tests/register.rs @@ -0,0 +1,232 @@ +mod common; + +use axum::body::Body; +use axum::http::{Request, StatusCode, header}; +use common::test_pool; +use keyflow_api::db::accounts::Account; +use keyflow_api::google::auth::ServiceAccountKey; +use keyflow_api::google::integrity::IntegrityClient; +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 integrity = IntegrityClient::new( + reqwest::Client::new(), + google_uri.clone(), + package_name.to_string(), + 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"), + }, + ); + AppState::new(pool, integrity, package_name.to_string(), false) +} + +async fn register_request(state: AppState, device_key: &str) -> (StatusCode, Value) { + let response = app(state) + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/register") + .header(header::CONTENT_TYPE, "application/json") + .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); +} From 912b8f23a6b7a1446f053d4c2ca8b049f660df4a Mon Sep 17 00:00:00 2001 From: Yan Lucas Date: Thu, 2 Jul 2026 19:04:23 -0300 Subject: [PATCH 06/13] feat(api): rate limiting por IP (register) e por conta (API-T6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RateLimiters (governor, GCRA) no AppState com dois limitadores keyed independentes — IpAddr no /v1/register (ConnectInfo), Uuid da conta nas rotas autenticadas. Estouro -> 429 + Retry-After (segundos, calculado de NotUntil::wait_time_from). Limites configuráveis via REGISTER_RATE_LIMIT_PER_MINUTE/ACCOUNT_RATE_LIMIT_PER_MINUTE. "Por conta" ainda não tem rota de negócio real (GET /v1/account é a API-T7); testado com o extractor Account real sobre um router efêmero, mesmo padrão já usado pro Bearer na T5. --- .specs/features/keyflow-api/tasks.md | 16 ++- api/Cargo.lock | 117 +++++++++++++++++ api/Cargo.toml | 1 + api/README.md | 3 + api/src/config.rs | 85 ++++++++++++ api/src/lib.rs | 20 ++- api/src/main.rs | 11 +- api/src/middleware/mod.rs | 1 + api/src/middleware/ratelimit.rs | 130 +++++++++++++++++++ api/src/routes/register.rs | 16 ++- api/tests/ratelimit.rs | 187 +++++++++++++++++++++++++++ api/tests/register.rs | 13 +- 12 files changed, 593 insertions(+), 7 deletions(-) create mode 100644 api/src/middleware/mod.rs create mode 100644 api/src/middleware/ratelimit.rs create mode 100644 api/tests/ratelimit.rs diff --git a/.specs/features/keyflow-api/tasks.md b/.specs/features/keyflow-api/tasks.md index 989e8170..d3d2fc28 100644 --- a/.specs/features/keyflow-api/tasks.md +++ b/.specs/features/keyflow-api/tasks.md @@ -173,7 +173,7 @@ então `[P]` real se houver dois agentes — senão seguir a ordem sequencial. 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`. @@ -181,6 +181,20 @@ 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]` - **What**: saldo (segundos) + entitlements (`adsOff`, `skinsUnlocked`) num payload único e barato. diff --git a/api/Cargo.lock b/api/Cargo.lock index 8c105e98..511933a4 100644 --- a/api/Cargo.lock +++ b/api/Cargo.lock @@ -352,6 +352,20 @@ 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" @@ -602,6 +616,12 @@ 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" @@ -668,6 +688,29 @@ dependencies = [ "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 = "h2" version = "0.4.15" @@ -687,6 +730,12 @@ dependencies = [ "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" @@ -1084,6 +1133,7 @@ version = "0.1.0" dependencies = [ "axum", "base64", + "governor", "jsonwebtoken", "rand 0.10.2", "reqwest", @@ -1198,6 +1248,12 @@ dependencies = [ "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" @@ -1320,6 +1376,12 @@ 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" @@ -1353,6 +1415,21 @@ 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" @@ -1485,6 +1562,15 @@ 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" @@ -1929,6 +2015,15 @@ 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 = "sqlx" version = "0.9.0" @@ -2695,6 +2790,22 @@ 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" @@ -2704,6 +2815,12 @@ 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" diff --git a/api/Cargo.toml b/api/Cargo.toml index 548a2a3b..743d3ba8 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -6,6 +6,7 @@ edition = "2024" [dependencies] axum = "0.8.9" base64 = "0.22.1" +governor = "0.10.4" jsonwebtoken = { version = "10.4.0", features = ["aws_lc_rs"] } rand = "0.10.2" reqwest = { version = "0.13.4", features = ["form", "json", "rustls"] } diff --git a/api/README.md b/api/README.md index aafbffbf..0919a03a 100644 --- a/api/README.md +++ b/api/README.md @@ -26,6 +26,9 @@ Módulo disjunto do app Android (`app/`): o Gradle não enxerga esta pasta e vic 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). ```bash DATABASE_URL=postgres://keyflow:keyflow@localhost/keyflow_api \ diff --git a/api/src/config.rs b/api/src/config.rs index 737eafe1..40308a1c 100644 --- a/api/src/config.rs +++ b/api/src/config.rs @@ -1,8 +1,11 @@ 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; #[derive(Debug, Clone)] pub struct Config { @@ -15,6 +18,10 @@ pub struct Config { /// 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, } #[derive(Debug)] @@ -84,16 +91,56 @@ impl Config { .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, + )?; + 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, }) } } +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::*; @@ -148,6 +195,44 @@ mod tests { ); 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 + ); + } + + #[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] diff --git a/api/src/lib.rs b/api/src/lib.rs index cae3547c..aafa27e6 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -2,8 +2,10 @@ pub mod auth; pub mod config; pub mod db; pub mod google; +pub mod middleware; pub mod routes; +use std::num::NonZeroU32; use std::sync::Arc; use axum::extract::{MatchedPath, State}; @@ -14,12 +16,14 @@ use sqlx::PgPool; use tower_http::trace::TraceLayer; use google::integrity::IntegrityClient; +use middleware::ratelimit::RateLimiters; pub struct AppStateInner { pub pool: PgPool, pub integrity: IntegrityClient, pub package_name: String, pub integrity_relaxed: bool, + pub rate_limiters: RateLimiters, } /// Estado compartilhado entre handlers — `Clone` barato (`Arc` por dentro; `PgPool` também já é @@ -28,17 +32,24 @@ pub struct AppStateInner { pub struct AppState(Arc); impl AppState { + #[allow(clippy::too_many_arguments)] pub fn new( pool: PgPool, integrity: IntegrityClient, package_name: String, integrity_relaxed: bool, + register_rate_limit_per_minute: NonZeroU32, + account_rate_limit_per_minute: NonZeroU32, ) -> Self { Self(Arc::new(AppStateInner { pool, integrity, package_name, integrity_relaxed, + rate_limiters: RateLimiters::new( + register_rate_limit_per_minute, + account_rate_limit_per_minute, + ), })) } } @@ -129,7 +140,14 @@ mod tests { token_uri: "http://127.0.0.1:0/token".to_string(), }, ); - AppState::new(pool, integrity, "com.keyflow".to_string(), false) + AppState::new( + pool, + integrity, + "com.keyflow".to_string(), + false, + std::num::NonZeroU32::new(1000).unwrap(), + std::num::NonZeroU32::new(1000).unwrap(), + ) } #[tokio::test] diff --git a/api/src/main.rs b/api/src/main.rs index 78eb8241..92a0f950 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -33,13 +33,18 @@ async fn main() { integrity, config.package_name, config.integrity_relaxed, + config.register_rate_limit_per_minute, + config.account_rate_limit_per_minute, ); 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)) - .await - .expect("server error"); + 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/routes/register.rs b/api/src/routes/register.rs index c948c3d8..c133ab81 100644 --- a/api/src/routes/register.rs +++ b/api/src/routes/register.rs @@ -1,5 +1,7 @@ +use std::net::SocketAddr; + use axum::Json; -use axum::extract::State; +use axum::extract::{ConnectInfo, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use serde::{Deserialize, Serialize}; @@ -8,6 +10,7 @@ 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")] @@ -23,13 +26,21 @@ pub struct RegisterResponse { } 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" })), @@ -42,8 +53,11 @@ impl IntoResponse for RegisterError { 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) diff --git a/api/tests/ratelimit.rs b/api/tests/ratelimit.rs new file mode 100644 index 00000000..fda61616 --- /dev/null +++ b/api/tests/ratelimit.rs @@ -0,0 +1,187 @@ +mod common; + +use axum::body::Body; +use axum::http::{Request, StatusCode, header}; +use common::test_pool; +use keyflow_api::db::accounts::Account; +use keyflow_api::google::auth::ServiceAccountKey; +use keyflow_api::google::integrity::IntegrityClient; +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 integrity = IntegrityClient::new( + reqwest::Client::new(), + google_uri.clone(), + PACKAGE_NAME.to_string(), + 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"), + }, + ); + AppState::new( + pool, + integrity, + PACKAGE_NAME.to_string(), + false, + std::num::NonZeroU32::new(register_per_minute).unwrap(), + std::num::NonZeroU32::new(account_per_minute).unwrap(), + ) +} + +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/register.rs b/api/tests/register.rs index e8a00c15..32acbe3b 100644 --- a/api/tests/register.rs +++ b/api/tests/register.rs @@ -80,16 +80,27 @@ async fn state_with_google(google_uri: String, package_name: &str) -> AppState { token_uri: format!("{google_uri}/token"), }, ); - AppState::new(pool, integrity, package_name.to_string(), false) + // 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, + package_name.to_string(), + false, + std::num::NonZeroU32::new(1000).unwrap(), + std::num::NonZeroU32::new(1000).unwrap(), + ) } 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(), From 4939be4a180983d239703f89dcb9b24e5ef978fc Mon Sep 17 00:00:00 2001 From: Yan Lucas Date: Thu, 2 Jul 2026 19:12:23 -0300 Subject: [PATCH 07/13] feat(api): GET /v1/account (API-T7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handler trivial sobre o extractor Account (T5): saldo + entitlements num payload único, mais o rate limit por conta (T6) — primeira rota de negócio real a usar os dois. Fecha a Fase B (Identidade), API-T3..T7. --- .specs/features/keyflow-api/tasks.md | 9 +- api/src/lib.rs | 1 + api/src/routes/account.rs | 49 +++++++++++ api/src/routes/mod.rs | 1 + api/tests/account.rs | 119 +++++++++++++++++++++++++++ 5 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 api/src/routes/account.rs create mode 100644 api/tests/account.rs diff --git a/.specs/features/keyflow-api/tasks.md b/.specs/features/keyflow-api/tasks.md index d3d2fc28..5c0d9929 100644 --- a/.specs/features/keyflow-api/tasks.md +++ b/.specs/features/keyflow-api/tasks.md @@ -196,13 +196,20 @@ então `[P]` real se houver dois agentes — senão seguir a ordem sequencial. 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 diff --git a/api/src/lib.rs b/api/src/lib.rs index aafa27e6..c0cb959d 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -76,6 +76,7 @@ pub fn app(state: AppState) -> Router { Router::new() .route("/health", get(health)) .route("/v1/register", post(routes::register::register)) + .route("/v1/account", get(routes::account::get_account)) .route_layer( TraceLayer::new_for_http().make_span_with(|request: &Request<_>| { let matched_path = request 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/mod.rs b/api/src/routes/mod.rs index f862bee2..3d2283ef 100644 --- a/api/src/routes/mod.rs +++ b/api/src/routes/mod.rs @@ -1 +1,2 @@ +pub mod account; pub mod register; diff --git a/api/tests/account.rs b/api/tests/account.rs new file mode 100644 index 00000000..40be9b1a --- /dev/null +++ b/api/tests/account.rs @@ -0,0 +1,119 @@ +mod common; + +use axum::body::Body; +use axum::http::{Request, StatusCode, header}; +use common::test_pool; +use keyflow_api::app; +use keyflow_api::google::auth::ServiceAccountKey; +use keyflow_api::google::integrity::IntegrityClient; +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 integrity = IntegrityClient::new( + reqwest::Client::new(), + "http://127.0.0.1:0".to_string(), + PACKAGE_NAME.to_string(), + ServiceAccountKey { + client_email: "unused@example.com".to_string(), + private_key: String::new(), + token_uri: "http://127.0.0.1:0/token".to_string(), + }, + ); + AppState::new( + pool, + integrity, + PACKAGE_NAME.to_string(), + false, + std::num::NonZeroU32::new(1000).unwrap(), + std::num::NonZeroU32::new(1000).unwrap(), + ) +} + +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); +} From e2ec632772864a301b0f2f71d181cffe34bfdc3a Mon Sep 17 00:00:00 2001 From: Yan Lucas Date: Thu, 2 Jul 2026 19:45:22 -0300 Subject: [PATCH 08/13] =?UTF-8?q?feat(api):=20POST=20/v1/purchases/redeem?= =?UTF-8?q?=20=E2=80=94=20valida=C3=A7=C3=A3o,=20idempot=C3=AAncia,=20enti?= =?UTF-8?q?tlements=20(API-T8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PublisherClient (purchases.products.get/acknowledge) com contrato conferido na discovery doc oficial do androidpublisher — purchaseState é inteiro (0/1/2), não string. Catálogo estático em catalog.rs (credits_10/20/50, unlock_forever; minutos placeholder até o Yan calibrar). Fluxo: Integrity -> purchase lookup -> checa obfuscatedExternalAccountId contra sha256hex(deviceKey) -> PENDING responde sem creditar -> PURCHASED credita numa tx idempotente por token_hash (savepoint com credit() da T3) -> liga entitlements se SKU >= R$20 -> acknowledge só na primeira vez. db::accounts::Account ganhou device_key (necessário pro elo compra<->identidade). 10 testes novos. --- .specs/features/keyflow-api/tasks.md | 18 +- api/README.md | 11 + api/src/auth.rs | 41 ++++ api/src/catalog.rs | 56 +++++ api/src/db/accounts.rs | 9 +- api/src/google/mod.rs | 1 + api/src/google/publisher.rs | 194 ++++++++++++++++ api/src/lib.rs | 24 +- api/src/main.rs | 9 + api/src/routes/mod.rs | 1 + api/src/routes/redeem.rs | 174 +++++++++++++++ api/tests/account.rs | 19 +- api/tests/ratelimit.rs | 19 +- api/tests/redeem.rs | 321 +++++++++++++++++++++++++++ api/tests/register.rs | 19 +- 15 files changed, 892 insertions(+), 24 deletions(-) create mode 100644 api/src/catalog.rs create mode 100644 api/src/google/publisher.rs create mode 100644 api/src/routes/redeem.rs create mode 100644 api/tests/redeem.rs diff --git a/.specs/features/keyflow-api/tasks.md b/.specs/features/keyflow-api/tasks.md index 5c0d9929..f6ba1ce4 100644 --- a/.specs/features/keyflow-api/tasks.md +++ b/.specs/features/keyflow-api/tasks.md @@ -213,7 +213,7 @@ então `[P]` real se houver dois agentes — senão seguir a ordem sequencial. ## 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 @@ -225,6 +225,22 @@ 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 - **What**: task tokio periódica (intervalo na config): `voidedpurchases.list` (wiremock) → p/ diff --git a/api/README.md b/api/README.md index 0919a03a..4e649544 100644 --- a/api/README.md +++ b/api/README.md @@ -30,6 +30,17 @@ Módulo disjunto do app Android (`app/`): o Gradle não enxerga esta pasta e vic IP (ID-04). `ACCOUNT_RATE_LIMIT_PER_MINUTE` (opcional, default `60`) — limite por conta nas rotas autenticadas. Estouro → `429` + header `Retry-After` (segundos). +## 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`. + ```bash DATABASE_URL=postgres://keyflow:keyflow@localhost/keyflow_api \ GOOGLE_SERVICE_ACCOUNT_JSON_PATH=/path/to/service-account.json \ diff --git a/api/src/auth.rs b/api/src/auth.rs index 48e94f5d..b13ce97b 100644 --- a/api/src/auth.rs +++ b/api/src/auth.rs @@ -23,6 +23,20 @@ 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, @@ -101,4 +115,31 @@ mod tests { .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/db/accounts.rs b/api/src/db/accounts.rs index 0a60f684..8b1d61c7 100644 --- a/api/src/db/accounts.rs +++ b/api/src/db/accounts.rs @@ -4,6 +4,7 @@ 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, @@ -11,7 +12,8 @@ pub struct Account { pub async fn find_by_token_hash(pool: &PgPool, token_hash: &[u8]) -> sqlx::Result> { sqlx::query_as( - "select id, balance_seconds, ads_off, skins_unlocked from accounts where token_hash = $1", + "select id, device_key, balance_seconds, ads_off, skins_unlocked from accounts \ + where token_hash = $1", ) .bind(token_hash) .fetch_optional(pool) @@ -20,7 +22,8 @@ pub async fn find_by_token_hash(pool: &PgPool, token_hash: &[u8]) -> sqlx::Resul pub async fn find_by_device_key(pool: &PgPool, device_key: &str) -> sqlx::Result> { sqlx::query_as( - "select id, balance_seconds, ads_off, skins_unlocked from accounts where device_key = $1", + "select id, device_key, balance_seconds, ads_off, skins_unlocked from accounts \ + where device_key = $1", ) .bind(device_key) .fetch_optional(pool) @@ -30,7 +33,7 @@ pub async fn find_by_device_key(pool: &PgPool, device_key: &str) -> sqlx::Result 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, balance_seconds, ads_off, skins_unlocked", + returning id, device_key, balance_seconds, ads_off, skins_unlocked", ) .bind(device_key) .bind(token_hash) diff --git a/api/src/google/mod.rs b/api/src/google/mod.rs index 44fb9f92..536e9250 100644 --- a/api/src/google/mod.rs +++ b/api/src/google/mod.rs @@ -1,2 +1,3 @@ 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..d27ce066 --- /dev/null +++ b/api/src/google/publisher.rs @@ -0,0 +1,194 @@ +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, 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(()) + } +} + +#[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(); + } +} diff --git a/api/src/lib.rs b/api/src/lib.rs index c0cb959d..6275d965 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -1,4 +1,5 @@ pub mod auth; +pub mod catalog; pub mod config; pub mod db; pub mod google; @@ -16,11 +17,13 @@ use sqlx::PgPool; use tower_http::trace::TraceLayer; use google::integrity::IntegrityClient; +use google::publisher::PublisherClient; use middleware::ratelimit::RateLimiters; pub struct AppStateInner { pub pool: PgPool, pub integrity: IntegrityClient, + pub publisher: PublisherClient, pub package_name: String, pub integrity_relaxed: bool, pub rate_limiters: RateLimiters, @@ -36,6 +39,7 @@ impl AppState { pub fn new( pool: PgPool, integrity: IntegrityClient, + publisher: PublisherClient, package_name: String, integrity_relaxed: bool, register_rate_limit_per_minute: NonZeroU32, @@ -44,6 +48,7 @@ impl AppState { Self(Arc::new(AppStateInner { pool, integrity, + publisher, package_name, integrity_relaxed, rate_limiters: RateLimiters::new( @@ -77,6 +82,7 @@ pub fn app(state: AppState) -> Router { .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_layer( TraceLayer::new_for_http().make_span_with(|request: &Request<_>| { let matched_path = request @@ -131,19 +137,27 @@ mod tests { 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(), - 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(), - }, + 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, "com.keyflow".to_string(), false, std::num::NonZeroU32::new(1000).unwrap(), diff --git a/api/src/main.rs b/api/src/main.rs index 92a0f950..8942c326 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -1,9 +1,11 @@ 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}; use tokio::net::TcpListener; const PLAY_INTEGRITY_BASE_URL: &str = "https://playintegrity.googleapis.com"; +const ANDROID_PUBLISHER_BASE_URL: &str = "https://androidpublisher.googleapis.com"; #[tokio::main] async fn main() { @@ -26,11 +28,18 @@ async fn main() { 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, ); let state = AppState::new( pool, integrity, + publisher, config.package_name, config.integrity_relaxed, config.register_rate_limit_per_minute, diff --git a/api/src/routes/mod.rs b/api/src/routes/mod.rs index 3d2283ef..3218adf4 100644 --- a/api/src/routes/mod.rs +++ b/api/src/routes/mod.rs @@ -1,2 +1,3 @@ pub mod account; +pub mod redeem; pub mod register; 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/tests/account.rs b/api/tests/account.rs index 40be9b1a..6b9aea8a 100644 --- a/api/tests/account.rs +++ b/api/tests/account.rs @@ -6,6 +6,7 @@ use common::test_pool; 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; @@ -16,19 +17,27 @@ 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(), - ServiceAccountKey { - client_email: "unused@example.com".to_string(), - private_key: String::new(), - token_uri: "http://127.0.0.1:0/token".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, PACKAGE_NAME.to_string(), false, std::num::NonZeroU32::new(1000).unwrap(), diff --git a/api/tests/ratelimit.rs b/api/tests/ratelimit.rs index fda61616..8e6cc3bc 100644 --- a/api/tests/ratelimit.rs +++ b/api/tests/ratelimit.rs @@ -6,6 +6,7 @@ use common::test_pool; 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; @@ -49,19 +50,27 @@ async fn state_with_limits( 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(), - 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"), - }, + key.clone(), + ); + let publisher = PublisherClient::new( + reqwest::Client::new(), + google_uri, + PACKAGE_NAME.to_string(), + key, ); AppState::new( pool, integrity, + publisher, PACKAGE_NAME.to_string(), false, std::num::NonZeroU32::new(register_per_minute).unwrap(), diff --git a/api/tests/redeem.rs b/api/tests/redeem.rs new file mode 100644 index 00000000..44c79345 --- /dev/null +++ b/api/tests/redeem.rs @@ -0,0 +1,321 @@ +mod common; + +use axum::body::Body; +use axum::http::{Request, StatusCode, header}; +use common::test_pool; +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, + PACKAGE_NAME.to_string(), + false, + std::num::NonZeroU32::new(1000).unwrap(), + std::num::NonZeroU32::new(1000).unwrap(), + ) +} + +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 index 32acbe3b..1a253861 100644 --- a/api/tests/register.rs +++ b/api/tests/register.rs @@ -6,6 +6,7 @@ use common::test_pool; 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; @@ -70,21 +71,29 @@ async fn mock_google_bad_device(package_name: &str) -> MockServer { 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(), - 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"), - }, + 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, package_name.to_string(), false, std::num::NonZeroU32::new(1000).unwrap(), From 7b5add7d359063abe4c0c22865c652a3a2c45f48 Mon Sep 17 00:00:00 2001 From: Yan Lucas Date: Thu, 2 Jul 2026 19:52:19 -0300 Subject: [PATCH 09/13] =?UTF-8?q?feat(api):=20job=20de=20voided=20purchase?= =?UTF-8?q?s=20=E2=80=94=20reverte=20reembolsos=20(API-T9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PublisherClient::list_voided_purchases (contrato conferido na discovery doc). jobs::voided::run_periodically roda em background (tokio::spawn no main.rs, intervalo configurável) e reverte cada compra reembolsada/chargeback conhecida: UPDATE condicional em purchases.status marca 'voided' (idempotente, mesmo padrão do debit_guarded), debita o ledger sem guarda (saldo pode negativar) e recomputa adsOff/skinsUnlocked do zero a partir das compras restantes válidas — evita drift quando há mais de uma compra concedendo entitlements. Erros só logam, não travam a próxima janela. Fecha a Fase C (Compras): API-T8/T9 completas. --- .specs/features/keyflow-api/tasks.md | 18 +- api/README.md | 9 + api/src/config.rs | 24 ++ api/src/google/publisher.rs | 62 +++++ api/src/jobs/mod.rs | 1 + api/src/jobs/voided.rs | 323 +++++++++++++++++++++++++++ api/src/lib.rs | 1 + api/src/main.rs | 17 +- 8 files changed, 453 insertions(+), 2 deletions(-) create mode 100644 api/src/jobs/mod.rs create mode 100644 api/src/jobs/voided.rs diff --git a/.specs/features/keyflow-api/tasks.md b/.specs/features/keyflow-api/tasks.md index f6ba1ce4..77064712 100644 --- a/.specs/features/keyflow-api/tasks.md +++ b/.specs/features/keyflow-api/tasks.md @@ -242,7 +242,7 @@ então `[P]` real se houver dois agentes — senão seguir a ordem sequencial. `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×). @@ -251,6 +251,22 @@ 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 diff --git a/api/README.md b/api/README.md index 4e649544..8c10074a 100644 --- a/api/README.md +++ b/api/README.md @@ -41,6 +41,15 @@ Módulo disjunto do app Android (`app/`): o Gradle não enxerga esta pasta e vic (`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`. +## 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 \ diff --git a/api/src/config.rs b/api/src/config.rs index 40308a1c..44433a62 100644 --- a/api/src/config.rs +++ b/api/src/config.rs @@ -6,6 +6,7 @@ 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; #[derive(Debug, Clone)] pub struct Config { @@ -22,6 +23,8 @@ pub struct Config { 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, } #[derive(Debug)] @@ -101,6 +104,11 @@ impl Config { "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, + )?; Ok(Config { database_url, @@ -110,6 +118,7 @@ impl Config { integrity_relaxed, register_rate_limit_per_minute, account_rate_limit_per_minute, + voided_purchases_job_interval_secs, }) } } @@ -203,6 +212,21 @@ mod tests { 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 + ); + } + + #[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] diff --git a/api/src/google/publisher.rs b/api/src/google/publisher.rs index d27ce066..11c4aedb 100644 --- a/api/src/google/publisher.rs +++ b/api/src/google/publisher.rs @@ -19,6 +19,19 @@ pub struct ProductPurchase { 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}")] @@ -103,6 +116,25 @@ impl PublisherClient { } 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)] @@ -191,4 +223,34 @@ mod tests { .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 index 6275d965..0917e683 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -3,6 +3,7 @@ pub mod catalog; pub mod config; pub mod db; pub mod google; +pub mod jobs; pub mod middleware; pub mod routes; diff --git a/api/src/main.rs b/api/src/main.rs index 8942c326..8c1097c2 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -1,7 +1,8 @@ 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}; +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"; @@ -31,11 +32,25 @@ async fn main() { 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, From bfd9db5f18273389b4c83bb42a6137f415b99029 Mon Sep 17 00:00:00 2001 From: Yan Lucas Date: Thu, 2 Jul 2026 19:59:46 -0300 Subject: [PATCH 10/13] feat(api): GET /v1/config + GET /v1/models com ETag/304 (API-T10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seed em migrations/0002_seed_documents.sql (shape do design: products/ ads/transcription/minAppVersion/killSwitch; manifest de models) — valores de ads/transcription e o manifest são placeholder explícito até native-voice-models colher os modelos reais e o Yan calibrar os caps. ETag calculado em request-time a partir dos mesmos bytes enviados no corpo (sha256 hex entre aspas); If-None-Match (exato ou *) -> 304 sem corpo; Cache-Control max-age=300. Rotas sem Bearer (conteúdo público). 10 testes novos. Fecha a dependência de T3 que faltava pra Fase E (proxy) rodar em paralelo. --- .specs/features/keyflow-api/tasks.md | 15 ++- api/README.md | 3 + api/migrations/0002_seed_documents.sql | 39 +++++++ api/src/lib.rs | 2 + api/src/routes/content.rs | 123 +++++++++++++++++++++ api/src/routes/mod.rs | 1 + api/tests/content.rs | 143 +++++++++++++++++++++++++ 7 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 api/migrations/0002_seed_documents.sql create mode 100644 api/src/routes/content.rs create mode 100644 api/tests/content.rs diff --git a/.specs/features/keyflow-api/tasks.md b/.specs/features/keyflow-api/tasks.md index 77064712..47a77103 100644 --- a/.specs/features/keyflow-api/tasks.md +++ b/.specs/features/keyflow-api/tasks.md @@ -270,7 +270,7 @@ então `[P]` real se houver dois agentes — senão seguir a ordem sequencial. ## 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 @@ -280,6 +280,19 @@ 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 diff --git a/api/README.md b/api/README.md index 8c10074a..221dd331 100644 --- a/api/README.md +++ b/api/README.md @@ -40,6 +40,9 @@ Módulo disjunto do app Android (`app/`): o Gradle não enxerga esta pasta e vic `{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`. ## Job em background 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/src/lib.rs b/api/src/lib.rs index 0917e683..86475334 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -84,6 +84,8 @@ pub fn app(state: AppState) -> Router { .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_layer( TraceLayer::new_for_http().make_span_with(|request: &Request<_>| { let matched_path = request 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 index 3218adf4..e5e910c6 100644 --- a/api/src/routes/mod.rs +++ b/api/src/routes/mod.rs @@ -1,3 +1,4 @@ pub mod account; +pub mod content; pub mod redeem; pub mod register; diff --git a/api/tests/content.rs b/api/tests/content.rs new file mode 100644 index 00000000..bcc35aee --- /dev/null +++ b/api/tests/content.rs @@ -0,0 +1,143 @@ +mod common; + +use axum::body::Body; +use axum::http::{Request, StatusCode, header}; +use common::test_pool; +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, + PACKAGE_NAME.to_string(), + false, + std::num::NonZeroU32::new(1000).unwrap(), + std::num::NonZeroU32::new(1000).unwrap(), + ) +} + +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); +} From ac12de7877dddf36906771375a1e864015191ac7 Mon Sep 17 00:00:00 2001 From: Yan Lucas Date: Fri, 3 Jul 2026 01:20:43 -0300 Subject: [PATCH 11/13] =?UTF-8?q?feat(api):=20POST=20/v1/transcribe=20?= =?UTF-8?q?=E2=80=94=20proxy=20streaming=20+=20d=C3=A9bito=20por=20bytes?= =?UTF-8?q?=20(API-T11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corpo multipart repassado byte a byte pro provedor via reqwest::Body::wrap_stream (nunca bufferizado, TRX-04) com contador que corta em 413 ao passar do cap. Saldo <=0 (request nova) -> 402 antes de tocar o provedor; sucesso credita -ceil(bytes/8000)s numa tx atômica com o registro de idempotência (transcribe_requests por X-Request-Id) + header X-KeyFlow-Balance-Seconds; timeout -> 504; provedor caindo (conexão/5xx) -> 502. TRANSCRIPTION_PROVIDERS_JSON tipa a lista ordenada de provedores do design (segredo de cada um numa env var à parte, via authValueEnv, nunca inline). SPEC_DEVIATION documentada: fallback de provedor é entre requests (circuit breaker com cooldown), não dentro da mesma request — reenviar o corpo pro provedor 2 depois de já ter streamado pro 1 exigiria bufferizar tudo, contradizendo TRX-04. Em regime permanente o efeito é equivalente ao fallback do design. credit() (T3) passou a aceitar delta negativo (débito sem guarda, usado aqui). 9 testes de integração + unit de arredondamento + testes de providers.rs (parsing/circuit breaker). --- .specs/features/keyflow-api/tasks.md | 37 ++- api/Cargo.lock | 18 ++ api/Cargo.toml | 4 +- api/README.md | 35 ++- api/src/config.rs | 103 +++++++ api/src/db/ledger.rs | 6 +- api/src/lib.rs | 45 +++ api/src/main.rs | 9 + api/src/providers.rs | 179 ++++++++++++ api/src/routes/mod.rs | 1 + api/src/routes/transcribe.rs | 250 +++++++++++++++++ api/tests/account.rs | 4 +- api/tests/common/mod.rs | 21 ++ api/tests/content.rs | 4 +- api/tests/ratelimit.rs | 4 +- api/tests/redeem.rs | 4 +- api/tests/register.rs | 4 +- api/tests/transcribe.rs | 394 +++++++++++++++++++++++++++ 18 files changed, 1112 insertions(+), 10 deletions(-) create mode 100644 api/src/providers.rs create mode 100644 api/src/routes/transcribe.rs create mode 100644 api/tests/transcribe.rs diff --git a/.specs/features/keyflow-api/tasks.md b/.specs/features/keyflow-api/tasks.md index 47a77103..f4656b88 100644 --- a/.specs/features/keyflow-api/tasks.md +++ b/.specs/features/keyflow-api/tasks.md @@ -296,7 +296,7 @@ então `[P]` real se houver dois agentes — senão seguir a ordem sequencial. ## 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 @@ -311,6 +311,41 @@ 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 diff --git a/api/Cargo.lock b/api/Cargo.lock index 511933a4..077d3852 100644 --- a/api/Cargo.lock +++ b/api/Cargo.lock @@ -1133,6 +1133,8 @@ version = "0.1.0" dependencies = [ "axum", "base64", + "bytes", + "futures-util", "governor", "jsonwebtoken", "rand 0.10.2", @@ -1619,6 +1621,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-core", + "futures-util", "h2", "http", "http-body", @@ -1641,12 +1644,14 @@ dependencies = [ "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", ] @@ -2746,6 +2751,19 @@ 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" diff --git a/api/Cargo.toml b/api/Cargo.toml index 743d3ba8..2f531889 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -6,10 +6,12 @@ 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"] } rand = "0.10.2" -reqwest = { version = "0.13.4", features = ["form", "json", "rustls"] } +reqwest = { version = "0.13.4", features = ["form", "json", "rustls", "stream"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" sha2 = "0.11.0" diff --git a/api/README.md b/api/README.md index 221dd331..7098307b 100644 --- a/api/README.md +++ b/api/README.md @@ -29,6 +29,14 @@ Módulo disjunto do app Android (`app/`): o Gradle não enxerga esta pasta e vic - `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`). ## Rotas @@ -43,6 +51,29 @@ Módulo disjunto do app Android (`app/`): o Gradle não enxerga esta pasta e vic - `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×. + +### 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 @@ -57,7 +88,9 @@ execução só loga e tenta de novo na próxima janela (não é caminho crítico DATABASE_URL=postgres://keyflow:keyflow@localhost/keyflow_api \ GOOGLE_SERVICE_ACCOUNT_JSON_PATH=/path/to/service-account.json \ PACKAGE_NAME=com.keyflow \ -cargo run # GET /health, POST /v1/register +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 diff --git a/api/src/config.rs b/api/src/config.rs index 44433a62..f6821066 100644 --- a/api/src/config.rs +++ b/api/src/config.rs @@ -7,6 +7,9 @@ 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; #[derive(Debug, Clone)] pub struct Config { @@ -25,6 +28,12 @@ pub struct Config { 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, } #[derive(Debug)] @@ -110,6 +119,35 @@ impl Config { 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, + )?; + Ok(Config { database_url, bind_addr, @@ -119,6 +157,10 @@ impl Config { 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, }) } } @@ -181,6 +223,11 @@ mod tests { "/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 { @@ -216,6 +263,17 @@ mod tests { 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 + ); } #[test] @@ -324,4 +382,49 @@ mod tests { 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/ledger.rs b/api/src/db/ledger.rs index 2b396c90..0fb018a8 100644 --- a/api/src/db/ledger.rs +++ b/api/src/db/ledger.rs @@ -20,8 +20,10 @@ impl LedgerReason { } } -/// Credita `delta_seconds` (> 0) na conta: `ledger` + `accounts.balance_seconds` na mesma tx -/// (savepoint se `conn` já estiver dentro de uma transação do chamador — nunca parcial). +/// 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, diff --git a/api/src/lib.rs b/api/src/lib.rs index 86475334..4cbd342f 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -5,10 +5,12 @@ 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}; @@ -20,14 +22,30 @@ use tower_http::trace::TraceLayer; 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, +} 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, } /// Estado compartilhado entre handlers — `Clone` barato (`Arc` por dentro; `PgPool` também já é @@ -41,21 +59,30 @@ impl AppState { 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, ) -> Self { + let provider_health = ProviderHealth::new(transcription.providers.len()); 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, })) } } @@ -86,6 +113,7 @@ pub fn app(state: AppState) -> Router { .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_layer( TraceLayer::new_for_http().make_span_with(|request: &Request<_>| { let matched_path = request @@ -161,13 +189,30 @@ mod tests { 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(), ) } + 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), + } + } + #[tokio::test] async fn health_returns_200_with_real_select_1() { let state = test_state().await; diff --git a/api/src/main.rs b/api/src/main.rs index 8c1097c2..35f3b42d 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -55,10 +55,19 @@ async fn main() { 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 + ), + }, ); let listener = TcpListener::bind(config.bind_addr) 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/mod.rs b/api/src/routes/mod.rs index e5e910c6..e1a4f749 100644 --- a/api/src/routes/mod.rs +++ b/api/src/routes/mod.rs @@ -2,3 +2,4 @@ pub mod account; pub mod content; pub mod redeem; pub mod register; +pub mod transcribe; 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 index 6b9aea8a..5dab1912 100644 --- a/api/tests/account.rs +++ b/api/tests/account.rs @@ -2,7 +2,7 @@ mod common; use axum::body::Body; use axum::http::{Request, StatusCode, header}; -use common::test_pool; +use common::{test_pool, unused_transcription_config}; use keyflow_api::app; use keyflow_api::google::auth::ServiceAccountKey; use keyflow_api::google::integrity::IntegrityClient; @@ -38,10 +38,12 @@ async fn test_state() -> AppState { 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(), ) } diff --git a/api/tests/common/mod.rs b/api/tests/common/mod.rs index f35cf8fc..41d3a273 100644 --- a/api/tests/common/mod.rs +++ b/api/tests/common/mod.rs @@ -1,4 +1,7 @@ +use keyflow_api::TranscriptionConfig; +use keyflow_api::providers::ResolvedProvider; use sqlx::PgPool; +use std::time::Duration; pub async fn test_pool() -> PgPool { let database_url = std::env::var("DATABASE_URL").expect( @@ -47,3 +50,21 @@ pub async fn balance_of(pool: &PgPool, account_id: uuid::Uuid) -> i64 { .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), + } +} diff --git a/api/tests/content.rs b/api/tests/content.rs index bcc35aee..cdcfb79f 100644 --- a/api/tests/content.rs +++ b/api/tests/content.rs @@ -2,7 +2,7 @@ mod common; use axum::body::Body; use axum::http::{Request, StatusCode, header}; -use common::test_pool; +use common::{test_pool, unused_transcription_config}; use keyflow_api::AppState; use keyflow_api::app; use keyflow_api::google::auth::ServiceAccountKey; @@ -35,10 +35,12 @@ async fn test_state() -> AppState { 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(), ) } diff --git a/api/tests/ratelimit.rs b/api/tests/ratelimit.rs index 8e6cc3bc..2545dfe3 100644 --- a/api/tests/ratelimit.rs +++ b/api/tests/ratelimit.rs @@ -2,7 +2,7 @@ mod common; use axum::body::Body; use axum::http::{Request, StatusCode, header}; -use common::test_pool; +use common::{test_pool, unused_transcription_config}; use keyflow_api::db::accounts::Account; use keyflow_api::google::auth::ServiceAccountKey; use keyflow_api::google::integrity::IntegrityClient; @@ -71,10 +71,12 @@ async fn state_with_limits( 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(), ) } diff --git a/api/tests/redeem.rs b/api/tests/redeem.rs index 44c79345..f01244cd 100644 --- a/api/tests/redeem.rs +++ b/api/tests/redeem.rs @@ -2,7 +2,7 @@ mod common; use axum::body::Body; use axum::http::{Request, StatusCode, header}; -use common::test_pool; +use common::{test_pool, unused_transcription_config}; use keyflow_api::auth::{device_key_account_id, generate_token}; use keyflow_api::db::accounts; use keyflow_api::google::auth::ServiceAccountKey; @@ -93,10 +93,12 @@ async fn state_with_google(google_uri: String) -> AppState { 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(), ) } diff --git a/api/tests/register.rs b/api/tests/register.rs index 1a253861..75cb5f15 100644 --- a/api/tests/register.rs +++ b/api/tests/register.rs @@ -2,7 +2,7 @@ mod common; use axum::body::Body; use axum::http::{Request, StatusCode, header}; -use common::test_pool; +use common::{test_pool, unused_transcription_config}; use keyflow_api::db::accounts::Account; use keyflow_api::google::auth::ServiceAccountKey; use keyflow_api::google::integrity::IntegrityClient; @@ -94,10 +94,12 @@ async fn state_with_google(google_uri: String, package_name: &str) -> AppState { 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(), ) } diff --git a/api/tests/transcribe.rs b/api/tests/transcribe.rs new file mode 100644 index 00000000..8d4c77fd --- /dev/null +++ b/api/tests/transcribe.rs @@ -0,0 +1,394 @@ +mod common; + +use axum::body::Body; +use axum::http::{Request, StatusCode, header}; +use common::test_pool; +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), + }, + ) +} + +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); +} From c4f7c5d5326016357f3d25b44fe63fbf55e9756d Mon Sep 17 00:00:00 2001 From: Yan Lucas Date: Fri, 3 Jul 2026 02:15:56 -0300 Subject: [PATCH 12/13] =?UTF-8?q?feat(api):=20GET=20/v1/ssv/admob=20?= =?UTF-8?q?=E2=80=94=20verifica=C3=A7=C3=A3o=20ECDSA=20+=20cap=20di=C3=A1r?= =?UTF-8?q?io=20(API-T12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit admob.rs verifica a assinatura ECDSA P-256/SHA-256 (DER) das callbacks de rewarded SSV do AdMob contra as chaves públicas de gstatic.com/admob/reward/verifier-keys.json (cache com refetch completo quando um key_id desconhecido aparece). Conteúdo assinado = query string crua até "&signature=", nunca reordenada/reserializada, conforme a doc oficial. custom_data = accountId (UUID); conta inexistente ou inválida -> 200 sem crédito, só loga (não vazar existência). Crédito de ADS_REWARD_SECONDS (decisão nossa, não vem do reward_amount do AdMob) idempotente por transaction_id, sujeito a ADS_DAILY_CAP; sempre 200 exceto assinatura inválida (403), porque o AdMob reenvia a callback até 5x sem um 200. db::accounts::exists evita uma FK do ledger estourando 500 quando custom_data é um UUID bem-formado de conta inexistente. Testes de transaction_id fixos trocados por sufixo aleatório (mesma classe do bug de idempotência "vazando" entre execuções que apareceu na T5). Fecha a Fase F (Rewarded). 12 testes novos (6 integração + 6 unit). --- .specs/features/keyflow-api/tasks.md | 29 ++- api/Cargo.lock | 192 ++++++++++++++++- api/Cargo.toml | 3 + api/README.md | 11 + api/src/config.rs | 27 +++ api/src/db/accounts.rs | 7 + api/src/db/ledger.rs | 1 - api/src/google/admob.rs | 254 ++++++++++++++++++++++ api/src/google/mod.rs | 1 + api/src/lib.rs | 26 +++ api/src/main.rs | 6 + api/src/routes/mod.rs | 1 + api/src/routes/ssv.rs | 131 +++++++++++ api/tests/account.rs | 3 +- api/tests/common/mod.rs | 13 +- api/tests/content.rs | 3 +- api/tests/ratelimit.rs | 3 +- api/tests/redeem.rs | 3 +- api/tests/register.rs | 3 +- api/tests/ssv.rs | 310 +++++++++++++++++++++++++++ api/tests/transcribe.rs | 3 +- 21 files changed, 1018 insertions(+), 12 deletions(-) create mode 100644 api/src/google/admob.rs create mode 100644 api/src/routes/ssv.rs create mode 100644 api/tests/ssv.rs diff --git a/.specs/features/keyflow-api/tasks.md b/.specs/features/keyflow-api/tasks.md index f4656b88..cbc55a42 100644 --- a/.specs/features/keyflow-api/tasks.md +++ b/.specs/features/keyflow-api/tasks.md @@ -349,7 +349,7 @@ então `[P]` real se houver dois agentes — senão seguir a ordem sequencial. ## 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. @@ -360,6 +360,33 @@ 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 diff --git a/api/Cargo.lock b/api/Cargo.lock index 077d3852..01ee7c59 100644 --- a/api/Cargo.lock +++ b/api/Cargo.lock @@ -124,12 +124,24 @@ dependencies = [ "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" @@ -244,6 +256,12 @@ 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" @@ -324,6 +342,18 @@ 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" @@ -384,6 +414,17 @@ 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" @@ -397,7 +438,9 @@ 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]] @@ -407,7 +450,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", ] @@ -435,6 +478,20 @@ 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" @@ -444,6 +501,26 @@ 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" @@ -490,6 +567,16 @@ dependencies = [ "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" @@ -647,6 +734,7 @@ checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -711,6 +799,17 @@ dependencies = [ "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" @@ -786,7 +885,16 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ - "hmac", + "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]] @@ -1137,10 +1245,13 @@ dependencies = [ "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", @@ -1321,6 +1432,18 @@ 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" @@ -1360,6 +1483,15 @@ dependencies = [ "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" @@ -1372,6 +1504,16 @@ 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" @@ -1408,6 +1550,15 @@ 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" @@ -1655,6 +1806,16 @@ dependencies = [ "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" @@ -1796,6 +1957,20 @@ 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" @@ -1955,6 +2130,7 @@ 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", ] @@ -2029,6 +2205,16 @@ 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" @@ -2163,7 +2349,7 @@ dependencies = [ "futures-util", "hex", "hkdf", - "hmac", + "hmac 0.13.0", "itoa", "log", "md-5", diff --git a/api/Cargo.toml b/api/Cargo.toml index 2f531889..6a0c4e16 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -10,10 +10,12 @@ 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" @@ -24,5 +26,6 @@ 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/README.md b/api/README.md index 7098307b..0922081a 100644 --- a/api/README.md +++ b/api/README.md @@ -37,6 +37,9 @@ Módulo disjunto do app Android (`app/`): o Gradle não enxerga esta pasta e vic `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 @@ -61,6 +64,14 @@ Módulo disjunto do app Android (`app/`): o Gradle não enxerga esta pasta e vic `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 diff --git a/api/src/config.rs b/api/src/config.rs index f6821066..02280030 100644 --- a/api/src/config.rs +++ b/api/src/config.rs @@ -10,6 +10,8 @@ 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 { @@ -34,6 +36,11 @@ pub struct Config { 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)] @@ -148,6 +155,10 @@ impl Config { 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, @@ -161,6 +172,8 @@ impl Config { transcription_timeout_ms, transcription_max_bytes, provider_unhealthy_cooldown_secs, + ads_reward_seconds, + ads_daily_cap, }) } } @@ -274,6 +287,20 @@ mod tests { 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] diff --git a/api/src/db/accounts.rs b/api/src/db/accounts.rs index 8b1d61c7..911483e3 100644 --- a/api/src/db/accounts.rs +++ b/api/src/db/accounts.rs @@ -10,6 +10,13 @@ pub struct Account { 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 \ diff --git a/api/src/db/ledger.rs b/api/src/db/ledger.rs index 0fb018a8..7959525b 100644 --- a/api/src/db/ledger.rs +++ b/api/src/db/ledger.rs @@ -6,7 +6,6 @@ use uuid::Uuid; pub enum LedgerReason { Purchase, Transcription, - #[allow(dead_code, reason = "consumido pela API-T12 (crédito de rewarded ad)")] Reward, } 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/mod.rs b/api/src/google/mod.rs index 536e9250..0f936ff1 100644 --- a/api/src/google/mod.rs +++ b/api/src/google/mod.rs @@ -1,3 +1,4 @@ +pub mod admob; pub mod auth; pub mod integrity; pub mod publisher; diff --git a/api/src/lib.rs b/api/src/lib.rs index 4cbd342f..7612cc41 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -19,6 +19,7 @@ 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; @@ -33,6 +34,13 @@ pub struct TranscriptionConfig { 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, @@ -46,6 +54,9 @@ pub struct AppStateInner { 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á é @@ -65,8 +76,10 @@ impl AppState { 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, @@ -83,6 +96,9 @@ impl AppState { 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, })) } } @@ -114,6 +130,7 @@ pub fn app(state: AppState) -> Router { .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 @@ -195,6 +212,7 @@ mod tests { std::num::NonZeroU32::new(1000).unwrap(), std::num::NonZeroU32::new(1000).unwrap(), test_transcription_config(), + test_ads_config(), ) } @@ -213,6 +231,14 @@ mod tests { } } + 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; diff --git a/api/src/main.rs b/api/src/main.rs index 35f3b42d..7f7d8b0d 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -7,6 +7,7 @@ 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() { @@ -68,6 +69,11 @@ async fn main() { 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) diff --git a/api/src/routes/mod.rs b/api/src/routes/mod.rs index e1a4f749..2a3436b2 100644 --- a/api/src/routes/mod.rs +++ b/api/src/routes/mod.rs @@ -2,4 +2,5 @@ pub mod account; pub mod content; pub mod redeem; pub mod register; +pub mod ssv; pub mod transcribe; 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/tests/account.rs b/api/tests/account.rs index 5dab1912..2b1272a9 100644 --- a/api/tests/account.rs +++ b/api/tests/account.rs @@ -2,7 +2,7 @@ mod common; use axum::body::Body; use axum::http::{Request, StatusCode, header}; -use common::{test_pool, unused_transcription_config}; +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; @@ -44,6 +44,7 @@ async fn test_state() -> AppState { std::num::NonZeroU32::new(1000).unwrap(), std::num::NonZeroU32::new(1000).unwrap(), unused_transcription_config(), + unused_ads_config(), ) } diff --git a/api/tests/common/mod.rs b/api/tests/common/mod.rs index 41d3a273..bf06eae9 100644 --- a/api/tests/common/mod.rs +++ b/api/tests/common/mod.rs @@ -1,5 +1,5 @@ -use keyflow_api::TranscriptionConfig; use keyflow_api::providers::ResolvedProvider; +use keyflow_api::{AdsConfig, TranscriptionConfig}; use sqlx::PgPool; use std::time::Duration; @@ -68,3 +68,14 @@ pub fn unused_transcription_config() -> TranscriptionConfig { 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 index cdcfb79f..1cb6a084 100644 --- a/api/tests/content.rs +++ b/api/tests/content.rs @@ -2,7 +2,7 @@ mod common; use axum::body::Body; use axum::http::{Request, StatusCode, header}; -use common::{test_pool, unused_transcription_config}; +use common::{test_pool, unused_ads_config, unused_transcription_config}; use keyflow_api::AppState; use keyflow_api::app; use keyflow_api::google::auth::ServiceAccountKey; @@ -41,6 +41,7 @@ async fn test_state() -> AppState { std::num::NonZeroU32::new(1000).unwrap(), std::num::NonZeroU32::new(1000).unwrap(), unused_transcription_config(), + unused_ads_config(), ) } diff --git a/api/tests/ratelimit.rs b/api/tests/ratelimit.rs index 2545dfe3..5180de92 100644 --- a/api/tests/ratelimit.rs +++ b/api/tests/ratelimit.rs @@ -2,7 +2,7 @@ mod common; use axum::body::Body; use axum::http::{Request, StatusCode, header}; -use common::{test_pool, unused_transcription_config}; +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; @@ -77,6 +77,7 @@ async fn state_with_limits( std::num::NonZeroU32::new(register_per_minute).unwrap(), std::num::NonZeroU32::new(account_per_minute).unwrap(), unused_transcription_config(), + unused_ads_config(), ) } diff --git a/api/tests/redeem.rs b/api/tests/redeem.rs index f01244cd..6535bf48 100644 --- a/api/tests/redeem.rs +++ b/api/tests/redeem.rs @@ -2,7 +2,7 @@ mod common; use axum::body::Body; use axum::http::{Request, StatusCode, header}; -use common::{test_pool, unused_transcription_config}; +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; @@ -99,6 +99,7 @@ async fn state_with_google(google_uri: String) -> AppState { std::num::NonZeroU32::new(1000).unwrap(), std::num::NonZeroU32::new(1000).unwrap(), unused_transcription_config(), + unused_ads_config(), ) } diff --git a/api/tests/register.rs b/api/tests/register.rs index 75cb5f15..fafeed9c 100644 --- a/api/tests/register.rs +++ b/api/tests/register.rs @@ -2,7 +2,7 @@ mod common; use axum::body::Body; use axum::http::{Request, StatusCode, header}; -use common::{test_pool, unused_transcription_config}; +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; @@ -100,6 +100,7 @@ async fn state_with_google(google_uri: String, package_name: &str) -> AppState { std::num::NonZeroU32::new(1000).unwrap(), std::num::NonZeroU32::new(1000).unwrap(), unused_transcription_config(), + unused_ads_config(), ) } 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 index 8d4c77fd..1474d218 100644 --- a/api/tests/transcribe.rs +++ b/api/tests/transcribe.rs @@ -2,7 +2,7 @@ mod common; use axum::body::Body; use axum::http::{Request, StatusCode, header}; -use common::test_pool; +use common::{test_pool, unused_ads_config}; use keyflow_api::auth::generate_token; use keyflow_api::db::accounts; use keyflow_api::google::auth::ServiceAccountKey; @@ -63,6 +63,7 @@ async fn state_with_providers(providers: Vec, max_bytes: u64) max_bytes, unhealthy_cooldown: Duration::from_secs(60), }, + unused_ads_config(), ) } From 80d6aef79312a6ec88ccf2ebb23630150f6e5354 Mon Sep 17 00:00:00 2001 From: Yan Lucas Date: Fri, 3 Jul 2026 03:19:35 -0300 Subject: [PATCH 13/13] feat(api): Dockerfile + guia de deploy (API-T13 parcial, parou no gate [YAN]) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dockerfile multi-stage (rust:1.96-slim-bookworm -> debian:bookworm-slim, tags conferidas no Docker Hub) com as migrations embutidas no binário via sqlx::migrate!() -- nada roda separado no boot. Runtime só com ca-certificates (rustls/sqlx-postgres são puro-Rust, mas rustls-platform-verifier usa o trust store do SO). Validado de verdade: docker build local (imagem final 101MB) + docker run contra o Postgres do docker-compose.yml -- /health e /v1/config responderam 200, migrations aplicaram no boot. docs/deploy.md: guia completo (reverse proxy/TLS com Caddyfile, tabela de env vars, service account do Google, checklist de credenciais pendentes). Não subiu staging real nem tocou infraestrutura fora deste ambiente -- credenciais reais (Postgres gerenciado, domínio, service account, chaves de provedor, AdMob) são do Yan, conforme DEVELOPMENT-ORDER.md. API-T14 segue bloqueada até isso e até MZ-T1..T8 no app. --- .specs/features/keyflow-api/tasks.md | 23 +++++- api/Dockerfile | 31 ++++++++ api/README.md | 7 ++ api/docs/deploy.md | 113 +++++++++++++++++++++++++++ 4 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 api/Dockerfile create mode 100644 api/docs/deploy.md diff --git a/.specs/features/keyflow-api/tasks.md b/.specs/features/keyflow-api/tasks.md index cbc55a42..d4ef0392 100644 --- a/.specs/features/keyflow-api/tasks.md +++ b/.specs/features/keyflow-api/tasks.md @@ -390,7 +390,7 @@ então `[P]` real se houver dois agentes — senão seguir a ordem sequencial. ## 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 @@ -401,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/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 index 0922081a..01b45fe1 100644 --- a/api/README.md +++ b/api/README.md @@ -151,6 +151,13 @@ Postgres efêmero (mesmas credenciais do `docker-compose.yml`). - 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 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.