intermesh 0.3.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.
- intermesh/__init__.py +56 -0
- intermesh/adapters.py +90 -0
- intermesh/admin.py +733 -0
- intermesh/agent.py +520 -0
- intermesh/apikeys.py +273 -0
- intermesh/audit.py +153 -0
- intermesh/bridge.py +181 -0
- intermesh/cli.py +453 -0
- intermesh/config.py +145 -0
- intermesh/crypto.py +150 -0
- intermesh/egress.py +180 -0
- intermesh/escrow.py +208 -0
- intermesh/guardrails.py +235 -0
- intermesh/hardware.py +32 -0
- intermesh/health.py +56 -0
- intermesh/hub.py +996 -0
- intermesh/identity.py +94 -0
- intermesh/logger.py +90 -0
- intermesh/message.py +152 -0
- intermesh/metrics.py +49 -0
- intermesh/peering.py +103 -0
- intermesh/pipeline.py +218 -0
- intermesh/policy.py +35 -0
- intermesh/ratelimit.py +42 -0
- intermesh/schema.py +125 -0
- intermesh/secret.py +115 -0
- intermesh/signing.py +64 -0
- intermesh/snapshot.py +342 -0
- intermesh/store.py +177 -0
- intermesh/task.py +122 -0
- intermesh-0.3.0.dist-info/METADATA +198 -0
- intermesh-0.3.0.dist-info/RECORD +35 -0
- intermesh-0.3.0.dist-info/WHEEL +5 -0
- intermesh-0.3.0.dist-info/entry_points.txt +2 -0
- intermesh-0.3.0.dist-info/top_level.txt +1 -0
intermesh/crypto.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from cryptography.hazmat.primitives.asymmetric import rsa, padding
|
|
6
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
7
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
8
|
+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def generate_keypair():
|
|
12
|
+
"""Génère une paire de clés RSA-2048."""
|
|
13
|
+
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_public_key_pem(private_key) -> str:
|
|
17
|
+
"""Exporte la clé publique au format PEM."""
|
|
18
|
+
return private_key.public_key().public_bytes(
|
|
19
|
+
encoding=serialization.Encoding.PEM,
|
|
20
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo
|
|
21
|
+
).decode("utf-8")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_private_key_pem(private_key) -> str:
|
|
25
|
+
"""Exporte la clé privée au format PEM."""
|
|
26
|
+
return private_key.private_bytes(
|
|
27
|
+
encoding=serialization.Encoding.PEM,
|
|
28
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
29
|
+
encryption_algorithm=serialization.NoEncryption()
|
|
30
|
+
).decode("utf-8")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_public_key(pem_string: str):
|
|
34
|
+
"""Charge une clé publique depuis un PEM."""
|
|
35
|
+
return serialization.load_pem_public_key(pem_string.encode("utf-8"))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def load_private_key(pem_string: str):
|
|
39
|
+
"""Charge une clé privée depuis un PEM."""
|
|
40
|
+
return serialization.load_pem_private_key(pem_string.encode("utf-8"), password=None)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def encrypt_for(recipient_public_key_pem: str, plaintext: str) -> str:
|
|
44
|
+
"""Chiffrement hybride RSA-OAEP + AES-256-GCM."""
|
|
45
|
+
plaintext_bytes = plaintext.encode("utf-8")
|
|
46
|
+
aes_key = AESGCM.generate_key(bit_length=256)
|
|
47
|
+
nonce = os.urandom(12)
|
|
48
|
+
aesgcm = AESGCM(aes_key)
|
|
49
|
+
ciphertext = aesgcm.encrypt(nonce, plaintext_bytes, None)
|
|
50
|
+
pub_key = load_public_key(recipient_public_key_pem)
|
|
51
|
+
encrypted_aes_key = pub_key.encrypt(
|
|
52
|
+
aes_key,
|
|
53
|
+
padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None)
|
|
54
|
+
)
|
|
55
|
+
envelope = {
|
|
56
|
+
"ek": base64.b64encode(encrypted_aes_key).decode(),
|
|
57
|
+
"n": base64.b64encode(nonce).decode(),
|
|
58
|
+
"ct": base64.b64encode(ciphertext).decode()
|
|
59
|
+
}
|
|
60
|
+
return base64.b64encode(json.dumps(envelope).encode()).decode()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def decrypt_with(private_key, encrypted_b64: str) -> str:
|
|
64
|
+
"""Déchiffrement hybride RSA-OAEP + AES-256-GCM."""
|
|
65
|
+
envelope = json.loads(base64.b64decode(encrypted_b64))
|
|
66
|
+
aes_key = private_key.decrypt(
|
|
67
|
+
base64.b64decode(envelope["ek"]),
|
|
68
|
+
padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None)
|
|
69
|
+
)
|
|
70
|
+
aesgcm = AESGCM(aes_key)
|
|
71
|
+
return aesgcm.decrypt(base64.b64decode(envelope["n"]), base64.b64decode(envelope["ct"]), None).decode("utf-8")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# --- CHIFFREMENT AU REPOS PAR PASSPHRASE (PBKDF2 + AES-256-GCM) ---
|
|
75
|
+
|
|
76
|
+
PBKDF2_ITERATIONS = 100_000
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _derive_key(passphrase: str, salt: bytes) -> bytes:
|
|
80
|
+
"""Dérive une clé AES-256 depuis une passphrase, via PBKDF2-HMAC-SHA256."""
|
|
81
|
+
kdf = PBKDF2HMAC(
|
|
82
|
+
algorithm=hashes.SHA256(),
|
|
83
|
+
length=32,
|
|
84
|
+
salt=salt,
|
|
85
|
+
iterations=PBKDF2_ITERATIONS,
|
|
86
|
+
)
|
|
87
|
+
return kdf.derive(passphrase.encode("utf-8"))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def encrypt_blob(plaintext: str, passphrase: str) -> dict:
|
|
91
|
+
"""
|
|
92
|
+
Chiffre un texte quelconque avec une passphrase.
|
|
93
|
+
|
|
94
|
+
Retourne un dict JSON-sérialisable (`salt`/`nonce`/`ciphertext` en
|
|
95
|
+
base64) : le sel est stocké en clair, il n'est pas secret — il empêche
|
|
96
|
+
seulement de pré-calculer une table pour toutes les passphrases.
|
|
97
|
+
"""
|
|
98
|
+
salt = os.urandom(16)
|
|
99
|
+
nonce = os.urandom(12)
|
|
100
|
+
ciphertext = AESGCM(_derive_key(passphrase, salt)).encrypt(
|
|
101
|
+
nonce, plaintext.encode("utf-8"), None
|
|
102
|
+
)
|
|
103
|
+
return {
|
|
104
|
+
"salt": base64.b64encode(salt).decode("utf-8"),
|
|
105
|
+
"nonce": base64.b64encode(nonce).decode("utf-8"),
|
|
106
|
+
"ciphertext": base64.b64encode(ciphertext).decode("utf-8"),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def decrypt_blob(blob: dict, passphrase: str) -> str:
|
|
111
|
+
"""
|
|
112
|
+
Déchiffre un blob produit par `encrypt_blob`.
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
cryptography.exceptions.InvalidTag: passphrase incorrecte, ou
|
|
116
|
+
contenu altéré — AES-GCM ne distingue pas les deux cas, et
|
|
117
|
+
c'est voulu : les deux signifient « ne fais pas confiance ».
|
|
118
|
+
"""
|
|
119
|
+
key = _derive_key(passphrase, base64.b64decode(blob["salt"]))
|
|
120
|
+
return AESGCM(key).decrypt(
|
|
121
|
+
base64.b64decode(blob["nonce"]),
|
|
122
|
+
base64.b64decode(blob["ciphertext"]),
|
|
123
|
+
None,
|
|
124
|
+
).decode("utf-8")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# --- COFFRE-FORT DE CLÉS LOCAL CHIFFRÉ (LOCAL KEY VAULT) ---
|
|
128
|
+
|
|
129
|
+
def save_encrypted_vault(file_path: str, private_key_pem: str, passphrase: str):
|
|
130
|
+
"""
|
|
131
|
+
Chiffre et sauvegarde la clé privée sur le disque avec PBKDF2 (100 000 itérations) + AES-256-GCM.
|
|
132
|
+
"""
|
|
133
|
+
vault_data = encrypt_blob(private_key_pem, passphrase)
|
|
134
|
+
vault_data["version"] = "nexus_vault_v1"
|
|
135
|
+
|
|
136
|
+
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
|
137
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
138
|
+
json.dump(vault_data, f, indent=2)
|
|
139
|
+
|
|
140
|
+
# Restreindre les permissions du fichier sous Linux (600 = lecture/écriture propriétaire seul)
|
|
141
|
+
os.chmod(file_path, 0o600)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def load_encrypted_vault(file_path: str, passphrase: str) -> str:
|
|
145
|
+
"""
|
|
146
|
+
Déchiffre et charge la clé privée depuis le coffre-fort local.
|
|
147
|
+
"""
|
|
148
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
149
|
+
vault_data = json.load(f)
|
|
150
|
+
return decrypt_blob(vault_data, passphrase)
|
intermesh/egress.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Filtrage de sortie : ce qu'une organisation accepte de laisser franchir
|
|
3
|
+
sa frontière.
|
|
4
|
+
|
|
5
|
+
L'isolement multi-tenant, le pairage explicite et la signature Ed25519
|
|
6
|
+
répondent à « qui parle à qui ». Ils ne disent rien de « ce qui sort » :
|
|
7
|
+
une fois le lien de fédération établi, un agent peut transmettre à un
|
|
8
|
+
partenaire n'importe quel contenu auquel il a accès. C'est le manque que
|
|
9
|
+
ce module comble.
|
|
10
|
+
|
|
11
|
+
Deux points d'application, parce qu'ils ne voient pas la même chose :
|
|
12
|
+
|
|
13
|
+
* L'agent émetteur (SDK), avant chiffrement. C'est le seul endroit où le
|
|
14
|
+
contenu est en clair sur le chemin nominal, donc le seul filtre qui
|
|
15
|
+
morde réellement quand le chiffrement E2E est actif.
|
|
16
|
+
* Le Hub, au moment de relayer vers un pair. Il ne voit que ce qui n'est
|
|
17
|
+
pas chiffré, mais il applique la politique même à un agent qui ne
|
|
18
|
+
l'aurait pas fait — un agent est du code, pas une autorité.
|
|
19
|
+
|
|
20
|
+
Ni l'un ni l'autre ne protège d'un agent interne malveillant qui
|
|
21
|
+
chiffrerait lui-même avant l'envoi. Le filtre d'egress traite la fuite
|
|
22
|
+
par négligence, pas l'exfiltration délibérée.
|
|
23
|
+
|
|
24
|
+
Politique vide par défaut : rien n'est filtré tant que rien n'est déclaré.
|
|
25
|
+
Caviarder en silence serait pire que ne pas filtrer.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import re
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
REDACT = "redact"
|
|
37
|
+
DROP = "drop"
|
|
38
|
+
BLOCK = "block"
|
|
39
|
+
ACTIONS = (REDACT, DROP, BLOCK)
|
|
40
|
+
|
|
41
|
+
DEFAULT_REPLACEMENT = "[REDACTED]"
|
|
42
|
+
ANY_ORG = "*"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class EgressBlocked(PermissionError):
|
|
46
|
+
"""Le contenu contient quelque chose qui ne doit pas franchir la frontière."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, rule_name: str, target_org: str):
|
|
49
|
+
self.rule_name = rule_name
|
|
50
|
+
self.target_org = target_org
|
|
51
|
+
super().__init__(
|
|
52
|
+
f"EGRESS_BLOCKED: la règle '{rule_name}' interdit d'envoyer ce contenu "
|
|
53
|
+
f"à l'organisation '{target_org}'."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class EgressRule:
|
|
59
|
+
"""Une règle de sortie.
|
|
60
|
+
|
|
61
|
+
`action` :
|
|
62
|
+
- `redact` : remplace les portions de texte correspondant à `pattern`.
|
|
63
|
+
- `drop` : supprime toute clé nommée `field` (à n'importe quelle
|
|
64
|
+
profondeur), quelle que soit sa valeur.
|
|
65
|
+
- `block` : refuse l'envoi entier si `pattern` apparaît.
|
|
66
|
+
|
|
67
|
+
`to_orgs` restreint la règle à certaines organisations destinataires ;
|
|
68
|
+
`["*"]` l'applique à toute sortie hors de l'organisation.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
name: str
|
|
72
|
+
action: str
|
|
73
|
+
pattern: str | None = None
|
|
74
|
+
field_name: str | None = None
|
|
75
|
+
replacement: str = DEFAULT_REPLACEMENT
|
|
76
|
+
to_orgs: list[str] = field(default_factory=lambda: [ANY_ORG])
|
|
77
|
+
|
|
78
|
+
def __post_init__(self):
|
|
79
|
+
if self.action not in ACTIONS:
|
|
80
|
+
raise ValueError(f"action inconnue : {self.action!r} (attendu {ACTIONS})")
|
|
81
|
+
if self.action in (REDACT, BLOCK) and not self.pattern:
|
|
82
|
+
raise ValueError(f"règle '{self.name}' : action {self.action} exige un `pattern`")
|
|
83
|
+
if self.action == DROP and not self.field_name:
|
|
84
|
+
raise ValueError(f"règle '{self.name}' : action drop exige un `field`")
|
|
85
|
+
self._compiled = re.compile(self.pattern, re.IGNORECASE) if self.pattern else None
|
|
86
|
+
|
|
87
|
+
def applies_to(self, target_org: str) -> bool:
|
|
88
|
+
return ANY_ORG in self.to_orgs or target_org in self.to_orgs
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def from_dict(cls, raw: dict) -> "EgressRule":
|
|
92
|
+
return cls(
|
|
93
|
+
name=raw["name"],
|
|
94
|
+
action=raw["action"],
|
|
95
|
+
pattern=raw.get("pattern"),
|
|
96
|
+
field_name=raw.get("field"),
|
|
97
|
+
replacement=raw.get("replacement", DEFAULT_REPLACEMENT),
|
|
98
|
+
to_orgs=raw.get("to_orgs", [ANY_ORG]),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class EgressPolicy:
|
|
104
|
+
name: str = "empty"
|
|
105
|
+
rules: list[EgressRule] = field(default_factory=list)
|
|
106
|
+
|
|
107
|
+
def rules_for(self, target_org: str) -> list[EgressRule]:
|
|
108
|
+
return [r for r in self.rules if r.applies_to(target_org)]
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def is_empty(self) -> bool:
|
|
112
|
+
return not self.rules
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def from_dict(cls, raw: dict) -> "EgressPolicy":
|
|
116
|
+
return cls(
|
|
117
|
+
name=raw.get("name", "unnamed"),
|
|
118
|
+
rules=[EgressRule.from_dict(r) for r in raw.get("rules", [])],
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
def load(cls, path: str | Path) -> "EgressPolicy":
|
|
123
|
+
return cls.from_dict(json.loads(Path(path).read_text(encoding="utf-8")))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def apply_egress(payload: Any, target_org: str, policy: EgressPolicy | None
|
|
127
|
+
) -> tuple[Any, list[str]]:
|
|
128
|
+
"""Applique la politique de sortie à `payload`.
|
|
129
|
+
|
|
130
|
+
Retourne le contenu filtré et la liste des noms de règles déclenchées —
|
|
131
|
+
des *noms*, jamais les valeurs retirées : un journal d'audit ne doit pas
|
|
132
|
+
devenir l'endroit où fuit ce qu'on vient de caviarder.
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
EgressBlocked: si une règle `block` correspond.
|
|
136
|
+
"""
|
|
137
|
+
if policy is None or policy.is_empty:
|
|
138
|
+
return payload, []
|
|
139
|
+
|
|
140
|
+
rules = policy.rules_for(target_org)
|
|
141
|
+
if not rules:
|
|
142
|
+
return payload, []
|
|
143
|
+
|
|
144
|
+
triggered: list[str] = []
|
|
145
|
+
|
|
146
|
+
def visit(node: Any) -> Any:
|
|
147
|
+
if isinstance(node, dict):
|
|
148
|
+
out = {}
|
|
149
|
+
for key, value in node.items():
|
|
150
|
+
dropped = False
|
|
151
|
+
for rule in rules:
|
|
152
|
+
if rule.action == DROP and key.lower() == rule.field_name.lower():
|
|
153
|
+
if rule.name not in triggered:
|
|
154
|
+
triggered.append(rule.name)
|
|
155
|
+
dropped = True
|
|
156
|
+
break
|
|
157
|
+
if not dropped:
|
|
158
|
+
out[key] = visit(value)
|
|
159
|
+
return out
|
|
160
|
+
|
|
161
|
+
if isinstance(node, list):
|
|
162
|
+
return [visit(item) for item in node]
|
|
163
|
+
|
|
164
|
+
if isinstance(node, str):
|
|
165
|
+
text = node
|
|
166
|
+
for rule in rules:
|
|
167
|
+
if rule.action == BLOCK and rule._compiled.search(text):
|
|
168
|
+
raise EgressBlocked(rule.name, target_org)
|
|
169
|
+
for rule in rules:
|
|
170
|
+
if rule.action == REDACT:
|
|
171
|
+
replaced, count = rule._compiled.subn(rule.replacement, text)
|
|
172
|
+
if count:
|
|
173
|
+
text = replaced
|
|
174
|
+
if rule.name not in triggered:
|
|
175
|
+
triggered.append(rule.name)
|
|
176
|
+
return text
|
|
177
|
+
|
|
178
|
+
return node
|
|
179
|
+
|
|
180
|
+
return visit(payload), triggered
|
intermesh/escrow.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""
|
|
2
|
+
InterMesh Escrow — séquestre inter-organisations pour les tâches payantes.
|
|
3
|
+
|
|
4
|
+
Portée volontairement limitée : ceci est le PROTOCOLE (états, règles,
|
|
5
|
+
traçabilité), pas une passerelle de paiement. Le `SimulatedLedger` tient des
|
|
6
|
+
soldes en mémoire, alimentés uniquement par `grant()` (un robinet de test/démo,
|
|
7
|
+
jamais une vraie source de fonds). Brancher un vrai mouvement d'argent (Stripe
|
|
8
|
+
Connect, stablecoins...) suppose de remplacer `SimulatedLedger` par un backend
|
|
9
|
+
qui parle à un processeur de paiement réel, en respectant la même interface
|
|
10
|
+
(`debit`/`credit`/`balance`) — volontairement pas fourni ici.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import time
|
|
16
|
+
import uuid
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from enum import Enum
|
|
19
|
+
from typing import Dict, List, Optional, Tuple
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class EscrowError(Exception):
|
|
23
|
+
"""Séquestre impossible ou opération invalide sur un séquestre existant."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class InsufficientFundsError(EscrowError):
|
|
27
|
+
"""Solde insuffisant pour couvrir le montant demandé."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class EscrowStatus(str, Enum):
|
|
31
|
+
HELD = "held"
|
|
32
|
+
RELEASED = "released"
|
|
33
|
+
REFUNDED = "refunded"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class EscrowHold:
|
|
38
|
+
hold_id: str
|
|
39
|
+
task_id: str
|
|
40
|
+
payer_org: str
|
|
41
|
+
payee_org: str
|
|
42
|
+
amount: float
|
|
43
|
+
currency: str
|
|
44
|
+
status: EscrowStatus
|
|
45
|
+
auto_release: bool
|
|
46
|
+
created_at: float
|
|
47
|
+
resolved_at: Optional[float] = None
|
|
48
|
+
metadata: dict = field(default_factory=dict)
|
|
49
|
+
|
|
50
|
+
def to_dict(self) -> dict:
|
|
51
|
+
return {
|
|
52
|
+
"hold_id": self.hold_id,
|
|
53
|
+
"task_id": self.task_id,
|
|
54
|
+
"payer_org": self.payer_org,
|
|
55
|
+
"payee_org": self.payee_org,
|
|
56
|
+
"amount": self.amount,
|
|
57
|
+
"currency": self.currency,
|
|
58
|
+
"status": self.status.value,
|
|
59
|
+
"auto_release": self.auto_release,
|
|
60
|
+
"created_at": self.created_at,
|
|
61
|
+
"resolved_at": self.resolved_at,
|
|
62
|
+
"metadata": self.metadata,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class SimulatedLedger:
|
|
67
|
+
"""
|
|
68
|
+
Soldes en mémoire, par (organisation, devise).
|
|
69
|
+
|
|
70
|
+
`grant()` est le seul moyen de créer des fonds : un robinet explicite de
|
|
71
|
+
démonstration/test, jamais une source de vérité financière. Aucune
|
|
72
|
+
écriture ici ne représente un mouvement d'argent réel.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self):
|
|
76
|
+
self._balances: Dict[Tuple[str, str], float] = {}
|
|
77
|
+
|
|
78
|
+
def balance(self, org_id: str, currency: str = "USD") -> float:
|
|
79
|
+
return self._balances.get((org_id, currency), 0.0)
|
|
80
|
+
|
|
81
|
+
def grant(self, org_id: str, amount: float, currency: str = "USD") -> float:
|
|
82
|
+
if amount <= 0:
|
|
83
|
+
raise EscrowError("Le montant crédité doit être positif.")
|
|
84
|
+
key = (org_id, currency)
|
|
85
|
+
self._balances[key] = self._balances.get(key, 0.0) + amount
|
|
86
|
+
return self._balances[key]
|
|
87
|
+
|
|
88
|
+
def debit(self, org_id: str, amount: float, currency: str = "USD") -> bool:
|
|
89
|
+
"""Retourne False sans rien modifier si le solde est insuffisant."""
|
|
90
|
+
key = (org_id, currency)
|
|
91
|
+
current = self._balances.get(key, 0.0)
|
|
92
|
+
if current < amount:
|
|
93
|
+
return False
|
|
94
|
+
self._balances[key] = current - amount
|
|
95
|
+
return True
|
|
96
|
+
|
|
97
|
+
def credit(self, org_id: str, amount: float, currency: str = "USD") -> float:
|
|
98
|
+
key = (org_id, currency)
|
|
99
|
+
self._balances[key] = self._balances.get(key, 0.0) + amount
|
|
100
|
+
return self._balances[key]
|
|
101
|
+
|
|
102
|
+
def export_state(self) -> List[dict]:
|
|
103
|
+
"""Soldes sérialisables — le tuple (org, devise) ne survit pas à JSON."""
|
|
104
|
+
return [
|
|
105
|
+
{"org_id": org, "currency": currency, "amount": amount}
|
|
106
|
+
for (org, currency), amount in sorted(self._balances.items())
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
def import_state(self, rows: List[dict]) -> None:
|
|
110
|
+
"""Remplace intégralement les soldes."""
|
|
111
|
+
self._balances = {
|
|
112
|
+
(r["org_id"], r["currency"]): float(r["amount"]) for r in rows
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class EscrowManager:
|
|
117
|
+
"""Un séquestre par tâche : créé à la soumission, résolu à l'issue de la tâche."""
|
|
118
|
+
|
|
119
|
+
def __init__(self, ledger: Optional[SimulatedLedger] = None):
|
|
120
|
+
self.ledger = ledger or SimulatedLedger()
|
|
121
|
+
self._holds: Dict[str, EscrowHold] = {} # task_id -> EscrowHold
|
|
122
|
+
|
|
123
|
+
def create_hold(
|
|
124
|
+
self,
|
|
125
|
+
task_id: str,
|
|
126
|
+
payer_org: str,
|
|
127
|
+
payee_org: str,
|
|
128
|
+
amount: float,
|
|
129
|
+
currency: str = "USD",
|
|
130
|
+
auto_release: bool = True,
|
|
131
|
+
metadata: Optional[dict] = None,
|
|
132
|
+
) -> EscrowHold:
|
|
133
|
+
if not task_id:
|
|
134
|
+
raise EscrowError("task_id requis pour créer un séquestre.")
|
|
135
|
+
if amount <= 0:
|
|
136
|
+
raise EscrowError("Le montant séquestré doit être positif.")
|
|
137
|
+
if task_id in self._holds:
|
|
138
|
+
raise EscrowError(f"Un séquestre existe déjà pour la tâche '{task_id}'.")
|
|
139
|
+
|
|
140
|
+
if not self.ledger.debit(payer_org, amount, currency):
|
|
141
|
+
balance = self.ledger.balance(payer_org, currency)
|
|
142
|
+
raise InsufficientFundsError(
|
|
143
|
+
f"ESCROW_INSUFFICIENT_FUNDS: '{payer_org}' a {balance:.2f} {currency}, "
|
|
144
|
+
f"{amount:.2f} {currency} requis."
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
hold = EscrowHold(
|
|
148
|
+
hold_id=str(uuid.uuid4()), task_id=task_id, payer_org=payer_org, payee_org=payee_org,
|
|
149
|
+
amount=amount, currency=currency, status=EscrowStatus.HELD, auto_release=auto_release,
|
|
150
|
+
created_at=time.time(), metadata=metadata or {},
|
|
151
|
+
)
|
|
152
|
+
self._holds[task_id] = hold
|
|
153
|
+
return hold
|
|
154
|
+
|
|
155
|
+
def get(self, task_id: str) -> Optional[EscrowHold]:
|
|
156
|
+
return self._holds.get(task_id)
|
|
157
|
+
|
|
158
|
+
def list_holds(self, org_id: Optional[str] = None) -> List[EscrowHold]:
|
|
159
|
+
holds = list(self._holds.values())
|
|
160
|
+
if org_id:
|
|
161
|
+
holds = [h for h in holds if org_id in (h.payer_org, h.payee_org)]
|
|
162
|
+
return holds
|
|
163
|
+
|
|
164
|
+
def release(self, task_id: str) -> EscrowHold:
|
|
165
|
+
"""Débloque les fonds vers le bénéficiaire (tâche livrée avec succès)."""
|
|
166
|
+
hold = self._require_held(task_id)
|
|
167
|
+
self.ledger.credit(hold.payee_org, hold.amount, hold.currency)
|
|
168
|
+
hold.status = EscrowStatus.RELEASED
|
|
169
|
+
hold.resolved_at = time.time()
|
|
170
|
+
return hold
|
|
171
|
+
|
|
172
|
+
def refund(self, task_id: str) -> EscrowHold:
|
|
173
|
+
"""Rend les fonds au payeur (tâche échouée, annulée, ou litige tranché en sa faveur)."""
|
|
174
|
+
hold = self._require_held(task_id)
|
|
175
|
+
self.ledger.credit(hold.payer_org, hold.amount, hold.currency)
|
|
176
|
+
hold.status = EscrowStatus.REFUNDED
|
|
177
|
+
hold.resolved_at = time.time()
|
|
178
|
+
return hold
|
|
179
|
+
|
|
180
|
+
def export_state(self) -> dict:
|
|
181
|
+
"""Séquestres et soldes, pour un instantané du Hub."""
|
|
182
|
+
return {
|
|
183
|
+
"ledger": self.ledger.export_state(),
|
|
184
|
+
"holds": [h.to_dict() for h in self._holds.values()],
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
def import_state(self, state: dict) -> None:
|
|
188
|
+
"""Remplace intégralement séquestres et soldes."""
|
|
189
|
+
self.ledger.import_state(state.get("ledger") or [])
|
|
190
|
+
self._holds = {}
|
|
191
|
+
for raw in state.get("holds") or []:
|
|
192
|
+
hold = EscrowHold(
|
|
193
|
+
hold_id=raw["hold_id"], task_id=raw["task_id"],
|
|
194
|
+
payer_org=raw["payer_org"], payee_org=raw["payee_org"],
|
|
195
|
+
amount=float(raw["amount"]), currency=raw["currency"],
|
|
196
|
+
status=EscrowStatus(raw["status"]), auto_release=bool(raw["auto_release"]),
|
|
197
|
+
created_at=float(raw["created_at"]), resolved_at=raw.get("resolved_at"),
|
|
198
|
+
metadata=raw.get("metadata") or {},
|
|
199
|
+
)
|
|
200
|
+
self._holds[hold.task_id] = hold
|
|
201
|
+
|
|
202
|
+
def _require_held(self, task_id: str) -> EscrowHold:
|
|
203
|
+
hold = self._holds.get(task_id)
|
|
204
|
+
if hold is None:
|
|
205
|
+
raise EscrowError(f"Aucun séquestre pour la tâche '{task_id}'.")
|
|
206
|
+
if hold.status != EscrowStatus.HELD:
|
|
207
|
+
raise EscrowError(f"Séquestre déjà résolu ({hold.status.value}).")
|
|
208
|
+
return hold
|