abstractgateway 0.1.0__py3-none-any.whl → 0.1.1__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.
Files changed (40) hide show
  1. abstractgateway/__init__.py +1 -2
  2. abstractgateway/__main__.py +7 -0
  3. abstractgateway/app.py +4 -4
  4. abstractgateway/cli.py +568 -8
  5. abstractgateway/config.py +15 -5
  6. abstractgateway/embeddings_config.py +45 -0
  7. abstractgateway/host_metrics.py +274 -0
  8. abstractgateway/hosts/bundle_host.py +528 -55
  9. abstractgateway/hosts/visualflow_host.py +30 -3
  10. abstractgateway/integrations/__init__.py +2 -0
  11. abstractgateway/integrations/email_bridge.py +782 -0
  12. abstractgateway/integrations/telegram_bridge.py +534 -0
  13. abstractgateway/maintenance/__init__.py +5 -0
  14. abstractgateway/maintenance/action_tokens.py +100 -0
  15. abstractgateway/maintenance/backlog_exec_runner.py +1592 -0
  16. abstractgateway/maintenance/backlog_parser.py +184 -0
  17. abstractgateway/maintenance/draft_generator.py +451 -0
  18. abstractgateway/maintenance/llm_assist.py +212 -0
  19. abstractgateway/maintenance/notifier.py +109 -0
  20. abstractgateway/maintenance/process_manager.py +1064 -0
  21. abstractgateway/maintenance/report_models.py +81 -0
  22. abstractgateway/maintenance/report_parser.py +219 -0
  23. abstractgateway/maintenance/text_similarity.py +123 -0
  24. abstractgateway/maintenance/triage.py +507 -0
  25. abstractgateway/maintenance/triage_queue.py +142 -0
  26. abstractgateway/migrate.py +155 -0
  27. abstractgateway/routes/__init__.py +2 -2
  28. abstractgateway/routes/gateway.py +10817 -179
  29. abstractgateway/routes/triage.py +118 -0
  30. abstractgateway/runner.py +689 -14
  31. abstractgateway/security/gateway_security.py +425 -110
  32. abstractgateway/service.py +213 -6
  33. abstractgateway/stores.py +64 -4
  34. abstractgateway/workflow_deprecations.py +225 -0
  35. abstractgateway-0.1.1.dist-info/METADATA +135 -0
  36. abstractgateway-0.1.1.dist-info/RECORD +40 -0
  37. abstractgateway-0.1.0.dist-info/METADATA +0 -101
  38. abstractgateway-0.1.0.dist-info/RECORD +0 -18
  39. {abstractgateway-0.1.0.dist-info → abstractgateway-0.1.1.dist-info}/WHEEL +0 -0
  40. {abstractgateway-0.1.0.dist-info → abstractgateway-0.1.1.dist-info}/entry_points.txt +0 -0
@@ -13,18 +13,25 @@ Design constraints:
13
13
  from __future__ import annotations
14
14
 
15
15
  import asyncio
16
+ import datetime
17
+ import fnmatch
16
18
  import hmac
19
+ import hashlib
17
20
  import json
18
21
  import logging
19
22
  import os
20
23
  import threading
21
24
  import time
25
+ import uuid
22
26
  from dataclasses import dataclass
27
+ from pathlib import Path
23
28
  from typing import Any, Dict, Iterable, Optional, Tuple
24
29
 
25
30
 
26
31
  logger = logging.getLogger(__name__)
27
32
 
33
+ _AUDIT_LOCK = threading.Lock()
34
+
28
35
 
29
36
  def _as_bool(raw: Any, default: bool = False) -> bool:
30
37
  if raw is None:
@@ -68,6 +75,96 @@ def _env(name: str, fallback: Optional[str] = None) -> Optional[str]:
68
75
  return None
69
76
 
70
77
 
