easycontract 0.1.0__tar.gz → 0.1.4__tar.gz

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.
@@ -0,0 +1,162 @@
1
+ # Changelog
2
+
3
+ All notable changes to the `easycontract` Python SDK.
4
+
5
+ ## 0.1.4 (2026-10-20)
6
+
7
+ **Features**
8
+
9
+ - Projects (paid plans): `client.projects.list/create/get/update`. An
10
+ organization key sees everything; a project key only its project.
11
+ `project=` on `envelopes.create`, `envelopes.list`, `templates.create_envelope`
12
+ and `webhooks.create` (organization keys only). `client.whoami()` now returns
13
+ `project` (null = organization key) and `key`.
14
+ - Webhooks: `client.webhooks.get`, `update` (url, event_types, is_active,
15
+ description) and `rotate_secret(grace_period_hours=24)`, which returns the
16
+ new `secret` once.
17
+
18
+ **Fixes**
19
+
20
+ - `easycontract.webhooks.verify_signature` accepts ANY `v1=` in the header
21
+ and `secret` may be a list: after a rotation, during the grace period,
22
+ deliveries carry `v1=<new>,v1=<old>`. 0.1.3 and earlier only checked the
23
+ last `v1` (the old secret), so they failed once you switched to the new one
24
+ before the grace period ended.
25
+
26
+ ## 0.1.3 (2026-10-20)
27
+
28
+ **Features**
29
+
30
+ - Cryptographic restrictions: `client.organization.update(seal_profile=
31
+ "hybrid" | "classic", acknowledge=True)` chooses how the platform seals
32
+ the certificate, the final PDFs and the evidence package (hybrid ECDSA +
33
+ ML-DSA-87, or ECDSA only). `client.organization.get()["cryptography"]`
34
+ shows the choice and what each option implies; every envelope reports the
35
+ profile frozen at send in `seal_profile`.
36
+ - The evidence package (`client.envelopes.download_evidence(...)`) is now an ASiC-E
37
+ container (still a `.zip`) signed by the platform.
38
+
39
+ ## 0.1.2 (2026-10-19)
40
+
41
+ **Features**
42
+
43
+ - Machine-drafted template translations: `client.templates.autotranslate(
44
+ template_id, document_id, language, acknowledge=True)` queues a draft made
45
+ by the platform's translators and reviewed by a panel with quorum;
46
+ `client.templates.get_translation_job(job_id)` returns its progress,
47
+ `verdict` (`consensus`, `quorum`, `no_quorum`, `unreviewed`, `simulated`)
48
+ and the reviewers' notes with English back-translations. The result is
49
+ always a draft that a person approves in the panel.
50
+ - The platform now has 16 languages (es, en, ca, gl, eu, ast, an, oc, pt, fr,
51
+ it, de, ru, zh-hans, ar, he) for `language` everywhere.
52
+
53
+ ## 0.1.1 (2026-10-07)
54
+
55
+ **Security (audit 2026-10-07)**
56
+
57
+ - `requests>=2.33.0,<3` (was `>=2.31`): older `requests` releases are affected
58
+ by CVE-2024-35195, CVE-2024-47081 and CVE-2026-25645.
59
+ - Resource identifiers are validated as UUIDs before a path is built, and
60
+ free-text segments (catalog keys, language codes) are percent-encoded: an
61
+ id such as `"abc/../../webhooks/123"` used to make `requests` resolve the
62
+ dot segments and call ANOTHER endpoint with the caller's API key. Invalid
63
+ ids now raise `ValueError` before any request is sent.
64
+
65
+ **Features**
66
+
67
+ - Asynchronous malware scan of uploaded documents: `document["scan"]`
68
+ (`status` pending/clean/infected/error/not_scanned, `engine`, `signature`,
69
+ `scanned_at`), event `document.scanned`, and
70
+ `client.envelopes.rescan_document(envelope_id, document_id)` to retry a
71
+ failed scan. Signers only see and sign documents once they are clean.
72
+ - Invitation e-mails are held back until the malware verdict:
73
+ `signer["link_delivery"] == "pending_scan"` while waiting (the `sign_url`
74
+ values stay valid); `envelope["void_reason_code"] == "malware_detected"`
75
+ when the platform stopped an envelope.
76
+ - Identity images retention mode: `envelopes.create(..., kyc_images_retention=
77
+ "after_in_person_check")` (also on `templates.create`/`update` and
78
+ `templates.create_envelope`) keeps a verified signer's document/selfie
79
+ images until the requester records the face-to-face check with
80
+ `client.identity.confirm_in_person(envelope_id, signer_id, note=...)`
81
+ (event `signer.kyc_confirmed_in_person`); the default "after_review" purges
82
+ N days after the reviewer's decision. `identity_verification` carries
83
+ `images_retention` and `confirmed_in_person`.
84
+ - Embedded signing (§10.3): `client.organization.update(embed_allowed_origins=[...])`
85
+ registers the web origins allowed to frame the signer flow, and
86
+ `client.envelopes.create_embedded_session(envelope_id, signer_id, origin=...,
87
+ return_url=..., language=..., ttl_minutes=...)` mints the ephemeral `url`
88
+ (token `em_…`) to open in an `<iframe allow="camera">` (postMessage events
89
+ `ready`/`signed`/`declined`/`expired`) or by redirect (`return_url`). The
90
+ organization exposes `embed_allowed_origins`.
91
+ - Biometrics (Enterprise): `envelopes.create(..., biometrics=True)` and
92
+ `templates.create_envelope(..., biometrics=True)` offer signers the optional
93
+ stroke capture; `signer["biometrics"]` carries its status (hash, points,
94
+ consent version, `erased_at`), never the vector;
95
+ `envelopes.erase_signer_biometrics(envelope_id, signer_id, reason=...)`
96
+ erases it (GDPR art. 17); `download_evidence(..., include_biometrics=True)`
97
+ adds the vectors to the ZIP on explicit request.
98
+ - `envelopes.create(..., language=...)` sets the document language.
99
+ - Identity proofing (Pro+): `auth_methods` accepts `"kyc"` on signers and
100
+ template roles; `signer["identity_verification"]` carries the status
101
+ (provider, method, document type/country, name match, hash), never the
102
+ provider payload; `download_evidence(..., include_identity_proofs=True)` adds
103
+ the full results to the ZIP on explicit request. The organization exposes
104
+ `kyc` (switch state) and `features.kyc`.
105
+ - In-house identity review: `client.identity.pending_reviews()`,
106
+ `client.identity.get(envelope_id, signer_id)`,
107
+ `client.identity.download_document(envelope_id, signer_id, document_id)`
108
+ (every download is recorded in the evidence trail) and
109
+ `client.identity.review(envelope_id, signer_id, decision="approve"|"reject",
110
+ verified_name=..., reason=...)`. `identity_verification` now carries
111
+ `checks`, `documents` (hashes, never the images), `reviewed_by` and
112
+ `images_purge_at`; `download_evidence(..., include_identity_proofs=True)`
113
+ also adds the images still retained.
114
+ - `envelopes.add_signer(..., kyc_document_type=..., kyc_document_number=...)`
115
+ registers what the requester knows about the signer's document; a
116
+ different declaration is refused at capture. Signers expose
117
+ `kyc_expected_document_type` and `kyc_expected_document_number_set`.
118
+ - Identity endpoints (`identity.pending_reviews()`, `identity.get()`) now
119
+ return `declared_document_number`, `verified_name` and `signer_full_name`
120
+ so the tenant can validate on its own platform; the signer summary inside
121
+ envelopes keeps status only. New outbound events `signer.kyc_submitted`,
122
+ `signer.kyc_verified` and `signer.kyc_failed` (webhooks and `GET /events/`).
123
+ - Retention: `envelopes.create(..., document_type=...)` (catalogue in
124
+ `retention.document_types()`, unsuitable types are refused, caution types
125
+ carry `document_type_warning`), `envelopes.set_legal_hold(envelope_id,
126
+ reason=...)` / `release_legal_hold()`, `retention.overview(within_days=90)`
127
+ and `organization.update(retention_policies={...})`. Envelopes expose
128
+ `document_type`, `document_type_warning` and `legal_hold`; new outbound
129
+ event `envelope.blocked` is documented.
130
+
131
+ - `envelopes.download_final_document(envelope_id, document_id, to=None)`:
132
+ downloads the stamped final PDF of one document (PAdES-B-LTA sealed on the
133
+ Enterprise plan). Seal details are exposed under `documents[].final.pades`
134
+ when fetching the envelope with `expand=["documents"]`.
135
+
136
+ ## 0.1.0 (2026-09-25) — published: https://pypi.org/project/easycontract/0.1.0/
137
+
138
+ Initial release.
139
+
140
+ - `EasyContract` client: `X-Api-Key` auth, per-POST `Idempotency-Key`,
141
+ bounded retries (429 + GET 5xx) honoring `Retry-After`, typed RFC 7807
142
+ error hierarchy, context-manager support (`close()`).
143
+ - Full resource coverage of the v1 API (envelopes, templates, webhooks,
144
+ events, organization, members, invitations) with cursor auto-paging and
145
+ anti-loop guards.
146
+ - `webhooks.verify_signature`: fail-closed HMAC-SHA256 with timestamp
147
+ replay check.
148
+
149
+ ### Behaviour notes
150
+
151
+ - `idempotency_key` accepts a non-empty `str` (sent as-is), `None` or
152
+ `True` (a random UUID is generated per call), or `False` (header
153
+ omitted). Any other value is coerced with `str()` (e.g. a `uuid.UUID`
154
+ is sent as its string form).
155
+ - File uploads: seekable files are re-wound and re-sent in full on
156
+ retries; non-seekable streams (pipes, sockets) are sent exactly once —
157
+ a 429 on such an upload raises `RateLimitError` instead of retrying
158
+ with an empty body.
159
+ - `webhooks.verify_signature` verifies over the raw request bytes; the
160
+ payload is decoded only after the HMAC check passes.
161
+ - `tolerance=None` in `verify_signature` disables the timestamp replay
162
+ check (opt-out; not recommended).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: easycontract
3
- Version: 0.1.0
3
+ Version: 0.1.4
4
4
  Summary: SDK oficial de Python para el API de easycontract (firma electrónica eIDAS)
