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 +46 -0
- hummand/_cliente.py +503 -0
- hummand/_modelos.py +213 -0
- hummand/erros.py +46 -0
- hummand/prova.py +66 -0
- hummand/py.typed +0 -0
- hummand/servidor_mcp.py +157 -0
- hummand/verificacao.py +187 -0
- hummand-0.4.0.dist-info/METADATA +131 -0
- hummand-0.4.0.dist-info/RECORD +14 -0
- hummand-0.4.0.dist-info/WHEEL +4 -0
- hummand-0.4.0.dist-info/entry_points.txt +2 -0
- hummand-0.4.0.dist-info/licenses/LICENSE +202 -0
- hummand-0.4.0.dist-info/licenses/NOTICE +4 -0
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]
|