78
+ def _now_utc_iso() -> str:
79
+ try:
80
+ return datetime.datetime.now(datetime.timezone.utc).isoformat()
81
+ except Exception:
82
+ # Best-effort: avoid ever throwing in security middleware.
83
+ return ""
84
+
85
+
86
+ def _sha256_hex(text: str) -> str:
87
+ try:
88
+ h = hashlib.sha256()
89
+ h.update(str(text or "").encode("utf-8", errors="ignore"))
90
+ return h.hexdigest()
91
+ except Exception:
92
+ return ""
93
+
94
+
95
+ def _audit_data_dir_from_env() -> Path:
96
+ raw = (
97
+ os.getenv("ABSTRACTGATEWAY_DATA_DIR")
98
+ or os.getenv("ABSTRACTFLOW_RUNTIME_DIR")
99
+ or os.getenv("ABSTRACTFLOW_GATEWAY_DATA_DIR")
100
+ or "./runtime"
101
+ )
102
+ try:
103
+ return Path(str(raw)).expanduser().resolve()
104
+ except Exception:
105
+ return Path("./runtime").resolve()
106
+
107
+
108
+ def _audit_log_enabled(*, default: bool) -> bool:
109
+ raw = os.getenv("ABSTRACTGATEWAY_AUDIT_LOG")
110
+ if raw is None:
111
+ return bool(default)
112
+ return _as_bool(raw, bool(default))
113
+
114
+
115
+ def _audit_log_max_bytes() -> int:
116
+ raw = os.getenv("ABSTRACTGATEWAY_AUDIT_LOG_MAX_BYTES") or ""
117
+ try:
118
+ v = int(str(raw).strip())
119
+ return max(0, v)
120
+ except Exception:
121
+ # Default: 50MB (bounded, but large enough for multi-day local dev).
122
+ return 50 * 1024 * 1024
123
+
124
+
125
+ def _audit_log_rotations() -> int:
126
+ raw = os.getenv("ABSTRACTGATEWAY_AUDIT_LOG_ROTATIONS") or ""
127
+ try:
128
+ v = int(str(raw).strip())
129
+ return max(0, min(200, v))
130
+ except Exception:
131
+ return 10
132
+
133
+
134
+ def _audit_header_allowlist() -> Tuple[str, ...]:
135
+ raw = os.getenv("ABSTRACTGATEWAY_AUDIT_LOG_HEADERS") or ""
136
+ if not str(raw).strip():
137
+ return ("x-client-id", "x-client-version", "x-forwarded-for")
138
+ out: list[str] = []
139
+ for part in str(raw).split(","):
140
+ p = part.strip().lower()
141
+ if p:
142
+ out.append(p)
143
+ return tuple(out)
144
+
145
+
146
+ def _audit_redact_query(query_string: bytes) -> str:
147
+ # Do not log full query params by default (can contain secrets). Keep only keys.
148
+ try:
149
+ qs = (query_string or b"").decode("utf-8", errors="replace")
150
+ except Exception:
151
+ return ""
152
+ if not qs:
153
+ return ""
154
+ # Parse best-effort without importing urllib for speed/robustness.
155
+ parts = []
156
+ for kv in qs.split("&"):
157
+ if not kv:
158
+ continue
159
+ k = kv.split("=", 1)[0].strip()
160
+ if k:
161
+ parts.append(k)
162
+ if not parts:
163
+ return ""
164
+ parts = parts[:50]
165
+ return "&".join([f"{k}=<redacted>" for k in parts])
166
+
167
+
71
168
  @dataclass(frozen=True)
72
169
  class GatewayAuthPolicy:
73
170
  """Configuration for the Run Gateway security layer."""
@@ -91,6 +188,12 @@ class GatewayAuthPolicy:
91
188
 
92
189
  # Abuse resistance
93
190
  max_body_bytes: int = 256_000
191
+ # Upload endpoints can legitimately exceed `max_body_bytes` (multipart). 0 means "auto"
192
+ # (derived from the explicit endpoint caps).
193
+ max_upload_body_bytes: int = 0
194
+ # Endpoint caps (mirrors /attachments/upload and /bundles/upload defaults).
195
+ max_attachment_bytes: int = 25 * 1024 * 1024
196
+ max_bundle_bytes: int = 75 * 1024 * 1024
94
197
  max_concurrency: int = 64
95
198
  max_sse_connections: int = 32
96
199
 
@@ -114,6 +217,9 @@ def load_gateway_auth_policy_from_env() -> GatewayAuthPolicy:
114
217
  - ABSTRACTGATEWAY_DEV_READ_NO_AUTH=1|0 (loopback only)