5
5
  Project-URL: Documentation, https://easycontract.online/docs/api
6
6
  Author-email: Román Ramírez <roman@easycontract.online>
@@ -18,7 +18,7 @@ Classifier: Programming Language :: Python :: 3.13
18
18
  Classifier: Topic :: Security
19
19
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
20
  Requires-Python: >=3.10
21
- Requires-Dist: requests<3,>=2.31
21
+ Requires-Dist: requests<3,>=2.33.0
22
22
  Description-Content-Type: text/markdown
23
23
 
24
24
  # easycontract — SDK de Python
@@ -81,8 +81,17 @@ client.templates.add_field(
81
81
 
82
82
  ## Receta 2 — Firma embebida (embedded signing)
83
83
 
84
- Con `deliver_emails=False` recibes el `sign_url` de un solo uso por firmante y
85
- lo integras en tu propia aplicación (redirect o iframe):
84
+ El firmante firma dentro de tu aplicación (el caso RootedArbiter). Tres pasos:
85
+
86
+ 1. Registra una vez los orígenes de tu web (https; `http://localhost:…` solo
87
+ en desarrollo). La lista se sustituye entera:
88
+
89
+ ```python
90
+ client.organization.update(embed_allowed_origins=["https://arbiter.rootedcon.com"])
91
+ ```
92
+
93
+ 2. Envía sin emails y, cuando el ponente esté delante de tu página, acuña una
94
+ sesión efímera para ESE firmante (60 minutos por defecto, caduca al firmar):
86
95
 
87
96
  ```python
88
97
  result = client.templates.create_envelope(
@@ -91,12 +100,41 @@ result = client.templates.create_envelope(
91
100
  send=True,
92
101
  deliver_emails=False,
93
102
  )
94
- sign_url = result["signers"][0]["sign_url"]
95
- # → redirige al ponente a sign_url sin salir de tu flujo
103
+ signer = result["signers"][0]
104
+ session = client.envelopes.create_embedded_session(
105
+ result["envelope"]["id"],
106
+ signer["id"],
107
+ origin="https://arbiter.rootedcon.com",
108
+ return_url="https://arbiter.rootedcon.com/contratos/42/firmado", # opcional
109
+ language="es",
110
+ )
111
+ embed_url = session["url"] # token em_…: un firmante, un origen, caduca
112
+ ```
113
+
114
+ 3. Ábrela en un iframe y escucha los eventos, o redirige al ponente y recíbelo
115
+ de vuelta en `return_url` con `?easycontract_event=signed&envelope_id=…&signer_id=…`:
116
+
117
+ ```html
118
+ <iframe src="{{ embed_url }}" allow="camera" style="width:100%;height:80vh;border:0"></iframe>
119
+ <script>
120
+ window.addEventListener("message", (e) => {
121
+ if (e.origin !== "https://app.easycontract.online") return; // el origen de easycontract
122
+ if (e.data?.source !== "easycontract") return;
123
+ // e.data.event ∈ ready | signed | declined | expired; e.data.envelope_id, signer_id
124
+ });
125
+ </script>
96
126
  ```
97
127
 
98
- El enlace es personal, caduca (14 días por defecto, `expires_in_days` para
99
- cambiarlo) y queda invalidado al firmar o rechazar.
128
+ La página de firma responde con `Content-Security-Policy: frame-ancestors
129
+ <tu origen>` y no depende de cookies (funciona con las de terceros
130
+ bloqueadas); el OTP y la verificación de identidad, si el firmante los exige,
131
+ se piden dentro del mismo marco (`allow="camera"` para la captura del
132
+ documento). El `postMessage` es solo señal de interfaz: confirma siempre en
133
+ servidor con el webhook `signer.signed` o `client.envelopes.get(...)`. Cada
134
+ sesión y su apertura (con el origen) quedan en el expediente de evidencias.
135
+
136
+ El `sign_url` clásico de `deliver_emails=False` sigue existiendo para el
137
+ redirect simple (14 días, `expires_in_days` para cambiarlo).
100
138
 
101
139
  ## Receta 3 — Webhooks vs. polling
102
140
 
@@ -174,3 +212,42 @@ members = client.members.list()
174
212
  invite = client.invitations.create(email="colega@example.com", role="member")
175
213
  # invite["accept_url"] — entrégalo tú o deja que easycontract lo envíe por email
176
214
  ```
215
+
216
+ ## Validar la identidad del firmante en tu plataforma (KYC)
217
+
218
+ Con el proceso propio de easycontract, el firmante fotografía su documento y se hace un
219
+ selfie; la decisión es de la organización. Puedes tomarla desde tu propio sistema:
220
+
221
+ ```python
222
+ from easycontract import EasyContract
223
+
224
+ client = EasyContract(api_key="ec_live_...")
225
+
226
+ # 1. Suscríbete a los eventos (o sondea GET /events/): signer.kyc_submitted avisa de
227
+ # cada caso pendiente; signer.kyc_verified / signer.kyc_failed, de la decisión.
228
+ for case in client.identity.pending_reviews()["data"]:
229
+ envelope_id, signer = case["envelope_id"], case["signer"]
230
+ identity = case["identity_verification"] # estado, comprobaciones, datos declarados
231
+
232
+ # 2. Recupera las imágenes (cada descarga queda en el audit trail del documento)
233
+ for document in identity["documents"]:
234
+ client.identity.download_document(
235
+ envelope_id, signer["id"], document["id"], to=f"/tmp/{document['kind']}.jpg"
236
+ )
237
+
238
+ # 3. Valida con tus reglas: número declarado, caducidad, cotejo visual o tu propio motor
239
+ ok = identity["declared_document_number"] == mi_registro[signer["email"]].dni
240
+
241
+ # 4. Decide; el firmante recibe el resultado por correo
242
+ client.identity.review(
243
+ envelope_id,
244
+ signer["id"],
245
+ decision="approve" if ok else "reject",
246
+ verified_name=signer["full_name"],
247
+ reason="" if ok else "El número de documento no coincide con nuestro registro",
248
+ )
249
+ ```
250
+
251
+ Las imágenes se purgan a los N días de la decisión (`organization.kyc.image_retention_days`);
252
+ el resultado completo sigue disponible en el paquete de evidencias con
253
+ `download_evidence(..., include_identity_proofs=True)`.
@@ -58,8 +58,17 @@ client.templates.add_field(
58
58
 
59
59
  ## Receta 2 — Firma embebida (embedded signing)
60
60
 
61
- Con `deliver_emails=False` recibes el `sign_url` de un solo uso por firmante y
62
- lo integras en tu propia aplicación (redirect o iframe):
61
+ El firmante firma dentro de tu aplicación (el caso RootedArbiter). Tres pasos:
62
+
63
+ 1. Registra una vez los orígenes de tu web (https; `http://localhost:…` solo
64
+ en desarrollo). La lista se sustituye entera:
65
+
66
+ ```python
67
+ client.organization.update(embed_allowed_origins=["https://arbiter.rootedcon.com"])
68
+ ```
69
+
70
+ 2. Envía sin emails y, cuando el ponente esté delante de tu página, acuña una
71
+ sesión efímera para ESE firmante (60 minutos por defecto, caduca al firmar):
63
72
 
