diff --git a/KNOWN_LIMITATIONS.md b/KNOWN_LIMITATIONS.md index 76f3dd3..ae8b2b2 100644 --- a/KNOWN_LIMITATIONS.md +++ b/KNOWN_LIMITATIONS.md @@ -9,7 +9,10 @@ GuardianNode is alpha/developer-preview software. scaling. - Vision models may misclassify images or miss important context. - Local models vary by license, hardware requirements, speed, and quality. -- Screenshot capture may include sensitive on-screen information. +- Screenshot capture may include sensitive on-screen information. The default + full-screen mode currently captures only the primary monitor. It does not + provide all-monitor coverage, and downsampling can reduce small-text OCR + legibility on high-resolution displays. - Guardian Review is a fallible external-model second opinion, not a finding of intent, wrongdoing, diagnosis, or legal status. - Guardian Review redaction is deterministic defense-in-depth, not a guarantee. diff --git a/README.md b/README.md index f457581..8e723f2 100644 --- a/README.md +++ b/README.md @@ -281,10 +281,12 @@ the backend directly to the public internet. Mutual TLS is not implemented. See ## What It Monitors GuardianNode reviews visible screen content from the configured Windows session. -Current installer defaults enable visible desktop screenshot capture so parents -should assume screenshots may contain sensitive on-screen content. Depending on -policy/settings, deployments may use full-screen captures or capture only when -configured apps are active. +Current installer defaults capture the primary display of the visible desktop, +including content outside named apps. Parents should assume those screenshots +may contain sensitive on-screen content. Secondary monitors are not captured +by this mode; an active window on a secondary monitor can therefore be missed. +Depending on policy/settings, deployments may capture the primary display or +capture only when configured apps are active. GuardianNode can process: diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index c9dd9f5..669fc64 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -4,8 +4,8 @@ GuardianNode bundles or directly depends on the following third-party components. Each component retains its original license. @@ -28,6 +28,7 @@ other: inno setup, winsw, pyinstaller, ollama | zeroconf | LGPL-2.1 | https://github.com/python-zeroconf/python-zeroconf | | qrcode | BSD-3-Clause | https://github.com/lincolnloop/python-qrcode | | Pillow | HPND | https://python-pillow.org/ | +| certifi | MPL-2.0 | https://github.com/certifi/python-certifi | | pytesseract | Apache-2.0 | https://github.com/madmaze/pytesseract | | python-ulid | MIT | https://github.com/mdomke/python-ulid | | itsdangerous | BSD-3-Clause | https://github.com/pallets/itsdangerous | @@ -46,6 +47,7 @@ deployment if their compliance requirements demand it. | psutil | BSD-3-Clause | https://github.com/giampaolo/psutil | | mss | MIT | https://github.com/BoboTiG/python-mss | | Pillow | HPND | https://python-pillow.org/ | +| certifi | MPL-2.0 | https://github.com/certifi/python-certifi | | Pydantic | MIT | https://github.com/pydantic/pydantic | | argon2-cffi | MIT | https://github.com/hynek/argon2-cffi | | cryptography | Apache-2.0 / BSD-3-Clause | https://github.com/pyca/cryptography | diff --git a/agent-windows/pyproject.toml b/agent-windows/pyproject.toml index fb147f5..b3931bc 100644 --- a/agent-windows/pyproject.toml +++ b/agent-windows/pyproject.toml @@ -14,7 +14,8 @@ dependencies = [ "pyyaml>=6.0,<7.0", "psutil>=5.9,<8.0", "mss>=9.0,<11.0", - "Pillow>=12.2,<13.0", + "Pillow>=13.0.0,<14.0", + "certifi>=2026.10.18", "pydantic>=2.5,<3.0", "argon2-cffi>=23.1,<26.0", "cryptography>=50.0.0,<51.0", diff --git a/agent-windows/src/backend_client.py b/agent-windows/src/backend_client.py index 037da73..97812da 100644 --- a/agent-windows/src/backend_client.py +++ b/agent-windows/src/backend_client.py @@ -59,6 +59,7 @@ async def send_screenshot( capture_scope: str = "monitored_app", policy_id: str | None = None, policy_version: str | None = None, + policy_generation: str | None = None, collector_version: str | None = None, timestamp: str | None = None, idempotency_key: str | None = None, @@ -77,6 +78,8 @@ async def send_screenshot( data["policy_id"] = policy_id if policy_version: data["policy_version"] = policy_version + if policy_generation: + data["policy_generation"] = policy_generation if collector_version: data["collector_version"] = collector_version if timestamp: @@ -93,15 +96,18 @@ async def send_screenshot( r.raise_for_status() return r.json() - async def heartbeat(self, queued_frames: int = 0) -> bool: + async def heartbeat(self, queued_frames: int = 0, applied_policy_generation: str | None = None) -> bool: """Liveness + upload-backlog report. Falls back to the plain health probe when talking to a backend that predates /api/devices/heartbeat.""" try: async with httpx.AsyncClient(timeout=5.0, verify=self._verify()) as c: + body: dict[str, int | str] = {"queued_frames": queued_frames} + if applied_policy_generation: + body["applied_policy_generation"] = applied_policy_generation r = await c.post( f"{self.base_url}/api/devices/heartbeat", headers=self._headers(), - json={"queued_frames": queued_frames}, + json=body, ) if r.status_code in (404, 405): r = await c.get(f"{self.base_url}/api/health", headers=self._headers()) diff --git a/agent-windows/src/broker_client.py b/agent-windows/src/broker_client.py index 66cad4e..ae87cfd 100644 --- a/agent-windows/src/broker_client.py +++ b/agent-windows/src/broker_client.py @@ -2,10 +2,13 @@ from __future__ import annotations import os +import time from typing import Any from src.broker_protocol import ( + MAX_MESSAGE_BYTES, PIPE_NAME, + PROTOCOL_VERSION, ProtocolError, decode_frame, encode_frame, @@ -29,12 +32,13 @@ def request(self, action: str, payload: dict[str, Any] | None = None) -> dict[st import win32file # type: ignore import win32pipe # type: ignore - frame = encode_frame(make_request(action, payload)) + request = make_request(action, payload) + frame = encode_frame(request) try: win32pipe.WaitNamedPipe(self.pipe_name, self.timeout_ms) handle = win32file.CreateFile( self.pipe_name, - win32file.GENERIC_READ | win32file.GENERIC_WRITE, + 0x00120003, # SYNCHRONIZE | READ_CONTROL | read/write data, not pipe-instance creation 0, None, win32file.OPEN_EXISTING, @@ -44,8 +48,9 @@ def request(self, action: str, payload: dict[str, Any] | None = None) -> dict[st except Exception as exc: raise BrokerUnavailable(str(exc)) from exc try: + self._verify_broker_owner(handle) win32file.WriteFile(handle, frame) - response = self._read_response(handle) + response = self._read_response(handle, request["request_id"]) finally: win32file.CloseHandle(handle) if not response.get("ok"): @@ -56,13 +61,53 @@ def request(self, action: str, payload: dict[str, Any] | None = None) -> dict[st return payload_obj @staticmethod - def _read_response(handle) -> dict[str, Any]: # noqa: ANN001 + def _verify_broker_owner(handle) -> None: # noqa: ANN001 + import win32security # type: ignore + + descriptor = win32security.GetSecurityInfo( + handle, + win32security.SE_KERNEL_OBJECT, + win32security.OWNER_SECURITY_INFORMATION, + ) + owner = descriptor.GetSecurityDescriptorOwner() + if win32security.ConvertSidToStringSid(owner) != "S-1-5-18": + raise BrokerUnavailable("local broker pipe is not owned by SYSTEM") + + @staticmethod + def _read_exact(handle, size: int) -> bytes: # noqa: ANN001 import win32file # type: ignore + import win32pipe # type: ignore - _, header = win32file.ReadFile(handle, 4) + data = bytearray() + deadline = time.monotonic() + 5.0 + while len(data) < size: + if time.monotonic() >= deadline: + raise BrokerUnavailable("broker response deadline exceeded") + _peeked, available, _left = win32pipe.PeekNamedPipe(handle, 0) + if not available: + time.sleep(0.01) + continue + _, chunk = win32file.ReadFile(handle, min(size - len(data), available)) + if not chunk: + raise ProtocolError("broker disconnected during response") + data.extend(chunk) + return bytes(data) + + @staticmethod + def _read_response(handle, request_id: str) -> dict[str, Any]: # noqa: ANN001 + header = BrokerClient._read_exact(handle, 4) size = int.from_bytes(header, "big") - _, body = win32file.ReadFile(handle, size) - return decode_frame(header + body) + if size > MAX_MESSAGE_BYTES: + raise ProtocolError("broker response too large") + body = BrokerClient._read_exact(handle, size) + response = decode_frame(header + body) + if response.get("version") != PROTOCOL_VERSION: + raise ProtocolError("unsupported broker protocol version") + if response.get("request_id") != request_id: + raise ProtocolError("broker response request_id mismatch") + if not isinstance(response.get("ok"), bool): + raise ProtocolError("broker returned invalid response") + return response def health(self) -> dict[str, Any]: return self.request("health") @@ -84,6 +129,7 @@ def submit_screenshot(self, payload: dict[str, Any]) -> dict[str, Any]: "capture_scope", "policy_id", "policy_version", + "policy_generation", "collector_version", "timestamp", "idempotency_key", diff --git a/agent-windows/src/broker_protocol.py b/agent-windows/src/broker_protocol.py index 044b4c9..427da49 100644 --- a/agent-windows/src/broker_protocol.py +++ b/agent-windows/src/broker_protocol.py @@ -10,13 +10,22 @@ PROTOCOL_VERSION = 1 PIPE_NAME = r"\\.\pipe\GuardianNodeEndpointService" -PIPE_SECURITY_SDDL = "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;IU)" +# Interactive users may exchange data, but may not create a competing pipe +# instance (FILE_CREATE_PIPE_INSTANCE is bit 0x4, included in GENERIC_WRITE). +# 0x0012019B includes READ_CONTROL and the pipe metadata rights Windows checks +# when a medium-integrity client opens a pipe. It excludes bit 0x4, so clients +# still cannot create another pipe instance. +# SYSTEM's default token owner may be Administrators. Set the pipe owner +# explicitly so interactive clients can verify they are talking to our broker. +PIPE_SECURITY_SDDL = "O:SYD:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;0x0012019B;;;IU)" MAX_MESSAGE_BYTES = 2 * 1024 * 1024 MAX_SCREENSHOT_BYTES = 1024 * 1024 MAX_STRING_BYTES = 4096 MAX_REQUEST_ID_BYTES = 80 ALLOWED_ACTIONS = { + "child_request_create", + "child_request_list", "capture_config", "health", "status", @@ -123,6 +132,24 @@ def image_from_b64(value: Any) -> bytes: def _validate_payload(action: str, payload: dict[str, Any]) -> None: + if action == "child_request_list": + if payload: + raise ProtocolError("child_request_list takes no payload") + return + if action == "child_request_create": + if set(payload) - {"request_type", "target", "reason"}: + raise ProtocolError("unsupported child request field") + if payload.get("request_type") not in { + "more_time", "site_exception", "app_exception", "other", + }: + raise ProtocolError("invalid child request type") + for key, limit in (("target", 1024), ("reason", 4096)): + value = payload.get(key) + if value is not None: + if not isinstance(value, str): + raise ProtocolError(f"{key} must be a string") + _bound_string(key, value, limit) + return if action == "submit_screenshot": image_from_b64(payload.get("image_b64")) for key in ( @@ -131,6 +158,7 @@ def _validate_payload(action: str, payload: dict[str, Any]) -> None: "capture_scope", "policy_id", "policy_version", + "policy_generation", "collector_version", "timestamp", "idempotency_key", diff --git a/agent-windows/src/broker_service.py b/agent-windows/src/broker_service.py index a91967b..57b9248 100644 --- a/agent-windows/src/broker_service.py +++ b/agent-windows/src/broker_service.py @@ -17,9 +17,12 @@ import threading import time from dataclasses import dataclass +from datetime import UTC, datetime from pathlib import Path from typing import Any, Protocol +import httpx + from src import __version__ from src.broker_protocol import ( PIPE_NAME, @@ -114,7 +117,32 @@ def __init__( self.replay_cache = replay_cache or RequestReplayCache() self._capture_config_lock = threading.Lock() self._capture_config: dict[str, Any] = {} - self.set_capture_config(capture_config or {}) + self._persisted_policy_generation: str | None = None + self.capture_config_path = self.pause_path.with_name("capture_config.json") + try: + cached = json.loads(self.capture_config_path.read_text(encoding="utf-8")) + if isinstance(cached, dict): + self.set_capture_config(cached) + except (OSError, ValueError): + pass + if capture_config: + self.set_capture_config(capture_config) + + def _server_paused(self) -> bool: + with self._capture_config_lock: + cfg = dict(self._capture_config) + if cfg.get("paused") is not True: + return False + until = cfg.get("paused_until") + if not until: + return True + try: + expiry = datetime.fromisoformat(until) + if expiry.tzinfo is None: + return True + return expiry.astimezone(UTC) > datetime.now(UTC) + except ValueError: + return True def handle_message(self, message: dict[str, Any]) -> dict[str, Any]: try: @@ -146,18 +174,63 @@ def handle_request(self, request: BrokerRequest) -> dict[str, Any]: "device_id": creds.get("device_id") or "", "backend_url": creds.get("backend_url") or "", "queue_depth": self.queue.qsize(), - "paused": pause.paused, + "paused": pause.paused or self._server_paused(), "paused_until": pause.paused_until, } if request.action == "capture_config": with self._capture_config_lock: return dict(self._capture_config) + if request.action in {"child_request_create", "child_request_list"}: + return self._child_request(request.action, request.payload) if request.action == "submit_screenshot": + if self._load_pause().paused or self._server_paused(): + raise ProtocolError("capture is paused by parent policy") payload = self._screenshot_payload(request.payload) self.queue.put_nowait(payload) return {"status": "queued", "queue_depth": self.queue.qsize()} raise ProtocolError("unsupported action") + def _child_request(self, action: str, payload: dict[str, Any]) -> dict[str, Any]: + """Use broker-owned credentials; return only child-request DTO fields.""" + from src.backend_client import BackendClient + + creds = self._load_credentials() + token = str(creds.get("device_token") or "") + if not token: + raise ProtocolError("device is not paired") + client = BackendClient( + str(creds.get("backend_url") or ""), token, ca_path=creds.get("ca_path") + ) + try: + with httpx.Client(timeout=5.0, verify=client._verify()) as transport: + if action == "child_request_create": + response = transport.post( + f"{client.base_url}/api/child-requests", + json=payload, + headers=client._headers(), + ) + else: + response = transport.get( + f"{client.base_url}/api/child-requests/mine", + headers=client._headers(), + ) + response.raise_for_status() + body = response.json() + except (httpx.HTTPError, ValueError) as exc: + log.warning("child request backend call failed: %s", type(exc).__name__) + raise ProtocolError("child request could not reach the parent server") from exc + fields = { + "request_id", "device_id", "profile_id", "request_type", "target", "reason", + "status", "response_note", "created_at", "reviewed_at", "expires_at", + } + if action == "child_request_create": + if not isinstance(body, dict): + raise ProtocolError("invalid child request response") + return {key: body[key] for key in fields if key in body} + if not isinstance(body, list) or len(body) > 20 or not all(isinstance(item, dict) for item in body): + raise ProtocolError("invalid child request list") + return {"items": [{key: item[key] for key in fields if key in item} for item in body]} + def set_capture_config(self, payload: dict[str, Any]) -> None: """Cache only bounded, non-secret capture controls for session agents.""" clean: dict[str, Any] = {} @@ -177,8 +250,40 @@ def set_capture_config(self, payload: dict[str, Any]) -> None: full_screen = payload.get("full_screen_capture_enabled") if isinstance(full_screen, bool): clean["full_screen_capture_enabled"] = full_screen + paused = payload.get("paused") + if isinstance(paused, bool): + clean["paused"] = paused + capture_allowed = payload.get("capture_allowed") + if isinstance(capture_allowed, bool): + clean["capture_allowed"] = capture_allowed + if not capture_allowed: + clean["paused"] = True + context_allowed = payload.get("apps_and_urls_allowed") + if isinstance(context_allowed, bool): + clean["apps_and_urls_allowed"] = context_allowed + paused_until = payload.get("paused_until") + if isinstance(paused_until, str) and len(paused_until) <= 40: + clean["paused_until"] = paused_until + generation = payload.get("policy_generation") + if isinstance(generation, str) and len(generation) <= 64: + clean["policy_generation"] = generation with self._capture_config_lock: self._capture_config = clean + self._persisted_policy_generation = None + try: + self.capture_config_path.parent.mkdir(parents=True, exist_ok=True) + temporary = self.capture_config_path.with_suffix(".tmp") + temporary.write_text(json.dumps(clean), encoding="utf-8") + os.replace(temporary, self.capture_config_path) + with self._capture_config_lock: + if self._capture_config == clean: + self._persisted_policy_generation = clean.get("policy_generation") + except OSError as exc: + log.warning("capture policy could not be persisted: %s", type(exc).__name__) + + def applied_policy_generation(self) -> str | None: + with self._capture_config_lock: + return self._persisted_policy_generation def ensure_broker_credentials(self) -> dict[str, Any]: creds = load_credentials(self.credential_path) @@ -210,6 +315,7 @@ def _screenshot_payload(self, payload: dict[str, Any]) -> dict[str, Any]: "capture_scope", "policy_id", "policy_version", + "policy_generation", "collector_version", "timestamp", "idempotency_key", @@ -481,12 +587,28 @@ async def _run_sender(handler: BrokerCommandHandler, cfg: AgentConfig) -> None: token = str(creds.get("device_token") or "") backend_url = str(creds.get("backend_url") or cfg.backend_url) if not token: + try: + await asyncio.to_thread( + bootstrap_pairing, + socket.gethostname(), + __version__, + pending_path=pending_pairing_path(), + device_path=handler.credential_path, + bootstrap_token_path=broker_bootstrap_token_path(), + attempts=1, + ) + except Exception as exc: + log.warning("broker pairing retry failed: %s", type(exc).__name__) await asyncio.sleep(10) continue - await screenshot_sender_loop( - BackendClient(backend_url, token, ca_path=creds.get("ca_path")), - handler.queue, - ) # type: ignore[arg-type] + try: + await screenshot_sender_loop( + BackendClient(backend_url, token, ca_path=creds.get("ca_path")), + handler.queue, + ) # type: ignore[arg-type] + except Exception as exc: + log.warning("broker sender restarted after %s", type(exc).__name__) + await asyncio.sleep(10) async def _run_device_control(handler: BrokerCommandHandler, cfg: AgentConfig) -> None: @@ -501,19 +623,40 @@ async def _run_device_control(handler: BrokerCommandHandler, cfg: AgentConfig) - if not token: await asyncio.sleep(10) continue + if pending_pairing_path().exists(): + try: + await asyncio.to_thread( + bootstrap_pairing, + socket.gethostname(), + __version__, + pending_path=pending_pairing_path(), + device_path=handler.credential_path, + bootstrap_token_path=broker_bootstrap_token_path(), + attempts=1, + replace_existing=True, + ) + except Exception as exc: + log.warning("broker re-pair retry failed: %s", type(exc).__name__) + creds = handler.ensure_broker_credentials() + token = str(creds.get("device_token") or "") client = BackendClient( str(creds.get("backend_url") or cfg.backend_url), token, ca_path=creds.get("ca_path"), ) try: - await client.heartbeat(queued_frames=handler.queue.qsize()) + await client.heartbeat( + queued_frames=handler.queue.qsize(), + applied_policy_generation=handler.applied_policy_generation(), + ) capture_config = await client.get_capture_config() if capture_config: handler.set_capture_config(capture_config) for command in await client.get_commands(): command_id = str(command.get("command_id") or "") try: + if command.get("command_type") in {"pause_app", "block_domain"} and not cfg.enforcement_enabled: + raise ValueError("device enforcement is disabled pending Windows qualification") result = enforcer.execute(command) await client.report_command(command_id, "succeeded", result) except ValueError as exc: @@ -586,6 +729,9 @@ def cli() -> None: force=True, ) cfg = AgentConfig.from_path(Path(args.config)) + if args.self_test: + log.info("broker self-test ok") + return handler = build_handler(cfg) try: bootstrap_pairing( @@ -594,13 +740,11 @@ def cli() -> None: pending_path=pending_pairing_path(), device_path=handler.credential_path, bootstrap_token_path=broker_bootstrap_token_path(), + replace_existing=True, ) except Exception as exc: log.warning("broker pairing bootstrap failed: %s", exc) handler.ensure_broker_credentials() - if args.self_test: - log.info("broker self-test ok") - return if os.name != "nt": raise SystemExit("GuardianNode endpoint broker service currently runs only on Windows") diff --git a/agent-windows/src/config.py b/agent-windows/src/config.py index 7c9876a..5e06ad8 100644 --- a/agent-windows/src/config.py +++ b/agent-windows/src/config.py @@ -41,6 +41,7 @@ class AgentConfig: age_group: str = "10_13" policy_id: str = "default" policy_version: str = "0.1.0-alpha.3" + policy_generation: str | None = None ocr_engine: str = "tesseract" # tesseract | paddle | none # Capture cadence. The phash is now computed on the foreground-window # region, so it correctly detects small text edits (e.g. lines added in @@ -77,6 +78,9 @@ class AgentConfig: durable_queue_max_bytes: int = 256 * 1024 * 1024 durable_queue_max_age_seconds: int = 7 * 24 * 60 * 60 broker_enabled: bool = True + # Parent-confirmed process/domain enforcement needs Windows crash/undo + # qualification before it may be enabled for a family installation. + enforcement_enabled: bool = False def __post_init__(self) -> None: # PyYAML treats unquoted values like 10_13 as numeric 1013. Normalize diff --git a/agent-windows/src/main.py b/agent-windows/src/main.py index 13f9d24..3b84c46 100644 --- a/agent-windows/src/main.py +++ b/agent-windows/src/main.py @@ -88,9 +88,9 @@ async def capture_loop(cfg: AgentConfig, screenshot_queue: asyncio.Queue) -> Non app_name = active.name if active else None win_title = win.title if win else None active_rect = win.rect if win else None - # Full-screen mode means the visible desktop is the monitoring - # boundary. Every foreground app is therefore monitored; the app - # list only gates captures when full-screen mode is disabled. + # Full-screen mode captures the primary display. Every foreground + # app is eligible when it is on that display; other displays are + # outside this capture boundary until multi-monitor support lands. in_monitored = cfg.full_screen_capture_enabled or is_monitored( active, cfg.monitored_apps ) @@ -100,7 +100,7 @@ async def capture_loop(cfg: AgentConfig, screenshot_queue: asyncio.Queue) -> Non await asyncio.sleep(cfg.ocr_cadence_seconds) continue - capture_scope = "visible_desktop" if cfg.full_screen_capture_enabled else "monitored_app" + capture_scope = "primary_monitor" if cfg.full_screen_capture_enabled else "monitored_app" shot = ( capture_full(active_rect=active_rect) if cfg.full_screen_capture_enabled @@ -140,6 +140,9 @@ async def capture_loop(cfg: AgentConfig, screenshot_queue: asyncio.Queue) -> Non ) if send: + if _is_locally_paused(): + await asyncio.sleep(cfg.ocr_cadence_seconds) + continue last_phash = shot.phash last_full_phash = shot.full_phash last_sent_at = time.time() @@ -153,6 +156,7 @@ async def capture_loop(cfg: AgentConfig, screenshot_queue: asyncio.Queue) -> Non "capture_scope": capture_scope, "policy_id": cfg.policy_id, "policy_version": cfg.policy_version, + "policy_generation": cfg.policy_generation, "collector_version": __version__, "timestamp": datetime.now(timezone.utc).isoformat(), "phash_dist": dist, @@ -219,6 +223,7 @@ async def screenshot_sender_loop(client: BackendClient, screenshot_queue: asynci capture_scope=payload.get("capture_scope", "monitored_app"), policy_id=payload.get("policy_id"), policy_version=payload.get("policy_version"), + policy_generation=payload.get("policy_generation"), collector_version=payload.get("collector_version"), timestamp=payload.get("timestamp"), idempotency_key=payload.get("idempotency_key"), @@ -237,8 +242,8 @@ async def screenshot_sender_loop(client: BackendClient, screenshot_queue: asynci except httpx.HTTPStatusError as e: status = e.response.status_code if status in (401, 403): - log.error("screenshot upload unauthorized (%s); re-pair this device", status) - backoff = 60.0 + log.error("screenshot upload unauthorized (%s); refreshing broker credentials", status) + backoff = 10.0 _retry_payload( screenshot_queue, payload, @@ -246,8 +251,9 @@ async def screenshot_sender_loop(client: BackendClient, screenshot_queue: asynci error=f"unauthorized: {status}", ) handled = True - await asyncio.sleep(backoff) - continue + # Return to the broker supervisor so a repaired pairing or + # rotated trust bundle creates a fresh BackendClient. + return if status == 413: log.error("screenshot rejected as too large; discarding frame") backoff = 1.0 @@ -381,6 +387,9 @@ async def heartbeat_loop(client: BackendClient, screenshot_queue: asyncio.Queue) def _apply_capture_config(cfg: AgentConfig, cc: dict) -> None: + generation = cc.get("policy_generation") + if isinstance(generation, str) and len(generation) <= 64: + cfg.policy_generation = generation if isinstance(cc.get("cadence_seconds"), (int, float)): cfg.ocr_cadence_seconds = max(2, int(cc["cadence_seconds"])) if isinstance(cc.get("phash_threshold"), (int, float)): diff --git a/agent-windows/src/pairing_client.py b/agent-windows/src/pairing_client.py index 26806ff..c61d507 100644 --- a/agent-windows/src/pairing_client.py +++ b/agent-windows/src/pairing_client.py @@ -13,6 +13,7 @@ from datetime import UTC, datetime from pathlib import Path from urllib.parse import urlsplit +from uuid import uuid4 import httpx from cryptography import x509 @@ -236,6 +237,8 @@ def _enroll_bundle( pending: dict, backend_url: str, device_path: Path | None, + *, + preserve_existing: bool = False, ) -> tuple[Path | None, str]: """Validate and persist an authenticated CA supplied out-of-band by the parent.""" bundle = pending.get("bundle") @@ -274,6 +277,8 @@ def _enroll_bundle( if not expected or not hmac.compare_digest(actual, expected): raise ValueError("pairing bundle CA fingerprint mismatch") enrolled = default_ca_path(device_path) + if preserve_existing and enrolled.exists(): + enrolled = enrolled.with_name(f"family-ca-{uuid4().hex}.pem") _restricted_write(enrolled, pem.encode("ascii")) return enrolled, actual @@ -332,6 +337,7 @@ def bootstrap_pairing( bootstrap_token_path: Path | None = None, attempts: int = 5, retry_delay: float = 10.0, + replace_existing: bool = False, ) -> dict | None: """Complete pairing from the installer's pending_pairing.json, if present. @@ -344,7 +350,7 @@ def bootstrap_pairing( """ pending_path = pending_path or pending_pairing_path() creds = load_credentials(device_path) - if creds and creds.get("device_token"): + if creds and creds.get("device_token") and not (replace_existing and pending_path.exists()): if pending_path.exists(): try: pending_path.unlink() @@ -388,7 +394,10 @@ def bootstrap_pairing( return None try: - ca_path, ca_sha256 = _enroll_bundle(pending, backend_url, device_path) + ca_path, ca_sha256 = _enroll_bundle( + pending, backend_url, device_path, + preserve_existing=bool(creds and creds.get("device_token") and replace_existing), + ) except (OSError, ValueError) as exc: log.error("pairing trust validation failed: %s", exc) return None diff --git a/agent-windows/src/screenshot_capture.py b/agent-windows/src/screenshot_capture.py index 89cebae..e700c98 100644 --- a/agent-windows/src/screenshot_capture.py +++ b/agent-windows/src/screenshot_capture.py @@ -104,8 +104,9 @@ def capture_active(rect: tuple[int, int, int, int] | None) -> Screenshot | None: def capture_full(active_rect: tuple[int, int, int, int] | None = None) -> Screenshot | None: """Whole-primary-monitor capture. - Catches content in background apps / wallpapers / picture viewers — anything - visually present on the user's desktop, not just the focused window. + Catches content in background apps / wallpapers / picture viewers on the + primary display, not just the focused window. Other monitors are outside + this capture region and must not be described as covered. If `active_rect` (absolute screen coordinates of the foreground window) is given, the dedup phash is computed on that subregion of the captured frame diff --git a/agent-windows/src/tray_app.py b/agent-windows/src/tray_app.py index ca3ea31..529189d 100644 --- a/agent-windows/src/tray_app.py +++ b/agent-windows/src/tray_app.py @@ -165,6 +165,9 @@ def _device_text(*_args) -> str: pystray.MenuItem(_status_text, None, enabled=False), pystray.MenuItem(_device_text, None, enabled=False), pystray.Menu.SEPARATOR, + pystray.MenuItem("Ask parent for an exception", lambda *_: _show_child_request_dialog()), + pystray.MenuItem("View my requests", lambda *_: _show_child_requests()), + pystray.Menu.SEPARATOR, pystray.MenuItem("Pause monitoring in parent dashboard", _menu_pause), pystray.MenuItem("Resume in parent dashboard", _menu_resume), pystray.MenuItem("Open dashboard", lambda *_: _open_dashboard()), @@ -182,6 +185,75 @@ def _open_dashboard() -> None: webbrowser.open(url) +def _show_child_request_dialog() -> None: + """Let a standard user request an exception through broker-owned transport.""" + import tkinter as tk + from tkinter import messagebox, ttk + + window = tk.Tk() + window.title("Ask parent — GuardianNode") + window.resizable(False, False) + frame = ttk.Frame(window, padding=16) + frame.grid(sticky="nsew") + ttk.Label(frame, text="What would you like to ask your parent?").grid(row=0, column=0, columnspan=2, sticky="w") + request_type = tk.StringVar(value="more_time") + type_box = ttk.Combobox( + frame, textvariable=request_type, state="readonly", + values=("more_time", "site_exception", "app_exception", "other"), width=24, + ) + type_box.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(4, 12)) + ttk.Label(frame, text="Site or app (optional)").grid(row=2, column=0, sticky="w") + target = ttk.Entry(frame, width=48) + target.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(4, 12)) + ttk.Label(frame, text="Why are you asking? (optional)").grid(row=4, column=0, sticky="w") + reason = tk.Text(frame, width=48, height=4) + reason.grid(row=5, column=0, columnspan=2, sticky="ew", pady=(4, 12)) + + def submit() -> None: + payload = { + "request_type": request_type.get(), + "target": target.get().strip()[:1024] or None, + "reason": reason.get("1.0", "end-1c").strip()[:4096] or None, + } + try: + BrokerClient().request("child_request_create", payload) + except Exception as exc: + messagebox.showerror("Request not sent", str(exc), parent=window) + return + messagebox.showinfo("Request sent", "Your parent can review this request in GuardianNode.", parent=window) + window.destroy() + + ttk.Button(frame, text="Send request", command=submit).grid(row=6, column=0, sticky="w") + ttk.Button(frame, text="Cancel", command=window.destroy).grid(row=6, column=1, sticky="e") + type_box.focus_set() + window.mainloop() + + +def _show_child_requests() -> None: + import tkinter as tk + from tkinter import messagebox, ttk + + try: + response = BrokerClient().request("child_request_list") + except Exception as exc: + messagebox.showerror("Requests unavailable", str(exc)) + return + items = response.get("items", []) if isinstance(response, dict) else [] + window = tk.Tk() + window.title("My requests — GuardianNode") + frame = ttk.Frame(window, padding=16) + frame.grid(sticky="nsew") + if not items: + ttk.Label(frame, text="You have no requests yet.").grid(sticky="w") + for index, item in enumerate(items[:20]): + if not isinstance(item, dict): + continue + label = f"{item.get('request_type', 'request')}: {item.get('target') or 'general'} — {item.get('status', 'unknown')}" + ttk.Label(frame, text=label, wraplength=450).grid(row=index, column=0, sticky="w", pady=3) + ttk.Button(frame, text="Close", command=window.destroy).grid(row=21, column=0, sticky="e", pady=(12, 0)) + window.mainloop() + + def _self_test() -> None: """Validate dependencies required by the frozen tray without opening UI.""" import _tkinter # noqa: F401 diff --git a/agent-windows/tests/test_broker_client.py b/agent-windows/tests/test_broker_client.py new file mode 100644 index 0000000..84ba52a --- /dev/null +++ b/agent-windows/tests/test_broker_client.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import sys +from types import SimpleNamespace + +import pytest + +from src.broker_client import BrokerClient, BrokerUnavailable +from src.broker_protocol import MAX_MESSAGE_BYTES, ProtocolError, encode_frame, make_response + + +def _response_reader(monkeypatch, chunks: list[bytes]): + reads: list[int] = [] + + def read(_handle, size: int): + reads.append(size) + chunk = chunks.pop(0) + assert len(chunk) <= size + return 0, chunk + + monkeypatch.setitem(sys.modules, "win32file", SimpleNamespace(ReadFile=read)) + monkeypatch.setitem(sys.modules, "win32pipe", SimpleNamespace( + PeekNamedPipe=lambda *_args: (b"", max(1, len(chunks[0])) if chunks else 0, 0), + )) + return reads + + +def test_response_reader_assembles_partial_header_and_body(monkeypatch): + frame = encode_frame(make_response("request-1", ok=True, payload={"status": "queued"})) + _response_reader(monkeypatch, [frame[:2], frame[2:4], frame[4:10], frame[10:]]) + assert BrokerClient._read_response(object(), "request-1")["payload"] == {"status": "queued"} + + +def test_response_reader_rejects_large_length_before_body_read(monkeypatch): + reads = _response_reader(monkeypatch, [(MAX_MESSAGE_BYTES + 1).to_bytes(4, "big")]) + with pytest.raises(ProtocolError, match="too large"): + BrokerClient._read_response(object(), "request-1") + assert reads == [4] + + +@pytest.mark.parametrize("frame", [ + encode_frame(make_response("other-request", ok=True)), + encode_frame({"version": 99, "request_id": "request-1", "ok": True, "payload": {}}), + encode_frame({"version": 1, "request_id": "request-1", "ok": "yes", "payload": {}}), +]) +def test_response_reader_rejects_unbound_or_invalid_response(monkeypatch, frame): + _response_reader(monkeypatch, [frame[:4], frame[4:]]) + with pytest.raises(ProtocolError): + BrokerClient._read_response(object(), "request-1") + + +def test_response_reader_rejects_disconnect_mid_frame(monkeypatch): + frame = encode_frame(make_response("request-1", ok=True)) + _response_reader(monkeypatch, [frame[:4], frame[4:8], b""]) + with pytest.raises(ProtocolError, match="disconnected"): + BrokerClient._read_response(object(), "request-1") + + +def test_broker_client_rejects_pipe_not_owned_by_system(monkeypatch): + fake_security = SimpleNamespace( + SE_KERNEL_OBJECT=1, + OWNER_SECURITY_INFORMATION=2, + GetSecurityInfo=lambda *_args: SimpleNamespace(GetSecurityDescriptorOwner=lambda: "fake-user"), + ConvertSidToStringSid=lambda owner: owner, + ) + monkeypatch.setitem(sys.modules, "win32security", fake_security) + with pytest.raises(BrokerUnavailable, match="not owned by SYSTEM"): + BrokerClient._verify_broker_owner(object()) + + +def test_response_reader_times_out_when_broker_stays_silent(monkeypatch): + from src import broker_client + + _response_reader(monkeypatch, []) + ticks = iter([0.0, 6.0]) + monkeypatch.setattr(broker_client.time, "monotonic", lambda: next(ticks)) + with pytest.raises(BrokerUnavailable, match="deadline"): + BrokerClient._read_response(object(), "request-1") diff --git a/agent-windows/tests/test_broker_protocol.py b/agent-windows/tests/test_broker_protocol.py index 706a689..e83d1a2 100644 --- a/agent-windows/tests/test_broker_protocol.py +++ b/agent-windows/tests/test_broker_protocol.py @@ -27,6 +27,23 @@ def test_protocol_allows_capture_config_without_payload() -> None: assert request.payload == {} +def test_child_request_protocol_limits_content_and_profile_authority() -> None: + request = parse_request(make_request("child_request_create", { + "request_type": "site_exception", "target": "example.test", "reason": "school project", + })) + assert request.payload["request_type"] == "site_exception" + parse_request(make_request("child_request_list")) + for payload in ( + {"request_type": "site_exception", "profile_id": "another-child"}, + {"request_type": "site_exception", "reason": "x" * 4097}, + {"request_type": "arbitrary"}, + ): + with pytest.raises(ProtocolError): + parse_request(make_request("child_request_create", payload)) + with pytest.raises(ProtocolError): + parse_request(make_request("child_request_list", {"device_id": "another-device"})) + + def test_protocol_rejects_unknown_action() -> None: message = make_request("health") message["action"] = "read_device_token" diff --git a/agent-windows/tests/test_broker_service.py b/agent-windows/tests/test_broker_service.py index c41b05a..ab789bc 100644 --- a/agent-windows/tests/test_broker_service.py +++ b/agent-windows/tests/test_broker_service.py @@ -3,9 +3,12 @@ import asyncio import inspect import json +from datetime import UTC, datetime, timedelta from types import SimpleNamespace -from src.broker_protocol import image_to_b64, make_request +import httpx + +from src.broker_protocol import BrokerRequest, image_to_b64, make_request from src.broker_service import BrokerCommandHandler, CaptureProcessManager, WindowsNamedPipeServer from src.pairing_client import save_credentials @@ -70,6 +73,67 @@ def test_broker_returns_only_bounded_capture_config(tmp_path) -> None: assert "must-not-cross-the-pipe" not in json.dumps(response) +def test_parent_pause_reaches_capture_and_blocks_submit(tmp_path) -> None: + handler = _handler(tmp_path) + handler.set_capture_config({ + "level": "balanced", "cadence_seconds": 5, "paused": True, + "paused_until": (datetime.now(UTC) + timedelta(minutes=5)).isoformat(), + "capture_allowed": False, + "policy_generation": "generation-2", "device_token": "must-not-cross-the-pipe", + }) + config = handler.handle_message(make_request("capture_config"))["payload"] + assert config["paused"] is True + assert config["capture_allowed"] is False + assert config["policy_generation"] == "generation-2" + assert handler.applied_policy_generation() == "generation-2" + assert "device_token" not in config + assert handler.handle_message(make_request("status"))["payload"]["paused"] is True + restarted = _handler(tmp_path) + assert restarted.handle_message(make_request("status"))["payload"]["paused"] is True + assert restarted.applied_policy_generation() == "generation-2" + restarted.set_capture_config({"paused": False, "capture_allowed": True}) + assert restarted.handle_message(make_request("status"))["payload"]["paused"] is False + + +def test_failed_capture_policy_persistence_cannot_ack_generation(tmp_path, monkeypatch) -> None: + from src import broker_service + + handler = _handler(tmp_path) + monkeypatch.setattr(broker_service.os, "replace", lambda *_args: (_ for _ in ()).throw(OSError("locked"))) + handler.set_capture_config({"capture_allowed": False, "policy_generation": "generation-3"}) + assert handler.applied_policy_generation() is None + assert handler.handle_message(make_request("capture_config"))["payload"]["capture_allowed"] is False + + +def test_broker_child_request_uses_owned_token_but_returns_only_dto(tmp_path, monkeypatch) -> None: + from src import broker_service + + handler = _handler(tmp_path) + save_credentials("dev-1", "private-device-token", "http://127.0.0.1:8787", handler.credential_path) + requests = [] + + def backend(request: httpx.Request) -> httpx.Response: + requests.append(request) + assert request.headers["authorization"] == "Bearer private-device-token" + return httpx.Response(200, json={ + "request_id": "request-1", "status": "open", "request_type": "more_time", + "device_id": "dev-1", "device_token": "must-not-return", + }) + + real_client = httpx.Client + monkeypatch.setattr( + broker_service.httpx, "Client", + lambda **kwargs: real_client(transport=httpx.MockTransport(backend), **kwargs), + ) + result = handler.handle_request(BrokerRequest( + action="child_request_create", request_id="ipc-1", + payload={"request_type": "more_time", "reason": "Synthetic test"}, + )) + assert result["status"] == "open" + assert "device_token" not in json.dumps(result) + assert requests[0].url.path == "/api/child-requests" + + def test_broker_migrates_legacy_credentials_to_secure_path(tmp_path) -> None: handler = _handler(tmp_path) save_credentials("dev-1", "secret-token", "http://127.0.0.1:8787", handler.legacy_credential_path) @@ -88,6 +152,7 @@ def test_broker_queues_screenshot_without_profile_authority(tmp_path) -> None: { "image_b64": image_to_b64(b"jpeg bytes"), "app_name": "Browser", + "policy_generation": "generation-1", "profile_id": "should-not-be-accepted", "age_group": "5_7", }, @@ -97,6 +162,7 @@ def test_broker_queues_screenshot_without_profile_authority(tmp_path) -> None: assert response["ok"] assert handler.queue.qsize() == 1 assert handler.queue.items[0]["image_bytes"] == b"jpeg bytes" # type: ignore[attr-defined] + assert handler.queue.items[0]["policy_generation"] == "generation-1" # type: ignore[attr-defined] assert "profile_id" not in handler.queue.items[0] # type: ignore[attr-defined] assert "age_group" not in handler.queue.items[0] # type: ignore[attr-defined] diff --git a/agent-windows/tests/test_pairing_bootstrap.py b/agent-windows/tests/test_pairing_bootstrap.py index 8c3e18e..9dab095 100644 --- a/agent-windows/tests/test_pairing_bootstrap.py +++ b/agent-windows/tests/test_pairing_bootstrap.py @@ -373,3 +373,46 @@ def fail_bootstrap(*args, **kwargs): assert changed is False assert client.token == "already-token" + + +def test_broker_repair_can_replace_existing_device_token(tmp_path, monkeypatch): + device = tmp_path / "device.json" + pending = tmp_path / "pending_pairing.json" + pairing_client.save_credentials("dev-old", "old-token", "http://127.0.0.1:8787", device) + pending.write_text(json.dumps({ + "backend_url": "http://127.0.0.1:8787", + "local_bootstrap": True, + "device_bootstrap_token": "synthetic-repair-token", + })) + monkeypatch.setattr( + pairing_client, "bootstrap_local_with_server", + lambda *_args, **_kwargs: ("dev-new", "new-token"), + ) + + result = pairing_client.bootstrap_pairing( + "kid-pc", "0.1.0-alpha.3", pending_path=pending, + device_path=device, replace_existing=True, attempts=1, + ) + + assert result["device_token"] == "new-token" + assert pairing_client.load_credentials(device)["device_token"] == "new-token" + assert not pending.exists() + + +def test_failed_broker_repair_keeps_existing_credentials(tmp_path, monkeypatch): + device = tmp_path / "device.json" + pending = tmp_path / "pending_pairing.json" + pairing_client.save_credentials("dev-old", "old-token", "http://127.0.0.1:8787", device) + pending.write_text(json.dumps({ + "backend_url": "http://127.0.0.1:8787", + "local_bootstrap": True, + "device_bootstrap_token": "synthetic-repair-token", + })) + monkeypatch.setattr(pairing_client, "bootstrap_local_with_server", lambda *_args, **_kwargs: (_ for _ in ()).throw(ConnectionError("offline"))) + + assert pairing_client.bootstrap_pairing( + "kid-pc", "0.1.0-alpha.3", pending_path=pending, + device_path=device, replace_existing=True, attempts=1, + ) is None + assert pairing_client.load_credentials(device)["device_token"] == "old-token" + assert pending.exists() diff --git a/agent-windows/tests/test_sender_retry.py b/agent-windows/tests/test_sender_retry.py index 3e73607..c686e65 100644 --- a/agent-windows/tests/test_sender_retry.py +++ b/agent-windows/tests/test_sender_retry.py @@ -34,3 +34,17 @@ async def test_sender_discards_permanent_payload_errors(): assert client.calls == 1 assert q.empty() + + +@pytest.mark.asyncio +async def test_sender_returns_on_unauthorized_without_losing_queued_frame(): + q: asyncio.Queue = asyncio.Queue(maxsize=2) + await q.put({"image_bytes": b"synthetic-frame", "app_name": "ExampleApp"}) + client = RejectingClient(401) + + await asyncio.wait_for(screenshot_sender_loop(client, q), timeout=1) + + assert client.calls == 1 + assert q.qsize() == 1 + assert (await q.get())["image_bytes"] == b"synthetic-frame" + q.task_done() diff --git a/agent-windows/uv.lock b/agent-windows/uv.lock index ba32753..a077217 100644 --- a/agent-windows/uv.lock +++ b/agent-windows/uv.lock @@ -74,11 +74,11 @@ wheels = [ [[package]] name = "certifi" -version = "2026.6.17" +version = "2026.10.23" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, + { url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" }, ] [[package]] @@ -250,6 +250,7 @@ version = "0.1.0a3" source = { editable = "." } dependencies = [ { name = "argon2-cffi" }, + { name = "certifi" }, { name = "cryptography" }, { name = "httpx" }, { name = "mss" }, @@ -279,11 +280,12 @@ windows = [ [package.metadata] requires-dist = [ { name = "argon2-cffi", specifier = ">=23.1,<26.0" }, + { name = "certifi", specifier = ">=2026.10.18" }, { name = "cryptography", specifier = ">=50.0.0,<51.0" }, { name = "httpx", specifier = ">=0.27,<1.0" }, { name = "mss", specifier = ">=9.0,<11.0" }, { name = "opencv-python", marker = "extra == 'ocr'", specifier = ">=4.8,<5.0" }, - { name = "pillow", specifier = ">=12.2,<13.0" }, + { name = "pillow", specifier = ">=13.0.0,<14.0" }, { name = "psutil", specifier = ">=5.9,<8.0" }, { name = "pydantic", specifier = ">=2.5,<3.0" }, { name = "pystray", marker = "extra == 'windows'", specifier = ">=0.19,<1.0" }, @@ -538,87 +540,92 @@ wheels = [ [[package]] name = "pillow" -version = "12.3.0" +version = "13.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/a4/9cc30690a394a8305e8be4c9036aa16d0dfddadd09079860b582f562496d/pillow-13.0.0.tar.gz", hash = "sha256:cdcd1ba258178f7356d74170c9a81ba4eab6a5d55748bc402a5a2a6fe6be3510", size = 47058631, upload-time = "2026-10-16T22:18:09.718Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, - { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, - { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, - { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, - { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, - { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, - { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, - { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, - { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, - { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, - { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, - { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, - { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, - { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, - { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, - { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, - { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, - { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, - { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, - { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, - { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, - { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, - { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, - { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, - { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, - { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, - { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, - { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, - { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, - { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, - { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, - { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, - { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, - { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, - { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, - { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, - { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, - { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, - { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, - { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, - { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, - { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, - { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, - { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, - { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, - { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, - { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, - { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, - { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, - { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, - { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, - { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, - { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, - { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, - { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, - { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, - { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, - { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, - { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, - { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, - { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, - { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, - { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, - { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, - { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, - { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, - { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, - { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, - { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, - { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, - { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d7/af6af1b8cc55e07cb0489d5b0950bf5a9bb1461eeba825700b6d8d0e26ea/pillow-13.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:6f38f3f885a09cafc93fd667b1173a92f379e43785d635e7983c893f0da319fb", size = 5592324, upload-time = "2026-10-16T22:15:24.672Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d2/2c12cb4fbb5d95f38d1001ff3d95fafcb830077018cba40e56a8f932dfbc/pillow-13.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37290fbef0c73e2d51d594eb78fded7dc7170d1c3ad0a890269e6fd8c8c4e061", size = 4856298, upload-time = "2026-10-16T22:15:27.669Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ee/229b27472483a4801ea94990815e710391836afdf987b4b722b62bd75db6/pillow-13.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c88c10dd432f3b39ccdafc9ed81d0c21b8fd634502574da76d1bfa4fe2c76d0", size = 6517304, upload-time = "2026-10-16T22:15:29.43Z" }, + { url = "https://files.pythonhosted.org/packages/36/46/f509b74787f40ce0627501089d630b2c741b63b244c1f40b640b3b01e181/pillow-13.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a625df9a2a5597fa5e5266e01aefc82604bb85d291e3547981e863560d75da6c", size = 7140667, upload-time = "2026-10-16T22:15:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/5a398b8ec2c37145397d6461c0b551c23ec03072aa64f357aed25e582b00/pillow-13.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:10b5019883c98f1dd819ca48ffc6f33a91cee04948fe637c605f4345c7cafd6f", size = 6610583, upload-time = "2026-10-16T22:15:34.025Z" }, + { url = "https://files.pythonhosted.org/packages/4f/7b/f2f1109b230fd4e2d57ab2305de31adc37ea9b4ff8719436c623cb484415/pillow-13.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f391dc92a015c478745192fe4f2e120c2af2aba18d9fe0616341cbf537599806", size = 7218241, upload-time = "2026-10-16T22:15:36.214Z" }, + { url = "https://files.pythonhosted.org/packages/ab/d3/069edb9083b267746baf59756282cacef904d3475c5288c7216fb5195a98/pillow-13.0.0-cp311-cp311-win32.whl", hash = "sha256:6a0920cb55df5a4d3f6862964908078c0bd59332bdd1bcd20d14e8c70ef27cc5", size = 6515278, upload-time = "2026-10-16T22:15:38.129Z" }, + { url = "https://files.pythonhosted.org/packages/7d/f6/6e2d96413e4c9dbec12ea584d9e646d602877d4cce3ea669099f959deac6/pillow-13.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:d7710eb676e9bb68965cb045be0d333b4c0d7f9dfbaa7fbf121385b1588683d3", size = 7278471, upload-time = "2026-10-16T22:15:40.286Z" }, + { url = "https://files.pythonhosted.org/packages/70/e7/baff21f8cb60496c8c250609501b88919eeedb4088d3da106f6c62f3f3c0/pillow-13.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:12fbb851cf323cc19aac3eea1df5ad17b57e91e9fe3cfd06100dba7bb8208c13", size = 2698981, upload-time = "2026-10-16T22:15:42.637Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b8/58d79332229482eb0d27c82973850808c19d37e5bdc14443d3417bd373f5/pillow-13.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:67c8d44ad89fa7eb88de5d6ef6199b1e03dd24974f85a67639fc8badb801fe79", size = 5568643, upload-time = "2026-10-16T22:15:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/39/4b/f2ed53d83aa44ac19599ac0a54897f867fafb95f406980d1e24083f8cbe3/pillow-13.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc481f742ccda20e9ed760b8f978dff50f2a4e9bc253ac167fb0a05b0783ef52", size = 4852329, upload-time = "2026-10-16T22:15:46.284Z" }, + { url = "https://files.pythonhosted.org/packages/3b/bb/9e30e8ed18c20800ee327a12107d5249ad8dc444867b3adfcc21654e3c71/pillow-13.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8bf295fe459343bc6093c8e5e0df60bfa189e0fcb775a22e2c2488fdeccc6c0d", size = 6519309, upload-time = "2026-10-16T22:15:48.14Z" }, + { url = "https://files.pythonhosted.org/packages/e6/99/fdf65cc539e266ab93546c261bdf98411fe165c6e4e13f9dbf597097192b/pillow-13.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42d63be3af4f9b951b199b0a9d23365d37ab14687e6d77b74940432c8515c4ca", size = 7144542, upload-time = "2026-10-16T22:15:50.093Z" }, + { url = "https://files.pythonhosted.org/packages/26/9b/f38b7c4d8f599537e2d1a6d546cec9beef7536ff8f5c49a3f9c78179d8ac/pillow-13.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4bd024d567eca931b668978d672dba914d4b8e2628e2c3073a2ad178de8a2620", size = 6618042, upload-time = "2026-10-16T22:15:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a8/7c/45ee28531f89ee4582967d8a17f7a26d71ca64cd902b93c269590cd60a00/pillow-13.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23edf4ec4b6f4f240e8df6e4985e9ee2ced86474632433e55fb1e2548744bf40", size = 7228137, upload-time = "2026-10-16T22:15:54.009Z" }, + { url = "https://files.pythonhosted.org/packages/01/bc/9c2a969cb1b70201d04fa17fd5aedf02a732870b8da86bceaaa30b0ca385/pillow-13.0.0-cp312-cp312-win32.whl", hash = "sha256:02464da86f561f410e13b061ec37c3068a9cb557a095494157742d07b3c24a27", size = 6515423, upload-time = "2026-10-16T22:15:56.25Z" }, + { url = "https://files.pythonhosted.org/packages/ff/cd/73a79ecdc822076002eb24b5e1e4a530b0bb5310fcb6d239abbec877110b/pillow-13.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:ff3fe9eb621fbb9db4c4be33ab946f6bac5f98ab50ca8d3260503653656c961b", size = 7272166, upload-time = "2026-10-16T22:15:58.597Z" }, + { url = "https://files.pythonhosted.org/packages/4a/a3/be5a1236efd085b3984e4305da8f179ae9dab80c991d2ad78f2bd5c4c843/pillow-13.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:c3bc8ee0271387e0b1e3ad0ff893645990a656e79bf9c42f4f734a3642b11d51", size = 2698943, upload-time = "2026-10-16T22:16:00.637Z" }, + { url = "https://files.pythonhosted.org/packages/82/e2/9132d38df320e8099e2642d9fa538f18a7dfebf68a875a31725dc2a115aa/pillow-13.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e98b4f91f99b24b1da72e10621ba181f7281922384a7403094debb4199de6c00", size = 4190620, upload-time = "2026-10-16T22:16:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/cb/be/436dac0996876f65ef1916ffb711c5ef4af2ebc57414b6f16642e07f6b47/pillow-13.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:34fb3db1149c879343861831728c243c432d4a5afc8f1878ca7612ef8de2a033", size = 4290286, upload-time = "2026-10-16T22:16:04.861Z" }, + { url = "https://files.pythonhosted.org/packages/60/16/7c1007162e4169ea0d5bec47e2e0b2174f56c50cf5b8c02a1dc23e41037e/pillow-13.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:2d50500a4a3bc6469adcdb319cf05d688b08ca3bf0f22a05339f3b25b433a599", size = 3723169, upload-time = "2026-10-16T22:16:06.956Z" }, + { url = "https://files.pythonhosted.org/packages/08/bc/578ab2eaa04705a8978bd9be0fe15c3d43a8fc95097c4f7e39540e79e4fa/pillow-13.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ef5fe6b7db3f501c56000ba8fe7c7e1a35ca1d45824fbeb95a67daa85cff4dbe", size = 5568469, upload-time = "2026-10-16T22:16:08.664Z" }, + { url = "https://files.pythonhosted.org/packages/98/c8/ccede3dcc4f274b80fb49199eed1ef34ba05566c8b71976b9d1f111da2e3/pillow-13.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f11a3c1358bb3c68012a7b1f41b223c3db9f7bfde90b481df028cfe1c3363313", size = 4852187, upload-time = "2026-10-16T22:16:10.693Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3c/cd0770e96d20bfd990db024bcbd43897ff3e88da1f4782cf90ea231b1d4f/pillow-13.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19b66ca0a4d25cc0200b381ce824c7ea090db61427315e73cfb05dbf1b35c369", size = 6515908, upload-time = "2026-10-16T22:16:12.896Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fe/f5cdaa2d1c4086e757430f368c271a58ee29cfe7378e7ae7b9d964015c25/pillow-13.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f56499c1a74d2b862beb0c33b8016f99d095858455df9d21583bbbc1c15b582", size = 7142544, upload-time = "2026-10-16T22:16:14.844Z" }, + { url = "https://files.pythonhosted.org/packages/fb/b5/a54f6d44adbcd23737c8c12953f42be107e4cca64e247c0ca3a5b563061c/pillow-13.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2a14cd73d5e6c927f644b0110ee81f59c4ef506259ec9183904747f2caacc81", size = 6613097, upload-time = "2026-10-16T22:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b4/9e6c1207c21b2e91db0d15c16d30f3701ce5add1bd4da0e787761e66cb6e/pillow-13.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08b826a9bf1265c76de04a42061d61ea6b109418b7826f28ff1ecdfaa26c5502", size = 7224846, upload-time = "2026-10-16T22:16:19.195Z" }, + { url = "https://files.pythonhosted.org/packages/78/9f/d3c3cb0518f3dd1eee0ef501535f53d15597a53c5c4118d178086143397a/pillow-13.0.0-cp313-cp313-win32.whl", hash = "sha256:12b0fa15879fe5a9d9eebe66e4e2d74128a773b1a4448010c9c714f45e04b9d8", size = 6516013, upload-time = "2026-10-16T22:16:21.389Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ed/1b16fb66a37e1b9e41370b93b2c614c7da761a5b2a41dc333f7af4639962/pillow-13.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:9dda0788a9932776a75d8bcc020076edc308ce2508b51f6739a45e3158b3fc42", size = 7284777, upload-time = "2026-10-16T22:16:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/7d/8d02eba9f7ecc87f8e90f731e032cc4773802f2a843763afd0c66a535ff4/pillow-13.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:40a266371ece581e5de13b6635ef78d7487824af80561b6ca27694a88c19bc0f", size = 2698826, upload-time = "2026-10-16T22:16:25.382Z" }, + { url = "https://files.pythonhosted.org/packages/8e/45/fbad9c7f5e2f1745c373a434d46072bc84eddfefaa28b852425ac957fda6/pillow-13.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:44629d4498c9cb6b2051806155f22141df0d8a929c56134b166655232dfb2f8c", size = 4190721, upload-time = "2026-10-16T22:16:27.524Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bf/2074902a259a90692c5dbf132885d21671644cbbd3f690047516243a55db/pillow-13.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:bf97ba1b5542050e9b0c74e632323b6d8136cc99d64e193abebee22fe6013c76", size = 4290236, upload-time = "2026-10-16T22:16:29.562Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a5/3d1bb93b8ea38d15d21356c1b8e9f50971ac592aaa248f7fb71739094c0e/pillow-13.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:aa694c49787040943570d61c9b8fc01271bd2a2cfdd55f0d896f8aecdce1a981", size = 3723093, upload-time = "2026-10-16T22:16:31.249Z" }, + { url = "https://files.pythonhosted.org/packages/84/a1/be060135d85187b25fa8b2573786c0e45c62ae27527fff5257675eb97f4a/pillow-13.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:49c12af515073e9bf511e4ef736743bf70d531a643668722279314b937742ba4", size = 5572253, upload-time = "2026-10-16T22:16:33.103Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f4/0bf6f1ac9ba7ee2ffe9914aeb410a1035a0c1a3c42f122388d7fc950bc4b/pillow-13.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d9b0a2bee3f3e03e9f29057aedb807dbf1e1d96ee1b9d0f422d9db6c8583271d", size = 4852269, upload-time = "2026-10-16T22:16:35.102Z" }, + { url = "https://files.pythonhosted.org/packages/83/16/b603274617b3e6728da3a0133d4fba1ea8d7fbe0c4ea5fef9cbffa398b8b/pillow-13.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5933fcea5a5ba64c686f55c886a115cbfd92f2900460784a1e79909fdbafd7b6", size = 6516058, upload-time = "2026-10-16T22:16:37.228Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f2/1371707ce438ba9a43f9d24089828494ec6f9396e28cd7903aa704dd6306/pillow-13.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e91c2b1cbeae768c98752e67e717e283d68dae45717b41f2d91eaee648e37b7d", size = 7142574, upload-time = "2026-10-16T22:16:39.172Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a9/62cf68587f910bc87c5951bcb6e9430c7643a65076adf5f18b4bf350e2bd/pillow-13.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f6299e494e46253d40c34653cac369412f44ca53b5f85d848d3f95b1ed0546c", size = 6614295, upload-time = "2026-10-16T22:16:41.117Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c9/9e9b48178649730299be0bafbf5f810a5b6977538f5a92d47e4ba5f5d608/pillow-13.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f9bd7d260d986f3553518c5d723544346fa48031f8aef3265d786aeb59845bc0", size = 7224841, upload-time = "2026-10-16T22:16:43.118Z" }, + { url = "https://files.pythonhosted.org/packages/8f/48/c44e91384b1b6c072a714a63d00608acc16a6a968735e478e4be4384e5ac/pillow-13.0.0-cp314-cp314-win32.whl", hash = "sha256:2e31c4e68cffd7b1f116127626813b82fcba19edf6b940a7b0c266d6484d0055", size = 6508694, upload-time = "2026-10-16T22:16:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/50/e2/7ad17747517e4c813dca78257d6bb593466f8231baf1384bc98d92745edb/pillow-13.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:f7cbb7a64e7ec664775195e4f81923bc8a93d9451b963f844e59118f63147a7a", size = 7281830, upload-time = "2026-10-16T22:16:47.725Z" }, + { url = "https://files.pythonhosted.org/packages/41/9f/e5b51f2ce8ef4563215334035403ce61a244f93a2e65c24a940131618f87/pillow-13.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:a6aaa7871aebca081eb5c45f83e845bbd9712ae99fa9286dbfdcee835e63f4b4", size = 2695468, upload-time = "2026-10-16T22:16:49.515Z" }, + { url = "https://files.pythonhosted.org/packages/97/73/a53502f731f6c84805a4ce87a8881e86d07a65aee64728b900d112dc703e/pillow-13.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7b999816b5850a35ada08443e6e03945f1f6488144a83afe2d37b240a552aafa", size = 5578196, upload-time = "2026-10-16T22:16:51.291Z" }, + { url = "https://files.pythonhosted.org/packages/21/50/7e949959a56b5559c78ef172b70fc6025706acb289f3f648a15329e4fa6f/pillow-13.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:67871a13cf47275201553b536c025da777f997fa562a19eb798af4ad4b97c18f", size = 4857276, upload-time = "2026-10-16T22:16:53.399Z" }, + { url = "https://files.pythonhosted.org/packages/c9/52/43ded0ac08e77e39d17bd6d894162572201b5e8fdc4a022b14c55a1960a4/pillow-13.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8ca8f69b7c77fa96dad99f8638c1fc4f1c0bed4acaa1baf7a3c31bdb80a0ab1", size = 6607008, upload-time = "2026-10-16T22:16:55.525Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2e/4a57ed216d5bf877387cba4d44c02aa87abe51ad2e06404aa6b597c4da39/pillow-13.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bce6a29c3898191f4c9998e4d4e8f69790caeabde46e54da4bc235ce5bde683", size = 7224281, upload-time = "2026-10-16T22:16:57.738Z" }, + { url = "https://files.pythonhosted.org/packages/5c/3d/2929b248e135752d63aaeabf53f6de797e905b7b6263f9c1e2adade20aba/pillow-13.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:816752a5ef76a80484c8c441c85593b7ea9304f6231fc5776ad1fbff7e773058", size = 6695600, upload-time = "2026-10-16T22:16:59.842Z" }, + { url = "https://files.pythonhosted.org/packages/bc/33/fb0da992cf084ed1ee6ee05e96b6191e35c351eb707ca2df874feb24ce47/pillow-13.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5bdb38ba220b61b4ec31dd1046dea6b016d3c6850a23f43ed8b58332b2fc668e", size = 7300589, upload-time = "2026-10-16T22:17:01.686Z" }, + { url = "https://files.pythonhosted.org/packages/bb/af/c30dd9ef6a3dce84ecb1b2393cac629047a32e66279056c6cb46c3ffd65c/pillow-13.0.0-cp314-cp314t-win32.whl", hash = "sha256:06903aac074d00c988e77a1fc435286dc498506d4ca8d6f69df9babddf4ecc15", size = 6517204, upload-time = "2026-10-16T22:17:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/85/d5/ef3a8ae7609c8ab26570abf18194882534b480d245a9148b895a2262c004/pillow-13.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9a635068d9793c7f0d929f39f18b58f1bd86c236e8e7b64167b86af9232ebca0", size = 7286695, upload-time = "2026-10-16T22:17:05.543Z" }, + { url = "https://files.pythonhosted.org/packages/84/3c/0861db0c69a03689ebe3e7599d35668a414deb5aaafd8255215e4e62e3d3/pillow-13.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0d1a9cf7bc6917538b20f8d06b9ab2147e542f68849e96fc58eb66bdf6acc692", size = 2698098, upload-time = "2026-10-16T22:17:07.674Z" }, + { url = "https://files.pythonhosted.org/packages/6e/eb/e86a41962a3a31ad495a3d6a912d9002b01bba4a6fa71c28703abdca7f69/pillow-13.0.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7d5e097eabd5d4915c27ac9f6e9325bb1e01a7c94d7348b5c4451d3fc30e8521", size = 4191213, upload-time = "2026-10-16T22:17:09.449Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/7e7445e706afec84ef06dc25b2c77f9cecff242c23730c62928ad1089d4a/pillow-13.0.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:51ebb529fc5e8abdd55302f6c3f8943037348ec2384329a3e55677f69ae2fdfc", size = 4290650, upload-time = "2026-10-16T22:17:11.196Z" }, + { url = "https://files.pythonhosted.org/packages/46/85/d534089068ac40c0f61f650389a296c812eab745a8a733f81feec39ae955/pillow-13.0.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:c87a04e7f152ee4db5dbe15f0bfa3fe628a1aeedbe66b81c43b87f52c813f9ac", size = 3723078, upload-time = "2026-10-16T22:17:12.772Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2b/00b5c3b401df538b5dbe311d3899730fae5ba1e21cb8b80a128887ac6747/pillow-13.0.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:20722cd5375533ad754053363a80b8fb30c79d37930a6f7228030e237ce3bd5d", size = 5572292, upload-time = "2026-10-16T22:17:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/72/f5/a6b0610f946e9b54ae888ba7ad29d6e2f8d9b11f69e212d70416b7186c09/pillow-13.0.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:04a63720f79e22fd54d897c233a68314c50ca0603360f54ce2bd5db8f0f43782", size = 4852222, upload-time = "2026-10-16T22:17:16.521Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d0/c0ff25bb0427ec70f32aac995453321fc3c4494a46c7292ff02d9d260b04/pillow-13.0.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f8db54177035a2b5f717f94475b74907e12a96dc74f6d487f3966b0a4694071", size = 6527349, upload-time = "2026-10-16T22:17:18.534Z" }, + { url = "https://files.pythonhosted.org/packages/85/11/e46fa0bcc0fc117f1eacc206b3d51f04d583fab328b4b740672d0438d83f/pillow-13.0.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f36aa77f74b14a2103622b196402c71330e4e7cf4122ee45c854e9a01e0eecf", size = 7152873, upload-time = "2026-10-16T22:17:20.415Z" }, + { url = "https://files.pythonhosted.org/packages/7c/53/2bdeca705f3a58679fa9201233193c86a30238340ca29b3a152c3fcc719b/pillow-13.0.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:149a1c323ba366f90b48e6f1a6db6582eb50512df374588de56fc909abc0f0f3", size = 6626871, upload-time = "2026-10-16T22:17:22.361Z" }, + { url = "https://files.pythonhosted.org/packages/14/de/3829030f209722ade3753bb456b805a0e7f885cf885b483407365c82e9ca/pillow-13.0.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e892354a424b1e82a78ba16785822bdd40de06f19446a3d4a28047df1a4f8932", size = 7236439, upload-time = "2026-10-16T22:17:24.352Z" }, + { url = "https://files.pythonhosted.org/packages/44/b1/67fc37388382bc036a74f04da5bb6fcbf90ec3ac1f45893e06176c373fd3/pillow-13.0.0-cp315-cp315-win32.whl", hash = "sha256:8e459b613cf31f8980f584387b4fe47bf08e076e288d61aa8eba1f7fd4ddbb81", size = 6508688, upload-time = "2026-10-16T22:17:26.314Z" }, + { url = "https://files.pythonhosted.org/packages/44/05/fdb6a79aee10861165526b41b7b440e36ee1ff58613b2fbefe3acbf06d4f/pillow-13.0.0-cp315-cp315-win_amd64.whl", hash = "sha256:d4737f50d23ddd11d6685faacbb9252887dde0bc7997127757247983d74399d6", size = 7281107, upload-time = "2026-10-16T22:17:28.198Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9c/6c6f7dc2a566467baf121394e83de25ac287cffe34cc030726beb4be0224/pillow-13.0.0-cp315-cp315-win_arm64.whl", hash = "sha256:2310e2adfc13c6d457d7d0f2ffdd5c56a74a317650cf765887df8d515a647ac0", size = 2695513, upload-time = "2026-10-16T22:17:30.013Z" }, + { url = "https://files.pythonhosted.org/packages/23/16/e9120defdb8bff693b582d035312acc6f530cdc2bc5238a87f410efccd82/pillow-13.0.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:21c32061a5512dc13f87cc7303e120e4d77c2f70df89a35af0e3ce246e5ac57f", size = 5578129, upload-time = "2026-10-16T22:17:31.642Z" }, + { url = "https://files.pythonhosted.org/packages/47/22/76faf2bb2448f48a84cf894c1e2bb1090d7d2b1aa521ece131cb2f05d631/pillow-13.0.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c774b9c0ba0d0b356f71530677b39028878a2cae5a93e0a13779cfd17120f88", size = 4857428, upload-time = "2026-10-16T22:17:33.43Z" }, + { url = "https://files.pythonhosted.org/packages/b3/64/0861ea4e03c50b14af9ed9dd8b9f388ab42f445c623a9fcbab19aef7aa55/pillow-13.0.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20822140c9a2ba9c5e42df342b0a892ab2e102fc0e3db967a133bb9ce3f898bb", size = 6612582, upload-time = "2026-10-16T22:17:35.415Z" }, + { url = "https://files.pythonhosted.org/packages/5c/20/cd1e9b3f60a9f3d5ebe46da36955c26004092a501f7f2c42f9fcc26611bc/pillow-13.0.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffea6b2986a797c84b6d1d27d4f43e0b83c73fc16281a930891cd237cf0d08fe", size = 7230176, upload-time = "2026-10-16T22:17:37.362Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ef/ffb0a2fc85690714fb7ac662956949645d57f15bf4426f520da361e7b6e3/pillow-13.0.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:05cd2cd39055d0b9650060e4513ef4a9a0aea870083950bd046a0d6490f93a22", size = 6703640, upload-time = "2026-10-16T22:17:39.313Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c2/6402ee3ccb3363aeed8fdb3f9fe56703ac5377a539f0b81b53999d7e121f/pillow-13.0.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c6fd031333dedcad7ce3e03d244093f43504f209e86925ebdb2b868fa4037630", size = 7309231, upload-time = "2026-10-16T22:17:41.021Z" }, + { url = "https://files.pythonhosted.org/packages/fc/96/d6b82e8b8c06ad143b66b12e0b31051e4bf489b4fabca1f390a14fe1ebde/pillow-13.0.0-cp315-cp315t-win32.whl", hash = "sha256:c50c80e3591e5cf9a8dfa6007d4f10732f02e32263850999286e56ccd650cfb2", size = 6517200, upload-time = "2026-10-16T22:17:42.886Z" }, + { url = "https://files.pythonhosted.org/packages/60/d4/0062e6005e9b270b165015aafd343364ac378ec5a685431ea42baad33357/pillow-13.0.0-cp315-cp315t-win_amd64.whl", hash = "sha256:adb3c2124c68e480f4632d06cb7caa3b48a51678475f483b4edb1c6dee29ea43", size = 7287488, upload-time = "2026-10-16T22:17:45.56Z" }, + { url = "https://files.pythonhosted.org/packages/e4/81/b8eb349746be7bce47dd95337c6634e84910af91b59aaa9159c034c614fa/pillow-13.0.0-cp315-cp315t-win_arm64.whl", hash = "sha256:4a25756244bdcce3b50580baadf320fa48297f0eeb8e114ba64139a40d4d0fc8", size = 2698073, upload-time = "2026-10-16T22:17:47.556Z" }, + { url = "https://files.pythonhosted.org/packages/68/c0/fb7b1caf25ed5ac5d46116f09b8b0706b9559833c65c667cecb1a53153f2/pillow-13.0.0-pp311-pypy311_pp80-macosx_10_15_x86_64.whl", hash = "sha256:405be6058e206a86fedce2f715ecc2343f94072d7925251632dffa2c05f70dca", size = 5521630, upload-time = "2026-10-16T22:17:50.008Z" }, + { url = "https://files.pythonhosted.org/packages/1a/e6/4d11d5b1137f4fc080605cce410cfaae4f303ad400bdec75d27d3083fa29/pillow-13.0.0-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:b84be41387fc00db28c13c02e30d42b02d8d8f6a4a741a1aee6012ca7f1f6039", size = 4806023, upload-time = "2026-10-16T22:17:51.932Z" }, + { url = "https://files.pythonhosted.org/packages/f1/45/0923202a658e8ff6aafd72c1a4395ebadcdebaa182c437d8595d3dd3db32/pillow-13.0.0-pp311-pypy311_pp80-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a192360d512305fe92d3053e4f7627bbad1f7708f1f4ad38cccd9336acabe9ad", size = 5464831, upload-time = "2026-10-16T22:17:53.742Z" }, + { url = "https://files.pythonhosted.org/packages/07/52/d3e38f0cad1dadea2a658b9a7d6ba8a04048f5ae14f011b806e5c3bd6bcb/pillow-13.0.0-pp311-pypy311_pp80-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83b37abd87b590acd8f47ca8a61f70893322b11834d36e07c2298dacad4d14d2", size = 6043133, upload-time = "2026-10-16T22:17:55.625Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f9/2ed7685980c1d64f8131a7d87a19c369424747577152622b73ddeff163b7/pillow-13.0.0-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:73ee25cf8cf8b75d5a5aeed7cb6588f5e5dbfbb0fde51707b630bbb23897645c", size = 7365208, upload-time = "2026-10-16T22:17:57.509Z" }, + { url = "https://files.pythonhosted.org/packages/60/da/f6d644e631c5b370c5d0b899f3ccadd2780aab070b29a110527cf4e1fd2c/pillow-13.0.0-pp312-pypy312_pp80-macosx_10_15_x86_64.whl", hash = "sha256:8619685cad8efc4c38c66d3b36aad854523297c43d748b3b6f0cc03c90ba3463", size = 5523188, upload-time = "2026-10-16T22:17:59.334Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b0/2594f202d743a35d4fe80682bfd96f277e5ca7aecb27cf64ef5bfc1d000b/pillow-13.0.0-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:7e91e97a929232cdb828889ee6ad0e48d7f40f4ec38818f4e4a8968b2b1af22e", size = 4806808, upload-time = "2026-10-16T22:18:01.188Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ed/d504165ef79af6fafe25c76726cf30f544cc236002f7ed054f1d51adc4ff/pillow-13.0.0-pp312-pypy312_pp80-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93ff9c9c3663ce23aaa5d0b6b6abac55359e2a0c06c7e30f3771829af816be63", size = 5464358, upload-time = "2026-10-16T22:18:02.779Z" }, + { url = "https://files.pythonhosted.org/packages/7b/fe/a10aba5503ca13e9e009b8bd0a1a4ce5584bdce246245fd6f3a83701369c/pillow-13.0.0-pp312-pypy312_pp80-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e65dd6e75de67b2362a4fa3e16aa627595f65054dc4ec73d64680c84d592fa61", size = 6043514, upload-time = "2026-10-16T22:18:04.538Z" }, + { url = "https://files.pythonhosted.org/packages/c0/37/66b12dc476041fe523bca29c9d58fbf1ea92a4eaf73acfb9e993948f1191/pillow-13.0.0-pp312-pypy312_pp80-win_amd64.whl", hash = "sha256:567f20f1c75897d7663830da6ea8af719e03d712b1d9ee7f813319f9ece42e5a", size = 7366030, upload-time = "2026-10-16T22:18:06.638Z" }, ] [[package]] diff --git a/backend/app/api/alerts.py b/backend/app/api/alerts.py index 20bd043..c8a5bc4 100644 --- a/backend/app/api/alerts.py +++ b/backend/app/api/alerts.py @@ -11,6 +11,7 @@ from sqlalchemy.orm import Session from ulid import ULID +from app import settings as settings_mod from app.api.deps import current_user, get_db_dep, require_recent_auth from app.db.models import Alert, DeviceCommand, Event, RiskResult, User from app.services import encryption, purge @@ -68,6 +69,19 @@ class AlertPage(BaseModel): open_count: int +def _visible_alerts(db: Session): + return ( + db.query(Alert) + .join(RiskResult, Alert.risk_id == RiskResult.risk_id) + .join(Event, RiskResult.event_id == Event.event_id) + .filter(Event.deletion_state == "active") + ) + + +def _visible_alert(db: Session, alert_id: str) -> Alert | None: + return _visible_alerts(db).filter(Alert.alert_id == alert_id).first() + + @router.get("", response_model=AlertPage) def list_alerts( severity: str | None = None, @@ -79,7 +93,7 @@ def list_alerts( db: Session = Depends(get_db_dep), _: User = Depends(current_user), ): - q = db.query(Alert) + q = _visible_alerts(db) if severity: q = q.filter(Alert.severity == severity) if status: @@ -89,7 +103,7 @@ def list_alerts( if profile_id: q = q.filter(Alert.profile_id == profile_id) if cursor: - anchor = db.get(Alert, cursor) + anchor = _visible_alert(db, cursor) if anchor is None: raise HTTPException(400, "Invalid alert cursor") q = q.filter(or_( @@ -118,7 +132,7 @@ def list_alerts( return AlertPage( items=out, next_cursor=rows[-1].alert_id if len(rows_with_extra) > limit else None, - open_count=db.query(Alert).filter(Alert.status == "open").count(), + open_count=_visible_alerts(db).filter(Alert.status == "open").count(), ) @@ -137,7 +151,7 @@ def get_alert( db: Session = Depends(get_db_dep), user: User = Depends(current_user), ): - a = db.get(Alert, alert_id) + a = _visible_alert(db, alert_id) if a is None: raise HTTPException(404, "Alert not found") r = db.get(RiskResult, a.risk_id) @@ -201,7 +215,7 @@ def review_alert( db: Session = Depends(get_db_dep), user: User = Depends(current_user), ): - a = db.get(Alert, alert_id) + a = _visible_alert(db, alert_id) if a is None: raise HTTPException(404, "Alert not found") a.status = req.status @@ -229,7 +243,7 @@ def record_feedback( db: Session = Depends(get_db_dep), user: User = Depends(current_user), ): - a = db.get(Alert, alert_id) + a = _visible_alert(db, alert_id) if a is None: raise HTTPException(404, "Alert not found") r = db.get(RiskResult, a.risk_id) @@ -264,7 +278,7 @@ def take_action( db: Session = Depends(get_db_dep), user: User = Depends(current_user), ): - a = db.get(Alert, alert_id) + a = _visible_alert(db, alert_id) if a is None: raise HTTPException(404, "Alert not found") if req.action in {"pause_app", "block_app", "delete_evidence"}: @@ -385,11 +399,13 @@ def create_confirmed_action( user: User = Depends(current_user), _: None = Depends(require_recent_auth), ): - alert = db.get(Alert, alert_id) + alert = _visible_alert(db, alert_id) if alert is None: raise HTTPException(404, "Alert not found") if not alert.device_id: raise HTTPException(409, "This alert is not linked to a child device") + if req.action in {"pause_app", "block_domain"} and not settings_mod.settings.device_enforcement_enabled: + raise HTTPException(409, "Device enforcement is not enabled on this installation") payload, preview = _action_payload(req) if req.confirmed_preview != preview: raise HTTPException(409, {"code": "confirmation_required", "preview": preview}) diff --git a/backend/app/api/child_requests.py b/backend/app/api/child_requests.py index 8777404..f399afd 100644 --- a/backend/app/api/child_requests.py +++ b/backend/app/api/child_requests.py @@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import BaseModel, ConfigDict, Field -from sqlalchemy import and_, or_ +from sqlalchemy import and_, or_, update from sqlalchemy.orm import Session from ulid import ULID @@ -167,10 +167,28 @@ def review_child_request( row = db.get(ChildRequest, request_id) if row is None: raise HTTPException(404, "Request not found") - row.status = req.status - row.response_note = req.response_note - row.reviewed_by = str(user.id) - row.reviewed_at = datetime.now(UTC) + now = datetime.now(UTC) + # A request can be answered only once while it is still open. The SQL + # transition also prevents two concurrent parent sessions overwriting one + # another's decision. + changed = db.connection().execute( + update(ChildRequest) + .where( + ChildRequest.request_id == request_id, + ChildRequest.status == "open", + or_(ChildRequest.expires_at.is_(None), ChildRequest.expires_at > now), + ) + .execution_options(synchronize_session=False) + .values( + status=req.status, + response_note=req.response_note, + reviewed_by=str(user.id), + reviewed_at=now, + ) + ).rowcount + if changed != 1: + db.rollback() + raise HTTPException(409, "Request is closed or expired") log_action( db, actor=str(user.id), @@ -180,4 +198,5 @@ def review_child_request( source_ip=request.client.host if request.client else None, ) db.commit() + db.refresh(row) return row diff --git a/backend/app/api/consent.py b/backend/app/api/consent.py index b33823c..88ce8f3 100644 --- a/backend/app/api/consent.py +++ b/backend/app/api/consent.py @@ -9,7 +9,17 @@ from ulid import ULID from app.api.deps import current_user, get_db_dep, require_recent_auth -from app.db.models import ChildProfile, ConsentRecord, Device, Event, Setting, User +from app.db.models import ( + ChildProfile, + ConsentRecord, + Device, + Event, + GuardianReview, + NotificationJob, + RiskResult, + Setting, + User, +) from app.services import consent, purge from app.services.audit import log_action @@ -113,10 +123,23 @@ def withdraw_consent( supersedes_id=previous.consent_id if previous else None, ) db.add(row) + db.query(GuardianReview).filter(GuardianReview.status.in_(["queued", "running"])).update( + {"status": "cancelled", "error_code": "consent_withdrawn", "completed_at": datetime.now(UTC)}, + synchronize_session=False, + ) + db.query(NotificationJob).filter(NotificationJob.status.in_(["queued", "retry"])).update( + {"status": "dead", "last_error": "consent withdrawn", "next_attempt_at": None}, + synchronize_session=False, + ) for device in db.query(Device).all(): - device.paired = False - device.token_hash = None - device.status = "consent_withdrawn" + # Keep the existing token for control polling until the broker confirms + # it applied this generation. Other device endpoints reject it. + if device.paired and device.token_hash: + device.status = "consent_stop_requested" + else: + device.paired = False + device.token_hash = None + device.status = "consent_stop_offline" deletion = None if req.evidence_disposition == "delete": deletion = purge.delete_events(db, [item[0] for item in db.query(Event.event_id).all()]) @@ -138,6 +161,15 @@ def onboarding_status( _: User = Depends(current_user), ): active = consent.monitoring_allowed(db) + # A heartbeat proves only that the broker contacted the backend. Require + # an actual reviewed capture before marking this onboarding check done. + capture_reviewed = ( + db.query(Event.event_id) + .join(RiskResult, RiskResult.event_id == Event.event_id) + .filter(Event.source_type == "image", Event.deletion_state == "active") + .first() + is not None + ) steps = [ {"id": "account", "complete": db.query(User).count() > 0}, {"id": "timezone", "complete": db.get(Setting, "family_timezone") is not None}, @@ -145,7 +177,7 @@ def onboarding_status( {"id": "notice", "complete": active}, {"id": "privacy_choices", "complete": active}, {"id": "pairing", "complete": db.query(Device).filter(Device.paired.is_(True)).count() > 0}, - {"id": "self_test", "complete": db.query(Device).filter(Device.last_seen.isnot(None)).count() > 0}, + {"id": "self_test", "complete": capture_reviewed}, { "id": "recovery", "complete": db.get(Setting, "recovery_acknowledged_at") is not None, diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index af8116f..64b1ae4 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -153,6 +153,7 @@ def current_device(request: Request, db: Session = Depends(get_db_dep)) -> Devic raise HTTPException(status_code=401, detail="Empty token") blocked, retry_after = rate_limit.is_blocked("device_auth", client_ip) + device = None if blocked: # A child can legitimately recover from stale bad credentials by # re-pairing. Let a valid structured token clear its own IP's failure @@ -161,16 +162,21 @@ def current_device(request: Request, db: Session = Depends(get_db_dep)) -> Devic device = device_tokens.authenticate(db, token) if device is not None: rate_limit.reset("device_auth", client_ip) - return device - raise HTTPException( - status_code=429, - detail="Too many failed device authentication attempts.", - headers={"Retry-After": str(retry_after)}, - ) - - device = device_tokens.authenticate(db, token) + if device is None: + raise HTTPException( + status_code=429, + detail="Too many failed device authentication attempts.", + headers={"Retry-After": str(retry_after)}, + ) + + if device is None: + device = device_tokens.authenticate(db, token) if device is not None: rate_limit.reset("device_auth", client_ip) + if device.status == "consent_stop_requested" and request.url.path not in { + "/api/devices/capture-config", "/api/devices/heartbeat", + }: + raise HTTPException(status_code=403, detail="Consent withdrawn; control polling only") operation = device_budget.operation_for_path(request.url.path, request.method) if operation is not None: try: diff --git a/backend/app/api/devices.py b/backend/app/api/devices.py index fd0bf15..8fc3881 100644 --- a/backend/app/api/devices.py +++ b/backend/app/api/devices.py @@ -12,7 +12,7 @@ from app import settings as settings_mod from app.api.deps import current_device, current_user, get_db_dep, require_recent_auth -from app.db.models import Device, DeviceCommand, PairingCode, User +from app.db.models import ConsentRecord, Device, DeviceCommand, PairingCode, User from app.db.session import begin_immediate_if_sqlite from app.services import consent, device_tokens, rate_limit from app.services import pairing as pairing_svc @@ -335,13 +335,29 @@ def capture_config( child's policy. The agent polls this so a parent can tighten or loosen monitoring from the dashboard without touching the kid's PC.""" from app.db.models import ChildProfile - from app.services import profile_policy + from app.services import consent, profile_policy prof = db.get(ChildProfile, device.profile_id) if device.profile_id else None policy = (prof.alert_policy or {}) if prof is not None else {} age = prof.age_group if prof is not None else "10_13" cfg = profile_policy.capture_settings(policy, age) - cfg["paused"] = is_device_paused(device) + processing = consent.effective_policy(db) + cfg["paused"] = is_device_paused(device) or not processing.screenshots cfg["paused_until"] = device.paused_until.isoformat() if device.paused_until else None + cfg["capture_allowed"] = processing.screenshots + cfg["apps_and_urls_allowed"] = processing.apps_and_urls + cfg["policy_generation"] = processing.generation + if device.status == "consent_stop_requested": + # A later re-grant does not silently resume a device that has not yet + # acknowledged the withdrawal. It must stop and then re-pair. + withdrawn = ( + db.query(ConsentRecord) + .filter(ConsentRecord.status == "withdrawn") + .order_by(ConsentRecord.created_at.desc()) + .first() + ) + cfg["paused"] = True + cfg["capture_allowed"] = False + cfg["policy_generation"] = withdrawn.consent_id if withdrawn else processing.generation db.commit() # persist any expired-pause cleanup done by is_device_paused return cfg @@ -349,6 +365,7 @@ def capture_config( class HeartbeatRequest(BaseModel): queued_frames: int = Field(default=0, ge=0, le=10000) agent_version: str | None = None + applied_policy_generation: str | None = Field(default=None, max_length=64) @router.post("/heartbeat") @@ -361,7 +378,21 @@ def heartbeat( from app.services import pipeline_metrics paused_until = effective_paused_until(device) # clears expired pauses device.last_seen = datetime.now(UTC) - if device.status not in ("paused", "disabled"): + if device.status == "consent_stop_requested": + latest = ( + db.query(ConsentRecord) + .filter(ConsentRecord.status == "withdrawn") + .order_by(ConsentRecord.created_at.desc()) + .first() + ) + if ( + latest is not None + and req.applied_policy_generation == latest.consent_id + ): + device.paired = False + device.token_hash = None + device.status = "consent_stop_applied" + elif device.status not in ("paused", "disabled"): device.status = "online" if req.agent_version: device.agent_version = req.agent_version diff --git a/backend/app/api/events.py b/backend/app/api/events.py index 637f375..4c558a8 100644 --- a/backend/app/api/events.py +++ b/backend/app/api/events.py @@ -12,8 +12,8 @@ from sqlalchemy.orm import Session from app.api.deps import current_device, current_user, get_db_dep -from app.db.models import Device, Event, ScreenshotUploadReceipt, User -from app.services import encryption, event_ingest, screenshot_async +from app.db.models import ConsentRecord, Device, Event, ScreenshotUploadReceipt, User +from app.services import consent, encryption, event_ingest, screenshot_async from app.services.device_state import is_device_paused from app.services.evidence_paths import UnsafeEvidencePathError, resolve_stored_evidence_path from app.services.input_bounds import InputBoundsError, sanitize_metadata @@ -104,7 +104,8 @@ async def ingest( db: Session = Depends(get_db_dep), device: Device = Depends(current_device), ): - if is_device_paused(device): + policy = consent.effective_policy(db) + if is_device_paused(device) or not (policy.screenshots or policy.apps_and_urls): # Quietly accept but don't store — pause behavior is honored server-side too return IngestResponse( event_id=req.event_id or "", @@ -114,10 +115,13 @@ async def ingest( score=0, categories=[], ) + payload = req.model_dump() + if not policy.apps_and_urls: + payload.update(app_name=None, window_title=None, url=None) try: result = await event_ingest.ingest_event( db, - payload=req.model_dump(), + payload=payload, device_id=device.device_id, source_ip=request.client.host if request.client else None, ) @@ -167,7 +171,7 @@ def list_events( db: Session = Depends(get_db_dep), _: User = Depends(current_user), ): - q = db.query(Event).order_by(Event.timestamp.desc()) + q = db.query(Event).filter(Event.deletion_state == "active").order_by(Event.timestamp.desc()) if device_id: q = q.filter(Event.device_id == device_id) if profile_id: @@ -183,7 +187,7 @@ def get_event_text( user: User = Depends(current_user), ): e = db.get(Event, event_id) - if e is None: + if e is None or e.deletion_state != "active": raise HTTPException(404, "Not found") if not e.redacted_text_enc: return {"text": ""} @@ -244,6 +248,7 @@ async def ingest_screenshot( capture_scope: str = Form(default="monitored_app", max_length=64), policy_id: str | None = Form(default=None, max_length=64), policy_version: str | None = Form(default=None, max_length=64), + policy_generation: str | None = Form(default=None, max_length=64), collector_version: str | None = Form(default=None, max_length=64), timestamp: str | None = Form(default=None, max_length=64), idempotency_key: str | None = Form(default=None, max_length=128), @@ -262,16 +267,23 @@ async def ingest_screenshot( if existing_receipt is not None: return _receipt_response(existing_receipt) - if is_device_paused(device): + policy = consent.effective_policy(db) + if is_device_paused(device) or not policy.screenshots: # Honor pause server-side: drop quietly, don't store. return ScreenshotIngestResponse(event_id="", status="paused", queued=False) + if policy_generation is not None and policy_generation != policy.generation: + raise HTTPException(409, "Capture policy changed; discard this older frame") + if policy_generation is None and db.query(ConsentRecord).filter( + ConsentRecord.status == "withdrawn" + ).first() is not None: + raise HTTPException(409, "Capture policy generation is required after withdrawal") image_bytes = await _read_upload_with_cap(image) if not image_bytes: raise HTTPException(400, "Empty image") if age_group not in {"under_10", "10_13", "14_17"}: raise HTTPException(400, "Invalid age group") - if capture_scope not in {"monitored_app", "visible_desktop", "browser_dom"}: + if capture_scope not in {"monitored_app", "primary_monitor", "visible_desktop", "browser_dom"}: raise HTTPException(400, "Invalid capture scope") mime_type = _validate_image_bytes(image_bytes) accepted, capacity_detail = screenshot_async.can_accept(device.device_id, len(image_bytes)) @@ -292,9 +304,9 @@ async def ingest_screenshot( image_bytes, { "device_id": device.device_id, - "app_name": app_name, - "window_title": window_title, - "url": url, + "app_name": app_name if policy.apps_and_urls else None, + "window_title": window_title if policy.apps_and_urls else None, + "url": url if policy.apps_and_urls else None, "profile_id": profile_id, "age_group": age_group, "capture_scope": capture_scope, @@ -305,6 +317,7 @@ async def ingest_screenshot( "idempotency_key": idempotency_key, "mime_type": mime_type, "source_ip": request.client.host if request.client else None, + "policy_generation": policy.generation, }, ) if not screenshot_async.enqueue_nowait(token): @@ -349,18 +362,18 @@ def get_event_screenshot( from app.db.models import EvidenceBlob e = db.get(Event, event_id) - if e is None or not e.screenshot_blob_id: + if e is None or e.deletion_state != "active" or not e.screenshot_blob_id: raise HTTPException(404, "No screenshot") blob = db.get(EvidenceBlob, e.screenshot_blob_id) - if blob is None: + if blob is None or blob.deletion_state != "active": raise HTTPException(404, "Blob missing") try: path = resolve_stored_evidence_path(blob.encrypted_path) pt = encryption.decrypt_blob_from_disk(path, aad=blob.blob_id.encode("ascii")) - except (FileNotFoundError, UnsafeEvidencePathError) as e: - raise HTTPException(404, "Blob missing") from e - except Exception as e: - raise HTTPException(500, "Decryption failed") from e + except (FileNotFoundError, UnsafeEvidencePathError) as exc: + raise HTTPException(404, "Blob missing") from exc + except Exception as exc: + raise HTTPException(500, "Decryption failed") from exc from app.services.audit import log_action log_action(db, actor=str(user.id), action="evidence.view", target=event_id) db.commit() diff --git a/backend/app/api/risks.py b/backend/app/api/risks.py index b05b5e5..49f07c8 100644 --- a/backend/app/api/risks.py +++ b/backend/app/api/risks.py @@ -8,7 +8,7 @@ from sqlalchemy.orm import Session from app.api.deps import current_user, get_db_dep -from app.db.models import RiskResult, User +from app.db.models import Event, RiskResult, User from app.services.audit import log_action router = APIRouter(prefix="/risks", tags=["risks"]) @@ -47,7 +47,12 @@ def list_risks( db: Session = Depends(get_db_dep), _: User = Depends(current_user), ): - q = db.query(RiskResult).order_by(RiskResult.created_at.desc()) + q = ( + db.query(RiskResult) + .join(Event, RiskResult.event_id == Event.event_id) + .filter(Event.deletion_state == "active") + .order_by(RiskResult.created_at.desc()) + ) if risk_level: q = q.filter(RiskResult.risk_level == risk_level) return [_to_dto(r) for r in q.limit(limit).all()] @@ -65,7 +70,12 @@ def feedback( db: Session = Depends(get_db_dep), user: User = Depends(current_user), ): - r = db.get(RiskResult, risk_id) + r = ( + db.query(RiskResult) + .join(Event, RiskResult.event_id == Event.event_id) + .filter(RiskResult.risk_id == risk_id, Event.deletion_state == "active") + .first() + ) if r is None: raise HTTPException(404, "Risk not found") if req.is_false_positive: diff --git a/backend/app/archive/format.py b/backend/app/archive/format.py index 8f28403..9081586 100644 --- a/backend/app/archive/format.py +++ b/backend/app/archive/format.py @@ -92,6 +92,90 @@ def _encoded_field(table: str, record_id: dict[str, Any], name: str, value: Any) return encoded +def _sanitize_snapshot(database: Path) -> None: + """Apply the same deletion boundary to the restorable DB and JSONL view. + + This operates only on the copied SQLite backup, never on the live database. + The archive must not reintroduce a tombstoned event or a pending blob whose + file is deliberately excluded from evidence coverage. + """ + with closing(sqlite3.connect(database)) as conn: + conn.execute("PRAGMA foreign_keys=ON") + tables = { + str(row[0]) for row in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ) + } + if not {"events", "evidence_blobs"}.issubset(tables): + return + conn.execute( + "CREATE TEMP TABLE archive_deleted_events AS " + "SELECT event_id FROM events WHERE deletion_state != 'active'" + ) + if "risk_results" in tables: + conn.execute( + "CREATE TEMP TABLE archive_deleted_risks AS " + "SELECT risk_id FROM risk_results WHERE event_id IN " + "(SELECT event_id FROM archive_deleted_events)" + ) + if "alerts" in tables: + conn.execute( + "CREATE TEMP TABLE archive_deleted_alerts AS " + "SELECT alert_id FROM alerts WHERE risk_id IN " + "(SELECT risk_id FROM archive_deleted_risks)" + ) + if "guardian_reviews" in tables: + if "guardian_review_feedback" in tables: + conn.execute( + "DELETE FROM guardian_review_feedback WHERE review_id IN " + "(SELECT review_id FROM guardian_reviews WHERE alert_id IN " + "(SELECT alert_id FROM archive_deleted_alerts))" + ) + conn.execute( + "DELETE FROM guardian_reviews WHERE alert_id IN " + "(SELECT alert_id FROM archive_deleted_alerts)" + ) + if "guardian_review_previews" in tables: + conn.execute( + "DELETE FROM guardian_review_previews WHERE alert_id IN " + "(SELECT alert_id FROM archive_deleted_alerts)" + ) + if "notification_jobs" in tables: + conn.execute( + "DELETE FROM notification_jobs WHERE alert_id IN " + "(SELECT alert_id FROM archive_deleted_alerts)" + ) + conn.execute( + "DELETE FROM alerts WHERE alert_id IN " + "(SELECT alert_id FROM archive_deleted_alerts)" + ) + if "risk_results" in tables: + conn.execute( + "DELETE FROM risk_results WHERE risk_id IN " + "(SELECT risk_id FROM archive_deleted_risks)" + ) + conn.execute( + "UPDATE events SET screenshot_blob_id=NULL WHERE screenshot_blob_id IN " + "(SELECT blob_id FROM evidence_blobs WHERE deletion_state != 'active')" + ) + conn.execute( + "UPDATE events SET image_blob_id=NULL WHERE image_blob_id IN " + "(SELECT blob_id FROM evidence_blobs WHERE deletion_state != 'active')" + ) + conn.execute( + "DELETE FROM evidence_blobs WHERE deletion_state != 'active' OR event_id IN " + "(SELECT event_id FROM archive_deleted_events)" + ) + conn.execute( + "DELETE FROM events WHERE event_id IN " + "(SELECT event_id FROM archive_deleted_events)" + ) + violations = conn.execute("PRAGMA foreign_key_check").fetchall() + if violations: + raise ArchiveError("archive snapshot contains broken references") + conn.commit() + + def _write_logical_records(database: Path, records_dir: Path) -> dict[str, int]: counts: dict[str, int] = {} records_dir.mkdir(parents=True, exist_ok=True) @@ -222,6 +306,7 @@ def create_archive( # The database backup helper emits an adjacent implementation manifest; # GNA has its own complete signed manifest, so do not duplicate it. database.with_name(database.name + ".manifest.json").unlink(missing_ok=True) + _sanitize_snapshot(database) counts = _write_logical_records(database, payload / "records") evidence_count, evidence_bytes, evidence_map = _copy_evidence(data_dir, payload, database) _write_json(payload / "evidence-map.json", evidence_map) @@ -624,6 +709,11 @@ def restore_archive( ) conn.execute("UPDATE pairing_codes SET used=1") conn.execute("UPDATE notification_jobs SET status='cancelled'") + conn.execute( + "UPDATE guardian_reviews SET status='cancelled', " + "error_code='restored_reauthorization_required' " + "WHERE status IN ('queued', 'running')" + ) for key in ("notification_settings", "complete_backup_config"): row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone() if row: diff --git a/backend/app/db/maintenance.py b/backend/app/db/maintenance.py index b5d75e8..a0a6e9a 100644 --- a/backend/app/db/maintenance.py +++ b/backend/app/db/maintenance.py @@ -30,7 +30,16 @@ class IntegrityResult: BACKUP_MANIFEST_FORMAT = "guardiannode-sqlite-backup-v1" -SUPPORTED_SCHEMA_REVISIONS = {None, "0001_beta_baseline", "0002_complete_backups"} +SUPPORTED_SCHEMA_REVISIONS = { + None, + "0001_beta_baseline", + "0002_complete_backups", + "0003_guardian_reviews", + "0004_guardian_review_privacy", + "0005_guardian_review_feedback", + "0006_beta_security_break", + "0007_family_beta_workflows", +} def backup_manifest_path(database: Path) -> Path: diff --git a/backend/app/services/alert_dedup.py b/backend/app/services/alert_dedup.py index 47c554b..ebea234 100644 --- a/backend/app/services/alert_dedup.py +++ b/backend/app/services/alert_dedup.py @@ -20,7 +20,7 @@ from sqlalchemy.orm import Session from ulid import ULID -from app.db.models import Alert +from app.db.models import Alert, Event, RiskResult from app.services.audit import log_action from app.settings import settings @@ -63,7 +63,10 @@ def upsert_alert( cutoff = now - window existing = ( session.query(Alert) + .join(RiskResult, Alert.risk_id == RiskResult.risk_id) + .join(Event, RiskResult.event_id == Event.event_id) .filter( + Event.deletion_state == "active", Alert.dedup_key == key, Alert.status == "open", or_(Alert.last_seen_at >= cutoff, Alert.created_at >= cutoff), diff --git a/backend/app/services/consent.py b/backend/app/services/consent.py index d798764..36d6b21 100644 --- a/backend/app/services/consent.py +++ b/backend/app/services/consent.py @@ -1,6 +1,8 @@ """Append-only parental consent and monitoring gate.""" from __future__ import annotations +from dataclasses import dataclass + from sqlalchemy.orm import Session from app.db.models import ConsentRecord, User @@ -8,19 +10,47 @@ NOTICE_VERSION = "privacy-notice-2026-08-v1" -def latest(session: Session) -> ConsentRecord | None: - return session.query(ConsentRecord).order_by(ConsentRecord.created_at.desc()).first() +@dataclass(frozen=True) +class ProcessingPolicy: + generation: str | None + active: bool + screenshots: bool + apps_and_urls: bool + retention: bool + notifications: bool + external_ai: bool -def monitoring_allowed(session: Session) -> bool: - # Setup/bootstrap may run before the first administrator exists. Once the - # family account exists, explicit current-version consent is mandatory. +def effective_policy(session: Session) -> ProcessingPolicy: + """Resolve the latest parent choices at a processing boundary. + + Capture needs durable retention because its upload/classification pipeline + persists encrypted frames before analysis. Until an ephemeral pipeline is + implemented, disabling retention also disables new capture. + """ if session.query(User).count() == 0: - return True + return ProcessingPolicy(None, True, True, True, True, True, False) row = latest(session) - return bool( - row - and row.status == "granted" - and row.notice_version == NOTICE_VERSION - and row.choices.get("child_notice_acknowledged") is True + choices = row.choices or {} if row else {} + active = bool( + row and row.status == "granted" and row.notice_version == NOTICE_VERSION + and choices.get("child_notice_acknowledged") is True + ) + return ProcessingPolicy( + row.consent_id if row else None, + active, + active and choices.get("screenshots") is True and choices.get("retention") is True, + active and choices.get("apps_and_urls") is True and choices.get("retention") is True, + active and choices.get("retention") is True, + active and choices.get("notifications") is True, + active and choices.get("external_ai") is True, ) + + +def latest(session: Session) -> ConsentRecord | None: + return session.query(ConsentRecord).order_by(ConsentRecord.created_at.desc()).first() + + +def monitoring_allowed(session: Session) -> bool: + policy = effective_policy(session) + return policy.active and (policy.screenshots or policy.apps_and_urls) diff --git a/backend/app/services/daily_digest.py b/backend/app/services/daily_digest.py index 855e3ac..5543255 100644 --- a/backend/app/services/daily_digest.py +++ b/backend/app/services/daily_digest.py @@ -9,8 +9,8 @@ from sqlalchemy.orm import Session from ulid import ULID -from app.db.models import Alert, DigestRun, RiskResult, Setting -from app.services import notifications +from app.db.models import Alert, DigestRun, Event, RiskResult, Setting +from app.services import consent, notifications def family_timezone(session: Session) -> str: @@ -43,6 +43,9 @@ def build_summary(session: Session, *, local_day: date, timezone: str) -> dict: start, end = _day_bounds(local_day, timezone) alerts = ( session.query(Alert) + .join(RiskResult, Alert.risk_id == RiskResult.risk_id) + .join(Event, RiskResult.event_id == Event.event_id) + .filter(Event.deletion_state == "active") .filter(Alert.created_at >= start, Alert.created_at < end) .order_by(Alert.created_at.desc()) .all() @@ -97,6 +100,8 @@ def _body(summary: dict) -> str: def run_due(session: Session, *, now: datetime | None = None) -> DigestRun | None: + if not consent.effective_policy(session).notifications: + return None config = _notification_config(session) if not config.get("enabled") or not config.get("daily_digest_enabled", True): return None diff --git a/backend/app/services/guardian_review.py b/backend/app/services/guardian_review.py index f29bb83..73eef81 100644 --- a/backend/app/services/guardian_review.py +++ b/backend/app/services/guardian_review.py @@ -34,7 +34,7 @@ ReviewStatus, ReviewSummary, ) -from app.services import encryption +from app.services import consent, encryption from app.services import guardian_review_codex_auth as codex_auth from app.services.audit import log_action from app.services.guardian_review_minimization import InvalidIncidentError, build_minimized_incident @@ -136,6 +136,20 @@ def _ensure_provider_ready(provider: str) -> None: raise WorkflowError(str(readiness["blocking_reason"]), status_code=503) +def _ensure_review_policy(db: Session, provider: str, generation: str | None = None) -> None: + """Check the latest parent decision before each outbound review boundary.""" + policy = consent.effective_policy(db) + if generation is not None and policy.generation != generation: + raise WorkflowError("consent_changed", status_code=409) + # Mock mode is a local synthetic demonstration, but a recorded withdrawal + # still stops its queued work. Live providers require an explicit opt in. + current = consent.latest(db) + if current is not None and not policy.active: + raise WorkflowError("consent_required", status_code=409) + if provider != "mock" and not policy.external_ai: + raise WorkflowError("external_ai_not_allowed", status_code=409) + + def _audit_details( *, alert_id: str, @@ -173,6 +187,7 @@ def create_preview( if provider not in RETENTION_NOTICES: raise WorkflowError("configuration_error", status_code=503) _ensure_provider_ready(provider) + _ensure_review_policy(db, provider) schema_version = SCHEMA_VERSION prompt_version = _settings().guardian_review_prompt_version or PROMPT_VERSION try: @@ -193,6 +208,7 @@ def create_preview( created_at = _now() expires_at = created_at + timedelta(seconds=_settings().guardian_review_preview_ttl_seconds) encrypted_record = { + "policy_generation": consent.effective_policy(db).generation, "context": context.model_dump(mode="json"), "outbound_payload": incident.payload, "redactions": incident.redactions, @@ -287,6 +303,8 @@ def submit_review( raise WorkflowError("not_found", status_code=404) if preview.provider != _settings().guardian_review_provider.strip().lower(): raise WorkflowError("preview_stale", status_code=409) + stored = _load_preview_payload(preview) + _ensure_review_policy(db, preview.provider, stored.get("policy_generation")) _ensure_provider_ready(preview.provider) if preview.review_id: existing = db.get(GuardianReview, preview.review_id) @@ -296,7 +314,6 @@ def submit_review( raise WorkflowError("preview_expired", status_code=409) if not hmac.compare_digest(preview.payload_digest, preview_digest): raise WorkflowError("preview_stale", status_code=409) - stored = _load_preview_payload(preview) try: context = GuardianReviewContext.model_validate(stored["context"]) incident = build_minimized_incident( @@ -645,10 +662,16 @@ async def process_one(db: Session, *, provider_override=None, sleep=asyncio.slee row = db.query(GuardianReview).filter(GuardianReview.status == "queued").order_by(GuardianReview.created_at).first() if row is None: return False - row.status = "running" - row.started_at = _now() - row.error_code = None + claimed = db.query(GuardianReview).filter( + GuardianReview.review_id == row.review_id, GuardianReview.status == "queued" + ).update({"status": "running", "started_at": _now(), "error_code": None}, synchronize_session=False) db.commit() + if not claimed: + return False + db.expire_all() + row = db.get(GuardianReview, row.review_id) + assert row is not None + review_id = row.review_id preview = db.get(GuardianReviewPreview, row.preview_id) if preview is None: _fail(db, row, "preview_stale") @@ -662,6 +685,11 @@ async def process_one(db: Session, *, provider_override=None, sleep=asyncio.slee if not isinstance(payload, dict): _fail(db, row, "preview_stale") return True + try: + _ensure_review_policy(db, row.provider, stored.get("policy_generation")) + except WorkflowError as exc: + _fail(db, row, exc.code) + return True try: provider = provider_override or provider_for(_settings(), provider_name=row.provider) except ProviderError as exc: @@ -679,11 +707,29 @@ async def process_one(db: Session, *, provider_override=None, sleep=asyncio.slee status="sent", ) for attempt in range(1, max_attempts + 1): + db.expire_all() + row = db.get(GuardianReview, review_id) + if row is None or row.status != "running": + return True + try: + _ensure_review_policy(db, row.provider, stored.get("policy_generation")) + except WorkflowError as exc: + _fail(db, row, exc.code) + return True row.attempts = attempt log_action(db, actor="system", action="guardian_review.sent", target=row.review_id, details={**audit_base, "attempt": attempt}) db.commit() try: result = await provider.assess(payload=payload, prompt=_prompt(row.prompt_version), model=row.model_requested, timeout=float(_settings().guardian_review_timeout_seconds)) + db.expire_all() + row = db.get(GuardianReview, review_id) + if row is None or row.status != "running": + return True + try: + _ensure_review_policy(db, row.provider, stored.get("policy_generation")) + except WorkflowError as exc: + _fail(db, row, exc.code) + return True row.assessment_enc = encryption.encrypt_bytes(result.assessment.model_dump_json().encode(), aad=_review_aad(row.review_id)) row.model_returned = result.model_returned row.provider_response_id = result.response_id @@ -710,7 +756,14 @@ async def process_one(db: Session, *, provider_override=None, sleep=asyncio.slee return True -def _fail(db: Session, row: GuardianReview, code: str) -> None: +def _fail(db: Session, row: GuardianReview | None, code: str) -> None: + if row is None: + return + db.expire_all() + current = db.get(GuardianReview, row.review_id) + if current is None or current.status != "running": + return + row = current row.status = "failed" row.error_code = code row.completed_at = _now() @@ -744,8 +797,15 @@ def recover_stale_jobs(db: Session, *, older_than_seconds: int = 120) -> int: cutoff = _now() - timedelta(seconds=older_than_seconds) rows = db.query(GuardianReview).filter(GuardianReview.status == "running", GuardianReview.started_at < cutoff).all() for row in rows: - row.status = "queued" - row.started_at = None + if row.attempts: + # Once an attempt was recorded the external outcome is unknown. + # Retrying automatically could dispatch the same private context twice. + row.status = "failed" + row.error_code = "outcome_uncertain" + row.completed_at = _now() + else: + row.status = "queued" + row.started_at = None if rows: db.commit() return len(rows) diff --git a/backend/app/services/guardian_review_minimization.py b/backend/app/services/guardian_review_minimization.py index ff985f6..2b40eea 100644 --- a/backend/app/services/guardian_review_minimization.py +++ b/backend/app/services/guardian_review_minimization.py @@ -252,7 +252,7 @@ def build_minimized_incident( alert = db.get(Alert, alert_id) risk = db.get(RiskResult, alert.risk_id) if alert else None event = db.get(Event, risk.event_id) if risk else None - if alert is None or risk is None or event is None: + if alert is None or risk is None or event is None or event.deletion_state != "active": raise InvalidIncidentError("Incident not found or incomplete") profile_id = alert.profile_id or event.profile_id profile = db.get(ChildProfile, profile_id) if profile_id else None diff --git a/backend/app/services/notifications.py b/backend/app/services/notifications.py index 6d9ecbc..d345704 100644 --- a/backend/app/services/notifications.py +++ b/backend/app/services/notifications.py @@ -24,7 +24,7 @@ from sqlalchemy.orm import Session from app.db.models import Alert, NotificationJob, NotificationLog, Setting -from app.services import encryption +from app.services import consent, encryption log = logging.getLogger(__name__) @@ -317,7 +317,7 @@ def dispatch( logs.append(nl) # 2) Email if SMTP configured and severity warrants immediate - cfg = _get_smtp_config(session) + cfg = _get_smtp_config(session) if consent.effective_policy(session).notifications else None if cfg and is_immediate: subject = f"[GuardianNode] {alert.severity.upper()} alert" body = ( @@ -384,7 +384,7 @@ def enqueue( detail=None, ) ) - if not is_immediate: + if not is_immediate or not consent.effective_policy(session).notifications: return None job = NotificationJob( alert_id=alert.alert_id, @@ -422,6 +422,13 @@ def process_pending(session: Session, *, limit: int = 10, now: datetime | None = ) processed = 0 for job in candidates: + if not consent.effective_policy(session).notifications: + job.status = "dead" + job.last_error = "notifications disallowed by current consent" + job.next_attempt_at = None + job.updated_at = now + processed += 1 + continue due = _as_aware_utc(job.next_attempt_at) if due is not None and due > now: continue diff --git a/backend/app/services/purge.py b/backend/app/services/purge.py index aa3c030..3b13cd9 100644 --- a/backend/app/services/purge.py +++ b/backend/app/services/purge.py @@ -1,7 +1,7 @@ """Cascade-safe deletion of events and their dependent records. Events fan out into RiskResults, Alerts, EvidenceBlob rows, and encrypted blob -files on disk. SQLite (as configured) does not enforce FK cascades, so every +files on disk. SQLite enforces foreign keys but does not cascade them, so every delete path — retention cleanup and parent-initiated wipes — must go through these helpers or it strands orphaned rows and undeletable evidence files. """ @@ -18,7 +18,9 @@ Event, EvidenceBlob, GuardianReview, + GuardianReviewFeedback, GuardianReviewPreview, + NotificationJob, RiskResult, ) from app.services.evidence_paths import UnsafeEvidencePathError, resolve_stored_evidence_path @@ -32,6 +34,10 @@ def delete_blob(session: Session, blob: EvidenceBlob) -> bool: blob.deletion_state = "pending_delete" blob.delete_requested_at = blob.delete_requested_at or now blob.delete_attempts = int(blob.delete_attempts or 0) + 1 + # Check database constraints before unlinking the only on-disk copy. + # The caller still owns the transaction; durable intent requires a + # separate commit before this function is called. + session.flush() try: resolve_stored_evidence_path(blob.encrypted_path).unlink(missing_ok=True) except UnsafeEvidencePathError as e: @@ -65,6 +71,9 @@ def delete_events(session: Session, event_ids: Iterable[str]) -> dict[str, int]: rows = session.query(Event).filter(Event.event_id.in_(chunk)).all() deletable_ids: list[str] = [] for event in rows: + event.deletion_state = "pending_delete" + event.deletion_requested_at = event.deletion_requested_at or datetime.now(UTC) + session.flush() blobs = session.query(EvidenceBlob).filter(EvidenceBlob.event_id == event.event_id).all() failed = False for blob in blobs: @@ -95,6 +104,17 @@ def delete_events(session: Session, event_ids: Iterable[str]) -> dict[str, int]: row[0] for row in session.query(GuardianReviewPreview.preview_id) .filter(GuardianReviewPreview.alert_id.in_(alert_ids)).all() ] + review_ids = [ + row[0] for row in session.query(GuardianReview.review_id) + .filter(GuardianReview.alert_id.in_(alert_ids)).all() + ] + if review_ids: + session.query(GuardianReviewFeedback).filter( + GuardianReviewFeedback.review_id.in_(review_ids) + ).delete(synchronize_session=False) + session.query(NotificationJob).filter( + NotificationJob.alert_id.in_(alert_ids) + ).delete(synchronize_session=False) deleted["guardian_reviews"] += session.query(GuardianReview).filter( GuardianReview.alert_id.in_(alert_ids) ).delete(synchronize_session=False) @@ -131,7 +151,7 @@ def delete_orphaned_blob_files(session: Session) -> int: def retry_pending_deletions(session: Session, *, limit: int = 100) -> dict[str, int]: - """Retry failed blob unlinks and finish their pending event cascades.""" + """Retry file deletion, preserving events with only expired blobs.""" blobs = ( session.query(EvidenceBlob) .filter(EvidenceBlob.deletion_state.in_(["pending_delete", "delete_failed"])) @@ -140,11 +160,20 @@ def retry_pending_deletions(session: Session, *, limit: int = 100) -> dict[str, .all() ) event_ids = {blob.event_id for blob in blobs if blob.event_id} - removed = sum(1 for blob in blobs if delete_blob(session, blob)) + removed = 0 + for blob in blobs: + if delete_blob(session, blob): + removed += 1 + for event in session.query(Event).filter(Event.screenshot_blob_id == blob.blob_id).all(): + event.screenshot_blob_id = None + for event in session.query(Event).filter(Event.image_blob_id == blob.blob_id).all(): + event.image_blob_id = None session.flush() finished = {"events": 0, "risk_results": 0, "alerts": 0, "blobs": removed, "guardian_reviews": 0} for event_id in event_ids: - if session.query(EvidenceBlob).filter(EvidenceBlob.event_id == event_id).count() == 0: + pending_event = session.get(Event, event_id) + if (pending_event is not None and pending_event.deletion_state == "pending_delete" + and session.query(EvidenceBlob).filter(EvidenceBlob.event_id == event_id).count() == 0): counts = delete_events(session, [event_id]) for key, value in counts.items(): finished[key] += value diff --git a/backend/app/services/retention.py b/backend/app/services/retention.py index 8f7f1b8..0df9af2 100644 --- a/backend/app/services/retention.py +++ b/backend/app/services/retention.py @@ -6,7 +6,7 @@ from sqlalchemy.orm import Session -from app.db.models import Alert, AuditLog, Event, EvidenceBlob, RiskResult +from app.db.models import AuditLog, Event, EvidenceBlob, RiskResult log = logging.getLogger(__name__) @@ -46,22 +46,14 @@ def run_cleanup(session: Session, retention: dict[str, int] | None = None) -> di event_ids = [ row[0] for row in session.query(RiskResult.event_id) - .filter(RiskResult.risk_level.in_(levels), RiskResult.created_at < cutoff) + .join(Event, RiskResult.event_id == Event.event_id) + .filter(RiskResult.risk_level.in_(levels), Event.timestamp < cutoff) .all() ] counts = purge.delete_events(session, event_ids) for key in ("alerts", "risk_results", "events", "blobs"): deleted[key] += counts[key] - # Defensive: also expire alert rows of this severity whose chain is - # already gone (e.g. rows from before cascade deletion existed). - sev_levels = [sev_group] - deleted["alerts"] += ( - session.query(Alert) - .filter(Alert.severity.in_(sev_levels), Alert.created_at < cutoff) - .delete(synchronize_session=False) - ) - # 2) Events that never got a risk result (ingest interrupted mid-pipeline). cutoff = _cutoff(max(r["low"], 1)) orphan_event_ids = [ diff --git a/backend/app/services/screenshot_async.py b/backend/app/services/screenshot_async.py index c0500b0..ccfb8c9 100644 --- a/backend/app/services/screenshot_async.py +++ b/backend/app/services/screenshot_async.py @@ -394,7 +394,10 @@ def _mtime(token: str) -> float: async def _classify_token(token: str) -> None: + from app.db.models import Device from app.db.session import get_sessionmaker + from app.services import consent + from app.services.device_state import is_device_paused meta = _load_meta(token) if meta is None: @@ -417,6 +420,17 @@ async def _classify_token(token: str) -> None: session = get_sessionmaker()() try: + policy = consent.effective_policy(session) + device = session.get(Device, meta["device_id"]) + if ( + not policy.screenshots + or policy.generation != meta.get("policy_generation") + or device is None + or not device.paired + or is_device_paused(device) + ): + _discard(token) + return await screenshot_ingest.ingest_screenshot( session, image_bytes=image_bytes, diff --git a/backend/app/settings.py b/backend/app/settings.py index 11c0e11..650d995 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -117,6 +117,9 @@ class Settings(BaseSettings): pending_frame_max_age_seconds: int = 600 pending_replay_max_frames: int = 50 guardian_review_enabled: bool = False + # App suspension and hosts-file edits need Windows crash/reboot/undo + # qualification before they are offered to ordinary families. + device_enforcement_enabled: bool = False guardian_review_provider: str = "mock" guardian_review_model: str = "gpt-5.6" guardian_review_codex_model: str = "gpt-5.6-sol" diff --git a/backend/app/workers/guardian_review_worker.py b/backend/app/workers/guardian_review_worker.py index 3fdb788..0b258f1 100644 --- a/backend/app/workers/guardian_review_worker.py +++ b/backend/app/workers/guardian_review_worker.py @@ -20,14 +20,16 @@ async def run_once() -> bool: async def loop() -> None: - db = get_sessionmaker()() - try: - recovered = guardian_review.recover_stale_jobs(db) - if recovered: - log.warning("requeued %d stale Guardian Review job(s)", recovered) - finally: - db.close() while True: + # A job started shortly before a restart may not yet be stale during + # startup. Recheck on every pass so it cannot remain running forever. + db = get_sessionmaker()() + try: + recovered = guardian_review.recover_stale_jobs(db) + if recovered: + log.warning("recovered %d stale Guardian Review job(s)", recovered) + finally: + db.close() try: processed = await run_once() except Exception as exc: # pragma: no cover - supervisor handles repeated failures diff --git a/backend/pyproject.toml b/backend/pyproject.toml index b247f1f..da8078e 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -24,7 +24,8 @@ dependencies = [ "pyyaml>=6.0,<7.0", "zeroconf>=0.131,<1.0", "qrcode[pil]>=7.4,<9.0", - "Pillow>=12.2,<13.0", + "Pillow>=13.0.0,<14.0", + "certifi>=2026.10.18", "pytesseract>=0.3.10,<1.0", "python-ulid>=2.2,<4.0", "itsdangerous>=2.1,<3.0", diff --git a/backend/tests/test_archive_format.py b/backend/tests/test_archive_format.py index 7e8336c..39f6f9b 100644 --- a/backend/tests/test_archive_format.py +++ b/backend/tests/test_archive_format.py @@ -22,6 +22,7 @@ _safe_member_target, _safe_members, create_archive, + extract_archive, inspect_archive, restore_archive, verify_archive, @@ -111,6 +112,24 @@ def test_passphrase_archive_round_trip_and_restore(archive_instance, tmp_path: P assert (target / "keys" / "master.key").stat().st_size == 32 +def test_pending_deletion_excluded_from_both_archive_views(archive_instance, tmp_path: Path): + data_dir, database = archive_instance + with sqlite3.connect(database) as conn: + conn.execute("UPDATE events SET deletion_state='pending_delete' WHERE event_id='event-1'") + archive = tmp_path / "pending.gna" + create_archive( + archive, data_dir=data_dir, db_url=f"sqlite:///{database}", + passphrase="synthetic-test-passphrase", + ) + extracted = tmp_path / "extracted" + extract_archive(archive, extracted, passphrase="synthetic-test-passphrase") + assert not (extracted / "records" / "events.jsonl").read_text().strip() + with sqlite3.connect(extracted / "database.sqlite3") as conn: + assert conn.execute("SELECT COUNT(*) FROM events WHERE event_id='event-1'").fetchone()[0] == 0 + assert conn.execute("SELECT COUNT(*) FROM evidence_blobs WHERE blob_id='blob'").fetchone()[0] == 0 + assert conn.execute("PRAGMA foreign_key_check").fetchall() == [] + + def test_recipient_archive_and_wrong_key_fail(archive_instance, tmp_path: Path): data_dir, database = archive_instance private_path = tmp_path / "recovery.pem" diff --git a/backend/tests/test_child_request_transitions.py b/backend/tests/test_child_request_transitions.py new file mode 100644 index 0000000..98f7ef2 --- /dev/null +++ b/backend/tests/test_child_request_transitions.py @@ -0,0 +1,40 @@ +from datetime import UTC, datetime, timedelta + +import pytest +from fastapi import HTTPException, Request + +from app.api.child_requests import ChildRequestReview, review_child_request +from app.db.models import ChildRequest, User + + +def _request() -> Request: + return Request({ + "type": "http", "method": "POST", "path": "/api/child-requests/r/review", + "headers": [], "client": ("127.0.0.1", 1234), "scheme": "http", + "server": ("testserver", 80), + }) + + +def test_parent_decision_cannot_overwrite_or_approve_expired_request(db_session): + user = User(display_name="Synthetic Parent", password_hash="test", recovery_hash="test") + db_session.add(user) + db_session.add(ChildRequest( + request_id="open", request_type="more_time", status="open", + expires_at=datetime.now(UTC) + timedelta(days=1), + )) + db_session.add(ChildRequest( + request_id="expired", request_type="more_time", status="open", + expires_at=datetime.now(UTC) - timedelta(seconds=1), + )) + db_session.commit() + + decided = review_child_request("open", ChildRequestReview(status="approved"), _request(), db_session, user) + assert decided.status == "approved" + + for request_id in ("open", "expired"): + with pytest.raises(HTTPException) as conflict: + review_child_request(request_id, ChildRequestReview(status="denied"), _request(), db_session, user) + assert conflict.value.status_code == 409 + db_session.expire_all() + assert db_session.get(ChildRequest, "open").status == "approved" + assert db_session.get(ChildRequest, "expired").status == "open" diff --git a/backend/tests/test_db_session.py b/backend/tests/test_db_session.py index b9138d4..52ea63b 100644 --- a/backend/tests/test_db_session.py +++ b/backend/tests/test_db_session.py @@ -104,3 +104,22 @@ def test_backup_refuses_to_overwrite_without_flag(tmp_path): with pytest.raises(Exception, match="already exists"): backup_database(destination, source=source) + + +def test_current_schema_backup_restores(tmp_path, monkeypatch): + from app import settings as settings_mod + from app.db.maintenance import database_schema_revision + from app.db.migrations import upgrade_schema + from app.db.session import configure_sqlite_engine + + source = tmp_path / "current.sqlite" + monkeypatch.setattr(settings_mod.settings, "db_url", f"sqlite:///{source}") + engine = configure_sqlite_engine(create_engine(f"sqlite:///{source}")) + upgrade_schema(engine) + engine.dispose() + assert database_schema_revision(source) == "0007_family_beta_workflows" + backup = tmp_path / "current-backup.sqlite" + backup_database(backup, source=source) + restored = tmp_path / "current-restored.sqlite" + restore_database(backup, destination=restored) + assert database_schema_revision(restored) == "0007_family_beta_workflows" diff --git a/backend/tests/test_device_auth.py b/backend/tests/test_device_auth.py index bfc0f70..d2610bd 100644 --- a/backend/tests/test_device_auth.py +++ b/backend/tests/test_device_auth.py @@ -196,3 +196,25 @@ def test_valid_device_heartbeat_has_per_device_burst_budget(monkeypatch, tmp_pat ) assert limited.status_code == 429 assert "Retry-After" in limited.headers + + +def test_valid_token_during_ip_block_still_obeys_device_budget(monkeypatch, tmp_path): + client = _client(monkeypatch, tmp_path) + from app.services.device_bootstrap_token import ensure_device_bootstrap_token + + paired = client.post( + "/api/devices/bootstrap-local", + json={"hostname": "kid-pc", "device_bootstrap_token": ensure_device_bootstrap_token()}, + ) + token = paired.json()["device_token"] + path = "/api/devices/heartbeat" + payload = {"queued_frames": 0} + for _ in range(device_budget.BUDGETS["heartbeat"].request_burst): + assert client.post(path, json=payload, headers={"Authorization": f"Bearer {token}"}).status_code == 200 + + for _ in range(rate_limit.MAX_FAILURES): + assert client.post(path, json=payload, headers={"Authorization": "Bearer gn_dev_bogus_bogus"}).status_code == 401 + + recovered = client.post(path, json=payload, headers={"Authorization": f"Bearer {token}"}) + assert recovered.status_code == 429 + assert recovered.json()["detail"] == "Device request budget exceeded." diff --git a/backend/tests/test_device_pause.py b/backend/tests/test_device_pause.py index e216d31..e65ec8b 100644 --- a/backend/tests/test_device_pause.py +++ b/backend/tests/test_device_pause.py @@ -6,8 +6,8 @@ from fastapi.testclient import TestClient -from app.db.models import Device, Event, ScreenshotUploadReceipt -from app.services import pipeline_metrics, rate_limit +from app.db.models import ConsentRecord, Device, Event, ScreenshotUploadReceipt, User +from app.services import consent, device_tokens, pipeline_metrics, rate_limit from app.services.device_state import effective_paused_until, is_device_paused _JPEG_1X1 = base64.b64decode( @@ -195,6 +195,88 @@ def test_expired_pause_does_not_block_screenshot_ingest(monkeypatch, tmp_path): assert r.json()["queued"] is True +def test_screenshot_upload_rejects_stale_consent_generation(monkeypatch, tmp_path): + client = _client(monkeypatch, tmp_path) + _device_id, token = _pair(client) + from app.db.session import get_sessionmaker + + db = get_sessionmaker()() + try: + parent = User(display_name="Synthetic Parent", password_hash="synthetic", recovery_hash="synthetic") + db.add(parent) + db.flush() + db.add(ConsentRecord( + consent_id="current-generation", user_id=parent.id, + notice_version=consent.NOTICE_VERSION, status="granted", + choices={"screenshots": True, "retention": True, + "child_notice_acknowledged": True}, + )) + db.commit() + finally: + db.close() + denied = client.post( + "/api/events/screenshot", + files={"image": ("frame.jpg", _JPEG_1X1, "image/jpeg")}, + data={"policy_generation": "older-generation"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert denied.status_code == 409 + assert _pending_count() == 0 + accepted = client.post( + "/api/events/screenshot", + files={"image": ("frame.jpg", _JPEG_1X1, "image/jpeg")}, + data={"policy_generation": "current-generation"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert accepted.status_code == 200 + assert accepted.json()["queued"] is True + + +def test_queued_frame_cannot_replay_after_withdrawal_and_repair(monkeypatch, tmp_path): + client = _client(monkeypatch, tmp_path) + device_id, _old_token = _pair(client) + from app.db.session import get_sessionmaker + + db = get_sessionmaker()() + try: + parent = User(display_name="Synthetic Parent", password_hash="synthetic", recovery_hash="synthetic") + db.add(parent) + db.flush() + choices = {"screenshots": True, "retention": True, + "child_notice_acknowledged": True} + start = datetime.now(UTC) + for offset, generation, status in ( + (0, "before-withdrawal", "granted"), + (1, "withdrawal", "withdrawn"), + (2, "after-repair", "granted"), + ): + db.add(ConsentRecord( + consent_id=generation, user_id=parent.id, + notice_version=consent.NOTICE_VERSION, status=status, + choices=choices if status == "granted" else {"evidence_disposition": "delete"}, + created_at=start + timedelta(seconds=offset), + )) + token, digest = device_tokens.issue_token(device_id) + device = db.get(Device, device_id) + device.paired = True + device.token_hash = digest + db.commit() + finally: + db.close() + + # A durable local frame captured under the old generation is attempted + # with a newly paired token after consent was granted again. + replay = client.post( + "/api/events/screenshot", + files={"image": ("old-frame.jpg", _JPEG_1X1, "image/jpeg")}, + data={"policy_generation": "before-withdrawal"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert replay.status_code == 409 + assert _pending_count() == 0 + assert _event_count() == 0 + + def test_screenshot_idempotency_key_deduplicates_retries(monkeypatch, tmp_path): client = _client(monkeypatch, tmp_path) _device_id, token = _pair(client) diff --git a/backend/tests/test_processing_policy.py b/backend/tests/test_processing_policy.py new file mode 100644 index 0000000..5429855 --- /dev/null +++ b/backend/tests/test_processing_policy.py @@ -0,0 +1,257 @@ +"""Parent choices must hold at enqueue and worker side-effect boundaries.""" +from __future__ import annotations + +import asyncio +from datetime import UTC, datetime, timedelta + +import pytest +from fastapi import HTTPException, Request + +from app import settings as settings_mod +from app.api.consent import ConsentWithdrawalRequest, onboarding_status, withdraw_consent +from app.api.deps import current_device +from app.api.devices import HeartbeatRequest, capture_config, heartbeat +from app.db.models import Alert, ConsentRecord, Device, Event, RiskResult, User +from app.guardian_review_models import GuardianReviewContext +from app.services import consent, device_tokens, encryption, guardian_review +from app.services.guardian_review_providers import ProviderError + + +def _incident(db): + user = User(display_name="Synthetic Parent", password_hash="synthetic", recovery_hash="synthetic") + db.add(user) + db.add(Device(device_id="synthetic-device", hostname="synthetic", paired=True)) + db.flush() + db.add(Event( + event_id="synthetic-event", device_id="synthetic-device", source_type="image", + timestamp=datetime.now(UTC), + redacted_text_enc=encryption.encrypt_text("Synthetic secrecy request"), + )) + db.add(RiskResult( + risk_id="synthetic-risk", event_id="synthetic-event", risk_level="high", + score=70, summary="Synthetic secrecy request", evidence=["Synthetic secrecy request"], + categories=["secrecy_request"], + )) + db.flush() + db.add(Alert( + alert_id="synthetic-alert", risk_id="synthetic-risk", device_id="synthetic-device", + severity="high", + )) + db.commit() + return user + + +def _consent(db, user, *, consent_id="generation-1", status="granted", **changes): + choices = { + "screenshots": True, "apps_and_urls": True, "retention": True, + "notifications": True, "external_ai": False, + "child_notice_acknowledged": True, + } + choices.update(changes) + db.add(ConsentRecord( + consent_id=consent_id, user_id=user.id, notice_version=consent.NOTICE_VERSION, + status=status, choices=choices, + created_at=datetime.now(UTC) + ( + timedelta(seconds=2) if consent_id == "later-grant" + else timedelta(seconds=1) if status == "withdrawn" else timedelta() + ), + )) + db.commit() + + +def test_all_capture_choices_off_disable_monitoring(db_session): + user = _incident(db_session) + _consent(db_session, user, screenshots=False, apps_and_urls=False, retention=False, + notifications=False, external_ai=False) + assert consent.monitoring_allowed(db_session) is False + assert consent.effective_policy(db_session).apps_and_urls is False + + +def test_capture_and_context_choices_resolve_independently(db_session): + user = _incident(db_session) + _consent(db_session, user, screenshots=False, apps_and_urls=True) + policy = consent.effective_policy(db_session) + assert policy.screenshots is False + assert policy.apps_and_urls is True + assert consent.monitoring_allowed(db_session) is True + db_session.query(ConsentRecord).delete() + db_session.commit() + _consent(db_session, user, consent_id="no-retention", screenshots=True, + apps_and_urls=True, retention=False) + policy = consent.effective_policy(db_session) + assert policy.screenshots is False + assert policy.apps_and_urls is False + assert consent.monitoring_allowed(db_session) is False + + +def test_external_review_requires_global_opt_in(db_session, monkeypatch): + user = _incident(db_session) + _consent(db_session, user) + monkeypatch.setattr(settings_mod.settings, "guardian_review_enabled", True) + monkeypatch.setattr(settings_mod.settings, "guardian_review_provider", "openai") + monkeypatch.setattr(settings_mod.settings, "guardian_review_zdr_confirmed", True) + monkeypatch.setattr(settings_mod.settings, "openai_api_key", "synthetic-test-key") + with pytest.raises(guardian_review.WorkflowError) as exc: + guardian_review.create_preview( + db_session, alert_id="synthetic-alert", context=GuardianReviewContext(), user=user, + ) + assert exc.value.code == "external_ai_not_allowed" + + +def test_withdrawal_fences_queued_review_before_provider(db_session, monkeypatch): + user = _incident(db_session) + _consent(db_session, user, external_ai=True) + monkeypatch.setattr(settings_mod.settings, "guardian_review_enabled", True) + monkeypatch.setattr(settings_mod.settings, "guardian_review_provider", "mock") + preview = guardian_review.create_preview( + db_session, alert_id="synthetic-alert", context=GuardianReviewContext(), user=user, + ) + guardian_review.submit_review( + db_session, alert_id="synthetic-alert", preview_id=preview.preview_id, + preview_digest=preview.preview_digest, user=user, + ) + _consent(db_session, user, consent_id="withdrawn", status="withdrawn") + calls = [] + + class Spy: + async def assess(self, **kwargs): + calls.append(kwargs) + raise ProviderError("upstream_unavailable") + + asyncio.run(guardian_review.process_one(db_session, provider_override=Spy())) + assert calls == [] + + +def test_stale_running_review_with_dispatched_attempt_is_not_retried(db_session): + from app.db.models import GuardianReview, GuardianReviewPreview + + user = _incident(db_session) + db_session.add(GuardianReviewPreview( + preview_id="p", alert_id="synthetic-alert", actor_user_id=user.id, + provider="mock", model_requested="mock", schema_version="1.1.0", + prompt_version="guardian-review-v2", payload_digest="0" * 64, + incident_fingerprint="1" * 64, expires_at=datetime.now(UTC) + timedelta(minutes=10), + )) + db_session.flush() + db_session.add(GuardianReview( + review_id="r", preview_id="p", alert_id="synthetic-alert", + requester_user_id=user.id, provider="mock", dedup_key="2" * 64, + schema_version="1.1.0", prompt_version="guardian-review-v2", + model_requested="mock", status="running", attempts=1, + started_at=datetime.now(UTC) - timedelta(minutes=5), + )) + db_session.commit() + assert guardian_review.recover_stale_jobs(db_session) == 1 + assert db_session.get(GuardianReview, "r").error_code == "outcome_uncertain" + + +def _request(path: str, token: str = "") -> Request: + return Request({ + "type": "http", "method": "POST", "path": path, + "headers": [(b"authorization", f"Bearer {token}".encode())] if token else [], + "client": ("127.0.0.1", 1234), "scheme": "http", "server": ("testserver", 80), + }) + + +def test_withdrawal_token_is_control_only_until_broker_ack(db_session): + user = _incident(db_session) + _consent(db_session, user) + token, digest = device_tokens.issue_token("synthetic-device") + device = db_session.get(Device, "synthetic-device") + device.token_hash = digest + db_session.commit() + + result = withdraw_consent( + ConsentWithdrawalRequest(evidence_disposition="retain"), + _request("/api/consent/withdraw"), db_session, user, None, + ) + generation = result["record"]["consent_id"] + assert device.status == "consent_stop_requested" + assert device.paired is True + with pytest.raises(HTTPException) as denied: + current_device(_request("/api/events/screenshot", token), db_session) + assert denied.value.status_code == 403 + control_device = current_device(_request("/api/devices/capture-config", token), db_session) + assert control_device.device_id == device.device_id + + heartbeat(HeartbeatRequest(applied_policy_generation="stale-generation"), db_session, device) + assert device.status == "consent_stop_requested" + _consent(db_session, user, consent_id="later-grant") + config = capture_config(db_session, device) + assert config["paused"] is True + assert config["capture_allowed"] is False + assert config["policy_generation"] == generation + heartbeat(HeartbeatRequest(applied_policy_generation=generation), db_session, device) + assert device.status == "consent_stop_applied" + assert device.token_hash is None + with pytest.raises(HTTPException) as stale: + current_device(_request("/api/devices/capture-config", token), db_session) + assert stale.value.status_code == 401 + + +def test_withdrawal_marks_unpaired_device_offline(db_session): + user = _incident(db_session) + _consent(db_session, user) + result = withdraw_consent( + ConsentWithdrawalRequest(evidence_disposition="retain"), + _request("/api/consent/withdraw"), db_session, user, None, + ) + assert result["active"] is False + assert db_session.get(Device, "synthetic-device").status == "consent_stop_offline" + + +def test_onboarding_heartbeat_alone_does_not_complete_capture_check(db_session): + user = User(display_name="Synthetic Parent", password_hash="synthetic", recovery_hash="synthetic") + db_session.add(user) + db_session.add(Device( + device_id="synthetic-device", hostname="synthetic", paired=True, + last_seen=datetime.now(UTC), + )) + db_session.commit() + status = onboarding_status(db_session, user) + check = next(step for step in status["steps"] if step["id"] == "self_test") + assert check["complete"] is False + + +def test_fast_restart_recovers_recent_running_review_in_worker_loop(db_session, monkeypatch): + from sqlalchemy.orm import sessionmaker + + from app.db.models import GuardianReview, GuardianReviewPreview + from app.workers import guardian_review_worker + + user = _incident(db_session) + now = [datetime.now(UTC)] + db_session.add(GuardianReviewPreview( + preview_id="restart-preview", alert_id="synthetic-alert", actor_user_id=user.id, + provider="mock", model_requested="mock", schema_version="1.1.0", + prompt_version="guardian-review-v2", payload_digest="0" * 64, + incident_fingerprint="1" * 64, expires_at=now[0] + timedelta(minutes=10), + )) + db_session.flush() + db_session.add(GuardianReview( + review_id="restart-review", preview_id="restart-preview", alert_id="synthetic-alert", + requester_user_id=user.id, provider="mock", dedup_key="3" * 64, + schema_version="1.1.0", prompt_version="guardian-review-v2", + model_requested="mock", status="running", attempts=0, + started_at=now[0] - timedelta(seconds=10), + )) + db_session.commit() + factory = sessionmaker(bind=db_session.bind, autoflush=False) + monkeypatch.setattr(guardian_review_worker, "get_sessionmaker", lambda: factory) + monkeypatch.setattr(guardian_review, "_now", lambda: now[0]) + sleeps = [] + + class EndProbeError(Exception): + pass + + async def advance_clock(_seconds): + now[0] += timedelta(seconds=180) + sleeps.append(1) + if len(sleeps) >= 2: + raise EndProbeError() + + monkeypatch.setattr(guardian_review_worker.asyncio, "sleep", advance_clock) + with pytest.raises(EndProbeError): + asyncio.run(guardian_review_worker.loop()) + db_session.expire_all() + assert db_session.get(GuardianReview, "restart-review").status != "running" diff --git a/backend/tests/test_purge_cascade.py b/backend/tests/test_purge_cascade.py index 68f1d47..1d53de5 100644 --- a/backend/tests/test_purge_cascade.py +++ b/backend/tests/test_purge_cascade.py @@ -3,8 +3,23 @@ from datetime import UTC, datetime, timedelta +import pytest +from fastapi import HTTPException +from sqlalchemy.exc import IntegrityError + from app import settings as settings_mod -from app.db.models import Alert, Device, Event, EvidenceBlob, RiskResult +from app.db.models import ( + Alert, + Device, + Event, + EvidenceBlob, + GuardianReview, + GuardianReviewFeedback, + GuardianReviewPreview, + NotificationJob, + RiskResult, + User, +) from app.services import purge, retention @@ -68,6 +83,120 @@ def test_delete_events_cascades_risk_alert_blob(db_session, tmp_path): assert not blob_path.exists() +def test_delete_events_removes_review_feedback_and_notification_jobs(db_session, tmp_path): + s = db_session + event_id, _, _, _ = _mk_chain(s, tmp_path, suffix="dependent", severity="high", age_days=0) + user = User(display_name="Synthetic Parent", password_hash="x", recovery_hash="x") + s.add(user) + s.flush() + s.add(GuardianReviewPreview( + preview_id="preview-dependent", alert_id="a-dependent", actor_user_id=user.id, + provider="mock", model_requested="mock", schema_version="1", prompt_version="v1", + payload_digest="0" * 64, incident_fingerprint="1" * 64, + expires_at=datetime.now(UTC) + timedelta(minutes=10), + )) + s.flush() + s.add(GuardianReview( + review_id="review-dependent", preview_id="preview-dependent", alert_id="a-dependent", + requester_user_id=user.id, provider="mock", dedup_key="2" * 64, + schema_version="1", prompt_version="v1", model_requested="mock", status="completed", + )) + s.flush() + s.add(GuardianReviewFeedback( + feedback_id="feedback-dependent", review_id="review-dependent", user_id=user.id, + labels=["helpful"], schema_version="1", prompt_version="v1", + redaction_version="v1", model="mock", + )) + s.add(NotificationJob(alert_id="a-dependent", severity="high", risk_summary="synthetic")) + s.commit() + + purge.delete_events(s, [event_id]) + s.commit() + assert s.get(Event, event_id) is None + assert s.query(GuardianReviewFeedback).count() == 0 + assert s.query(NotificationJob).count() == 0 + assert s.connection().exec_driver_sql("PRAGMA foreign_key_check").fetchall() == [] + + +def test_retry_expired_blob_keeps_unexpired_high_alert(db_session, tmp_path, monkeypatch): + s = db_session + event_id, _, blob_id, path = _mk_chain( + s, tmp_path, suffix="blob-retry", severity="high", age_days=40, + ) + original_unlink = type(path).unlink + + def locked(candidate, *args, **kwargs): + if candidate == path: + raise PermissionError("synthetic lock") + return original_unlink(candidate, *args, **kwargs) + + monkeypatch.setattr(type(path), "unlink", locked) + retention.run_cleanup(s) + assert s.get(EvidenceBlob, blob_id).deletion_state == "delete_failed" + monkeypatch.setattr(type(path), "unlink", original_unlink) + purge.retry_pending_deletions(s) + s.commit() + assert s.get(Event, event_id) is not None + assert s.get(Event, event_id).screenshot_blob_id is None + assert s.get(Alert, "a-blob-retry") is not None + assert s.get(EvidenceBlob, blob_id) is None + + +def test_failed_full_retention_keeps_alert_until_retry(db_session, tmp_path, monkeypatch): + s = db_session + event_id, _, _, path = _mk_chain( + s, tmp_path, suffix="full-retry", severity="high", age_days=100, + ) + original_unlink = type(path).unlink + + def locked(candidate, *args, **kwargs): + if candidate == path: + raise PermissionError("synthetic lock") + return original_unlink(candidate, *args, **kwargs) + + monkeypatch.setattr(type(path), "unlink", locked) + retention.run_cleanup(s) + assert s.get(Event, event_id).deletion_state == "pending_delete" + assert s.get(Alert, "a-full-retry") is not None + + +def test_tombstoned_alert_and_risk_are_hidden_from_read_apis(db_session, tmp_path): + from app.api.alerts import get_alert, list_alerts + from app.api.risks import list_risks + + s = db_session + event_id, _, _, _ = _mk_chain( + s, tmp_path, suffix="hidden", severity="high", age_days=0, + ) + user = User(display_name="Synthetic Parent", password_hash="x", recovery_hash="x") + s.add(user) + event = s.get(Event, event_id) + event.deletion_state = "pending_delete" + s.commit() + + assert list_alerts(db=s, _=user, limit=50).items == [] + assert list_risks(db=s, _=user, limit=100) == [] + with pytest.raises(HTTPException) as exc: + get_alert("a-hidden", db=s, user=user) + assert exc.value.status_code == 404 + + +def test_database_failure_prevents_unlink(db_session, tmp_path): + s = db_session + event_id, _, _, path = _mk_chain( + s, tmp_path, suffix="sql-failure", severity="high", age_days=0, + ) + s.add(Event( + event_id="invalid-extra", device_id="missing-device", source_type="image", + timestamp=datetime.now(UTC), + )) + with pytest.raises(IntegrityError): + purge.delete_events(s, [event_id]) + s.rollback() + assert path.exists() + assert s.get(Event, event_id).deletion_state == "active" + + def test_wipe_old_events_leaves_no_orphans(db_session, tmp_path): """The storage-wipe path (older_than_days) must clear the full chain.""" s = db_session diff --git a/backend/tests/test_readiness_apis.py b/backend/tests/test_readiness_apis.py index 51f2cc3..fd33595 100644 --- a/backend/tests/test_readiness_apis.py +++ b/backend/tests/test_readiness_apis.py @@ -247,8 +247,20 @@ def test_alert_detail_includes_feed_context(monkeypatch, tmp_path): def test_alert_actions_are_effective_or_explicitly_unsupported(monkeypatch, tmp_path): client = _client(monkeypatch, tmp_path) + from app import settings as settings_mod from app.db.session import get_sessionmaker + notice_version = client.get("/api/consent").json()["notice_version"] + consent_response = client.post( + "/api/consent", + json={"notice_version": notice_version, "choices": { + "screenshots": True, "apps_and_urls": True, "retention": True, + "notifications": True, "external_ai": False, + "child_notice_acknowledged": True, + }}, + ) + assert consent_response.status_code == 200 + db = get_sessionmaker()() try: db.add(Device(device_id="action-dev", hostname="kid-pc", paired=True)) @@ -275,6 +287,14 @@ def test_alert_actions_are_effective_or_explicitly_unsupported(monkeypatch, tmp_ ) assert unsupported.status_code == 409 + gated = client.post( + "/api/alerts/action-alert/actions", + json={"action": "pause_app", "target": r"C:\Games\Chat.exe", "duration_seconds": 900}, + ) + assert gated.status_code == 409 + assert "not enabled" in gated.json()["detail"] + settings_mod.settings.device_enforcement_enabled = True + preview = client.post( "/api/alerts/action-alert/actions", json={ diff --git a/backend/uv.lock b/backend/uv.lock index 510b1c3..9a1c2ff 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -139,11 +139,11 @@ wheels = [ [[package]] name = "certifi" -version = "2026.6.17" +version = "2026.10.23" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, + { url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" }, ] [[package]] @@ -496,6 +496,7 @@ source = { editable = "." } dependencies = [ { name = "alembic" }, { name = "argon2-cffi" }, + { name = "certifi" }, { name = "cryptography" }, { name = "fastapi" }, { name = "httpx" }, @@ -529,6 +530,7 @@ dev = [ requires-dist = [ { name = "alembic", specifier = ">=1.13,<2.0" }, { name = "argon2-cffi", specifier = ">=23.1,<26.0" }, + { name = "certifi", specifier = ">=2026.10.18" }, { name = "cryptography", specifier = ">=50.0.0,<51.0" }, { name = "fastapi", specifier = ">=0.110,<1.0" }, { name = "httpx", specifier = ">=0.27,<1.0" }, @@ -537,7 +539,7 @@ requires-dist = [ { name = "itsdangerous", specifier = ">=2.1,<3.0" }, { name = "jinja2", specifier = ">=3.1,<4.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.8" }, - { name = "pillow", specifier = ">=12.2,<13.0" }, + { name = "pillow", specifier = ">=13.0.0,<14.0" }, { name = "pydantic", specifier = ">=2.5,<3.0" }, { name = "pydantic-settings", specifier = ">=2.1,<3.0" }, { name = "pytesseract", specifier = ">=0.3.10,<1.0" }, @@ -964,87 +966,92 @@ wheels = [ [[package]] name = "pillow" -version = "12.3.0" +version = "13.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/a4/9cc30690a394a8305e8be4c9036aa16d0dfddadd09079860b582f562496d/pillow-13.0.0.tar.gz", hash = "sha256:cdcd1ba258178f7356d74170c9a81ba4eab6a5d55748bc402a5a2a6fe6be3510", size = 47058631, upload-time = "2026-10-16T22:18:09.718Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, - { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, - { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, - { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, - { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, - { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, - { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, - { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, - { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, - { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, - { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, - { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, - { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, - { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, - { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, - { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, - { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, - { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, - { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, - { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, - { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, - { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, - { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, - { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, - { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, - { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, - { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, - { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, - { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, - { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, - { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, - { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, - { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, - { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, - { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, - { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, - { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, - { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, - { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, - { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, - { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, - { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, - { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, - { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, - { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, - { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, - { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, - { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, - { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, - { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, - { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, - { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, - { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, - { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, - { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, - { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, - { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, - { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, - { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, - { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, - { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, - { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, - { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, - { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, - { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, - { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, - { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, - { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, - { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, - { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, - { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, - { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d7/af6af1b8cc55e07cb0489d5b0950bf5a9bb1461eeba825700b6d8d0e26ea/pillow-13.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:6f38f3f885a09cafc93fd667b1173a92f379e43785d635e7983c893f0da319fb", size = 5592324, upload-time = "2026-10-16T22:15:24.672Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d2/2c12cb4fbb5d95f38d1001ff3d95fafcb830077018cba40e56a8f932dfbc/pillow-13.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37290fbef0c73e2d51d594eb78fded7dc7170d1c3ad0a890269e6fd8c8c4e061", size = 4856298, upload-time = "2026-10-16T22:15:27.669Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ee/229b27472483a4801ea94990815e710391836afdf987b4b722b62bd75db6/pillow-13.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c88c10dd432f3b39ccdafc9ed81d0c21b8fd634502574da76d1bfa4fe2c76d0", size = 6517304, upload-time = "2026-10-16T22:15:29.43Z" }, + { url = "https://files.pythonhosted.org/packages/36/46/f509b74787f40ce0627501089d630b2c741b63b244c1f40b640b3b01e181/pillow-13.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a625df9a2a5597fa5e5266e01aefc82604bb85d291e3547981e863560d75da6c", size = 7140667, upload-time = "2026-10-16T22:15:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/5a398b8ec2c37145397d6461c0b551c23ec03072aa64f357aed25e582b00/pillow-13.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:10b5019883c98f1dd819ca48ffc6f33a91cee04948fe637c605f4345c7cafd6f", size = 6610583, upload-time = "2026-10-16T22:15:34.025Z" }, + { url = "https://files.pythonhosted.org/packages/4f/7b/f2f1109b230fd4e2d57ab2305de31adc37ea9b4ff8719436c623cb484415/pillow-13.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f391dc92a015c478745192fe4f2e120c2af2aba18d9fe0616341cbf537599806", size = 7218241, upload-time = "2026-10-16T22:15:36.214Z" }, + { url = "https://files.pythonhosted.org/packages/ab/d3/069edb9083b267746baf59756282cacef904d3475c5288c7216fb5195a98/pillow-13.0.0-cp311-cp311-win32.whl", hash = "sha256:6a0920cb55df5a4d3f6862964908078c0bd59332bdd1bcd20d14e8c70ef27cc5", size = 6515278, upload-time = "2026-10-16T22:15:38.129Z" }, + { url = "https://files.pythonhosted.org/packages/7d/f6/6e2d96413e4c9dbec12ea584d9e646d602877d4cce3ea669099f959deac6/pillow-13.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:d7710eb676e9bb68965cb045be0d333b4c0d7f9dfbaa7fbf121385b1588683d3", size = 7278471, upload-time = "2026-10-16T22:15:40.286Z" }, + { url = "https://files.pythonhosted.org/packages/70/e7/baff21f8cb60496c8c250609501b88919eeedb4088d3da106f6c62f3f3c0/pillow-13.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:12fbb851cf323cc19aac3eea1df5ad17b57e91e9fe3cfd06100dba7bb8208c13", size = 2698981, upload-time = "2026-10-16T22:15:42.637Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b8/58d79332229482eb0d27c82973850808c19d37e5bdc14443d3417bd373f5/pillow-13.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:67c8d44ad89fa7eb88de5d6ef6199b1e03dd24974f85a67639fc8badb801fe79", size = 5568643, upload-time = "2026-10-16T22:15:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/39/4b/f2ed53d83aa44ac19599ac0a54897f867fafb95f406980d1e24083f8cbe3/pillow-13.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc481f742ccda20e9ed760b8f978dff50f2a4e9bc253ac167fb0a05b0783ef52", size = 4852329, upload-time = "2026-10-16T22:15:46.284Z" }, + { url = "https://files.pythonhosted.org/packages/3b/bb/9e30e8ed18c20800ee327a12107d5249ad8dc444867b3adfcc21654e3c71/pillow-13.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8bf295fe459343bc6093c8e5e0df60bfa189e0fcb775a22e2c2488fdeccc6c0d", size = 6519309, upload-time = "2026-10-16T22:15:48.14Z" }, + { url = "https://files.pythonhosted.org/packages/e6/99/fdf65cc539e266ab93546c261bdf98411fe165c6e4e13f9dbf597097192b/pillow-13.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42d63be3af4f9b951b199b0a9d23365d37ab14687e6d77b74940432c8515c4ca", size = 7144542, upload-time = "2026-10-16T22:15:50.093Z" }, + { url = "https://files.pythonhosted.org/packages/26/9b/f38b7c4d8f599537e2d1a6d546cec9beef7536ff8f5c49a3f9c78179d8ac/pillow-13.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4bd024d567eca931b668978d672dba914d4b8e2628e2c3073a2ad178de8a2620", size = 6618042, upload-time = "2026-10-16T22:15:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a8/7c/45ee28531f89ee4582967d8a17f7a26d71ca64cd902b93c269590cd60a00/pillow-13.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23edf4ec4b6f4f240e8df6e4985e9ee2ced86474632433e55fb1e2548744bf40", size = 7228137, upload-time = "2026-10-16T22:15:54.009Z" }, + { url = "https://files.pythonhosted.org/packages/01/bc/9c2a969cb1b70201d04fa17fd5aedf02a732870b8da86bceaaa30b0ca385/pillow-13.0.0-cp312-cp312-win32.whl", hash = "sha256:02464da86f561f410e13b061ec37c3068a9cb557a095494157742d07b3c24a27", size = 6515423, upload-time = "2026-10-16T22:15:56.25Z" }, + { url = "https://files.pythonhosted.org/packages/ff/cd/73a79ecdc822076002eb24b5e1e4a530b0bb5310fcb6d239abbec877110b/pillow-13.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:ff3fe9eb621fbb9db4c4be33ab946f6bac5f98ab50ca8d3260503653656c961b", size = 7272166, upload-time = "2026-10-16T22:15:58.597Z" }, + { url = "https://files.pythonhosted.org/packages/4a/a3/be5a1236efd085b3984e4305da8f179ae9dab80c991d2ad78f2bd5c4c843/pillow-13.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:c3bc8ee0271387e0b1e3ad0ff893645990a656e79bf9c42f4f734a3642b11d51", size = 2698943, upload-time = "2026-10-16T22:16:00.637Z" }, + { url = "https://files.pythonhosted.org/packages/82/e2/9132d38df320e8099e2642d9fa538f18a7dfebf68a875a31725dc2a115aa/pillow-13.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e98b4f91f99b24b1da72e10621ba181f7281922384a7403094debb4199de6c00", size = 4190620, upload-time = "2026-10-16T22:16:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/cb/be/436dac0996876f65ef1916ffb711c5ef4af2ebc57414b6f16642e07f6b47/pillow-13.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:34fb3db1149c879343861831728c243c432d4a5afc8f1878ca7612ef8de2a033", size = 4290286, upload-time = "2026-10-16T22:16:04.861Z" }, + { url = "https://files.pythonhosted.org/packages/60/16/7c1007162e4169ea0d5bec47e2e0b2174f56c50cf5b8c02a1dc23e41037e/pillow-13.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:2d50500a4a3bc6469adcdb319cf05d688b08ca3bf0f22a05339f3b25b433a599", size = 3723169, upload-time = "2026-10-16T22:16:06.956Z" }, + { url = "https://files.pythonhosted.org/packages/08/bc/578ab2eaa04705a8978bd9be0fe15c3d43a8fc95097c4f7e39540e79e4fa/pillow-13.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ef5fe6b7db3f501c56000ba8fe7c7e1a35ca1d45824fbeb95a67daa85cff4dbe", size = 5568469, upload-time = "2026-10-16T22:16:08.664Z" }, + { url = "https://files.pythonhosted.org/packages/98/c8/ccede3dcc4f274b80fb49199eed1ef34ba05566c8b71976b9d1f111da2e3/pillow-13.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f11a3c1358bb3c68012a7b1f41b223c3db9f7bfde90b481df028cfe1c3363313", size = 4852187, upload-time = "2026-10-16T22:16:10.693Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3c/cd0770e96d20bfd990db024bcbd43897ff3e88da1f4782cf90ea231b1d4f/pillow-13.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19b66ca0a4d25cc0200b381ce824c7ea090db61427315e73cfb05dbf1b35c369", size = 6515908, upload-time = "2026-10-16T22:16:12.896Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fe/f5cdaa2d1c4086e757430f368c271a58ee29cfe7378e7ae7b9d964015c25/pillow-13.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5f56499c1a74d2b862beb0c33b8016f99d095858455df9d21583bbbc1c15b582", size = 7142544, upload-time = "2026-10-16T22:16:14.844Z" }, + { url = "https://files.pythonhosted.org/packages/fb/b5/a54f6d44adbcd23737c8c12953f42be107e4cca64e247c0ca3a5b563061c/pillow-13.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2a14cd73d5e6c927f644b0110ee81f59c4ef506259ec9183904747f2caacc81", size = 6613097, upload-time = "2026-10-16T22:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b4/9e6c1207c21b2e91db0d15c16d30f3701ce5add1bd4da0e787761e66cb6e/pillow-13.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08b826a9bf1265c76de04a42061d61ea6b109418b7826f28ff1ecdfaa26c5502", size = 7224846, upload-time = "2026-10-16T22:16:19.195Z" }, + { url = "https://files.pythonhosted.org/packages/78/9f/d3c3cb0518f3dd1eee0ef501535f53d15597a53c5c4118d178086143397a/pillow-13.0.0-cp313-cp313-win32.whl", hash = "sha256:12b0fa15879fe5a9d9eebe66e4e2d74128a773b1a4448010c9c714f45e04b9d8", size = 6516013, upload-time = "2026-10-16T22:16:21.389Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ed/1b16fb66a37e1b9e41370b93b2c614c7da761a5b2a41dc333f7af4639962/pillow-13.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:9dda0788a9932776a75d8bcc020076edc308ce2508b51f6739a45e3158b3fc42", size = 7284777, upload-time = "2026-10-16T22:16:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/7d/8d02eba9f7ecc87f8e90f731e032cc4773802f2a843763afd0c66a535ff4/pillow-13.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:40a266371ece581e5de13b6635ef78d7487824af80561b6ca27694a88c19bc0f", size = 2698826, upload-time = "2026-10-16T22:16:25.382Z" }, + { url = "https://files.pythonhosted.org/packages/8e/45/fbad9c7f5e2f1745c373a434d46072bc84eddfefaa28b852425ac957fda6/pillow-13.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:44629d4498c9cb6b2051806155f22141df0d8a929c56134b166655232dfb2f8c", size = 4190721, upload-time = "2026-10-16T22:16:27.524Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bf/2074902a259a90692c5dbf132885d21671644cbbd3f690047516243a55db/pillow-13.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:bf97ba1b5542050e9b0c74e632323b6d8136cc99d64e193abebee22fe6013c76", size = 4290236, upload-time = "2026-10-16T22:16:29.562Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a5/3d1bb93b8ea38d15d21356c1b8e9f50971ac592aaa248f7fb71739094c0e/pillow-13.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:aa694c49787040943570d61c9b8fc01271bd2a2cfdd55f0d896f8aecdce1a981", size = 3723093, upload-time = "2026-10-16T22:16:31.249Z" }, + { url = "https://files.pythonhosted.org/packages/84/a1/be060135d85187b25fa8b2573786c0e45c62ae27527fff5257675eb97f4a/pillow-13.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:49c12af515073e9bf511e4ef736743bf70d531a643668722279314b937742ba4", size = 5572253, upload-time = "2026-10-16T22:16:33.103Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f4/0bf6f1ac9ba7ee2ffe9914aeb410a1035a0c1a3c42f122388d7fc950bc4b/pillow-13.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d9b0a2bee3f3e03e9f29057aedb807dbf1e1d96ee1b9d0f422d9db6c8583271d", size = 4852269, upload-time = "2026-10-16T22:16:35.102Z" }, + { url = "https://files.pythonhosted.org/packages/83/16/b603274617b3e6728da3a0133d4fba1ea8d7fbe0c4ea5fef9cbffa398b8b/pillow-13.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5933fcea5a5ba64c686f55c886a115cbfd92f2900460784a1e79909fdbafd7b6", size = 6516058, upload-time = "2026-10-16T22:16:37.228Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f2/1371707ce438ba9a43f9d24089828494ec6f9396e28cd7903aa704dd6306/pillow-13.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e91c2b1cbeae768c98752e67e717e283d68dae45717b41f2d91eaee648e37b7d", size = 7142574, upload-time = "2026-10-16T22:16:39.172Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a9/62cf68587f910bc87c5951bcb6e9430c7643a65076adf5f18b4bf350e2bd/pillow-13.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f6299e494e46253d40c34653cac369412f44ca53b5f85d848d3f95b1ed0546c", size = 6614295, upload-time = "2026-10-16T22:16:41.117Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c9/9e9b48178649730299be0bafbf5f810a5b6977538f5a92d47e4ba5f5d608/pillow-13.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f9bd7d260d986f3553518c5d723544346fa48031f8aef3265d786aeb59845bc0", size = 7224841, upload-time = "2026-10-16T22:16:43.118Z" }, + { url = "https://files.pythonhosted.org/packages/8f/48/c44e91384b1b6c072a714a63d00608acc16a6a968735e478e4be4384e5ac/pillow-13.0.0-cp314-cp314-win32.whl", hash = "sha256:2e31c4e68cffd7b1f116127626813b82fcba19edf6b940a7b0c266d6484d0055", size = 6508694, upload-time = "2026-10-16T22:16:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/50/e2/7ad17747517e4c813dca78257d6bb593466f8231baf1384bc98d92745edb/pillow-13.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:f7cbb7a64e7ec664775195e4f81923bc8a93d9451b963f844e59118f63147a7a", size = 7281830, upload-time = "2026-10-16T22:16:47.725Z" }, + { url = "https://files.pythonhosted.org/packages/41/9f/e5b51f2ce8ef4563215334035403ce61a244f93a2e65c24a940131618f87/pillow-13.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:a6aaa7871aebca081eb5c45f83e845bbd9712ae99fa9286dbfdcee835e63f4b4", size = 2695468, upload-time = "2026-10-16T22:16:49.515Z" }, + { url = "https://files.pythonhosted.org/packages/97/73/a53502f731f6c84805a4ce87a8881e86d07a65aee64728b900d112dc703e/pillow-13.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7b999816b5850a35ada08443e6e03945f1f6488144a83afe2d37b240a552aafa", size = 5578196, upload-time = "2026-10-16T22:16:51.291Z" }, + { url = "https://files.pythonhosted.org/packages/21/50/7e949959a56b5559c78ef172b70fc6025706acb289f3f648a15329e4fa6f/pillow-13.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:67871a13cf47275201553b536c025da777f997fa562a19eb798af4ad4b97c18f", size = 4857276, upload-time = "2026-10-16T22:16:53.399Z" }, + { url = "https://files.pythonhosted.org/packages/c9/52/43ded0ac08e77e39d17bd6d894162572201b5e8fdc4a022b14c55a1960a4/pillow-13.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8ca8f69b7c77fa96dad99f8638c1fc4f1c0bed4acaa1baf7a3c31bdb80a0ab1", size = 6607008, upload-time = "2026-10-16T22:16:55.525Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2e/4a57ed216d5bf877387cba4d44c02aa87abe51ad2e06404aa6b597c4da39/pillow-13.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bce6a29c3898191f4c9998e4d4e8f69790caeabde46e54da4bc235ce5bde683", size = 7224281, upload-time = "2026-10-16T22:16:57.738Z" }, + { url = "https://files.pythonhosted.org/packages/5c/3d/2929b248e135752d63aaeabf53f6de797e905b7b6263f9c1e2adade20aba/pillow-13.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:816752a5ef76a80484c8c441c85593b7ea9304f6231fc5776ad1fbff7e773058", size = 6695600, upload-time = "2026-10-16T22:16:59.842Z" }, + { url = "https://files.pythonhosted.org/packages/bc/33/fb0da992cf084ed1ee6ee05e96b6191e35c351eb707ca2df874feb24ce47/pillow-13.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5bdb38ba220b61b4ec31dd1046dea6b016d3c6850a23f43ed8b58332b2fc668e", size = 7300589, upload-time = "2026-10-16T22:17:01.686Z" }, + { url = "https://files.pythonhosted.org/packages/bb/af/c30dd9ef6a3dce84ecb1b2393cac629047a32e66279056c6cb46c3ffd65c/pillow-13.0.0-cp314-cp314t-win32.whl", hash = "sha256:06903aac074d00c988e77a1fc435286dc498506d4ca8d6f69df9babddf4ecc15", size = 6517204, upload-time = "2026-10-16T22:17:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/85/d5/ef3a8ae7609c8ab26570abf18194882534b480d245a9148b895a2262c004/pillow-13.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9a635068d9793c7f0d929f39f18b58f1bd86c236e8e7b64167b86af9232ebca0", size = 7286695, upload-time = "2026-10-16T22:17:05.543Z" }, + { url = "https://files.pythonhosted.org/packages/84/3c/0861db0c69a03689ebe3e7599d35668a414deb5aaafd8255215e4e62e3d3/pillow-13.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0d1a9cf7bc6917538b20f8d06b9ab2147e542f68849e96fc58eb66bdf6acc692", size = 2698098, upload-time = "2026-10-16T22:17:07.674Z" }, + { url = "https://files.pythonhosted.org/packages/6e/eb/e86a41962a3a31ad495a3d6a912d9002b01bba4a6fa71c28703abdca7f69/pillow-13.0.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7d5e097eabd5d4915c27ac9f6e9325bb1e01a7c94d7348b5c4451d3fc30e8521", size = 4191213, upload-time = "2026-10-16T22:17:09.449Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/7e7445e706afec84ef06dc25b2c77f9cecff242c23730c62928ad1089d4a/pillow-13.0.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:51ebb529fc5e8abdd55302f6c3f8943037348ec2384329a3e55677f69ae2fdfc", size = 4290650, upload-time = "2026-10-16T22:17:11.196Z" }, + { url = "https://files.pythonhosted.org/packages/46/85/d534089068ac40c0f61f650389a296c812eab745a8a733f81feec39ae955/pillow-13.0.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:c87a04e7f152ee4db5dbe15f0bfa3fe628a1aeedbe66b81c43b87f52c813f9ac", size = 3723078, upload-time = "2026-10-16T22:17:12.772Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2b/00b5c3b401df538b5dbe311d3899730fae5ba1e21cb8b80a128887ac6747/pillow-13.0.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:20722cd5375533ad754053363a80b8fb30c79d37930a6f7228030e237ce3bd5d", size = 5572292, upload-time = "2026-10-16T22:17:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/72/f5/a6b0610f946e9b54ae888ba7ad29d6e2f8d9b11f69e212d70416b7186c09/pillow-13.0.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:04a63720f79e22fd54d897c233a68314c50ca0603360f54ce2bd5db8f0f43782", size = 4852222, upload-time = "2026-10-16T22:17:16.521Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d0/c0ff25bb0427ec70f32aac995453321fc3c4494a46c7292ff02d9d260b04/pillow-13.0.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f8db54177035a2b5f717f94475b74907e12a96dc74f6d487f3966b0a4694071", size = 6527349, upload-time = "2026-10-16T22:17:18.534Z" }, + { url = "https://files.pythonhosted.org/packages/85/11/e46fa0bcc0fc117f1eacc206b3d51f04d583fab328b4b740672d0438d83f/pillow-13.0.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f36aa77f74b14a2103622b196402c71330e4e7cf4122ee45c854e9a01e0eecf", size = 7152873, upload-time = "2026-10-16T22:17:20.415Z" }, + { url = "https://files.pythonhosted.org/packages/7c/53/2bdeca705f3a58679fa9201233193c86a30238340ca29b3a152c3fcc719b/pillow-13.0.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:149a1c323ba366f90b48e6f1a6db6582eb50512df374588de56fc909abc0f0f3", size = 6626871, upload-time = "2026-10-16T22:17:22.361Z" }, + { url = "https://files.pythonhosted.org/packages/14/de/3829030f209722ade3753bb456b805a0e7f885cf885b483407365c82e9ca/pillow-13.0.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e892354a424b1e82a78ba16785822bdd40de06f19446a3d4a28047df1a4f8932", size = 7236439, upload-time = "2026-10-16T22:17:24.352Z" }, + { url = "https://files.pythonhosted.org/packages/44/b1/67fc37388382bc036a74f04da5bb6fcbf90ec3ac1f45893e06176c373fd3/pillow-13.0.0-cp315-cp315-win32.whl", hash = "sha256:8e459b613cf31f8980f584387b4fe47bf08e076e288d61aa8eba1f7fd4ddbb81", size = 6508688, upload-time = "2026-10-16T22:17:26.314Z" }, + { url = "https://files.pythonhosted.org/packages/44/05/fdb6a79aee10861165526b41b7b440e36ee1ff58613b2fbefe3acbf06d4f/pillow-13.0.0-cp315-cp315-win_amd64.whl", hash = "sha256:d4737f50d23ddd11d6685faacbb9252887dde0bc7997127757247983d74399d6", size = 7281107, upload-time = "2026-10-16T22:17:28.198Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9c/6c6f7dc2a566467baf121394e83de25ac287cffe34cc030726beb4be0224/pillow-13.0.0-cp315-cp315-win_arm64.whl", hash = "sha256:2310e2adfc13c6d457d7d0f2ffdd5c56a74a317650cf765887df8d515a647ac0", size = 2695513, upload-time = "2026-10-16T22:17:30.013Z" }, + { url = "https://files.pythonhosted.org/packages/23/16/e9120defdb8bff693b582d035312acc6f530cdc2bc5238a87f410efccd82/pillow-13.0.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:21c32061a5512dc13f87cc7303e120e4d77c2f70df89a35af0e3ce246e5ac57f", size = 5578129, upload-time = "2026-10-16T22:17:31.642Z" }, + { url = "https://files.pythonhosted.org/packages/47/22/76faf2bb2448f48a84cf894c1e2bb1090d7d2b1aa521ece131cb2f05d631/pillow-13.0.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c774b9c0ba0d0b356f71530677b39028878a2cae5a93e0a13779cfd17120f88", size = 4857428, upload-time = "2026-10-16T22:17:33.43Z" }, + { url = "https://files.pythonhosted.org/packages/b3/64/0861ea4e03c50b14af9ed9dd8b9f388ab42f445c623a9fcbab19aef7aa55/pillow-13.0.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20822140c9a2ba9c5e42df342b0a892ab2e102fc0e3db967a133bb9ce3f898bb", size = 6612582, upload-time = "2026-10-16T22:17:35.415Z" }, + { url = "https://files.pythonhosted.org/packages/5c/20/cd1e9b3f60a9f3d5ebe46da36955c26004092a501f7f2c42f9fcc26611bc/pillow-13.0.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffea6b2986a797c84b6d1d27d4f43e0b83c73fc16281a930891cd237cf0d08fe", size = 7230176, upload-time = "2026-10-16T22:17:37.362Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ef/ffb0a2fc85690714fb7ac662956949645d57f15bf4426f520da361e7b6e3/pillow-13.0.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:05cd2cd39055d0b9650060e4513ef4a9a0aea870083950bd046a0d6490f93a22", size = 6703640, upload-time = "2026-10-16T22:17:39.313Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c2/6402ee3ccb3363aeed8fdb3f9fe56703ac5377a539f0b81b53999d7e121f/pillow-13.0.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c6fd031333dedcad7ce3e03d244093f43504f209e86925ebdb2b868fa4037630", size = 7309231, upload-time = "2026-10-16T22:17:41.021Z" }, + { url = "https://files.pythonhosted.org/packages/fc/96/d6b82e8b8c06ad143b66b12e0b31051e4bf489b4fabca1f390a14fe1ebde/pillow-13.0.0-cp315-cp315t-win32.whl", hash = "sha256:c50c80e3591e5cf9a8dfa6007d4f10732f02e32263850999286e56ccd650cfb2", size = 6517200, upload-time = "2026-10-16T22:17:42.886Z" }, + { url = "https://files.pythonhosted.org/packages/60/d4/0062e6005e9b270b165015aafd343364ac378ec5a685431ea42baad33357/pillow-13.0.0-cp315-cp315t-win_amd64.whl", hash = "sha256:adb3c2124c68e480f4632d06cb7caa3b48a51678475f483b4edb1c6dee29ea43", size = 7287488, upload-time = "2026-10-16T22:17:45.56Z" }, + { url = "https://files.pythonhosted.org/packages/e4/81/b8eb349746be7bce47dd95337c6634e84910af91b59aaa9159c034c614fa/pillow-13.0.0-cp315-cp315t-win_arm64.whl", hash = "sha256:4a25756244bdcce3b50580baadf320fa48297f0eeb8e114ba64139a40d4d0fc8", size = 2698073, upload-time = "2026-10-16T22:17:47.556Z" }, + { url = "https://files.pythonhosted.org/packages/68/c0/fb7b1caf25ed5ac5d46116f09b8b0706b9559833c65c667cecb1a53153f2/pillow-13.0.0-pp311-pypy311_pp80-macosx_10_15_x86_64.whl", hash = "sha256:405be6058e206a86fedce2f715ecc2343f94072d7925251632dffa2c05f70dca", size = 5521630, upload-time = "2026-10-16T22:17:50.008Z" }, + { url = "https://files.pythonhosted.org/packages/1a/e6/4d11d5b1137f4fc080605cce410cfaae4f303ad400bdec75d27d3083fa29/pillow-13.0.0-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:b84be41387fc00db28c13c02e30d42b02d8d8f6a4a741a1aee6012ca7f1f6039", size = 4806023, upload-time = "2026-10-16T22:17:51.932Z" }, + { url = "https://files.pythonhosted.org/packages/f1/45/0923202a658e8ff6aafd72c1a4395ebadcdebaa182c437d8595d3dd3db32/pillow-13.0.0-pp311-pypy311_pp80-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a192360d512305fe92d3053e4f7627bbad1f7708f1f4ad38cccd9336acabe9ad", size = 5464831, upload-time = "2026-10-16T22:17:53.742Z" }, + { url = "https://files.pythonhosted.org/packages/07/52/d3e38f0cad1dadea2a658b9a7d6ba8a04048f5ae14f011b806e5c3bd6bcb/pillow-13.0.0-pp311-pypy311_pp80-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83b37abd87b590acd8f47ca8a61f70893322b11834d36e07c2298dacad4d14d2", size = 6043133, upload-time = "2026-10-16T22:17:55.625Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f9/2ed7685980c1d64f8131a7d87a19c369424747577152622b73ddeff163b7/pillow-13.0.0-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:73ee25cf8cf8b75d5a5aeed7cb6588f5e5dbfbb0fde51707b630bbb23897645c", size = 7365208, upload-time = "2026-10-16T22:17:57.509Z" }, + { url = "https://files.pythonhosted.org/packages/60/da/f6d644e631c5b370c5d0b899f3ccadd2780aab070b29a110527cf4e1fd2c/pillow-13.0.0-pp312-pypy312_pp80-macosx_10_15_x86_64.whl", hash = "sha256:8619685cad8efc4c38c66d3b36aad854523297c43d748b3b6f0cc03c90ba3463", size = 5523188, upload-time = "2026-10-16T22:17:59.334Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b0/2594f202d743a35d4fe80682bfd96f277e5ca7aecb27cf64ef5bfc1d000b/pillow-13.0.0-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:7e91e97a929232cdb828889ee6ad0e48d7f40f4ec38818f4e4a8968b2b1af22e", size = 4806808, upload-time = "2026-10-16T22:18:01.188Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ed/d504165ef79af6fafe25c76726cf30f544cc236002f7ed054f1d51adc4ff/pillow-13.0.0-pp312-pypy312_pp80-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93ff9c9c3663ce23aaa5d0b6b6abac55359e2a0c06c7e30f3771829af816be63", size = 5464358, upload-time = "2026-10-16T22:18:02.779Z" }, + { url = "https://files.pythonhosted.org/packages/7b/fe/a10aba5503ca13e9e009b8bd0a1a4ce5584bdce246245fd6f3a83701369c/pillow-13.0.0-pp312-pypy312_pp80-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e65dd6e75de67b2362a4fa3e16aa627595f65054dc4ec73d64680c84d592fa61", size = 6043514, upload-time = "2026-10-16T22:18:04.538Z" }, + { url = "https://files.pythonhosted.org/packages/c0/37/66b12dc476041fe523bca29c9d58fbf1ea92a4eaf73acfb9e993948f1191/pillow-13.0.0-pp312-pypy312_pp80-win_amd64.whl", hash = "sha256:567f20f1c75897d7663830da6ea8af719e03d712b1d9ee7f813319f9ece42e5a", size = 7366030, upload-time = "2026-10-16T22:18:06.638Z" }, ] [[package]] diff --git a/dashboard/e2e/family-dashboard.spec.ts b/dashboard/e2e/family-dashboard.spec.ts index 0603356..c7c5b34 100644 --- a/dashboard/e2e/family-dashboard.spec.ts +++ b/dashboard/e2e/family-dashboard.spec.ts @@ -187,6 +187,18 @@ test("family home is responsive, keyboard reachable, and accessible", async ({ p expect(errors).toEqual([]); }); +test("empty capture history never claims monitoring is active", async ({ page }) => { + await page.route("**/api/dashboard/capture-status", (route) => fulfill(route, { + latest_capture_at: null, latest_capture_device_id: null, latest_capture_hostname: null, + pending_review_count: 0, reviewing_count: 0, waiting_upload_count: 0, + estimated_wait_seconds: null, last_reviewed_at: null, + })); + await page.goto("/"); + await expect(page.getByRole("status").filter({ hasText: "Coverage needs checking" })).toBeVisible(); + await expect(page.getByText("No captures received yet")).toBeVisible(); + await expect(page.getByText("Local protection active")).toHaveCount(0); +}); + test("core parent journeys render without runtime errors", async ({ page }) => { const errors = collectBrowserErrors(page); const journeys = [ @@ -213,3 +225,43 @@ test("secure pairing instructions expose the exact trust handoff", async ({ page await expect(page.getByText("cedar · harbor · maple · river")).toBeVisible(); await expect(page.getByRole("link", { name: "Download .gnpair bundle" })).toHaveAttribute("download", ""); }); + +test("protected pause uses masked step-up and resumes only after confirmation", async ({ page }, testInfo) => { + const errors = collectBrowserErrors(page); + let pauseCalls = 0; + let confirmed = false; + await page.route("**/api/devices/device-1/pause", async (route) => { + pauseCalls += 1; + if (!confirmed) return fulfill(route, { detail: { code: "step_up_required", level: "standard" } }, 403); + return fulfill(route, { ok: true }); + }); + await page.route("**/api/auth/reauth", async (route) => { + const body = route.request().postDataJSON(); + if (body.password !== "synthetic-browser-password") return fulfill(route, { detail: "Invalid password" }, 401); + confirmed = true; + return fulfill(route, { ok: true }); + }); + + await page.goto("/devices"); + await expect(page).toHaveTitle(/GuardianNode/); + await page.getByRole("button", { name: "Pause", exact: true }).click(); + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("heading", { name: "Confirm your password" })).toBeVisible(); + await expect(dialog.getByLabel("Parent password")).toHaveAttribute("type", "password"); + await mkdir("/tmp/guardiannode-qa", { recursive: true }); + await page.screenshot({ path: `/tmp/guardiannode-qa/${testInfo.project.name}-step-up.png`, fullPage: false }); + await dialog.getByRole("button", { name: "Cancel" }).click(); + await expect(dialog).not.toBeVisible(); + expect(pauseCalls).toBe(1); + + await page.getByRole("button", { name: "Pause", exact: true }).click(); + await dialog.getByLabel("Parent password").fill("incorrect"); + await dialog.getByRole("button", { name: "Continue" }).click(); + await expect(dialog.getByRole("alert")).toContainText("Invalid password"); + expect(pauseCalls).toBe(2); + await dialog.getByLabel("Parent password").fill("synthetic-browser-password"); + await dialog.getByRole("button", { name: "Continue" }).click(); + await expect(page.getByText("Monitoring pause recorded.")).toBeVisible(); + expect(pauseCalls).toBe(3); + expect(errors.filter((message) => !/status of (401|403) \(/.test(message))).toEqual([]); +}); diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 86c8637..90c40ce 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -284,40 +284,6 @@ "node": ">=20.19.0" } }, - "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@exodus/bytes": { "version": "1.15.1", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", @@ -395,33 +361,14 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, "node_modules/@oxc-project/types": { - "version": "0.138.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", - "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "version": "0.156.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.156.0.tgz", + "integrity": "sha512-1Duyg48TdJNdyhEB8Xn1L36bT9Pv6GUtlKjHxOys8xkaF/rQq3XJE+bAurSK/AgIz5mGFsb77cn9Pb20+WCScw==", "dev": true, "license": "MIT", "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" } }, "node_modules/@playwright/test": { @@ -465,20 +412,20 @@ "license": "MIT" }, "node_modules/@redocly/openapi-core": { - "version": "1.34.7", - "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.7.tgz", - "integrity": "sha512-gn2P0OER6qxF/+f4GqNv9XsnU5+6oszD/0SunulOvPYJDhrNkNVrVZV5waX25uqw5UDn2+roViWlRDHKFfHH0g==", + "version": "1.34.22", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.22.tgz", + "integrity": "sha512-dEyx4uHh6Pc6ZkdU9Xe8rA4h12Odq6QKU/fqseBn07xwVwskhHiF3KHhqmw5BR+jAiFTvKTmwCafFql/k1pSzA==", "dev": true, "license": "MIT", "dependencies": { - "@redocly/ajv": "^8.11.2", - "@redocly/config": "^0.22.0", - "colorette": "^1.2.0", - "https-proxy-agent": "^7.0.5", - "js-levenshtein": "^1.1.6", - "js-yaml": "^4.1.0", - "minimatch": "^5.0.1", - "pluralize": "^8.0.0", + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.3.2", + "minimatch": "5.1.9", + "pluralize": "8.0.0", "yaml-ast-parser": "0.0.43" }, "engines": { @@ -486,10 +433,27 @@ "npm": ">=9.5.0" } }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.16.tgz", + "integrity": "sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", - "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.16.tgz", + "integrity": "sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==", "cpu": [ "arm64" ], @@ -504,9 +468,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", - "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.16.tgz", + "integrity": "sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==", "cpu": [ "arm64" ], @@ -521,9 +485,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", - "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.16.tgz", + "integrity": "sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==", "cpu": [ "x64" ], @@ -538,9 +502,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", - "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.16.tgz", + "integrity": "sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==", "cpu": [ "x64" ], @@ -555,9 +519,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", - "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.16.tgz", + "integrity": "sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==", "cpu": [ "arm" ], @@ -572,13 +536,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", - "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.16.tgz", + "integrity": "sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -589,13 +556,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", - "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.16.tgz", + "integrity": "sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -606,13 +576,16 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", - "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.16.tgz", + "integrity": "sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -623,13 +596,16 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", - "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.16.tgz", + "integrity": "sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -640,13 +616,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", - "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.16.tgz", + "integrity": "sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -657,13 +636,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", - "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.16.tgz", + "integrity": "sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -674,9 +656,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", - "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.16.tgz", + "integrity": "sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==", "cpu": [ "arm64" ], @@ -690,29 +672,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", - "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", - "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.16.tgz", + "integrity": "sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==", "cpu": [ "arm64" ], @@ -727,9 +690,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", - "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.16.tgz", + "integrity": "sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==", "cpu": [ "x64" ], @@ -1145,17 +1108,6 @@ } } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -1182,9 +1134,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz", + "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==", "dev": true, "license": "MIT" }, @@ -1233,16 +1185,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", - "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz", + "integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1251,13 +1203,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", - "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz", + "integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.11", + "@vitest/spy": "4.1.12", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1278,9 +1230,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", - "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz", + "integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==", "dev": true, "license": "MIT", "dependencies": { @@ -1291,13 +1243,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", - "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz", + "integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.11", + "@vitest/utils": "4.1.12", "pathe": "^2.0.3" }, "funding": { @@ -1305,14 +1257,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", - "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz", + "integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/pretty-format": "4.1.12", + "@vitest/utils": "4.1.12", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1321,9 +1273,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", - "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz", + "integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==", "dev": true, "license": "MIT", "funding": { @@ -1331,13 +1283,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", - "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz", + "integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.11", + "@vitest/pretty-format": "4.1.12", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -1492,9 +1444,9 @@ } }, "node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "dependencies": { @@ -1557,9 +1509,9 @@ "license": "CC-BY-4.0" }, "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", "engines": { @@ -2227,9 +2179,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "funding": [ { "type": "github", @@ -2330,6 +2282,19 @@ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/playwright": { "version": "1.62.1", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", @@ -2388,9 +2353,9 @@ } }, "node_modules/postcss": { - "version": "8.5.26", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", - "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", "funding": [ { "type": "opencollective", @@ -2407,9 +2372,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.17", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -2520,13 +2485,13 @@ } }, "node_modules/rolldown": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", - "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.16.tgz", + "integrity": "sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.138.0", + "@oxc-project/types": "=0.156.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -2536,21 +2501,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.4", - "@rolldown/binding-darwin-arm64": "1.1.4", - "@rolldown/binding-darwin-x64": "1.1.4", - "@rolldown/binding-freebsd-x64": "1.1.4", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", - "@rolldown/binding-linux-arm64-gnu": "1.1.4", - "@rolldown/binding-linux-arm64-musl": "1.1.4", - "@rolldown/binding-linux-ppc64-gnu": "1.1.4", - "@rolldown/binding-linux-s390x-gnu": "1.1.4", - "@rolldown/binding-linux-x64-gnu": "1.1.4", - "@rolldown/binding-linux-x64-musl": "1.1.4", - "@rolldown/binding-openharmony-arm64": "1.1.4", - "@rolldown/binding-wasm32-wasi": "1.1.4", - "@rolldown/binding-win32-arm64-msvc": "1.1.4", - "@rolldown/binding-win32-x64-msvc": "1.1.4" + "@rolldown/binding-android-arm-eabi": "1.2.16", + "@rolldown/binding-android-arm64": "1.2.16", + "@rolldown/binding-darwin-arm64": "1.2.16", + "@rolldown/binding-darwin-x64": "1.2.16", + "@rolldown/binding-freebsd-x64": "1.2.16", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.16", + "@rolldown/binding-linux-arm64-gnu": "1.2.16", + "@rolldown/binding-linux-arm64-musl": "1.2.16", + "@rolldown/binding-linux-ppc64-gnu": "1.2.16", + "@rolldown/binding-linux-s390x-gnu": "1.2.16", + "@rolldown/binding-linux-x64-gnu": "1.2.16", + "@rolldown/binding-linux-x64-musl": "1.2.16", + "@rolldown/binding-openharmony-arm64": "1.2.16", + "@rolldown/binding-win32-arm64-msvc": "1.2.16", + "@rolldown/binding-win32-x64-msvc": "1.2.16" } }, "node_modules/saxes": { @@ -2584,9 +2549,10 @@ "dev": true }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -2689,22 +2655,10 @@ } } }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tinyrainbow": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", - "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", "dev": true, "license": "MIT", "engines": { @@ -2753,14 +2707,6 @@ "node": ">=20" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, "node_modules/type-fest": { "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", @@ -2789,9 +2735,9 @@ } }, "node_modules/undici": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", - "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "version": "7.30.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz", + "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", "dev": true, "license": "MIT", "engines": { @@ -2837,16 +2783,16 @@ "license": "MIT" }, "node_modules/vite": { - "version": "8.1.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", - "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", + "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.16", - "rolldown": "~1.1.3", + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.30", + "rolldown": "~1.2.14", "tinyglobby": "^0.2.17" }, "bin": { @@ -2863,7 +2809,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", + "@vitejs/devtools": "^0.7.1 || ^0.8.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -2914,32 +2860,293 @@ } } }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=12" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/vitest": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", - "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz", + "integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.11", - "@vitest/mocker": "4.1.11", - "@vitest/pretty-format": "4.1.11", - "@vitest/runner": "4.1.11", - "@vitest/snapshot": "4.1.11", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/expect": "4.1.12", + "@vitest/mocker": "4.1.12", + "@vitest/pretty-format": "4.1.12", + "@vitest/runner": "4.1.12", + "@vitest/snapshot": "4.1.12", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -2967,12 +3174,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.11", - "@vitest/browser-preview": "4.1.11", - "@vitest/browser-webdriverio": "4.1.11", - "@vitest/coverage-istanbul": "4.1.11", - "@vitest/coverage-v8": "4.1.11", - "@vitest/ui": "4.1.11", + "@vitest/browser-playwright": "4.1.12", + "@vitest/browser-preview": "4.1.12", + "@vitest/browser-webdriverio": "^4.1.11", + "@vitest/coverage-istanbul": "4.1.12", + "@vitest/coverage-v8": "4.1.12", + "@vitest/ui": "4.1.12", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -3016,18 +3223,6 @@ } } }, - "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 4a30400..3a375c9 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -2,6 +2,7 @@ import { lazy, Suspense, useEffect, useState } from "react"; import { Navigate, Route, Routes, useNavigate } from "react-router-dom"; import { api } from "./api"; import Layout from "./components/Layout"; +import StepUpDialog from "./components/StepUpDialog"; import Login from "./pages/Login"; import Setup from "./pages/Setup"; @@ -81,6 +82,7 @@ export default function App() { return ( + Loading page…}> } /> diff --git a/dashboard/src/api.ts b/dashboard/src/api.ts index 34b0385..d6aedb1 100644 --- a/dashboard/src/api.ts +++ b/dashboard/src/api.ts @@ -1,5 +1,6 @@ // Typed API client. Uses cookie auth (no token to manage in localStorage). import type { components } from "./api-schema"; +import { requestStepUp } from "./stepUp"; export type Alert = components["schemas"]["AlertDTO"]; export type AlertPage = components["schemas"]["AlertPage"]; @@ -66,16 +67,7 @@ async function request(path: string, init: RequestInit = {}): Promise { if (res.status === 403 && mutating) { const challenge = await res.clone().json().catch(() => null); if (challenge?.detail?.code === "step_up_required") { - const password = window.prompt( - challenge.detail.level === "critical" - ? "Confirm your password for this critical security action." - : "Confirm your password to continue.", - ); - if (password === null) throw new Error("Recent authentication required"); - await request<{ ok: boolean }>("/auth/reauth", { - method: "POST", - body: JSON.stringify({ password }), - }); + await requestStepUp(challenge.detail.level); } else { cachedCsrfToken = null; headers.set("X-CSRF-Token", await getCsrfToken()); diff --git a/dashboard/src/components/StepUpDialog.test.tsx b/dashboard/src/components/StepUpDialog.test.tsx new file mode 100644 index 0000000..6ff3e3e --- /dev/null +++ b/dashboard/src/components/StepUpDialog.test.tsx @@ -0,0 +1,47 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { api } from "../api"; +import { requestStepUp } from "../stepUp"; +import StepUpDialog from "./StepUpDialog"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +function mount() { + vi.stubGlobal("HTMLDialogElement", HTMLDialogElement); + HTMLDialogElement.prototype.showModal = vi.fn(); + HTMLDialogElement.prototype.close = vi.fn(); + return render(); +} + +it("masks the password and resumes concurrent actions only after successful reauth", async () => { + mount(); + const reauth = vi.spyOn(api, "reauth").mockResolvedValue({ ok: true }); + let first!: Promise; + let second!: Promise; + await act(async () => { + first = requestStepUp("critical"); + second = requestStepUp("standard"); + }); + expect(first).toBe(second); + const input = screen.getByLabelText("Parent password") as HTMLInputElement; + expect(input.type).toBe("password"); + expect(input.autocomplete).toBe("current-password"); + fireEvent.change(input, { target: { value: "synthetic-password" } }); + fireEvent.click(screen.getByText("Continue")); + await first; + await waitFor(() => expect(reauth).toHaveBeenCalledOnce()); + expect(reauth).toHaveBeenCalledWith("synthetic-password"); +}); + +it("cancels without reauthenticating", async () => { + mount(); + const reauth = vi.spyOn(api, "reauth"); + let pending!: Promise; + await act(async () => { pending = requestStepUp("standard"); }); + fireEvent.click(screen.getByText("Cancel")); + await expect(pending).rejects.toThrow("cancelled"); + expect(reauth).not.toHaveBeenCalled(); +}); diff --git a/dashboard/src/components/StepUpDialog.tsx b/dashboard/src/components/StepUpDialog.tsx new file mode 100644 index 0000000..c5e00c7 --- /dev/null +++ b/dashboard/src/components/StepUpDialog.tsx @@ -0,0 +1,77 @@ +import { useEffect, useRef, useState } from "react"; +import { api, ApiError } from "../api"; +import { registerStepUpHandler } from "../stepUp"; + +type Challenge = { + level: string; + resolve: () => void; + reject: (reason: Error) => void; +}; + +export default function StepUpDialog() { + const [challenge, setChallenge] = useState(null); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + const dialogRef = useRef(null); + + useEffect(() => registerStepUpHandler((level) => new Promise((resolve, reject) => { + setPassword(""); + setError(""); + setChallenge({ level, resolve, reject }); + })), []); + + useEffect(() => { + if (challenge) dialogRef.current?.showModal(); + else dialogRef.current?.close(); + }, [challenge]); + + function cancel() { + if (busy || !challenge) return; + challenge.reject(new Error("Password confirmation cancelled.")); + setPassword(""); + setChallenge(null); + } + + async function confirm(event: React.FormEvent) { + event.preventDefault(); + if (!challenge || busy) return; + setBusy(true); + setError(""); + try { + await api.reauth(password); + challenge.resolve(); + setPassword(""); + setChallenge(null); + } catch (cause) { + setError(cause instanceof ApiError ? cause.message : "Could not confirm your password. Try again."); + setPassword(""); + } finally { + setBusy(false); + } + } + + return ( + { event.preventDefault(); cancel(); }} + className="step-up-dialog fixed left-1/2 top-1/2 w-[calc(100%-2rem)] max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-xl border border-gray-200 bg-white p-6 text-gray-900 shadow-xl backdrop:bg-gray-950/50"> + {challenge &&
+

Confirm your password

+

+ {challenge.level === "critical" ? "Confirm this security action." : "Confirm this action."} +

+ + setPassword(event.target.value)} + className="rounded-md border border-gray-300 px-3 py-2" /> + {error &&

{error}

} +
+ + +
+
} +
+ ); +} diff --git a/dashboard/src/pages/AlertDetail.tsx b/dashboard/src/pages/AlertDetail.tsx index 56db43d..26518f9 100644 --- a/dashboard/src/pages/AlertDetail.tsx +++ b/dashboard/src/pages/AlertDetail.tsx @@ -181,9 +181,9 @@ export default function AlertDetail() {

Parent-confirmed device action

-

GuardianNode never takes these actions from a classifier result. You will see and confirm the exact command first.

+

GuardianNode never takes these actions from a classifier result. You will see and confirm the exact command first. App pausing and domain blocking are unavailable while their Windows recovery behavior is being qualified.

- + {enforcementAction === "pause_app" && } {enforcementAction === "show_child_prompt" &&