115
218
  - ABSTRACTGATEWAY_ALLOWED_ORIGINS (comma-separated; supports '*' suffix wildcard)
116
219
  - ABSTRACTGATEWAY_MAX_BODY_BYTES
220
+ - ABSTRACTGATEWAY_MAX_UPLOAD_BODY_BYTES
221
+ - ABSTRACTGATEWAY_MAX_ATTACHMENT_BYTES
222
+ - ABSTRACTGATEWAY_MAX_BUNDLE_BYTES
117
223
  - ABSTRACTGATEWAY_MAX_CONCURRENCY
118
224
  - ABSTRACTGATEWAY_MAX_SSE
119
225
  - ABSTRACTGATEWAY_LOCKOUT_AFTER
@@ -168,6 +274,17 @@ def load_gateway_auth_policy_from_env() -> GatewayAuthPolicy:
168
274
  return default
169
275
 
170
276
  max_body = _as_int("ABSTRACTGATEWAY_MAX_BODY_BYTES", "ABSTRACTFLOW_GATEWAY_MAX_BODY_BYTES", 256_000)
277
+ max_upload_body = _as_int("ABSTRACTGATEWAY_MAX_UPLOAD_BODY_BYTES", "ABSTRACTFLOW_GATEWAY_MAX_UPLOAD_BODY_BYTES", 0)
278
+ max_attach = _as_int(
279
+ "ABSTRACTGATEWAY_MAX_ATTACHMENT_BYTES",
280
+ "ABSTRACTFLOW_GATEWAY_MAX_ATTACHMENT_BYTES",
281
+ 25 * 1024 * 1024,
282
+ )
283
+ max_bundle = _as_int(
284
+ "ABSTRACTGATEWAY_MAX_BUNDLE_BYTES",
285
+ "ABSTRACTFLOW_GATEWAY_MAX_BUNDLE_BYTES",
286
+ 75 * 1024 * 1024,
287
+ )
171
288
  max_conc = _as_int("ABSTRACTGATEWAY_MAX_CONCURRENCY", "ABSTRACTFLOW_GATEWAY_MAX_CONCURRENCY", 64)
172
289
  max_sse = _as_int("ABSTRACTGATEWAY_MAX_SSE", "ABSTRACTFLOW_GATEWAY_MAX_SSE", 32)
173
290
  lockout_after = _as_int("ABSTRACTGATEWAY_LOCKOUT_AFTER", "ABSTRACTFLOW_GATEWAY_LOCKOUT_AFTER", 5)
@@ -183,6 +300,9 @@ def load_gateway_auth_policy_from_env() -> GatewayAuthPolicy:
183
300
  dev_allow_unauthenticated_reads_on_loopback=bool(dev_read_no_auth),
184
301
  allowed_origins=tuple(allowed_origins),
185
302
  max_body_bytes=max(0, int(max_body)),
303
+ max_upload_body_bytes=max(0, int(max_upload_body)),
304
+ max_attachment_bytes=max(1, int(max_attach)) if int(max_attach) > 0 else 25 * 1024 * 1024,
305
+ max_bundle_bytes=max(1, int(max_bundle)) if int(max_bundle) > 0 else 75 * 1024 * 1024,
186
306
  max_concurrency=max(1, int(max_conc)),
187
307
  max_sse_connections=max(1, int(max_sse)),
188
308
  lockout_after_failures=max(1, int(lockout_after)),
@@ -273,6 +393,69 @@ class GatewaySecurityMiddleware:
273
393
  "(ABSTRACTGATEWAY_AUTH_TOKEN). Mutating endpoints will be rejected."
274
394
  )
275
395
 