64
73
  ```python
65
74
  result = client.templates.create_envelope(
@@ -68,12 +77,41 @@ result = client.templates.create_envelope(
68
77
  send=True,
69
78
  deliver_emails=False,
70
79
  )
71
- sign_url = result["signers"][0]["sign_url"]
72
- # → redirige al ponente a sign_url sin salir de tu flujo
80
+ signer = result["signers"][0]
81
+ session = client.envelopes.create_embedded_session(
82
+ result["envelope"]["id"],
83
+ signer["id"],
84
+ origin="https://arbiter.rootedcon.com",
85
+ return_url="https://arbiter.rootedcon.com/contratos/42/firmado", # opcional
86
+ language="es",
87
+ )
88
+ embed_url = session["url"] # token em_…: un firmante, un origen, caduca
89
+ ```
90
+
91
+ 3. Ábrela en un iframe y escucha los eventos, o redirige al ponente y recíbelo
92
+ de vuelta en `return_url` con `?easycontract_event=signed&envelope_id=…&signer_id=…`:
93
+
94
+ ```html
95
+ <iframe src="{{ embed_url }}" allow="camera" style="width:100%;height:80vh;border:0"></iframe>
96
+ <script>
97
+ window.addEventListener("message", (e) => {
98
+ if (e.origin !== "https://app.easycontract.online") return; // el origen de easycontract
99
+ if (e.data?.source !== "easycontract") return;
100
+ // e.data.event ∈ ready | signed | declined | expired; e.data.envelope_id, signer_id
101
+ });
102
+ </script>
73
103
  ```
