easycontract 0.1.1__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.
@@ -2,6 +2,54 @@
2
2
 
3
3
  All notable changes to the `easycontract` Python SDK.
4
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
+
5
53
  ## 0.1.1 (2026-10-07)
6
54
 
7
55
  **Security (audit 2026-10-07)**
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: easycontract
3
- Version: 0.1.1
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>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "easycontract"
3
- version = "0.1.1"
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"
@@ -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.1"
9
+ __version__ = "0.1.4"
10
10
 
11
11
  from . import webhooks
12
12
  from .client import EasyContract
@@ -29,6 +29,7 @@ from .resources import (
29
29
  Invitations,
30
30
  Members,
31
31
  Organization,
32
+ Projects,
32
33
  Retention,
33
34
  Templates,
34
35
  Webhooks,
@@ -109,6 +110,7 @@ class EasyContract:
109
110
  self.retention = Retention(self)
110
111
  self.templates = Templates(self)
111
112
  self.webhooks = Webhooks(self)
113
+ self.projects = Projects(self)
112
114
  self.events = Events(self)
113
115
  self.organization = Organization(self)
114
116
  self.members = Members(self)
@@ -135,6 +135,11 @@ OPERATIONS: dict[tuple[str, str], str] = {
135
135
  "DELETE",
136
136
  "/templates/{template_id}/documents/{document_id}/translations/{language}/",
137
137
  ): "client.templates.delete_translation",
138
+ (
139
+ "POST",
140
+ "/templates/{template_id}/documents/{document_id}/translations/{language}/autotranslate/",
141
+ ): "client.templates.autotranslate",
142
+ ("GET", "/translation-jobs/{job_id}/"): "client.templates.get_translation_job",
138
143
  ("POST", "/templates/{template_id}/preview/"): "client.templates.preview",
139
144
  ("POST", "/templates/{template_id}/duplicate/"): "client.templates.duplicate",
140
145
  ("POST", "/templates/{template_id}/roles/"): "client.templates.add_role",
@@ -143,7 +148,14 @@ OPERATIONS: dict[tuple[str, str], str] = {
143
148
  ("GET", "/events/"): "client.events.list",
144
149
  ("GET", "/webhooks/"): "client.webhooks.list",
145
150
  ("POST", "/webhooks/"): "client.webhooks.create",
151
+ ("GET", "/webhooks/{endpoint_id}/"): "client.webhooks.get",
152
+ ("PATCH", "/webhooks/{endpoint_id}/"): "client.webhooks.update",
146
153
  ("DELETE", "/webhooks/{endpoint_id}/"): "client.webhooks.delete",
154
+ ("POST", "/webhooks/{endpoint_id}/rotate-secret/"): "client.webhooks.rotate_secret",
155
+ ("GET", "/projects/"): "client.projects.list",
156
+ ("POST", "/projects/"): "client.projects.create",
157
+ ("GET", "/projects/{project_id}/"): "client.projects.get",
158
+ ("PATCH", "/projects/{project_id}/"): "client.projects.update",
147
159
  ("POST", "/webhooks/deliveries/{delivery_id}/redeliver/"): "client.webhooks.redeliver",
148
160
  }
149
161
 
@@ -198,9 +210,12 @@ class Envelopes(_Resource):
198
210
  biometrics: bool = False,
199
211
  document_type: str | None = None,
200
212
  kyc_images_retention: str | None = None,
213
+ project: str | None = None,
201
214
  idempotency_key: str | None = None,
202
215
  ) -> dict:
203
- """`biometrics=True` offers signers the OPTIONAL stroke capture (only
216
+ """`project` (organization keys only): create inside that project; a
217
+ project key always creates in its own.
218
+ `biometrics=True` offers signers the OPTIONAL stroke capture (only
204
219
  effective when the organization enabled it: Enterprise + DPIA).
205
220
  `document_type` is a key of `client.retention.document_types()`.
206
221
  `kyc_images_retention`: "after_review" (default) or
@@ -220,17 +235,25 @@ class Envelopes(_Resource):
220
235
  body["document_type"] = document_type
221
236
  if kyc_images_retention:
222
237
  body["kyc_images_retention"] = kyc_images_retention
238
+ if project:
239
+ body["project"] = project
223
240
  return self._client.request(
224
241
  "POST", "/envelopes/", json=body, idempotency_key=idempotency_key
225
242
  )
226
243
 
227
244
  def list(
228
- self, *, status: str | None = None, limit: int | None = None, cursor: str | None = None
245
+ self,
246
+ *,
247
+ status: str | None = None,
248
+ project: str | None = None,
249
+ limit: int | None = None,
250
+ cursor: str | None = None,
229
251
  ) -> dict:
230
252
  params = {
231
253
  k: v
232
254
  for k, v in {
233
255
  "status": status,
256
+ "project": project,
234
257
  "limit": limit,
235
258
  "cursor": cursor,
236
259
  }.items()
@@ -402,7 +425,9 @@ class Envelopes(_Resource):
402
425
  include_biometrics: bool = False,
403
426
  include_identity_proofs: bool = False,
404
427
  ) -> bytes:
405
- """The full evidence ZIP — what a tenant would take to court."""
428
+ """The full evidence ZIP — what a tenant would take to court. With the
429
+ platform seal on, it is an ASiC-E container signed by the platform
430
+ (manifest.json → `container` describes it)."""
406
431
  content = self._client.request(
407
432
  "GET",
408
433
  f"/envelopes/{_id(envelope_id)}/evidence/",
@@ -578,6 +603,34 @@ class Templates(_Resource):
578
603
  f"/templates/{_id(template_id)}/documents/{_id(document_id)}/translations/{_seg(language)}/",
579
604
  )
580
605
 
606
+ def autotranslate(
607
+ self,
608
+ template_id: str,
609
+ document_id: str,
610
+ language: str,
611
+ *,
612
+ acknowledge: bool,
613
+ overwrite: bool = False,
614
+ idempotency_key: str | None = None,
615
+ ) -> dict:
616
+ """Queue a MACHINE DRAFT of a language version: translators plus a
617
+ reviewer panel with quorum. Returns the job (202); poll
618
+ `get_translation_job`. `acknowledge=True` is required: the result is
619
+ a draft that a person reviews and approves in the panel. `overwrite`
620
+ replaces a translation that already has its own text."""
621
+ return self._client.request(
622
+ "POST",
623
+ f"/templates/{_id(template_id)}/documents/{_id(document_id)}/translations/{_seg(language)}/autotranslate/",
624
+ json={"acknowledge": acknowledge, "overwrite": overwrite},
625
+ idempotency_key=idempotency_key,
626
+ )
627
+
628
+ def get_translation_job(self, job_id: str) -> dict:
629
+ """Status of a machine translation: `verdict` (consensus, quorum,
630
+ no_quorum, unreviewed, simulated) and, when done, the reviewers'
631
+ `notes` with an English back-translation."""
632
+ return self._client.request("GET", f"/translation-jobs/{_id(job_id)}/")
633
+
581
634
  def download_docx(
582
635
  self, template_id: str, document_id: str, *, to: str | Path | None = None
583
636
  ) -> bytes:
@@ -714,6 +767,7 @@ class Templates(_Resource):
714
767
  language: str | None = None,
715
768
  kyc_images_retention: str | None = None,
716
769
  biometrics: bool = False,
770
+ project: str | None = None,
717
771
  idempotency_key: str | None = None,
718
772
  ) -> dict:
719
773
  """The one-call automation flow (§10.4): instantiate and optionally
@@ -736,6 +790,8 @@ class Templates(_Resource):
736
790
  body["biometrics"] = True
737
791
  if expires_in_days is not None:
738
792
  body["expires_in_days"] = expires_in_days
793
+ if project:
794
+ body["project"] = project
739
795
  return self._client.request(
740
796
  "POST",
741
797
  f"/templates/{_id(template_id)}/envelopes/",
@@ -746,19 +802,59 @@ class Templates(_Resource):
746
802
 
747
803
  class Webhooks(_Resource):
748
804
  def create(
749
- self, *, url: str, event_types: list[str] | None = None, description: str = ""
805
+ self,
806
+ *,
807
+ url: str,
808
+ event_types: list[str] | None = None,
809
+ description: str = "",
810
+ project: str | None = None,
750
811
  ) -> dict:
751
- """The response includes `secret` (whsec_…) ONCE — store it now."""
752
- return self._client.request(
753
- "POST",
754
- "/webhooks/",
755
- json={"url": url, "event_types": event_types or [], "description": description},
756
- )
812
+ """The response includes `secret` (whsec_…) ONCE — store it now.
813
+ Mode = the key's. `project` (organization keys only): receive only
814
+ that project's events; a project key always registers for its own."""
815
+ body: dict = {"url": url, "event_types": event_types or [], "description": description}
816
+ if project:
817
+ body["project"] = project
818
+ return self._client.request("POST", "/webhooks/", json=body)
757
819
 
758
820
  def list(self, *, limit: int | None = None, cursor: str | None = None) -> dict:
759
821
  params = {k: v for k, v in {"limit": limit, "cursor": cursor}.items() if v}
760
822
  return self._client.request("GET", "/webhooks/", params=params)
761
823
 