396
+ self._audit_enabled = _audit_log_enabled(default=bool(policy.enabled))
397
+ self._audit_max_bytes = int(_audit_log_max_bytes())
398
+ self._audit_rotations = int(_audit_log_rotations())
399
+ self._audit_headers = _audit_header_allowlist()
400
+
401
+ def _audit_append(self, entry: Dict[str, Any]) -> None:
402
+ if not self._audit_enabled:
403
+ return
404
+ try:
405
+ line = json.dumps(entry, ensure_ascii=False, separators=(",", ":")) + "\n"
406
+ except Exception:
407
+ return
408
+ data = line.encode("utf-8", errors="replace")
409
+ try:
410
+ with _AUDIT_LOCK:
411
+ path = (_audit_data_dir_from_env() / "audit_log.jsonl").resolve()
412
+ try:
413
+ path.parent.mkdir(parents=True, exist_ok=True)
414
+ except Exception:
415
+ return
416
+
417
+ # Rotate if needed (best-effort).
418
+ try:
419
+ max_bytes = int(self._audit_max_bytes)
420
+ except Exception:
421
+ max_bytes = 0
422
+ if max_bytes > 0 and path.exists():
423
+ try:
424
+ size = int(path.stat().st_size)
425
+ except Exception:
426
+ size = 0
427
+ if size >= max_bytes:
428
+ ts = _now_utc_iso().replace(":", "").replace("-", "")
429
+ rotated = path.with_name(f"audit_log.{ts}.jsonl")
430
+ try:
431
+ path.replace(rotated)
432
+ except Exception:
433
+ # If rotation fails, keep appending to the same file.
434
+ pass
435
+ else:
436
+ # Best-effort pruning: keep only N rotated files.
437
+ keep = int(self._audit_rotations)
438
+ if keep > 0:
439
+ try:
440
+ olds = sorted(
441
+ path.parent.glob("audit_log.*.jsonl"),
442
+ key=lambda p: p.name,
443
+ reverse=True,
444
+ )
445
+ for p in olds[keep:]:
446
+ try:
447
+ p.unlink()
448
+ except Exception:
449
+ pass
450
+ except Exception:
451
+ pass
452
+
453
+ with open(path, "ab") as f:
454
+ f.write(data)
455
+ except Exception:
456
+ # Never break request handling due to audit logging.
457
+ return
458
+
276
459
  # ---------------------------
277
460
  # Helpers
278
461
  # ---------------------------
@@ -312,8 +495,11 @@ class GatewaySecurityMiddleware:
312
495
  continue
313
496
  if p == "*":
314
497
  return True
315
- if p.endswith("*"):
316
- if o.startswith(p[:-1]):
498
+ # Glob-style matching (fnmatch): supports patterns like
499
+ # - http://localhost:*
500
+ # - https://*.ngrok-free.app
501
+ if any(ch in p for ch in ("*", "?", "[")):
502
+ if fnmatch.fnmatchcase(o, p):
317
503
  return True
318
504
  continue
319
505
  if o == p:
@@ -377,128 +563,257 @@ class GatewaySecurityMiddleware:
377
563
  is_write = method in {"POST", "PUT", "PATCH", "DELETE"}
378
564
  ip = self._client_ip(scope)
379
565
 
380
- # Origin checks (only when Origin is present).
381
- origin = self._header(scope, "origin")
382
- if origin is not None and not self._origin_allowed(origin):
383
- await self._reject(send, status=403, detail="Forbidden (origin not allowed)")
384
- return
566
+ request_id = (self._header(scope, "x-request-id") or "").strip()
567
+ if not request_id:
568
+ request_id = uuid.uuid4().hex
385
569
 
386
- # Lockout handling (only meaningful when auth is enabled).
387
- locked = self._lockouts.check_locked(ip)
388
- if locked is not None and locked > 0:
389
- await self._reject(
390
- send,
391
- status=429,
392
- detail="Too Many Requests (auth lockout)",
393
- headers=[(b"retry-after", str(int(locked)).encode("utf-8"))],
394
- )
395
- return
396
-
397
- # Auth decision.
398
- auth_required = False
399
- if is_write and self._policy.protect_write_endpoints:
400
- auth_required = True
401
- if is_read and self._policy.protect_read_endpoints:
402
- # Optional dev escape hatch (loopback-only).
403
- if self._policy.dev_allow_unauthenticated_reads_on_loopback and _is_loopback_ip(ip):
404
- auth_required = False
405
- else:
406
- auth_required = True
570
+ started_at_s = time.time()
571
+ status_code: Optional[int] = None
572
+ error: Optional[str] = None
573
+ auth_required: bool = False
574
+ presented_token_fp: str = ""
407
575
 
