hummand 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hummand/__init__.py ADDED
@@ -0,0 +1,46 @@
1
+ """`hummand` — SDK Python da camada de garantia humana.
2
+
3
+ Duas metades, que não se confundem:
4
+
5
+ - **verificação** (`verificar`, `problemas`, `Prova`): offline, sem rede, com o JWKS pinado por
6
+ quem chama. É o que o **executor** roda antes de executar a ação. Esta metade é a que importa:
7
+ é ela que torna verdadeira a frase de que o sistema que executa só executa com o recibo de que
8
+ uma pessoa específica, viva e presente, viu e autorizou.
9
+ - **cliente** (`Hummand`): o que o agente usa para pedir a prova ao gateway, com os tipos
10
+ derivados do OpenAPI. Conveniência; a trava não está aqui — se este módulo sumisse, o portão
11
+ continuaria de pé.
12
+
13
+ O SDK não decide o que é arriscado. Quem decide o que exige prova é o cliente, na política do
14
+ tenant; nenhuma heurística de risco mora aqui, e nenhuma vai morar.
15
+
16
+ Rótulos honestos: `disponível` desde 2026-10-24 o mandato a agente, o ato delegado, o
17
+ servidor MCP de mandato e a verificação do recibo `humanproof/1`; os adaptadores de framework
18
+ seguem `arquitetura-alvo`.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from ._cliente import Hummand, Pedido
24
+ from ._modelos import LinkAto, MandatoEstado, RevogarLoteResponse, RevogarMandatoResponse
25
+ from .erros import ErroDeApi, ErroHummand, ProvaInvalida, ProvaNaoConcluida
26
+ from .prova import Prova
27
+ from .verificacao import hash_de_contexto, problemas, verificar
28
+
29
+ __all__ = [
30
+ "ErroDeApi",
31
+ "ErroHummand",
32
+ "Hummand",
33
+ "LinkAto",
34
+ "MandatoEstado",
35
+ "Pedido",
36
+ "Prova",
37
+ "ProvaInvalida",
38
+ "ProvaNaoConcluida",
39
+ "RevogarLoteResponse",
40
+ "RevogarMandatoResponse",
41
+ "hash_de_contexto",
42
+ "problemas",
43
+ "verificar",
44
+ ]
45
+
46
+ __version__ = "0.4.0"
hummand/_cliente.py ADDED
@@ -0,0 +1,503 @@
1
+ """Cliente HTTP do gateway: o que o **agente** usa para pedir a prova.
2
+
3
+ É a metade de conveniência do SDK. A trava está na verificação, que o executor roda; se este
4
+ módulo sumisse, o portão continuaria de pé. Por isso ele não decide nada: cria
5
+ atos, espera veredito, busca recibo, revoga — e entrega tudo à `verificacao` para dizer se
6
+ serve.
7
+
8
+ Duas fronteiras que este módulo respeita e não vai afrouxar:
9
+
10
+ - **a entrega do link é do cliente**. O SDK devolve o link e chama a função que
11
+ você passou; nunca manda SMS, e-mail ou push por conta própria;
12
+ - **a Hummand nunca vê a ação** (D5). O que sobe em `contexto.dados` é o que você escolheu
13
+ declarar, e o que fica registrado no recibo é o hash disso mais os nomes das chaves.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import secrets
19
+ import time
20
+ import uuid
21
+ from collections.abc import Callable, Mapping
22
+ from dataclasses import dataclass, field, is_dataclass
23
+ from datetime import timedelta
24
+ from types import UnionType
25
+ from typing import Any, Union, cast, get_args, get_origin, get_type_hints
26
+
27
+ import httpx
28
+
29
+ from ._modelos import (
30
+ AtoResponse,
31
+ LinkAto,
32
+ MandatoEstado,
33
+ RevogarLoteResponse,
34
+ RevogarMandatoResponse,
35
+ )
36
+ from .erros import ErroDeApi, ErroHummand, ProvaNaoConcluida
37
+ from .prova import Prova
38
+ from .verificacao import verificar
39
+
40
+ BASE_PADRAO = "https://gateway.hummand.com.br"
41
+ JWKS_ROTA = "/.well-known/humanproof/keys.json"
42
+
43
+ TIPO_STEPUP = "stepup.transacao"
44
+ TIPO_MANDATO = "mandato.agente"
45
+ TIPO_DELEGADO = "ato.delegado"
46
+
47
+ # Estados terminais do ato, no vocabulário da API (`services/atos/estados.py`). `em_revisao` não
48
+ # entra: a fila humana ainda vai decidir, e desistir ali seria desistir cedo demais.
49
+ TERMINAIS = frozenset({"concluido", "expirado", "cancelado", "falhou", "revogado"})
50
+
51
+ CHAVE_NONCE = "nonce"
52
+ CHAVE_EXECUTOR = "executor"
53
+
54
+
55
+ def _de_dict(classe: type[Any], dado: Any) -> Any:
56
+ """Constrói um modelo gerado a partir do JSON da resposta.
57
+
58
+ Dataclass não sabe se montar a partir de dicionário, e o SDK não carrega uma biblioteca de
59
+ validação só para isso: o schema é do gateway, e quem confere o recibo é o núcleo. Campo
60
+ desconhecido é ignorado de propósito — API que ganha campo novo não derruba cliente velho.
61
+ """
62
+ if dado is None or not is_dataclass(classe):
63
+ return dado
64
+ tipos = get_type_hints(classe)
65
+ campos = {}
66
+ for nome, tipo in tipos.items():
67
+ if nome not in dado:
68
+ continue
69
+ campos[nome] = _converter(tipo, dado[nome])
70
+ return classe(**campos)
71
+
72
+
73
+ def _converter(tipo: Any, valor: Any) -> Any:
74
+ origem = get_origin(tipo)
75
+ if origem in (Union, UnionType):
76
+ interno = [a for a in get_args(tipo) if a is not type(None)]
77
+ return _converter(interno[0], valor) if len(interno) == 1 and valor is not None else valor
78
+ if origem is list and isinstance(valor, list):
79
+ (item,) = get_args(tipo)
80
+ return [_converter(item, v) for v in valor]
81
+ if is_dataclass(tipo) and isinstance(valor, dict):
82
+ # `is_dataclass` estreita para "instância ou classe"; aqui o que chega é sempre a
83
+ # classe, porque o tipo veio de `get_type_hints`.
84
+ return _de_dict(cast("type[Any]", tipo), valor)
85
+ return valor
86
+
87
+
88
+ def _sem_nulos(valor: Any) -> Any:
89
+ """Corpo da requisição sem as chaves nulas: `null` explícito tem significado na API."""
90
+ if isinstance(valor, dict):
91
+ return {k: _sem_nulos(v) for k, v in valor.items() if v is not None}
92
+ if isinstance(valor, list):
93
+ return [_sem_nulos(v) for v in valor]
94
+ return valor
95
+
96
+
97
+ @dataclass(frozen=True)
98
+ class Pedido:
99
+ """Um ato criado, à espera do humano. O link é seu para entregar."""
100
+
101
+ ato_id: str
102
+ tipo: str
103
+ nivel_minimo: int
104
+ contexto_dados: dict[str, Any] = field(default_factory=dict)
105
+ nonce: str | None = None
106
+ link: LinkAto | None = None
107
+
108
+ @property
109
+ def url(self) -> str | None:
110
+ return self.link.url if self.link else None
111
+
112
+
113
+ class Hummand:
114
+ """Cliente do gateway. Um por tenant; reutilize — mantém a conexão aberta.
115
+
116
+ `jwks` é o JWKS **pinado**: baixe uma vez, versione com o seu serviço e passe aqui. Verificar
117
+ contra a chave que o mesmo servidor acabou de entregar não prova nada; por isso não há
118
+ busca automática — `baixar_jwks` existe só para o momento de pinar.
119
+ """
120
+
121
+ def __init__(
122
+ self,
123
+ api_key: str,
124
+ *,
125
+ base_url: str = BASE_PADRAO,
126
+ jwks: str | Mapping[str, Any] | None = None,
127
+ tenant_id: str | None = None,
128
+ timeout: float = 10.0,
129
+ transport: httpx.BaseTransport | None = None,
130
+ ) -> None:
131
+ if not api_key:
132
+ raise ErroHummand("api_key é obrigatória")
133
+ self.jwks = jwks
134
+ self.tenant_id = tenant_id
135
+ self._http = httpx.Client(
136
+ base_url=base_url.rstrip("/"),
137
+ timeout=timeout,
138
+ transport=transport,
139
+ headers={"authorization": f"Bearer {api_key}"},
140
+ )
141
+
142
+ def __enter__(self) -> Hummand:
143
+ return self
144
+
145
+ def __exit__(self, *_: object) -> None:
146
+ self.fechar()
147
+
148
+ def fechar(self) -> None:
149
+ self._http.close()
150
+
151
+ # --- transporte ----------------------------------------------------------------
152
+
153
+ def _pedir(
154
+ self,
155
+ metodo: str,
156
+ rota: str,
157
+ *,
158
+ corpo: Any = None,
159
+ cabecalhos: Mapping[str, str] | None = None,
160
+ aceitar: tuple[int, ...] = (200, 201),
161
+ ) -> Any:
162
+ try:
163
+ resposta = self._http.request(
164
+ metodo, rota, json=_sem_nulos(corpo), headers=dict(cabecalhos or {})
165
+ )
166
+ except httpx.HTTPError as erro: # rede, DNS, timeout: infraestrutura, dá para retentar
167
+ raise ErroDeApi(f"{metodo} {rota}: {erro}") from erro
168
+ if resposta.status_code not in aceitar:
169
+ raise ErroDeApi(_detalhe(resposta), status=resposta.status_code)
170
+ return resposta.json() if resposta.content else None
171
+
172
+ # --- criação de atos -----------------------------------------------------------
173
+
174
+ def pedir_prova(
175
+ self,
176
+ *,
177
+ sujeito: str,
178
+ dados: Mapping[str, Any] | None = None,
179
+ tipo: str = TIPO_STEPUP,
180
+ nivel_minimo: int = 2,
181
+ executor: str | None = None,
182
+ nonce: str | None = None,
183
+ referencia_ato: str | None = None,
184
+ ) -> Pedido:
185
+ """Cria o ato e devolve o `Pedido` com o link. Não espera nada; não entrega nada.
186
+
187
+ O `nonce` é gerado aqui quando você não passa um, e entra em `contexto.dados` junto com
188
+ o `executor`: os dois viram parte do `contexto.hash` assinado (sem campo novo no
189
+ recibo). É o que amarra o recibo a **este** pedido.
190
+ """
191
+ contexto = dict(dados or {})
192
+ nonce = nonce or secrets.token_hex(16)
193
+ contexto[CHAVE_NONCE] = nonce
194
+ if executor:
195
+ contexto[CHAVE_EXECUTOR] = executor
196
+
197
+ corpo: dict[str, Any] = {
198
+ "tipo": tipo,
199
+ "sujeito": {"ref_cliente": sujeito},
200
+ "contexto": {"dados": contexto, "chaves_declaradas": sorted(contexto)},
201
+ "nivel_minimo": nivel_minimo,
202
+ }
203
+ if referencia_ato:
204
+ corpo["sujeito"]["referencia"] = {"tipo": "ato", "ato_id": referencia_ato}
205
+
206
+ ato = self._criar(corpo)
207
+ return Pedido(
208
+ ato_id=ato.ato_id,
209
+ tipo=ato.tipo,
210
+ nivel_minimo=nivel_minimo,
211
+ contexto_dados=contexto,
212
+ nonce=nonce,
213
+ link=ato.link,
214
+ )
215
+
216
+ def mandato(
217
+ self,
218
+ *,
219
+ sujeito: str,
220
+ mandatario: str,
221
+ acoes: list[str],
222
+ validade_ate: str,
223
+ limites: Mapping[str, Any] | None = None,
224
+ revogavel: bool = True,
225
+ dados: Mapping[str, Any] | None = None,
226
+ nivel_minimo: int = 2,
227
+ ) -> Pedido:
228
+ """Concede um `mandato.agente`: presença viva do mandante, uma vez, com escopo escrito.
229
+
230
+ O escopo é o que o executor vai comparar com a ação — a Hummand não conhece a ação.
231
+ `revogavel=False` cria mandato que nem você derruba depois; o padrão é o contrário.
232
+ """
233
+ contexto = dict(dados or {})
234
+ corpo: dict[str, Any] = {
235
+ "tipo": TIPO_MANDATO,
236
+ "sujeito": {"ref_cliente": sujeito},
237
+ "mandatario": {"ref": mandatario},
238
+ "escopo": {
239
+ "acoes": acoes,
240
+ "validade_ate": validade_ate,
241
+ "revogavel": revogavel,
242
+ "limites": dict(limites or {}),
243
+ },
244
+ "nivel_minimo": nivel_minimo,
245
+ }
246
+ if contexto:
247
+ corpo["contexto"] = {"dados": contexto, "chaves_declaradas": sorted(contexto)}
248
+
249
+ ato = self._criar(corpo)
250
+ return Pedido(
251
+ ato_id=ato.ato_id,
252
+ tipo=ato.tipo,
253
+ nivel_minimo=nivel_minimo,
254
+ contexto_dados=contexto,
255
+ link=ato.link,
256
+ )
257
+
258
+ def delegado(
259
+ self,
260
+ *,
261
+ mandato_ref: str,
262
+ acao: str,
263
+ sujeito: str,
264
+ valores: Mapping[str, Any] | None = None,
265
+ janela: timedelta | None = None,
266
+ timeout: float = 60.0,
267
+ exigir_positivo: bool = True,
268
+ ) -> Prova:
269
+ """Ato sob mandato: sem humano novo, resolve na hora, e já volta verificado.
270
+
271
+ O que sustenta o ato é o mandato, não a ação: `acao` e `valores` são comparados com o
272
+ escopo **no gateway**, e o recibo diz se passou. Ato recusado também gera recibo — uma
273
+ tentativa sob mandato revogado é fato provável, e é por isso que ele não some.
274
+
275
+ "Na hora" é o veredito, não a assinatura: o recibo é assinado por um worker, no KMS,
276
+ e nasce instantes depois. Por isso há espera aqui, mesmo sem humano no caminho.
277
+
278
+ `exigir_positivo=False` devolve a prova do ato **recusado** em vez de levantar. Serve
279
+ para quem quer ler a recusa — auditoria, demo, teste de política —, nunca para quem
280
+ vai executar a ação: ali o padrão é o certo.
281
+ """
282
+ corpo = {
283
+ "tipo": TIPO_DELEGADO,
284
+ "sujeito": {"ref_cliente": sujeito},
285
+ "delegado": {
286
+ "mandato_ref": mandato_ref,
287
+ "acao": acao,
288
+ "valores": dict(valores or {}),
289
+ },
290
+ }
291
+ ato = self._criar(corpo)
292
+ recibo = self.esperar_recibo(ato.ato_id, timeout=timeout)
293
+ return self._verificar(
294
+ recibo,
295
+ tipo=TIPO_DELEGADO,
296
+ nivel_minimo=0,
297
+ janela=janela,
298
+ exigir_positivo=exigir_positivo,
299
+ )
300
+
301
+ def _criar(self, corpo: dict[str, Any]) -> AtoResponse:
302
+ # `Idempotency-Key` retentar por timeout de rede não cria ato novo.
303
+ cabecalhos = {"Idempotency-Key": uuid.uuid4().hex}
304
+ dado = self._pedir("POST", "/v1/atos", corpo=corpo, cabecalhos=cabecalhos)
305
+ return _de_dict(AtoResponse, dado) # type: ignore[no-any-return]
306
+
307
+ # --- espera e verificação ------------------------------------------------------
308
+
309
+ def ato(self, ato_id: str) -> AtoResponse:
310
+ return _de_dict(AtoResponse, self._pedir("GET", f"/v1/atos/{ato_id}")) # type: ignore[no-any-return]
311
+
312
+ def recibo(self, ato_id: str) -> dict[str, Any] | None:
313
+ """O recibo assinado, ou `None` enquanto a assinatura não saiu (202) ou não haverá (404).
314
+
315
+ O corpo vem como o gateway o assinou; qualquer reserialização é sua, e o verificador
316
+ canonicaliza antes de conferir.
317
+ """
318
+ dado = self._pedir("GET", f"/v1/atos/{ato_id}/recibo", aceitar=(200, 202, 404))
319
+ if isinstance(dado, dict) and dado.get("formato"):
320
+ return dado
321
+ return None
322
+
323
+ def esperar_recibo(
324
+ self,
325
+ ato_id: str,
326
+ *,
327
+ timeout: float = 60.0,
328
+ intervalo: float = 0.5,
329
+ intervalo_maximo: float = 5.0,
330
+ ) -> dict[str, Any]:
331
+ """Espera a assinatura do recibo sair, para um ato que já tem veredito.
332
+
333
+ O veredito e a assinatura são momentos diferentes: o worker assina no KMS, fora do
334
+ caminho da requisição. Quem só quer o recibo de um ato terminal (uma revogação, por
335
+ exemplo) chama isto; quem quer a prova verificada chama `aguardar`.
336
+ """
337
+ limite = time.monotonic() + timeout
338
+ while True:
339
+ recibo = self.recibo(ato_id)
340
+ if recibo is not None:
341
+ return recibo
342
+ restante = limite - time.monotonic()
343
+ if restante <= 0:
344
+ raise ProvaNaoConcluida(ato_id, "recibo ainda não assinado")
345
+ time.sleep(min(intervalo, restante))
346
+ intervalo = min(intervalo * 2, intervalo_maximo)
347
+
348
+ def aguardar(
349
+ self,
350
+ pedido: Pedido | str,
351
+ *,
352
+ timeout: float = 300.0,
353
+ intervalo: float = 1.0,
354
+ intervalo_maximo: float = 5.0,
355
+ janela: timedelta | None = None,
356
+ ) -> Prova:
357
+ """Espera o veredito e devolve a prova verificada.
358
+
359
+ Espera com recuo progressivo, porque a jornada leva de 40 a 60 segundos e nada ganha com
360
+ polling agressivo. Estourar o tempo levanta `ProvaNaoConcluida` — **o ato continua de
361
+ pé** no gateway até expirar, e o humano ainda pode concluí-lo; quem chama decide se
362
+ espera mais, avisa alguém ou desiste.
363
+ """
364
+ ato_id = pedido if isinstance(pedido, str) else pedido.ato_id
365
+ limite = time.monotonic() + timeout
366
+ estado = "criado"
367
+ while time.monotonic() < limite:
368
+ atual = self.ato(ato_id)
369
+ estado = atual.estado
370
+ if estado in TERMINAIS:
371
+ recibo = self.recibo(ato_id)
372
+ if recibo is not None:
373
+ return self._verificar(recibo, pedido=pedido, janela=janela)
374
+ time.sleep(min(intervalo, max(0.0, limite - time.monotonic())))
375
+ intervalo = min(intervalo * 2, intervalo_maximo)
376
+ raise ProvaNaoConcluida(ato_id, estado)
377
+
378
+ def require_human_proof(
379
+ self,
380
+ *,
381
+ sujeito: str,
382
+ entregar: Callable[[LinkAto], None],
383
+ dados: Mapping[str, Any] | None = None,
384
+ tipo: str = TIPO_STEPUP,
385
+ nivel_minimo: int = 2,
386
+ executor: str | None = None,
387
+ nonce: str | None = None,
388
+ timeout: float = 300.0,
389
+ janela: timedelta | None = None,
390
+ ) -> Prova:
391
+ """Pede a prova, entrega o link pela sua função e espera o veredito verificado.
392
+
393
+ `entregar` é sua: push do seu app, SMS, QR na tela do operador. O SDK não tem canal
394
+ próprio e não vai ter — quem conhece a pessoa é você.
395
+ """
396
+ pedido = self.pedir_prova(
397
+ sujeito=sujeito,
398
+ dados=dados,
399
+ tipo=tipo,
400
+ nivel_minimo=nivel_minimo,
401
+ executor=executor,
402
+ nonce=nonce,
403
+ )
404
+ if pedido.link is None:
405
+ raise ErroHummand(f"ato {pedido.ato_id} nasceu sem link de verificação")
406
+ entregar(pedido.link)
407
+ return self.aguardar(pedido, timeout=timeout, janela=janela)
408
+
409
+ def _verificar(
410
+ self,
411
+ recibo: dict[str, Any],
412
+ *,
413
+ pedido: Pedido | str | None = None,
414
+ tipo: str | None = None,
415
+ nivel_minimo: int | None = None,
416
+ janela: timedelta | None = None,
417
+ exigir_positivo: bool = True,
418
+ ) -> Prova:
419
+ if self.jwks is None:
420
+ raise ErroHummand(
421
+ "sem JWKS pinado: passe `jwks=` ao criar o cliente (use `baixar_jwks` uma vez)"
422
+ )
423
+ do_pedido = pedido if isinstance(pedido, Pedido) else None
424
+ return verificar(
425
+ recibo,
426
+ jwks=self.jwks,
427
+ tipo=tipo if tipo is not None else (do_pedido.tipo if do_pedido else None),
428
+ tenant_id=self.tenant_id,
429
+ nivel_minimo=(
430
+ nivel_minimo
431
+ if nivel_minimo is not None
432
+ else (do_pedido.nivel_minimo if do_pedido else 0)
433
+ ),
434
+ contexto_dados=do_pedido.contexto_dados if do_pedido else None,
435
+ nonce=do_pedido.nonce if do_pedido else None,
436
+ janela=janela,
437
+ exigir_positivo=exigir_positivo,
438
+ )
439
+
440
+ # --- circuit breaker -----------------------------------------------------------
441
+
442
+ def estado_do_mandato(self, mandato_ato_id: str) -> MandatoEstado:
443
+ """Consulta barata de estado: é o que o executor chama antes de agir sob o mandato."""
444
+ return _de_dict(MandatoEstado, self._pedir("GET", f"/v1/mandatos/{mandato_ato_id}")) # type: ignore[no-any-return]
445
+
446
+ def revogar(self, mandato_ato_id: str) -> RevogarMandatoResponse:
447
+ """Derruba um mandato. Ato humano, com recibo — e só outro mandato o devolve."""
448
+ dado = self._pedir("POST", f"/v1/mandatos/{mandato_ato_id}/revogar")
449
+ return _de_dict(RevogarMandatoResponse, dado) # type: ignore[no-any-return]
450
+
451
+ def parar_tudo(
452
+ self, *, mandatario: str | None = None, todos: bool = False
453
+ ) -> RevogarLoteResponse:
454
+ """Revoga em lote, por mandatário ou por tenant: o "parar tudo" da camada 3.
455
+
456
+ Um alvo obrigatório e sem padrão, de propósito. O que isto para é a **autoridade** do
457
+ agente onde os executores a exigem — não a computação dele, que a Hummand não hospeda.
458
+ Mandato com `escopo.revogavel = false` não cai e volta em `ignorados`.
459
+ """
460
+ if bool(mandatario) == todos:
461
+ raise ErroHummand("escolha um alvo: `mandatario=` ou `todos=True`, nunca os dois")
462
+ corpo = {"mandatario_ref": mandatario, "todos": todos}
463
+ dado = self._pedir("POST", "/v1/mandatos/revogar-lote", corpo=corpo)
464
+ return _de_dict(RevogarLoteResponse, dado) # type: ignore[no-any-return]
465
+
466
+ # --- chave pública -------------------------------------------------------------
467
+
468
+ def baixar_jwks(self) -> str:
469
+ """Baixa o JWKS **para você pinar**, uma vez, e versionar com o seu serviço.
470
+
471
+ Não use isto dentro do caminho de verificação: conferir a assinatura contra a chave que
472
+ o mesmo servidor acabou de entregar não prova nada contra quem controla o servidor.
473
+ """
474
+ resposta = self._http.get(JWKS_ROTA)
475
+ if resposta.status_code != 200:
476
+ raise ErroDeApi(_detalhe(resposta), status=resposta.status_code)
477
+ return resposta.text
478
+
479
+ def ponto_de_conferencia(self) -> dict[str, Any]:
480
+ """Ponto de conferência assinado da sua cadeia: **guarde, e compare depois**.
481
+
482
+ A cadeia de recibos acusa buraco no meio, inserção, reordenação e bifurcação. Ela não
483
+ acusa o corte do fim: remover os últimos recibos deixa um lote contínuo desde o gênesis,
484
+ e nada dentro dele diz quantos deveriam existir. O ponto afirma, assinado, quantos havia
485
+ e qual era o último.
486
+
487
+ O valor está na distância no tempo entre guardar e usar. Pedir o ponto no mesmo instante
488
+ em que se pede o lote não prova nada, porque os dois vêm da mesma versão da história.
489
+ Peça de tempos em tempos, guarde fora daqui, e confira com
490
+ `humanproof-verify verificar lote.json --jwks keys.json --ponto ponto.json`.
491
+ """
492
+ dado = self._pedir("GET", "/v1/recibos/ponto")
493
+ return dict(dado)
494
+
495
+
496
+ def _detalhe(resposta: httpx.Response) -> str:
497
+ try:
498
+ corpo = resposta.json()
499
+ except ValueError:
500
+ return resposta.text[:200]
501
+ if isinstance(corpo, dict) and "detail" in corpo:
502
+ return str(corpo["detail"])
503
+ return str(corpo)[:200]