74
104
 
75
- El enlace es personal, caduca (14 días por defecto, `expires_in_days` para
76
- cambiarlo) y queda invalidado al firmar o rechazar.
105
+ La página de firma responde con `Content-Security-Policy: frame-ancestors
106
+ <tu origen>` y no depende de cookies (funciona con las de terceros
107
+ bloqueadas); el OTP y la verificación de identidad, si el firmante los exige,
108
+ se piden dentro del mismo marco (`allow="camera"` para la captura del
109
+ documento). El `postMessage` es solo señal de interfaz: confirma siempre en
110
+ servidor con el webhook `signer.signed` o `client.envelopes.get(...)`. Cada
111
+ sesión y su apertura (con el origen) quedan en el expediente de evidencias.
112
+
113
+ El `sign_url` clásico de `deliver_emails=False` sigue existiendo para el
114
+ redirect simple (14 días, `expires_in_days` para cambiarlo).
77
115
 
78
116
  ## Receta 3 — Webhooks vs. polling
79
117
 
@@ -151,3 +189,42 @@ members = client.members.list()
151
189
  invite = client.invitations.create(email="colega@example.com", role="member")
152
190
  # invite["accept_url"] — entrégalo tú o deja que easycontract lo envíe por email
153
191
  ```
192
+
193
+ ## Validar la identidad del firmante en tu plataforma (KYC)
194
+
195
+ Con el proceso propio de easycontract, el firmante fotografía su documento y se hace un
196
+ selfie; la decisión es de la organización. Puedes tomarla desde tu propio sistema:
197
+
198
+ ```python
199
+ from easycontract import EasyContract
200
+
201
+ client = EasyContract(api_key="ec_live_...")
202
+
203
+ # 1. Suscríbete a los eventos (o sondea GET /events/): signer.kyc_submitted avisa de
204
+ # cada caso pendiente; signer.kyc_verified / signer.kyc_failed, de la decisión.
205
+ for case in client.identity.pending_reviews()["data"]:
206
+ envelope_id, signer = case["envelope_id"], case["signer"]
207
+ identity = case["identity_verification"] # estado, comprobaciones, datos declarados
208
+
209
+ # 2. Recupera las imágenes (cada descarga queda en el audit trail del documento)
210
+ for document in identity["documents"]:
211
+ client.identity.download_document(
212
+ envelope_id, signer["id"], document["id"], to=f"/tmp/{document['kind']}.jpg"
213
+ )
214
+
215
+ # 3. Valida con tus reglas: número declarado, caducidad, cotejo visual o tu propio motor
216
+ ok = identity["declared_document_number"] == mi_registro[signer["email"]].dni
217
+
218
+ # 4. Decide; el firmante recibe el resultado por correo
219
+ client.identity.review(
220
+ envelope_id,
221
+ signer["id"],
222
+ decision="approve" if ok else "reject",
223
+ verified_name=signer["full_name"],
224
+ reason="" if ok else "El número de documento no coincide con nuestro registro",
225
+ )
226
+ ```
227
+
228
+ Las imágenes se purgan a los N días de la decisión (`organization.kyc.image_retention_days`);
229
+ el resultado completo sigue disponible en el paquete de evidencias con
230
+ `download_evidence(..., include_identity_proofs=True)`.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "easycontract"
3
- version = "0.1.0"
3
+ version = "0.1.4"
4
4
  description = "SDK oficial de Python para el API de easycontract (firma electrónica eIDAS)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -20,7 +20,9 @@ classifiers = [
20
20
  "Topic :: Software Development :: Libraries :: Python Modules",
21
21
  ]
22
22
  dependencies = [
23
- "requests>=2.31,<3",
23
+ # >=2.33.0: older releases carry CVE-2024-35195, CVE-2024-47081 and
24
+ # CVE-2026-25645 (audit 2026-10-07, A1)
25
+ "requests>=2.33.0,<3",
24
26
  ]
25
27
 
26
28
  [project.urls]
@@ -36,4 +38,4 @@ packages = ["src/easycontract"]
36
38
  [tool.hatch.build.targets.sdist]
37
39
  # Internal material never ships in the sdist. (hatchling still adds the
38
40
  # repo-root .gitignore to the sdist; it holds nothing sensitive.)
39
- exclude = ["SECURITY-AUDIT*.md", "dist/"]
41
+ exclude = ["SECURITY-AUDIT*.md", "dist/", "uv.lock"]
@@ -6,7 +6,7 @@ client = EasyContract(api_key="ec_test_...")
6
6
  envelope = client.envelopes.create(title="Contrato", retention_period_months=72)
7
7
  """
8
8
 
9
- __version__ = "0.1.0"
9
+ __version__ = "0.1.4"
10
10
 
11
11
  from . import webhooks
12
12
  from .client import EasyContract
@@ -25,9 +25,12 @@ from .errors import APIError, error_from_response
25
25
  from .resources import (
26
26
  Envelopes,
27
27
  Events,
28
+ Identity,
28
29
  Invitations,
29
30
  Members,
30
31
  Organization,
32
+ Projects,
33
+ Retention,
31
34
  Templates,
32
35
  Webhooks,
33
36
  )
@@ -102,8 +105,12 @@ class EasyContract:
102
105
  )
103
106
 
104
107
  self.envelopes = Envelopes(self)
108
+
109
+ self.identity = Identity(self)
110
+ self.retention = Retention(self)
105
111
  self.templates = Templates(self)
106
112
  self.webhooks = Webhooks(self)
113
+ self.projects = Projects(self)
107
114
  self.events = Events(self)
108
115
  self.organization = Organization(self)
109
116
  self.members = Members(self)