408
- # OPTIONS preflight: allow through (but still origin-checked above).
409
- if method == "OPTIONS":
410
- return await self._app(scope, receive, send)
576
+ async def _send_wrapped(message: dict) -> None:
577
+ nonlocal status_code
578
+ if message.get("type") == "http.response.start":
579
+ try:
580
+ status_code = int(message.get("status") or 0)
581
+ except Exception:
582
+ status_code = 0
411
583
 
412
- if auth_required:
413
- if not self._policy.tokens:
414
- await self._reject(send, status=503, detail="Gateway auth required but no token configured")
584
+ try:
585
+ hdrs = list(message.get("headers") or [])
586
+ except Exception:
587
+ hdrs = []
588
+ has_rid = False
589
+ for k, _v in hdrs:
590
+ try:
591
+ if bytes(k).lower() == b"x-request-id":
592
+ has_rid = True
593
+ break
594
+ except Exception:
595
+ continue
596
+ if not has_rid:
597
+ hdrs.append((b"x-request-id", str(request_id).encode("utf-8")))
598
+ message = dict(message)
599
+ message["headers"] = hdrs
600
+ await send(message)
601
+
602
+ def _finalize_audit() -> None:
603
+ if not is_write:
415
604
  return
416
- auth = self._header(scope, "authorization") or ""
417
- token = ""
418
- if auth.lower().startswith("bearer "):
419
- token = auth.split(" ", 1)[1].strip()
420
- if not token or not self._token_valid(token):
421
- lock = self._lockouts.record_failure(ip)
422
- if lock is not None and lock > 0:
423
- await self._reject(
424
- send,
425
- status=429,
426
- detail="Too Many Requests (auth lockout)",
427
- headers=[(b"retry-after", str(int(lock)).encode("utf-8"))],
428
- )
429
- return
430
- await self._reject(
431
- send,
432
- status=401,
433
- detail="Unauthorized",
434
- headers=[(b"www-authenticate", b"Bearer")],
435
- )
605
+ if not self._audit_enabled:
436
606
  return
437
- self._lockouts.record_success(ip)
438
-
439
- # Body size limits (for mutating endpoints).
440
- buffered_body: Optional[bytes] = None
441
- if is_write and self._policy.max_body_bytes > 0:
607
+ now = time.time()
608
+ duration_ms = int(max(0.0, (now - float(started_at_s))) * 1000.0)
609
+ qs = scope.get("query_string") or b""
610
+ entry: Dict[str, Any] = {
611
+ "ts": _now_utc_iso(),
612
+ "request_id": str(request_id),
613
+ "ip": str(ip),
614
+ "method": str(method),
615
+ "path": str(path),
616
+ "query": _audit_redact_query(qs if isinstance(qs, (bytes, bytearray)) else b""),
617
+ "status": int(status_code or 0),
618
+ "duration_ms": int(duration_ms),
619
+ "auth_required": bool(auth_required),
620
+ }
621
+ if presented_token_fp:
622
+ entry["auth_token_fp"] = str(presented_token_fp)
623
+
624
+ origin = self._header(scope, "origin")
625
+ if origin:
626
+ entry["origin"] = str(origin)
627
+ ua = self._header(scope, "user-agent")
628
+ if ua:
629
+ entry["user_agent"] = str(ua)
442
630
  cl = self._header(scope, "content-length")
443
- if cl is not None:
631
+ if cl and str(cl).strip().isdigit():
444
632
  try:
445
- if int(cl) > int(self._policy.max_body_bytes):
446
- await self._reject(send, status=413, detail="Payload Too Large")
447
- return
633
+ entry["bytes_in"] = int(str(cl).strip())
448
634
  except Exception:
449
635
  pass