824
+ def get(self, endpoint_id: str) -> dict:
825
+ return self._client.request("GET", f"/webhooks/{_id(endpoint_id)}/")
826
+
827
+ def update(
828
+ self,
829
+ endpoint_id: str,
830
+ *,
831
+ url: str | None = None,
832
+ event_types: list[str] | None = None,
833
+ is_active: bool | None = None,
834
+ description: str | None = None,
835
+ ) -> dict:
836
+ """Change only what you pass. Reactivating counts against the plan."""
837
+ body = {
838
+ k: v
839
+ for k, v in {
840
+ "url": url,
841
+ "event_types": event_types,
842
+ "is_active": is_active,
843
+ "description": description,
844
+ }.items()
845
+ if v is not None
846
+ }
847
+ return self._client.request("PATCH", f"/webhooks/{_id(endpoint_id)}/", json=body)
848
+
849
+ def rotate_secret(self, endpoint_id: str, *, grace_period_hours: int = 24) -> dict:
850
+ """New `secret` returned ONCE. For `grace_period_hours` (0-72) deliveries
851
+ carry both signatures: verify with [new, old] until you switch."""
852
+ return self._client.request(
853
+ "POST",
854
+ f"/webhooks/{_id(endpoint_id)}/rotate-secret/",
855
+ json={"grace_period_hours": grace_period_hours},
856
+ )
857
+
762
858
  def delete(self, endpoint_id: str) -> None:
763
859
  self._client.request("DELETE", f"/webhooks/{_id(endpoint_id)}/")
764
860
 
@@ -766,6 +862,69 @@ class Webhooks(_Resource):
766
862
  return self._client.request("POST", f"/webhooks/deliveries/{_id(delivery_id)}/redeliver/")
767
863
 
768
864
 
865
+ class Projects(_Resource):
866
+ """Projects inside the organization (paid plans). An organization key
867
+ manages them all; a project key only reads its own."""
868
+
869
+ def list(
870
+ self,
871
+ *,
872
+ is_active: bool | None = None,
873
+ limit: int | None = None,
874
+ cursor: str | None = None,
875
+ ) -> dict:
876
+ params = {
877
+ k: v
878
+ for k, v in {
879
+ "is_active": None if is_active is None else str(is_active).lower(),
880
+ "limit": limit,
881
+ "cursor": cursor,
882
+ }.items()
883
+ if v is not None
884
+ }
885
+ return self._client.request("GET", "/projects/", params=params)
886
+
887
+ def create(
888
+ self,
889
+ *,
890
+ name: str,
891
+ description: str = "",
892
+ embed_allowed_origins: list[str] | None = None,
893
+ idempotency_key: str | None = None,
894
+ ) -> dict:
895
+ body: dict = {"name": name, "description": description}
896
+ if embed_allowed_origins is not None:
897
+ body["embed_allowed_origins"] = list(embed_allowed_origins)
898
+ return self._client.request(
899
+ "POST", "/projects/", json=body, idempotency_key=idempotency_key
900
+ )
901
+
902
+ def get(self, project_id: str) -> dict:
903
+ return self._client.request("GET", f"/projects/{_id(project_id)}/")
904
+
905
+ def update(
906
+ self,
907
+ project_id: str,
908
+ *,
909
+ name: str | None = None,
910
+ description: str | None = None,
911
+ is_active: bool | None = None,
912
+ embed_allowed_origins: list[str] | None = None,
913
+ ) -> dict:
914
+ """`is_active=False` archives: its keys and webhooks stop working."""
915
+ body = {
916
+ k: v
917
+ for k, v in {
918
+ "name": name,
919
+ "description": description,
920
+ "is_active": is_active,
921
+ "embed_allowed_origins": embed_allowed_origins,
922
+ }.items()
923
+ if v is not None
924
+ }
925
+ return self._client.request("PATCH", f"/projects/{_id(project_id)}/", json=body)
926
+
927
+
769
928
  class Events(_Resource):
770
929
  """The consultable event log — reconciliation source when a webhook is
771
930
  lost (poll `list` and compare against what you received)."""
@@ -875,15 +1034,27 @@ class Organization(_Resource):
875
1034
  *,
876
1035
  retention_policies: dict | None = None,
877
1036
  embed_allowed_origins: list[str] | None = None,
1037
+ seal_profile: str | None = None,
1038
+ acknowledge: bool = False,
878
1039
  ) -> dict:
879
1040
  """Organization settings an API key may change: retention policy per
880
- document type (months within the plan's range) and the web origins
881
- allowed to embed the signer flow (the FULL list; [] disables it)."""
1041
+ document type (months within the plan's range), the web origins
1042
+ allowed to embed the signer flow (the FULL list; [] disables it) and
1043
+ the cryptographic restrictions of the platform seal.
1044
+
1045
+ `seal_profile`: "hybrid" (ECDSA P-384 + ML-DSA-87, quantum resistant;
1046
+ readers without ML-DSA show that signature as unknown) or "classic"
1047
+ (ECDSA only, no warnings, not quantum resistant). Changing it needs
1048
+ `acknowledge=True`: you confirm the trade-off described in
1049
+ `get()["cryptography"]["options"]`. It applies to envelopes sent
1050
+ afterwards."""
882
1051
  body: dict = {}
883
1052
  if retention_policies is not None:
884
1053
  body["retention_policies"] = retention_policies
885
1054
  if embed_allowed_origins is not None:
886
1055
  body["embed_allowed_origins"] = list(embed_allowed_origins)
1056
+ if seal_profile is not None:
1057
+ body["cryptography"] = {"seal_profile": seal_profile, "acknowledge": acknowledge}
887
1058
  return self._client.request("PATCH", "/organization/", json=body)
888
1059
 
889
1060
 
@@ -5,12 +5,16 @@ easycontract signs every delivery with
5
5
  X-EasyContract-Signature: t=<unix>,v1=<hex>
6
6
 
7
7
  where v1 = HMAC-SHA256(secret, f"{t}.{body}"). Verify with your endpoint's
8
- whsec_… secret (shown once at creation):
8
+ whsec_… secret (shown once at creation or rotation):
9
9
 
10
10
  event = easycontract.webhooks.verify_signature(
11
11
  payload=request.body, header=request.headers["X-EasyContract-Signature"],
12
12
  secret=WEBHOOK_SECRET,
13
13
  )
14
+
15
+ After a secret rotation, during the grace period, the header carries one v1
16
+ per valid secret (`t=…,v1=<new>,v1=<old>`): the delivery is valid if ANY v1
17
+ matches. `secret` may also be a list (e.g. [new, old]) while you switch.
14
18
  """
15
19
 
16
20
  from __future__ import annotations
@@ -29,7 +33,7 @@ def verify_signature(
29
33
  *,
30
34
  payload: bytes | str,
31
35
  header: str,
32
- secret: bytes | str,
36
+ secret: bytes | str | list | tuple,
33
37
  tolerance: int | None = DEFAULT_TOLERANCE_SECONDS,
34
38
  ) -> dict:
35
39
  """Verify a delivery and return the parsed event. Raises
@@ -51,20 +55,34 @@ def verify_signature(
51
55
  body = payload.encode("utf-8")
52
56
  else:
53
57
  raise SignatureVerificationError("payload must be bytes or str")
54
- secret_bytes = secret if isinstance(secret, (bytes, bytearray)) else str(secret).encode("utf-8")
58
+ secrets = list(secret) if isinstance(secret, (list, tuple)) else [secret]
59
+ if not secrets:
60
+ raise SignatureVerificationError("no secret given")
61
+ secret_keys = [
62
+ s if isinstance(s, (bytes, bytearray)) else str(s).encode("utf-8") for s in secrets
63
+ ]
55
64
 
56
65
  try:
57
- parts = dict(part.split("=", 1) for part in header.split(","))
58
- timestamp = int(parts["t"])
59
- signature = parts["v1"]
66
+ pairs = [part.split("=", 1) for part in header.split(",")]
67
+ timestamps = [value for key, value in pairs if key.strip() == "t"]
68
+ signatures = [value.strip() for key, value in pairs if key.strip() == "v1"]
69
+ if len(timestamps) != 1 or not signatures:
70
+ raise KeyError("t/v1")
71
+ timestamp = int(timestamps[0])
60
72
  except (ValueError, KeyError) as exc:
61
73
  raise SignatureVerificationError("malformed signature header") from exc
62
74
 
63
75
  if tolerance is not None and abs(time.time() - timestamp) > tolerance:
64
76
  raise SignatureVerificationError("timestamp outside tolerance (possible replay)")
65
77
 
66
- expected = hmac.new(secret_bytes, b"%d." % timestamp + body, hashlib.sha256).hexdigest()
67
- if not hmac.compare_digest(expected.encode(), signature.encode()):
78
+ signed = b"%d." % timestamp + body
79
+ matched = False
80
+ for key in secret_keys:
81
+ expected = hmac.new(key, signed, hashlib.sha256).hexdigest().encode()
82
+ for signature in signatures:
83
+ # no early exit: constant work whatever matches
84
+ matched |= hmac.compare_digest(expected, signature.encode())
85
+ if not matched:
68
86
  raise SignatureVerificationError("signature mismatch")
69
87
  try:
70
88
  return json.loads(body.decode("utf-8"))
File without changes
File without changes
File without changes