450
- else:
451
- # No content-length: buffer up to limit+1, then replay.
452
- limit = int(self._policy.max_body_bytes)
453
- chunks: list[bytes] = []
454
- size = 0
455
- more = True
456
- while more:
457
- message = await receive()
458
- if message.get("type") != "http.request":
459
- continue
460
- body = message.get("body", b"") or b""
461
- more = bool(message.get("more_body", False))
462
- if body:
463
- chunks.append(body)
464
- size += len(body)
465
- if size > limit:
466
- await self._reject(send, status=413, detail="Payload Too Large")
467
- return
468
- buffered_body = b"".join(chunks)
469
-
470
- # Concurrency limits: separate pool for SSE streams.
471
- is_sse = path.endswith("/ledger/stream")
472
- sem = self._sse_sema if is_sse else self._sema
473
- acquired = await self._try_acquire(sem)
474
- if not acquired:
475
- await self._reject(
476
- send,
477
- status=429,
478
- detail="Too Many Requests (concurrency limit)",
479
- headers=[(b"retry-after", b"1")],
480
- )
481
- return
636
+
637
+ headers_out: Dict[str, str] = {}
638
+ for hn in self._audit_headers:
639
+ try:
640
+ v = self._header(scope, hn)
641
+ except Exception:
642
+ v = None
643
+ if v is None:
644
+ continue
645
+ headers_out[str(hn)] = str(v)
646
+ if headers_out:
647
+ entry["headers"] = headers_out
648
+
649
+ if error:
650
+ entry["error"] = str(error)
651
+
652
+ self._audit_append(entry)
482
653
 
483
654
  try:
484
- if buffered_body is None:
485
- return await self._app(scope, receive, send)
655
+ # Origin checks (only when Origin is present).
656
+ origin = self._header(scope, "origin")
657
+ if origin is not None and not self._origin_allowed(origin):
658
+ await self._reject(_send_wrapped, status=403, detail="Forbidden (origin not allowed)")
659
+ return
486
660
 
487
- # Replay buffered body to downstream app.
488
- sent = False
661
+ # Lockout handling (only meaningful when auth is enabled).
662
+ locked = self._lockouts.check_locked(ip)
663
+ if locked is not None and locked > 0:
664
+ await self._reject(
665
+ _send_wrapped,
666
+ status=429,
667
+ detail="Too Many Requests (auth lockout)",
668
+ headers=[(b"retry-after", str(int(locked)).encode("utf-8"))],
669
+ )
670
+ return
489
671
 
490
- async def _replay_receive():
491
- nonlocal sent
492
- if sent:
493
- return {"type": "http.request", "body": b"", "more_body": False}
494
- sent = True
495
- return {"type": "http.request", "body": buffered_body, "more_body": False}
672
+ # Auth decision.
673
+ auth_required = False
674
+ if is_write and self._policy.protect_write_endpoints:
675
+ auth_required = True
676
+ if is_read and self._policy.protect_read_endpoints:
677
+ # Optional dev escape hatch (loopback-only).
678
+ if self._policy.dev_allow_unauthenticated_reads_on_loopback and _is_loopback_ip(ip):
679
+ auth_required = False
680
+ else:
681
+ auth_required = True
682
+
683
+ # OPTIONS preflight: allow through (but still origin-checked above).
684
+ if method == "OPTIONS":
685
+ return await self._app(scope, receive, _send_wrapped)
686
+
687
+ if auth_required:
688
+ if not self._policy.tokens:
689
+ await self._reject(_send_wrapped, status=503, detail="Gateway auth required but no token configured")
690
+ return
691
+ auth = self._header(scope, "authorization") or ""
692
+ token = ""
693
+ if auth.lower().startswith("bearer "):
694
+ token = auth.split(" ", 1)[1].strip()
695
+ if token:
696
+ presented_token_fp = _sha256_hex(token)[:12]
697
+ if not token or not self._token_valid(token):
698
+ lock = self._lockouts.record_failure(ip)
699
+ if lock is not None and lock > 0:
700
+ await self._reject(
701
+ _send_wrapped,
702
+ status=429,
703
+ detail="Too Many Requests (auth lockout)",
704
+ headers=[(b"retry-after", str(int(lock)).encode("utf-8"))],
705
+ )
706
+ return
707
+ await self._reject(
708
+ _send_wrapped,
709
+ status=401,
710
+ detail="Unauthorized",
711
+ headers=[(b"www-authenticate", b"Bearer")],
712
+ )
713
+ return
714
+ self._lockouts.record_success(ip)
715
+
716
+ def _upload_kind(path: str) -> str:
717
+ p = str(path or "").rstrip("/")
718
+ if p.endswith("/attachments/upload"):
719
+ return "attachments"
720
+ if p.endswith("/bundles/upload"):
721
+ return "bundles"
722
+ return ""
723
+
724
+ # Body size limits (for mutating endpoints).
725
+ buffered_body: Optional[bytes] = None
726
+ max_body_bytes = int(self._policy.max_body_bytes)
727
+ upload_kind = _upload_kind(path)
728
+ if upload_kind:
729
+ # Multipart requests include boundary + part headers in `Content-Length`.
730
+ # This overhead is typically tiny (KBs), but we allow a conservative cushion so the
731
+ # security layer cannot reject a valid upload that is still within the endpoint's
732
+ # per-file cap (e.g. 25MB for /attachments/upload).
733
+ overhead = 2 * 1024 * 1024
734
+ endpoint_cap = (
735
+ int(self._policy.max_attachment_bytes)
736
+ if upload_kind == "attachments"
737
+ else int(self._policy.max_bundle_bytes)
738
+ )
739
+ max_body_bytes = max(0, int(endpoint_cap) + int(overhead))
740
+ if int(self._policy.max_upload_body_bytes) > 0:
741
+ max_body_bytes = min(max_body_bytes, int(self._policy.max_upload_body_bytes))
742
+
743
+ if is_write and max_body_bytes > 0:
744
+ cl = self._header(scope, "content-length")
745
+ if cl is not None:
746
+ try:
747
+ content_length = int(cl)
748
+ if content_length > int(max_body_bytes):
749
+ await self._reject(
750
+ _send_wrapped,
751
+ status=413,
752
+ detail=f"Payload Too Large ({content_length} bytes > {int(max_body_bytes)} bytes)",
753
+ )
754
+ return
755
+ except Exception:
756
+ pass
757
+ else:
758
+ # No content-length: buffer up to limit+1, then replay.
759
+ limit = int(max_body_bytes)
760
+ chunks: list[bytes] = []
761
+ size = 0
762
+ more = True
763
+ while more:
764
+ message = await receive()
765
+ if message.get("type") != "http.request":
766
+ continue
767
+ body = message.get("body", b"") or b""
768
+ more = bool(message.get("more_body", False))
769
+ if body:
770
+ chunks.append(body)
771
+ size += len(body)
772
+ if size > limit:
773
+ await self._reject(
774
+ _send_wrapped, status=413, detail=f"Payload Too Large ({size} bytes > {limit} bytes)"
775
+ )
776
+ return
777
+ buffered_body = b"".join(chunks)
778
+
779
+ # Concurrency limits: separate pool for SSE streams.
780
+ is_sse = path.endswith("/ledger/stream")
781
+ sem = self._sse_sema if is_sse else self._sema
782
+ acquired = await self._try_acquire(sem)
783
+ if not acquired:
784
+ await self._reject(
785
+ _send_wrapped,
786
+ status=429,
787
+ detail="Too Many Requests (concurrency limit)",
788
+ headers=[(b"retry-after", b"1")],
789
+ )
790
+ return
496
791
 
497
- return await self._app(scope, _replay_receive, send)
498
- finally:
499
792
  try:
500
- sem.release()
501
- except Exception:
502
- pass
793
+ if buffered_body is None:
794
+ return await self._app(scope, receive, _send_wrapped)
503
795
 
796
+ # Replay buffered body to downstream app.
797
+ sent = False
504
798
 
799
+ async def _replay_receive():
800
+ nonlocal sent
801
+ if sent:
802
+ return {"type": "http.request", "body": b"", "more_body": False}
803
+ sent = True
804
+ return {"type": "http.request", "body": buffered_body, "more_body": False}
805
+
806
+ return await self._app(scope, _replay_receive, _send_wrapped)
807
+ finally:
808
+ try:
809
+ sem.release()
810
+ except Exception:
811
+ pass
812
+ except Exception as e: # pragma: no cover
813
+ try:
814
+ error = str(e)
815
+ except Exception:
816
+ error = "error"
817
+ raise
818
+ finally:
819
+ _finalize_audit()