From 98e00214e02c08ba91651b50a33ab8f90e13f2d7 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sun, 17 May 2026 04:16:26 +0530 Subject: [PATCH 01/10] feat(dashboard): add session-based auth with setup flow Users + sessions live in the existing dashboard_settings KV store, so all three storage backends work without new tables. Passwords use stdlib PBKDF2-HMAC-SHA256 (600k iters, OWASP 2023+ baseline). Session cookie is HttpOnly + SameSite=Strict; state-changing routes require a double-submit CSRF token. Until the first admin is created via /api/auth/setup or the TASKITO_DASHBOARD_ADMIN_USER / TASKITO_DASHBOARD_ADMIN_PASSWORD env vars, every protected route returns 503 setup_required. --- py_src/taskito/dashboard/_testing.py | 83 ++++ py_src/taskito/dashboard/auth.py | 354 +++++++++++++++ py_src/taskito/dashboard/handlers/auth.py | 126 ++++++ py_src/taskito/dashboard/request_context.py | 94 ++++ py_src/taskito/dashboard/routes.py | 78 ++++ py_src/taskito/dashboard/server.py | 330 ++++++++++---- tests/dashboard/test_auth.py | 453 ++++++++++++++++++++ tests/dashboard/test_dashboard.py | 92 ++-- tests/dashboard/test_dashboard_settings.py | 98 ++--- 9 files changed, 1526 insertions(+), 182 deletions(-) create mode 100644 py_src/taskito/dashboard/_testing.py create mode 100644 py_src/taskito/dashboard/auth.py create mode 100644 py_src/taskito/dashboard/handlers/auth.py create mode 100644 py_src/taskito/dashboard/request_context.py create mode 100644 tests/dashboard/test_auth.py diff --git a/py_src/taskito/dashboard/_testing.py b/py_src/taskito/dashboard/_testing.py new file mode 100644 index 00000000..67f7d479 --- /dev/null +++ b/py_src/taskito/dashboard/_testing.py @@ -0,0 +1,83 @@ +"""Shared helpers for dashboard endpoint tests. + +The dashboard requires a logged-in session for every API route once setup +is complete. :class:`AuthedClient` wraps the stdlib ``urllib.request`` so +tests can issue authenticated HTTP calls without repeating the cookie / +CSRF dance. +""" + +from __future__ import annotations + +import json +import urllib.error +import urllib.request +from dataclasses import dataclass +from typing import Any + +from taskito import Queue +from taskito.dashboard.auth import AuthStore, Session + + +@dataclass(frozen=True) +class AuthedClient: + """Stateless HTTP helper that attaches session + CSRF to every call.""" + + base: str + session: Session + + @property + def _cookies(self) -> dict[str, str]: + return { + "taskito_session": self.session.token, + "taskito_csrf": self.session.csrf_token, + } + + def _cookie_header(self) -> str: + return "; ".join(f"{k}={v}" for k, v in self._cookies.items()) + + def get(self, path: str, *, raise_for_status: bool = True) -> Any: + url = self.base + path + req = urllib.request.Request(url, method="GET") + req.add_header("Cookie", self._cookie_header()) + try: + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read() or b"{}") + except urllib.error.HTTPError as e: + if raise_for_status: + raise + return {"status": e.code, "body": json.loads(e.read() or b"{}")} + + def post(self, path: str, body: dict | None = None) -> Any: + return self._mutate("POST", path, body) + + def put(self, path: str, body: dict | None = None) -> Any: + return self._mutate("PUT", path, body) + + def delete(self, path: str) -> Any: + return self._mutate("DELETE", path, None) + + def _mutate(self, method: str, path: str, body: dict | None) -> Any: + url = self.base + path + data = json.dumps(body).encode() if body is not None else b"" + req = urllib.request.Request(url, method=method, data=data) + req.add_header("Cookie", self._cookie_header()) + req.add_header("X-CSRF-Token", self.session.csrf_token) + if body is not None: + req.add_header("Content-Type", "application/json") + with urllib.request.urlopen(req) as resp: + return json.loads(resp.read() or b"{}") + + +def seed_admin_and_session( + queue: Queue, + *, + username: str = "test-admin", + password: str = "test-pass-1234", +) -> Session: + """Create a one-off admin and return a fresh session for it.""" + store = AuthStore(queue) + if store.get_user(username) is None: + store.create_user(username, password, role="admin") + user = store.get_user(username) + assert user is not None + return store.create_session(user) diff --git a/py_src/taskito/dashboard/auth.py b/py_src/taskito/dashboard/auth.py new file mode 100644 index 00000000..0c57f03d --- /dev/null +++ b/py_src/taskito/dashboard/auth.py @@ -0,0 +1,354 @@ +"""Authentication primitives for the dashboard. + +Users and sessions are persisted through ``Queue.set_setting`` / ``get_setting`` +— the same key/value store that already backs dashboard branding and +integration settings. This avoids new database tables and keeps the auth +feature working uniformly across SQLite, Postgres, and Redis backends. + +Key layout in ``dashboard_settings``: + +- ``auth:users`` — JSON object ``{username: {password_hash, role, ...}}`` +- ``auth:session:`` — JSON object describing one active session +- ``auth:csrf_secret`` — random secret used as a HMAC key for CSRF tokens + +Password hashes use PBKDF2-HMAC-SHA256 (stdlib ``hashlib``) with +600,000 iterations — the OWASP 2023+ baseline for PBKDF2. No third-party +crypto dependency is required. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import logging +import secrets +import time +from dataclasses import asdict, dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from taskito.app import Queue + + +logger = logging.getLogger("taskito.dashboard.auth") + +# ── Storage keys ─────────────────────────────────────────────────────── + +USERS_KEY = "auth:users" +SESSION_PREFIX = "auth:session:" +CSRF_SECRET_KEY = "auth:csrf_secret" + +# ── Crypto parameters ────────────────────────────────────────────────── + +PBKDF2_ITERATIONS = 600_000 +PBKDF2_SALT_BYTES = 16 +PBKDF2_HASH_BYTES = 32 +SESSION_TOKEN_BYTES = 32 + +# ── Session lifetime ─────────────────────────────────────────────────── + +DEFAULT_SESSION_TTL_SECONDS = 24 * 60 * 60 # 24h + +# ── Validation ───────────────────────────────────────────────────────── + +USERNAME_MAX_LEN = 64 +PASSWORD_MIN_LEN = 8 +PASSWORD_MAX_LEN = 256 +VALID_ROLES = frozenset({"admin", "viewer"}) + + +# ── Password hashing ─────────────────────────────────────────────────── + + +def hash_password(password: str) -> str: + """Hash a password with PBKDF2-HMAC-SHA256. + + Returns a self-describing string of the form + ``pbkdf2_sha256$$$`` so the verifier can + parse out the salt and iteration count without separate columns. + """ + salt = secrets.token_bytes(PBKDF2_SALT_BYTES) + digest = hashlib.pbkdf2_hmac( + "sha256", password.encode("utf-8"), salt, PBKDF2_ITERATIONS, PBKDF2_HASH_BYTES + ) + return f"pbkdf2_sha256${PBKDF2_ITERATIONS}${salt.hex()}${digest.hex()}" + + +def verify_password(password: str, encoded: str) -> bool: + """Constant-time verify a password against the encoded hash.""" + try: + scheme, iters_str, salt_hex, hash_hex = encoded.split("$") + except ValueError: + return False + if scheme != "pbkdf2_sha256": + return False + try: + iters = int(iters_str) + salt = bytes.fromhex(salt_hex) + expected = bytes.fromhex(hash_hex) + except ValueError: + return False + candidate = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iters, len(expected)) + return hmac.compare_digest(candidate, expected) + + +# ── Tokens ───────────────────────────────────────────────────────────── + + +def generate_session_token() -> str: + """Cryptographically secure URL-safe session token.""" + return secrets.token_urlsafe(SESSION_TOKEN_BYTES) + + +# ── Data classes ─────────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class User: + """A persisted dashboard user.""" + + username: str + password_hash: str + role: str + created_at: int + last_login_at: int | None = None + + +@dataclass(frozen=True) +class Session: + """An active dashboard session.""" + + token: str + username: str + role: str + created_at: int + expires_at: int + csrf_token: str + + def is_expired(self, now: int | None = None) -> bool: + return (now if now is not None else int(time.time())) >= self.expires_at + + +# ── Validation helpers ───────────────────────────────────────────────── + + +def _validate_username(username: str) -> None: + if not username: + raise ValueError("username must not be empty") + if len(username) > USERNAME_MAX_LEN: + raise ValueError(f"username must be <= {USERNAME_MAX_LEN} chars") + if not all(c.isalnum() or c in "._-" for c in username): + raise ValueError("username may only contain letters, digits, '.', '_', or '-'") + + +def _validate_password(password: str) -> None: + if len(password) < PASSWORD_MIN_LEN: + raise ValueError(f"password must be >= {PASSWORD_MIN_LEN} chars") + if len(password) > PASSWORD_MAX_LEN: + raise ValueError(f"password must be <= {PASSWORD_MAX_LEN} chars") + + +def _validate_role(role: str) -> None: + if role not in VALID_ROLES: + raise ValueError(f"role must be one of {sorted(VALID_ROLES)}") + + +# ── Auth store ───────────────────────────────────────────────────────── + + +class AuthStore: + """Read/write users and sessions through ``Queue``'s settings store.""" + + def __init__(self, queue: Queue) -> None: + self._queue = queue + + # ── Users ────────────────────────────────────────────────────── + + def _load_users(self) -> dict[str, dict[str, object]]: + raw = self._queue.get_setting(USERS_KEY) + if not raw: + return {} + try: + data = json.loads(raw) + except json.JSONDecodeError: + logger.warning("auth:users entry is not valid JSON; treating as empty") + return {} + return data if isinstance(data, dict) else {} + + def _save_users(self, users: dict[str, dict[str, object]]) -> None: + self._queue.set_setting(USERS_KEY, json.dumps(users, separators=(",", ":"))) + + def count_users(self) -> int: + return len(self._load_users()) + + def list_users(self) -> list[User]: + return [self._row_to_user(name, row) for name, row in self._load_users().items()] + + def get_user(self, username: str) -> User | None: + row = self._load_users().get(username) + return self._row_to_user(username, row) if row else None + + def create_user(self, username: str, password: str, role: str = "admin") -> User: + _validate_username(username) + _validate_password(password) + _validate_role(role) + users = self._load_users() + if username in users: + raise ValueError(f"user '{username}' already exists") + now = int(time.time()) + users[username] = { + "password_hash": hash_password(password), + "role": role, + "created_at": now, + "last_login_at": None, + } + self._save_users(users) + return self._row_to_user(username, users[username]) + + def update_password(self, username: str, new_password: str) -> None: + _validate_password(new_password) + users = self._load_users() + if username not in users: + raise ValueError(f"user '{username}' does not exist") + users[username]["password_hash"] = hash_password(new_password) + self._save_users(users) + + def delete_user(self, username: str) -> bool: + users = self._load_users() + if username not in users: + return False + del users[username] + self._save_users(users) + return True + + def authenticate(self, username: str, password: str) -> User | None: + """Return the user iff username+password match; updates last_login_at.""" + users = self._load_users() + row = users.get(username) + if not row: + # Run a dummy verify against a fixed hash to keep timing constant + # for unknown vs. known usernames. + verify_password(password, _DUMMY_HASH) + return None + if not verify_password(password, str(row["password_hash"])): + return None + row["last_login_at"] = int(time.time()) + users[username] = row + self._save_users(users) + return self._row_to_user(username, row) + + @staticmethod + def _row_to_user(username: str, row: dict[str, object] | None) -> User: + assert row is not None + created_raw = row["created_at"] + last_raw = row.get("last_login_at") + return User( + username=username, + password_hash=str(row["password_hash"]), + role=str(row["role"]), + created_at=int(created_raw) if isinstance(created_raw, (int, float, str)) else 0, + last_login_at=(int(last_raw) if isinstance(last_raw, (int, float, str)) else None), + ) + + # ── Sessions ─────────────────────────────────────────────────── + + def create_session( + self, user: User, ttl_seconds: int = DEFAULT_SESSION_TTL_SECONDS + ) -> Session: + now = int(time.time()) + token = generate_session_token() + session = Session( + token=token, + username=user.username, + role=user.role, + created_at=now, + expires_at=now + ttl_seconds, + csrf_token=generate_session_token(), + ) + self._queue.set_setting( + SESSION_PREFIX + token, + json.dumps( + {k: v for k, v in asdict(session).items() if k != "token"}, + separators=(",", ":"), + ), + ) + return session + + def get_session(self, token: str) -> Session | None: + if not token: + return None + raw = self._queue.get_setting(SESSION_PREFIX + token) + if not raw: + return None + try: + data = json.loads(raw) + except json.JSONDecodeError: + return None + try: + session = Session(token=token, **data) + except TypeError: + return None + if session.is_expired(): + self.delete_session(token) + return None + return session + + def delete_session(self, token: str) -> bool: + if not token: + return False + return self._queue.delete_setting(SESSION_PREFIX + token) + + def prune_expired_sessions(self) -> int: + """Best-effort cleanup of expired session entries. Returns count removed.""" + now = int(time.time()) + removed = 0 + for key, value in self._queue.list_settings().items(): + if not key.startswith(SESSION_PREFIX): + continue + try: + data = json.loads(value) + expires_at = int(data.get("expires_at", 0)) + except (json.JSONDecodeError, TypeError, ValueError): + continue + if expires_at <= now: + self._queue.delete_setting(key) + removed += 1 + return removed + + +# Fixed hash used to keep authentication timing constant for unknown users. +# Value computed once with a throw-away password — never used for real auth. +_DUMMY_HASH = ( + "pbkdf2_sha256$600000$" + "00000000000000000000000000000000$" + "0000000000000000000000000000000000000000000000000000000000000000" +) + + +# ── Bootstrap from environment ───────────────────────────────────────── + + +def bootstrap_admin_from_env(queue: Queue) -> User | None: + """Idempotently create the first admin from environment variables. + + If ``TASKITO_DASHBOARD_ADMIN_USER`` and ``TASKITO_DASHBOARD_ADMIN_PASSWORD`` + are set AND the user does not exist yet, create it. Safe to call on every + startup — does nothing if the user already exists. + """ + import os + + username = os.environ.get("TASKITO_DASHBOARD_ADMIN_USER") + password = os.environ.get("TASKITO_DASHBOARD_ADMIN_PASSWORD") + if not username or not password: + return None + store = AuthStore(queue) + if store.get_user(username): + return None + try: + user = store.create_user(username, password, role="admin") + except ValueError as e: + logger.warning("Failed to bootstrap admin %r from env: %s", username, e) + return None + logger.info("Bootstrapped admin user %r from environment", username) + return user diff --git a/py_src/taskito/dashboard/handlers/auth.py b/py_src/taskito/dashboard/handlers/auth.py new file mode 100644 index 00000000..5b6744db --- /dev/null +++ b/py_src/taskito/dashboard/handlers/auth.py @@ -0,0 +1,126 @@ +"""Authentication route handlers.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from taskito.dashboard.auth import AuthStore +from taskito.dashboard.errors import _BadRequest, _NotFound + +if TYPE_CHECKING: + from taskito.app import Queue + from taskito.dashboard.request_context import RequestContext + + +def _require_field(body: dict, key: str) -> str: + value = body.get(key) + if not isinstance(value, str) or not value: + raise _BadRequest(f"missing or empty field '{key}'") + return value + + +def _serialize_user(user: Any) -> dict[str, Any]: + return { + "username": user.username, + "role": user.role, + "created_at": user.created_at, + "last_login_at": user.last_login_at, + } + + +def _serialize_session(session: Any) -> dict[str, Any]: + return { + "username": session.username, + "role": session.role, + "expires_at": session.expires_at, + "csrf_token": session.csrf_token, + } + + +def handle_auth_status(queue: Queue, _qs: dict) -> dict[str, bool]: + """Public endpoint: tells the SPA whether setup is required. + + Returns ``{setup_required: bool}``. The SPA uses this on cold-load to + decide between showing the setup page and the login page. + """ + return {"setup_required": AuthStore(queue).count_users() == 0} + + +def handle_setup(queue: Queue, body: dict) -> dict[str, Any]: + """Create the first admin user. Only callable when zero users exist.""" + store = AuthStore(queue) + if store.count_users() > 0: + raise _BadRequest("setup already complete") + username = _require_field(body, "username") + password = _require_field(body, "password") + try: + user = store.create_user(username, password, role="admin") + except ValueError as e: + raise _BadRequest(str(e)) from None + return {"user": _serialize_user(user)} + + +def handle_login(queue: Queue, body: dict) -> dict[str, Any]: + """Verify credentials and create a session. + + Returns ``{user, session}`` on success. The caller (server) reads the + session token from the returned object and sets it as an HttpOnly cookie. + On failure raises ``_BadRequest`` to drive a 400 — we intentionally + return the same generic error for unknown user / bad password to avoid + revealing which one was wrong. + """ + store = AuthStore(queue) + if store.count_users() == 0: + raise _BadRequest("setup_required") + username = _require_field(body, "username") + password = _require_field(body, "password") + user = store.authenticate(username, password) + if not user: + raise _BadRequest("invalid_credentials") + session = store.create_session(user) + return { + "user": _serialize_user(user), + "session": _serialize_session(session) | {"token": session.token}, + } + + +def handle_logout(queue: Queue, ctx: RequestContext) -> dict[str, bool]: + """Invalidate the current session. Idempotent.""" + if not ctx.session: + return {"ok": True} + AuthStore(queue).delete_session(ctx.session.token) + return {"ok": True} + + +def handle_whoami(queue: Queue, ctx: RequestContext) -> dict[str, Any]: + """Return the current user, or 401-equivalent if no session.""" + if not ctx.session: + raise _NotFound("not_authenticated") + store = AuthStore(queue) + user = store.get_user(ctx.session.username) + if not user: + # Session valid but user deleted — invalidate and treat as logged out. + store.delete_session(ctx.session.token) + raise _NotFound("not_authenticated") + return { + "user": _serialize_user(user), + "csrf_token": ctx.session.csrf_token, + "expires_at": ctx.session.expires_at, + } + + +def handle_change_password(queue: Queue, body: dict, ctx: RequestContext) -> dict[str, bool]: + """Change the current user's password. Requires the old password.""" + if not ctx.session: + raise _BadRequest("not_authenticated") + old_password = _require_field(body, "old_password") + new_password = _require_field(body, "new_password") + store = AuthStore(queue) + user = store.authenticate(ctx.session.username, old_password) + if not user: + raise _BadRequest("invalid_credentials") + try: + store.update_password(user.username, new_password) + except ValueError as e: + raise _BadRequest(str(e)) from None + return {"ok": True} diff --git a/py_src/taskito/dashboard/request_context.py b/py_src/taskito/dashboard/request_context.py new file mode 100644 index 00000000..03010c9e --- /dev/null +++ b/py_src/taskito/dashboard/request_context.py @@ -0,0 +1,94 @@ +"""Per-request authentication context for the dashboard. + +The HTTP server populates a :class:`RequestContext` for every request and +hands it to the dispatcher. Handlers that need the calling user (login, +logout, whoami, etc.) accept it as a keyword argument; pure-data handlers +ignore it. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from email.message import Message as _EmailMessage + +from taskito.dashboard.auth import Session + +# Cookie name used for the session token. HttpOnly + SameSite=Strict — the +# session cookie must never be readable from JavaScript or sent on +# third-party requests. +SESSION_COOKIE = "taskito_session" + +# Cookie name for the CSRF token. NOT HttpOnly — the SPA reads it and +# echoes it in the X-CSRF-Token header on state-changing requests. +CSRF_COOKIE = "taskito_csrf" +CSRF_HEADER = "X-CSRF-Token" + + +@dataclass(frozen=True) +class RequestContext: + """Auth state attached to a single HTTP request.""" + + session: Session | None + csrf_cookie: str | None + csrf_header: str | None + + @property + def is_authenticated(self) -> bool: + return self.session is not None + + @property + def username(self) -> str | None: + return self.session.username if self.session else None + + @property + def role(self) -> str | None: + return self.session.role if self.session else None + + def csrf_valid(self) -> bool: + """Double-submit cookie check. + + For state-changing requests we require: + - a non-empty CSRF cookie + - an ``X-CSRF-Token`` header that equals it byte-for-byte + - the value matches the session's stored CSRF token (defends + against an attacker who pre-seeds the cookie) + """ + if not self.session: + return False + if not self.csrf_cookie or not self.csrf_header: + return False + if self.csrf_cookie != self.csrf_header: + return False + return self.csrf_cookie == self.session.csrf_token + + +def parse_cookies(header: str | None) -> dict[str, str]: + """Parse a raw ``Cookie:`` header into a ``{name: value}`` dict. + + Empty or malformed cookies are silently skipped; only the first value + is kept for any duplicated cookie name. + """ + if not header: + return {} + cookies: dict[str, str] = {} + for part in header.split(";"): + if "=" not in part: + continue + name, _, value = part.strip().partition("=") + name = name.strip() + value = value.strip() + if name and name not in cookies: + cookies[name] = value + return cookies + + +def build_context(headers: _EmailMessage, session: Session | None) -> RequestContext: + """Construct a :class:`RequestContext` from raw HTTP headers and the + session resolved by the server. ``headers`` is the email.message-style + ``http.client.HTTPMessage`` exposed by :class:`BaseHTTPRequestHandler`.""" + cookies = parse_cookies(headers.get("Cookie")) + return RequestContext( + session=session, + csrf_cookie=cookies.get(CSRF_COOKIE), + csrf_header=headers.get(CSRF_HEADER), + ) diff --git a/py_src/taskito/dashboard/routes.py b/py_src/taskito/dashboard/routes.py index 1b45981d..29a3eefd 100644 --- a/py_src/taskito/dashboard/routes.py +++ b/py_src/taskito/dashboard/routes.py @@ -4,6 +4,17 @@ JSON-serializable data. Handlers may raise :class:`~taskito.dashboard.errors._BadRequest` (→ 400) or :class:`~taskito.dashboard.errors._NotFound` (→ 404). + +Authentication and authorization: + +- ``PUBLIC_PATHS`` — exact paths that bypass auth entirely. Used for the + setup/login/status endpoints, health checks, and Prometheus metrics. +- Routes outside ``PUBLIC_PATHS`` require a valid session cookie when at + least one user exists in the auth store. Without users, the server + returns ``503 setup_required`` for every API route so the SPA can show + the setup flow. +- State-changing routes (POST/PUT/DELETE) additionally require a valid + CSRF token. Login and setup are exempt because no session exists yet. """ from __future__ import annotations @@ -11,6 +22,14 @@ import re from typing import Any +from taskito.dashboard.handlers.auth import ( + handle_auth_status, + handle_change_password, + handle_login, + handle_logout, + handle_setup, + handle_whoami, +) from taskito.dashboard.handlers.dead_letters import _handle_dead_letters from taskito.dashboard.handlers.jobs import ( _handle_get_job, @@ -28,6 +47,29 @@ _handle_set_setting, ) +# ── Auth-exempt paths ────────────────────────────────────────────────── +# +# These bypass the session check. Static SPA files are also exempt but +# they are served outside the API dispatcher. +PUBLIC_PATHS: frozenset[str] = frozenset( + { + "/api/auth/status", + "/api/auth/login", + "/api/auth/setup", + "/health", + "/readiness", + "/metrics", + } +) + +# Paths handled directly by the server (live outside the regular dispatch +# tables because they take a RequestContext as well as the queue). +AUTH_CONTEXT_GET_PATHS: frozenset[str] = frozenset({"/api/auth/whoami"}) +AUTH_CONTEXT_POST_PATHS: frozenset[str] = frozenset( + {"/api/auth/logout", "/api/auth/change-password"} +) + + # ── Exact-match GET routes: path → handler(queue, qs) → JSON data ── GET_ROUTES: dict[str, Any] = { "/api/stats": lambda q, qs: q.stats(), @@ -45,6 +87,7 @@ "/api/stats/queues": _handle_stats_queues, "/api/scaler": lambda q, qs: build_scaler_response(q, queue_name=qs.get("queue", [None])[0]), "/api/settings": _handle_list_settings, + "/api/auth/status": handle_auth_status, } # ── Parameterized GET routes: regex → handler(queue, qs, captured_id) ── @@ -66,6 +109,27 @@ "/api/dead-letters/purge": lambda q: {"purged": q.purge_dead(0)}, } +# Exact-match POST routes that take a body (path → handler(queue, body)) +POST_BODY_ROUTES: dict[str, Any] = { + "/api/auth/login": handle_login, + "/api/auth/setup": handle_setup, +} + +# Auth-context POST routes: path → handler(queue, ctx) — no body +POST_CTX_ROUTES: dict[str, Any] = { + "/api/auth/logout": handle_logout, +} + +# Auth-context POST routes with body: path → handler(queue, body, ctx) +POST_CTX_BODY_ROUTES: dict[str, Any] = { + "/api/auth/change-password": handle_change_password, +} + +# Auth-context GET routes: path → handler(queue, ctx) +GET_CTX_ROUTES: dict[str, Any] = { + "/api/auth/whoami": handle_whoami, +} + # ── Parameterized POST routes: regex → handler(queue, captured_id) ── POST_PARAM_ROUTES: list[tuple[re.Pattern, Any]] = [ ( @@ -93,3 +157,17 @@ DELETE_PARAM_ROUTES: list[tuple[re.Pattern, Any]] = [ (re.compile(r"^/api/settings/(.+)$"), _handle_delete_setting), ] + + +def is_state_changing_method(method: str) -> bool: + """POST/PUT/DELETE/PATCH all require a CSRF token.""" + return method in {"POST", "PUT", "DELETE", "PATCH"} + + +def is_csrf_exempt(path: str) -> bool: + """Login and setup happen before a session exists, so they're CSRF-exempt. + + Every other state-changing endpoint requires a valid CSRF token even + though the session cookie is enforced — defense in depth. + """ + return path in {"/api/auth/login", "/api/auth/setup"} diff --git a/py_src/taskito/dashboard/server.py b/py_src/taskito/dashboard/server.py index 2b4f1d9c..bb009985 100644 --- a/py_src/taskito/dashboard/server.py +++ b/py_src/taskito/dashboard/server.py @@ -1,4 +1,12 @@ -"""HTTP server that wires routes to a Queue instance and serves the SPA.""" +"""HTTP server that wires routes to a Queue instance and serves the SPA. + +The server enforces dashboard authentication when at least one user has been +registered with :class:`taskito.dashboard.auth.AuthStore`. Until the first +user is created, all API routes return ``503 setup_required`` so the SPA can +guide the operator through one-time setup. ``TASKITO_DASHBOARD_ADMIN_USER`` / +``TASKITO_DASHBOARD_ADMIN_PASSWORD`` environment variables bootstrap a user +idempotently on server start. +""" from __future__ import annotations @@ -8,14 +16,34 @@ from typing import TYPE_CHECKING, Any from urllib.parse import parse_qs, urlparse +from taskito.dashboard.auth import ( + DEFAULT_SESSION_TTL_SECONDS, + AuthStore, + bootstrap_admin_from_env, +) from taskito.dashboard.errors import _BadRequest, _NotFound +from taskito.dashboard.request_context import ( + CSRF_COOKIE, + SESSION_COOKIE, + RequestContext, + build_context, +) from taskito.dashboard.routes import ( + AUTH_CONTEXT_GET_PATHS, + AUTH_CONTEXT_POST_PATHS, DELETE_PARAM_ROUTES, + GET_CTX_ROUTES, GET_PARAM_ROUTES, GET_ROUTES, + POST_BODY_ROUTES, + POST_CTX_BODY_ROUTES, + POST_CTX_ROUTES, POST_PARAM_ROUTES, POST_ROUTES, + PUBLIC_PATHS, PUT_PARAM_ROUTES, + is_csrf_exempt, + is_state_changing_method, ) from taskito.dashboard.static import ( IMMUTABLE_PREFIX, @@ -40,19 +68,12 @@ _LOG_UNSAFE_CHARS[127] = None _LOG_PATH_MAX = 256 -# Hard cap on the request body we'll parse for PUT requests. Settings and -# other config writes are tiny; anything larger is almost certainly an -# attacker probing for memory exhaustion. +# Hard cap on the request body we'll parse for PUT/POST requests. _MAX_BODY_BYTES = 1 * 1024 * 1024 # 1 MiB def _safe_path(path: str) -> str: - """Return ``path`` with control characters stripped and length capped. - - Used when including the request URI in log messages — never trust - user-controlled strings to be free of CR/LF/null bytes that would let - an attacker forge fake log lines. - """ + """Return ``path`` with control characters stripped and length capped.""" return path.translate(_LOG_UNSAFE_CHARS)[:_LOG_PATH_MAX] @@ -73,6 +94,7 @@ def serve_dashboard( test seam; downstream embedders can also use it to ship a customised dashboard bundle from a different location. """ + bootstrap_admin_from_env(queue) handler = _make_handler(queue, static_assets=static_assets) server = ThreadingHTTPServer((host, port), handler) print(f"taskito dashboard → http://{host}:{port}") @@ -87,16 +109,12 @@ def serve_dashboard( def _make_handler(queue: Queue, *, static_assets: StaticAssets | None = None) -> type: - """Create a request handler class bound to the given queue. - - Args: - queue: Queue inspected by the JSON routes. - static_assets: SPA asset source. Defaults to the package-bundled - assets resolved once per process; tests inject their own. - """ + """Create a request handler class bound to the given queue.""" assets = static_assets if static_assets is not None else _get_default_assets() class DashboardHandler(BaseHTTPRequestHandler): + # ── Entry points ──────────────────────────────────────────── + def do_GET(self) -> None: try: self._handle_get() @@ -106,35 +124,65 @@ def do_GET(self) -> None: logger.exception("Error handling GET %s", _safe_path(self.path)) self._json_response({"error": "Internal server error"}, status=500) + def do_POST(self) -> None: + try: + self._handle_post() + except BrokenPipeError: + pass + except Exception: + logger.exception("Error handling POST %s", _safe_path(self.path)) + self._json_response({"error": "Internal server error"}, status=500) + + def do_PUT(self) -> None: + try: + self._handle_put() + except BrokenPipeError: + pass + except Exception: + logger.exception("Error handling PUT %s", _safe_path(self.path)) + self._json_response({"error": "Internal server error"}, status=500) + + def do_DELETE(self) -> None: + try: + self._handle_delete() + except BrokenPipeError: + pass + except Exception: + logger.exception("Error handling DELETE %s", _safe_path(self.path)) + self._json_response({"error": "Internal server error"}, status=500) + + # ── Per-method dispatchers ────────────────────────────────── + def _handle_get(self) -> None: parsed = urlparse(self.path) path = parsed.path qs = parse_qs(parsed.query) - # Exact-match API routes + if not path.startswith("/api/") and path not in {"/health", "/readiness", "/metrics"}: + self._serve_spa(path) + return + + ctx, denied = self._authorize(path, "GET") + if denied: + return + + if path in AUTH_CONTEXT_GET_PATHS: + self._dispatch_with_handler(GET_CTX_ROUTES.get(path), lambda h: h(queue, ctx)) + return + handler = GET_ROUTES.get(path) if handler: - try: - self._json_response(handler(queue, qs)) - except _BadRequest as e: - self._json_response({"error": e.message}, status=400) - except _NotFound as e: - self._json_response({"error": e.message}, status=404) + self._dispatch_with_handler(handler, lambda h: h(queue, qs)) return - # Parameterized API routes for pattern, param_handler in GET_PARAM_ROUTES: m = pattern.match(path) if m: - try: - self._json_response(param_handler(queue, qs, m.group(1))) - except _BadRequest as e: - self._json_response({"error": e.message}, status=400) - except _NotFound as e: - self._json_response({"error": e.message}, status=404) + self._dispatch_with_handler( + param_handler, lambda h, m=m: h(queue, qs, m.group(1)) + ) return - # Non-JSON routes if path == "/health": self._json_response(check_health()) elif path == "/readiness": @@ -142,90 +190,199 @@ def _handle_get(self) -> None: elif path == "/metrics": self._serve_prometheus_metrics() else: - self._serve_spa(path) - - def do_POST(self) -> None: - try: - self._handle_post() - except BrokenPipeError: - pass - except Exception: - logger.exception("Error handling POST %s", _safe_path(self.path)) - self._json_response({"error": "Internal server error"}, status=500) + self._json_response({"error": "Not found"}, status=404) def _handle_post(self) -> None: path = urlparse(self.path).path + ctx, denied = self._authorize(path, "POST") + if denied: + return + + if path == "/api/auth/login": + body = self._read_json_body() + if body is None: + return + self._dispatch_with_handler( + POST_BODY_ROUTES[path], + lambda h: h(queue, body), + on_success=lambda resp: self._set_login_cookies(resp), + ) + return + + if path == "/api/auth/setup": + body = self._read_json_body() + if body is None: + return + self._dispatch_with_handler(POST_BODY_ROUTES[path], lambda h: h(queue, body)) + return + + if path in AUTH_CONTEXT_POST_PATHS: + if path in POST_CTX_BODY_ROUTES: + body = self._read_json_body() + if body is None: + return + self._dispatch_with_handler( + POST_CTX_BODY_ROUTES[path], + lambda h: h(queue, body, ctx), + ) + else: + self._dispatch_with_handler( + POST_CTX_ROUTES[path], + lambda h: h(queue, ctx), + on_success=lambda _resp: ( + self._clear_login_cookies() if path == "/api/auth/logout" else None + ), + ) + return - # Exact-match POST routes handler = POST_ROUTES.get(path) if handler: - self._json_response(handler(queue)) + self._dispatch_with_handler(handler, lambda h: h(queue)) return - # Parameterized POST routes for pattern, param_handler in POST_PARAM_ROUTES: m = pattern.match(path) if m: - self._json_response(param_handler(queue, m.group(1))) + self._dispatch_with_handler(param_handler, lambda h, m=m: h(queue, m.group(1))) return self._json_response({"error": "Not found"}, status=404) - def do_PUT(self) -> None: - try: - self._handle_put() - except BrokenPipeError: - pass - except Exception: - logger.exception("Error handling PUT %s", _safe_path(self.path)) - self._json_response({"error": "Internal server error"}, status=500) - def _handle_put(self) -> None: path = urlparse(self.path).path + _ctx, denied = self._authorize(path, "PUT") + if denied: + return + for pattern, param_handler in PUT_PARAM_ROUTES: m = pattern.match(path) if m: body = self._read_json_body() if body is None: return - try: - self._json_response(param_handler(queue, body, m.group(1))) - except _BadRequest as e: - self._json_response({"error": e.message}, status=400) - except _NotFound as e: - self._json_response({"error": e.message}, status=404) + self._dispatch_with_handler( + param_handler, lambda h, m=m, body=body: h(queue, body, m.group(1)) + ) return self._json_response({"error": "Not found"}, status=404) - def do_DELETE(self) -> None: - try: - self._handle_delete() - except BrokenPipeError: - pass - except Exception: - logger.exception("Error handling DELETE %s", _safe_path(self.path)) - self._json_response({"error": "Internal server error"}, status=500) - def _handle_delete(self) -> None: path = urlparse(self.path).path + _ctx, denied = self._authorize(path, "DELETE") + if denied: + return + for pattern, param_handler in DELETE_PARAM_ROUTES: m = pattern.match(path) if m: - try: - self._json_response(param_handler(queue, m.group(1))) - except _BadRequest as e: - self._json_response({"error": e.message}, status=400) - except _NotFound as e: - self._json_response({"error": e.message}, status=404) + self._dispatch_with_handler(param_handler, lambda h, m=m: h(queue, m.group(1))) return self._json_response({"error": "Not found"}, status=404) - def _read_json_body(self) -> Any | None: - """Read and parse the request body as JSON. + # ── Auth gating ───────────────────────────────────────────── + + def _authorize(self, path: str, method: str) -> tuple[RequestContext, bool]: + """Return ``(ctx, denied)``. When ``denied`` is true a response + has already been written and the caller must return.""" + ctx = self._build_context() + + # Setup-required short-circuit: before the first user is created + # every API endpoint (except the public ones) returns 503 so the + # SPA can show the setup page. + if ( + path.startswith("/api/") + and path not in PUBLIC_PATHS + and AuthStore(queue).count_users() == 0 + ): + self._json_response({"error": "setup_required"}, status=503) + return ctx, True + + if path in PUBLIC_PATHS or not path.startswith("/api/"): + # CSRF still applies to public state-changing routes that are + # NOT exempt — but login/setup are the only public POSTs and + # they're exempt. + return ctx, False + + if not ctx.is_authenticated: + self._json_response({"error": "not_authenticated"}, status=401) + return ctx, True + + if ( + is_state_changing_method(method) + and not is_csrf_exempt(path) + and not ctx.csrf_valid() + ): + self._json_response({"error": "csrf_failed"}, status=403) + return ctx, True + + return ctx, False + + def _build_context(self) -> RequestContext: + cookies_header = self.headers.get("Cookie") + session = None + if cookies_header: + from taskito.dashboard.request_context import parse_cookies + + cookies = parse_cookies(cookies_header) + token = cookies.get(SESSION_COOKIE) + if token: + session = AuthStore(queue).get_session(token) + return build_context(self.headers, session) + + # ── Cookie management ─────────────────────────────────────── + + def _set_login_cookies(self, response: dict[str, Any]) -> None: + """Set HttpOnly session cookie and CSRF cookie on a login response.""" + session = response.get("session") or {} + token = session.get("token") + csrf = session.get("csrf_token") + if not token or not csrf: + return + # 24-hour Max-Age matches the session TTL. + self._extra_set_cookies = [ + f"{SESSION_COOKIE}={token}; HttpOnly; SameSite=Strict; Path=/; " + f"Max-Age={DEFAULT_SESSION_TTL_SECONDS}", + f"{CSRF_COOKIE}={csrf}; SameSite=Strict; Path=/; " + f"Max-Age={DEFAULT_SESSION_TTL_SECONDS}", + ] + # Don't leak the raw token in the JSON body — the cookie holds it. + response["session"] = {k: v for k, v in session.items() if k != "token"} + + def _clear_login_cookies(self) -> None: + self._extra_set_cookies = [ + f"{SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0", + f"{CSRF_COOKIE}=; SameSite=Strict; Path=/; Max-Age=0", + ] + + # ── Dispatch helper ───────────────────────────────────────── + + def _dispatch_with_handler( + self, + handler: Any, + invoke: Any, + *, + on_success: Any | None = None, + ) -> None: + if handler is None: + self._json_response({"error": "Not found"}, status=404) + return + try: + result = invoke(handler) + except _BadRequest as e: + self._json_response({"error": e.message}, status=400) + return + except _NotFound as e: + self._json_response({"error": e.message}, status=404) + return + if on_success is not None: + on_success(result) + self._json_response(result) + + # ── Body / response helpers ───────────────────────────────── - Returns ``None`` after writing the appropriate error response - (400/413) when the body is missing, malformed, or oversized. - """ + def _read_json_body(self) -> Any | None: + """Read and parse the request body as JSON. Returns ``None`` after + writing the appropriate error response (400/413).""" length_header = self.headers.get("Content-Length") try: length = int(length_header) if length_header is not None else 0 @@ -252,7 +409,10 @@ def _json_response(self, data: Any, status: int = 200) -> None: self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) - self.send_header("Access-Control-Allow-Origin", "*") + # Cookies are first-party only — no wildcard CORS. The SPA is + # served from the same origin as the API. + for cookie in getattr(self, "_extra_set_cookies", ()): + self.send_header("Set-Cookie", cookie) self.end_headers() self.wfile.write(body) @@ -270,9 +430,6 @@ def _serve_prometheus_metrics(self) -> None: self._json_response({"error": "prometheus-client not installed"}, status=501) def _serve_spa(self, req_path: str) -> None: - """Serve a static asset from the SPA bundle, falling back to - ``index.html`` so client-side routes deep-link correctly. - """ if not assets.available: self._serve_missing_assets() return @@ -317,3 +474,6 @@ def log_message(self, format: str, *args: Any) -> None: pass return DashboardHandler + + +__all__ = ["_make_handler", "serve_dashboard"] diff --git a/tests/dashboard/test_auth.py b/tests/dashboard/test_auth.py new file mode 100644 index 00000000..c78a73c0 --- /dev/null +++ b/tests/dashboard/test_auth.py @@ -0,0 +1,453 @@ +"""Tests for dashboard authentication. + +Covers the auth helpers in :mod:`taskito.dashboard.auth` and the HTTP +endpoints under ``/api/auth/*``, plus the session-gating behaviour the +server applies to every other API route. +""" + +from __future__ import annotations + +import json +import threading +import urllib.error +import urllib.request +from collections.abc import Generator +from http.server import ThreadingHTTPServer +from pathlib import Path +from typing import Any + +import pytest + +from taskito import Queue +from taskito.dashboard import _make_handler +from taskito.dashboard.auth import ( + AuthStore, + bootstrap_admin_from_env, + hash_password, + verify_password, +) + + +@pytest.fixture +def queue(tmp_path: Path) -> Queue: + return Queue(db_path=str(tmp_path / "auth.db")) + + +# ── Password hashing primitives ───────────────────────────────────────── + + +def test_hash_password_round_trip() -> None: + encoded = hash_password("hunter2-correct-horse") + assert verify_password("hunter2-correct-horse", encoded) is True + assert verify_password("wrong", encoded) is False + + +def test_hash_password_produces_unique_salts() -> None: + a = hash_password("same-password") + b = hash_password("same-password") + assert a != b, "different salts must produce different hashes" + assert verify_password("same-password", a) + assert verify_password("same-password", b) + + +def test_verify_password_rejects_malformed_encoding() -> None: + assert verify_password("anything", "not-a-real-hash") is False + assert verify_password("anything", "scrypt$xxx$yyy$zzz") is False + assert verify_password("anything", "pbkdf2_sha256$abc$def$ghi") is False + + +# ── AuthStore: users ──────────────────────────────────────────────────── + + +def test_count_users_starts_at_zero(queue: Queue) -> None: + assert AuthStore(queue).count_users() == 0 + + +def test_create_user_persists(queue: Queue) -> None: + store = AuthStore(queue) + user = store.create_user("alice", "hunter2-secret") + assert user.username == "alice" + assert user.role == "admin" + assert store.count_users() == 1 + assert store.get_user("alice") is not None + assert store.get_user("missing") is None + + +def test_create_user_rejects_duplicate(queue: Queue) -> None: + store = AuthStore(queue) + store.create_user("alice", "hunter2-secret") + with pytest.raises(ValueError, match="already exists"): + store.create_user("alice", "another-pass") + + +def test_create_user_validates_username(queue: Queue) -> None: + store = AuthStore(queue) + with pytest.raises(ValueError, match="empty"): + store.create_user("", "hunter2-secret") + with pytest.raises(ValueError, match="may only contain"): + store.create_user("alice bob", "hunter2-secret") + + +def test_create_user_validates_password(queue: Queue) -> None: + store = AuthStore(queue) + with pytest.raises(ValueError, match=">= 8 chars"): + store.create_user("alice", "short") + + +def test_authenticate(queue: Queue) -> None: + store = AuthStore(queue) + store.create_user("alice", "hunter2-secret") + assert store.authenticate("alice", "hunter2-secret") is not None + assert store.authenticate("alice", "wrong") is None + # Unknown username also returns None, without timing leak (we don't + # assert timing here, just behaviour). + assert store.authenticate("bob", "anything") is None + + +def test_authenticate_updates_last_login(queue: Queue) -> None: + store = AuthStore(queue) + store.create_user("alice", "hunter2-secret") + assert store.get_user("alice").last_login_at is None # type: ignore[union-attr] + store.authenticate("alice", "hunter2-secret") + assert store.get_user("alice").last_login_at is not None # type: ignore[union-attr] + + +def test_delete_user(queue: Queue) -> None: + store = AuthStore(queue) + store.create_user("alice", "hunter2-secret") + assert store.delete_user("alice") is True + assert store.delete_user("alice") is False + assert store.get_user("alice") is None + + +def test_update_password(queue: Queue) -> None: + store = AuthStore(queue) + store.create_user("alice", "hunter2-secret") + store.update_password("alice", "new-secure-pass") + assert store.authenticate("alice", "new-secure-pass") is not None + assert store.authenticate("alice", "hunter2-secret") is None + + +# ── AuthStore: sessions ──────────────────────────────────────────────── + + +def test_create_and_get_session(queue: Queue) -> None: + store = AuthStore(queue) + user = store.create_user("alice", "hunter2-secret") + session = store.create_session(user) + fetched = store.get_session(session.token) + assert fetched is not None + assert fetched.username == "alice" + assert fetched.csrf_token == session.csrf_token + assert not fetched.is_expired() + + +def test_get_session_unknown_token_returns_none(queue: Queue) -> None: + assert AuthStore(queue).get_session("nope") is None + assert AuthStore(queue).get_session("") is None + + +def test_delete_session(queue: Queue) -> None: + store = AuthStore(queue) + user = store.create_user("alice", "hunter2-secret") + session = store.create_session(user) + assert store.delete_session(session.token) is True + assert store.get_session(session.token) is None + assert store.delete_session(session.token) is False + + +def test_expired_sessions_pruned_on_lookup(queue: Queue) -> None: + store = AuthStore(queue) + user = store.create_user("alice", "hunter2-secret") + session = store.create_session(user, ttl_seconds=0) + # ttl_seconds=0 means it expires immediately. + assert store.get_session(session.token) is None + + +def test_prune_expired_sessions(queue: Queue) -> None: + store = AuthStore(queue) + user = store.create_user("alice", "hunter2-secret") + long_lived = store.create_session(user, ttl_seconds=3600) + short_lived = store.create_session(user, ttl_seconds=0) + removed = store.prune_expired_sessions() + assert removed >= 1 + assert store.get_session(long_lived.token) is not None + assert store.get_session(short_lived.token) is None + + +# ── Env bootstrap ────────────────────────────────────────────────────── + + +def test_bootstrap_admin_from_env(queue: Queue, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("TASKITO_DASHBOARD_ADMIN_USER", "envadmin") + monkeypatch.setenv("TASKITO_DASHBOARD_ADMIN_PASSWORD", "from-environ-pass") + user = bootstrap_admin_from_env(queue) + assert user is not None + assert user.username == "envadmin" + + # Idempotent — second call is a no-op. + again = bootstrap_admin_from_env(queue) + assert again is None + + +def test_bootstrap_admin_noop_without_env(queue: Queue, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("TASKITO_DASHBOARD_ADMIN_USER", raising=False) + monkeypatch.delenv("TASKITO_DASHBOARD_ADMIN_PASSWORD", raising=False) + assert bootstrap_admin_from_env(queue) is None + assert AuthStore(queue).count_users() == 0 + + +# ── HTTP endpoints ───────────────────────────────────────────────────── + + +@pytest.fixture +def dashboard_server(queue: Queue) -> Generator[tuple[str, Queue]]: + handler = _make_handler(queue) + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + port = server.server_address[1] + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{port}", queue + finally: + server.shutdown() + + +def _get(url: str, *, cookies: dict[str, str] | None = None) -> tuple[int, Any, dict[str, str]]: + req = urllib.request.Request(url, method="GET") + if cookies: + req.add_header("Cookie", "; ".join(f"{k}={v}" for k, v in cookies.items())) + try: + resp = urllib.request.urlopen(req) + except urllib.error.HTTPError as e: + return e.code, json.loads(e.read() or b"{}"), dict(e.headers or {}) + body = json.loads(resp.read() or b"{}") + set_cookies = resp.headers.get_all("Set-Cookie") or [] + return resp.status, body, {"Set-Cookie": "\n".join(set_cookies)} + + +def _post( + url: str, + body: dict | None = None, + *, + cookies: dict[str, str] | None = None, + headers: dict[str, str] | None = None, +) -> tuple[int, Any, dict[str, str]]: + data = json.dumps(body or {}).encode() + req = urllib.request.Request(url, method="POST", data=data) + req.add_header("Content-Type", "application/json") + if cookies: + req.add_header("Cookie", "; ".join(f"{k}={v}" for k, v in cookies.items())) + for k, v in (headers or {}).items(): + req.add_header(k, v) + try: + resp = urllib.request.urlopen(req) + except urllib.error.HTTPError as e: + return e.code, json.loads(e.read() or b"{}"), dict(e.headers or {}) + parsed = json.loads(resp.read() or b"{}") + set_cookies = resp.headers.get_all("Set-Cookie") or [] + return resp.status, parsed, {"Set-Cookie": "\n".join(set_cookies)} + + +def _parse_set_cookie(raw: str) -> dict[str, str]: + """Pull out the cookie name→value pairs from one or more Set-Cookie lines.""" + out: dict[str, str] = {} + for line in raw.splitlines(): + if not line: + continue + nv = line.split(";", 1)[0] + if "=" in nv: + name, value = nv.split("=", 1) + out[name.strip()] = value.strip() + return out + + +def test_auth_status_before_setup(dashboard_server: tuple[str, Queue]) -> None: + base, _ = dashboard_server + status, body, _ = _get(f"{base}/api/auth/status") + assert status == 200 + assert body == {"setup_required": True} + + +def test_protected_route_returns_503_before_setup(dashboard_server: tuple[str, Queue]) -> None: + base, _ = dashboard_server + status, body, _ = _get(f"{base}/api/stats") + assert status == 503 + assert body == {"error": "setup_required"} + + +def test_setup_creates_first_admin(dashboard_server: tuple[str, Queue]) -> None: + base, queue = dashboard_server + status, body, _ = _post( + f"{base}/api/auth/setup", + {"username": "alice", "password": "hunter2-secret"}, + ) + assert status == 200 + assert body["user"]["username"] == "alice" + assert AuthStore(queue).count_users() == 1 + + +def test_setup_blocked_after_first_user(dashboard_server: tuple[str, Queue]) -> None: + base, queue = dashboard_server + AuthStore(queue).create_user("alice", "hunter2-secret") + status, body, _ = _post( + f"{base}/api/auth/setup", + {"username": "mallory", "password": "hijack-attempt"}, + ) + assert status == 400 + assert "setup already complete" in body["error"] + + +def test_login_and_session_cookie(dashboard_server: tuple[str, Queue]) -> None: + base, queue = dashboard_server + AuthStore(queue).create_user("alice", "hunter2-secret") + status, body, headers = _post( + f"{base}/api/auth/login", + {"username": "alice", "password": "hunter2-secret"}, + ) + assert status == 200 + assert body["user"]["username"] == "alice" + # Token must NOT leak in the body — it lives only in the HttpOnly cookie. + assert "token" not in body["session"] + + cookies = _parse_set_cookie(headers["Set-Cookie"]) + assert "taskito_session" in cookies + assert "taskito_csrf" in cookies + # HttpOnly must be set on the session cookie. + assert "HttpOnly" in headers["Set-Cookie"] + # CSRF cookie value must match what whoami says. + sess_token = cookies["taskito_session"] + csrf = cookies["taskito_csrf"] + status, body, _ = _get(f"{base}/api/auth/whoami", cookies={"taskito_session": sess_token}) + assert status == 200 + assert body["user"]["username"] == "alice" + assert body["csrf_token"] == csrf + + +def test_login_with_wrong_password(dashboard_server: tuple[str, Queue]) -> None: + base, queue = dashboard_server + AuthStore(queue).create_user("alice", "hunter2-secret") + status, body, _ = _post( + f"{base}/api/auth/login", + {"username": "alice", "password": "nope"}, + ) + assert status == 400 + assert body["error"] == "invalid_credentials" + + +def test_whoami_without_session_returns_404(dashboard_server: tuple[str, Queue]) -> None: + base, queue = dashboard_server + AuthStore(queue).create_user("alice", "hunter2-secret") + status, body, _ = _get(f"{base}/api/auth/whoami") + assert status == 401 + assert body["error"] == "not_authenticated" + + +def test_protected_get_requires_session(dashboard_server: tuple[str, Queue]) -> None: + base, queue = dashboard_server + AuthStore(queue).create_user("alice", "hunter2-secret") + status, _, _ = _get(f"{base}/api/stats") + assert status == 401 + + +def test_protected_get_works_with_session(dashboard_server: tuple[str, Queue]) -> None: + base, queue = dashboard_server + store = AuthStore(queue) + user = store.create_user("alice", "hunter2-secret") + session = store.create_session(user) + status, _, _ = _get(f"{base}/api/stats", cookies={"taskito_session": session.token}) + assert status == 200 + + +def test_post_requires_csrf(dashboard_server: tuple[str, Queue]) -> None: + base, queue = dashboard_server + store = AuthStore(queue) + user = store.create_user("alice", "hunter2-secret") + session = store.create_session(user) + # POST with only the session cookie but no CSRF → 403. + status, body, _ = _post( + f"{base}/api/dead-letters/purge", + {}, + cookies={"taskito_session": session.token}, + ) + assert status == 403 + assert body["error"] == "csrf_failed" + + +def test_post_succeeds_with_valid_csrf(dashboard_server: tuple[str, Queue]) -> None: + base, queue = dashboard_server + store = AuthStore(queue) + user = store.create_user("alice", "hunter2-secret") + session = store.create_session(user) + status, _, _ = _post( + f"{base}/api/dead-letters/purge", + {}, + cookies={ + "taskito_session": session.token, + "taskito_csrf": session.csrf_token, + }, + headers={"X-CSRF-Token": session.csrf_token}, + ) + assert status == 200 + + +def test_post_rejected_when_csrf_mismatched(dashboard_server: tuple[str, Queue]) -> None: + base, queue = dashboard_server + store = AuthStore(queue) + user = store.create_user("alice", "hunter2-secret") + session = store.create_session(user) + status, body, _ = _post( + f"{base}/api/dead-letters/purge", + {}, + cookies={ + "taskito_session": session.token, + "taskito_csrf": session.csrf_token, + }, + headers={"X-CSRF-Token": "different-value"}, + ) + assert status == 403 + assert body["error"] == "csrf_failed" + + +def test_logout_invalidates_session(dashboard_server: tuple[str, Queue]) -> None: + base, queue = dashboard_server + store = AuthStore(queue) + user = store.create_user("alice", "hunter2-secret") + session = store.create_session(user) + status, _, _ = _post( + f"{base}/api/auth/logout", + {}, + cookies={ + "taskito_session": session.token, + "taskito_csrf": session.csrf_token, + }, + headers={"X-CSRF-Token": session.csrf_token}, + ) + assert status == 200 + assert AuthStore(queue).get_session(session.token) is None + + +def test_change_password_flow(dashboard_server: tuple[str, Queue]) -> None: + base, queue = dashboard_server + store = AuthStore(queue) + user = store.create_user("alice", "hunter2-secret") + session = store.create_session(user) + status, _, _ = _post( + f"{base}/api/auth/change-password", + {"old_password": "hunter2-secret", "new_password": "brand-new-secure"}, + cookies={ + "taskito_session": session.token, + "taskito_csrf": session.csrf_token, + }, + headers={"X-CSRF-Token": session.csrf_token}, + ) + assert status == 200 + assert store.authenticate("alice", "brand-new-secure") is not None + assert store.authenticate("alice", "hunter2-secret") is None + + +def test_health_endpoint_is_public(dashboard_server: tuple[str, Queue]) -> None: + base, queue = dashboard_server + AuthStore(queue).create_user("alice", "hunter2-secret") + status, _, _ = _get(f"{base}/health") + assert status == 200 diff --git a/tests/dashboard/test_dashboard.py b/tests/dashboard/test_dashboard.py index de651b0a..f096738c 100644 --- a/tests/dashboard/test_dashboard.py +++ b/tests/dashboard/test_dashboard.py @@ -11,6 +11,7 @@ import pytest from taskito import Queue +from taskito.dashboard._testing import AuthedClient, seed_admin_and_session @pytest.fixture @@ -189,100 +190,103 @@ def _start_dashboard(queue: Queue, *, static_assets: Any = None) -> tuple[str, A @pytest.fixture def dashboard_server( populated_queue: tuple[Queue, list[Any]], -) -> Generator[tuple[str, Queue, list[Any]]]: - """Start a dashboard server on a random port.""" +) -> Generator[tuple[AuthedClient, Queue, list[Any]]]: + """Start a dashboard server on a random port and pre-seed an admin session. + + Yields ``(client, queue, jobs)`` — the client transparently attaches the + session cookie and CSRF header to every request. + """ queue, jobs = populated_queue url, server = _start_dashboard(queue) + session = seed_admin_and_session(queue) + client = AuthedClient(base=url, session=session) try: - yield url, queue, jobs + yield client, queue, jobs finally: server.shutdown() -def _get(url: str) -> Any: - """GET request and parse JSON.""" - with urllib.request.urlopen(url) as resp: - return json.loads(resp.read()) - - -def _post(url: str) -> Any: - """POST request and parse JSON.""" - req = urllib.request.Request(url, method="POST", data=b"") - with urllib.request.urlopen(req) as resp: - return json.loads(resp.read()) - - -def test_api_stats(dashboard_server: tuple[str, Queue, list[Any]]) -> None: +def test_api_stats(dashboard_server: tuple[AuthedClient, Queue, list[Any]]) -> None: """GET /api/stats returns valid stats dict.""" - base, _, __ = dashboard_server - data = _get(f"{base}/api/stats") + client, _, __ = dashboard_server + data = client.get("/api/stats") assert "pending" in data assert data["pending"] == 8 -def test_api_jobs_list(dashboard_server: tuple[str, Queue, list[Any]]) -> None: +def test_api_jobs_list(dashboard_server: tuple[AuthedClient, Queue, list[Any]]) -> None: """GET /api/jobs returns job list.""" - base, _, __ = dashboard_server - data = _get(f"{base}/api/jobs") + client, _, __ = dashboard_server + data = client.get("/api/jobs") assert isinstance(data, list) assert len(data) == 8 -def test_api_jobs_filter_status(dashboard_server: tuple[str, Queue, list[Any]]) -> None: +def test_api_jobs_filter_status( + dashboard_server: tuple[AuthedClient, Queue, list[Any]], +) -> None: """GET /api/jobs?status=pending filters correctly.""" - base, _, __ = dashboard_server - data = _get(f"{base}/api/jobs?status=pending") + client, _, __ = dashboard_server + data = client.get("/api/jobs?status=pending") assert len(data) == 8 - data = _get(f"{base}/api/jobs?status=running") + data = client.get("/api/jobs?status=running") assert len(data) == 0 -def test_api_jobs_filter_queue(dashboard_server: tuple[str, Queue, list[Any]]) -> None: +def test_api_jobs_filter_queue( + dashboard_server: tuple[AuthedClient, Queue, list[Any]], +) -> None: """GET /api/jobs?queue=email filters correctly.""" - base, _, __ = dashboard_server - data = _get(f"{base}/api/jobs?queue=email") + client, _, __ = dashboard_server + data = client.get("/api/jobs?queue=email") assert len(data) == 3 -def test_api_jobs_pagination(dashboard_server: tuple[str, Queue, list[Any]]) -> None: +def test_api_jobs_pagination( + dashboard_server: tuple[AuthedClient, Queue, list[Any]], +) -> None: """GET /api/jobs?limit=3&offset=0 paginates.""" - base, _, __ = dashboard_server - data = _get(f"{base}/api/jobs?limit=3&offset=0") + client, _, __ = dashboard_server + data = client.get("/api/jobs?limit=3&offset=0") assert len(data) == 3 -def test_api_job_detail(dashboard_server: tuple[str, Queue, list[Any]]) -> None: +def test_api_job_detail(dashboard_server: tuple[AuthedClient, Queue, list[Any]]) -> None: """GET /api/jobs/{id} returns job dict.""" - base, _, jobs = dashboard_server + client, _, jobs = dashboard_server job_id = jobs[0].id - data = _get(f"{base}/api/jobs/{job_id}") + data = client.get(f"/api/jobs/{job_id}") assert data["id"] == job_id assert "status" in data -def test_api_job_not_found(dashboard_server: tuple[str, Queue, list[Any]]) -> None: +def test_api_job_not_found( + dashboard_server: tuple[AuthedClient, Queue, list[Any]], +) -> None: """GET /api/jobs/nonexistent returns 404.""" - base, _, __ = dashboard_server + client, _, __ = dashboard_server try: - _get(f"{base}/api/jobs/nonexistent-id") + client.get("/api/jobs/nonexistent-id") raise AssertionError("Expected 404") except urllib.error.HTTPError as e: assert e.code == 404 -def test_api_cancel_job(dashboard_server: tuple[str, Queue, list[Any]]) -> None: +def test_api_cancel_job(dashboard_server: tuple[AuthedClient, Queue, list[Any]]) -> None: """POST /api/jobs/{id}/cancel cancels a pending job.""" - base, _, jobs = dashboard_server + client, _, jobs = dashboard_server job_id = jobs[0].id - data = _post(f"{base}/api/jobs/{job_id}/cancel") + data = client.post(f"/api/jobs/{job_id}/cancel") assert data["cancelled"] is True -def test_api_dead_letters_empty(dashboard_server: tuple[str, Queue, list[Any]]) -> None: +def test_api_dead_letters_empty( + dashboard_server: tuple[AuthedClient, Queue, list[Any]], +) -> None: """GET /api/dead-letters returns empty list initially.""" - base, _, __ = dashboard_server - data = _get(f"{base}/api/dead-letters") + client, _, __ = dashboard_server + data = client.get("/api/dead-letters") assert data == [] diff --git a/tests/dashboard/test_dashboard_settings.py b/tests/dashboard/test_dashboard_settings.py index ee9888de..9ba4ba24 100644 --- a/tests/dashboard/test_dashboard_settings.py +++ b/tests/dashboard/test_dashboard_settings.py @@ -13,11 +13,11 @@ import urllib.request from collections.abc import Generator from pathlib import Path -from typing import Any import pytest from taskito import Queue +from taskito.dashboard._testing import AuthedClient, seed_admin_and_session @pytest.fixture @@ -25,28 +25,6 @@ def queue(tmp_path: Path) -> Queue: return Queue(db_path=str(tmp_path / "settings.db")) -def _put(url: str, body: dict) -> Any: - req = urllib.request.Request( - url, - method="PUT", - data=json.dumps(body).encode(), - headers={"Content-Type": "application/json"}, - ) - with urllib.request.urlopen(req) as resp: - return json.loads(resp.read()) - - -def _delete(url: str) -> Any: - req = urllib.request.Request(url, method="DELETE") - with urllib.request.urlopen(req) as resp: - return json.loads(resp.read()) - - -def _get(url: str) -> Any: - with urllib.request.urlopen(url) as resp: - return json.loads(resp.read()) - - # ── Python API ────────────────────────────────────────── @@ -98,7 +76,7 @@ def test_setting_preserves_json(queue: Queue) -> None: @pytest.fixture -def dashboard_server(queue: Queue) -> Generator[tuple[str, Queue]]: +def dashboard_server(queue: Queue) -> Generator[tuple[AuthedClient, Queue]]: from http.server import ThreadingHTTPServer from taskito.dashboard import _make_handler @@ -108,65 +86,79 @@ def dashboard_server(queue: Queue) -> Generator[tuple[str, Queue]]: port = server.server_address[1] thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() + session = seed_admin_and_session(queue) + client = AuthedClient(base=f"http://127.0.0.1:{port}", session=session) try: - yield f"http://127.0.0.1:{port}", queue + yield client, queue finally: server.shutdown() -def test_get_settings_returns_empty_dict(dashboard_server: tuple[str, Queue]) -> None: - base, _ = dashboard_server - assert _get(f"{base}/api/settings") == {} +def test_get_settings_returns_empty_dict(dashboard_server: tuple[AuthedClient, Queue]) -> None: + client, _ = dashboard_server + # The admin user setting is the only one populated by the seed helper. + snapshot = client.get("/api/settings") + assert "auth:users" in snapshot + # No dashboard.* keys yet. + assert not any(k.startswith("dashboard.") for k in snapshot) -def test_put_then_get_setting(dashboard_server: tuple[str, Queue]) -> None: - base, _ = dashboard_server - _put(f"{base}/api/settings/dashboard.title", {"value": "My Queue"}) +def test_put_then_get_setting(dashboard_server: tuple[AuthedClient, Queue]) -> None: + client, _ = dashboard_server + client.put("/api/settings/dashboard.title", {"value": "My Queue"}) - data = _get(f"{base}/api/settings/dashboard.title") + data = client.get("/api/settings/dashboard.title") assert data == {"key": "dashboard.title", "value": "My Queue"} - snapshot = _get(f"{base}/api/settings") - assert snapshot == {"dashboard.title": "My Queue"} + snapshot = client.get("/api/settings") + assert snapshot["dashboard.title"] == "My Queue" -def test_put_setting_with_json_value(dashboard_server: tuple[str, Queue]) -> None: +def test_put_setting_with_json_value(dashboard_server: tuple[AuthedClient, Queue]) -> None: """Non-string ``value`` is JSON-encoded before persistence.""" - base, queue = dashboard_server + client, queue = dashboard_server payload = [ {"label": "Grafana", "url": "https://grafana.example/d/abc"}, {"label": "Sentry", "url": "https://sentry.example/issues"}, ] - _put(f"{base}/api/settings/dashboard.external_links", {"value": payload}) + client.put("/api/settings/dashboard.external_links", {"value": payload}) stored = queue.get_setting("dashboard.external_links") assert stored is not None assert json.loads(stored) == payload -def test_get_unknown_setting_returns_404(dashboard_server: tuple[str, Queue]) -> None: - base, _ = dashboard_server +def test_get_unknown_setting_returns_404(dashboard_server: tuple[AuthedClient, Queue]) -> None: + client, _ = dashboard_server with pytest.raises(urllib.error.HTTPError) as exc_info: - _get(f"{base}/api/settings/missing.key") + client.get("/api/settings/missing.key") assert exc_info.value.code == 404 def test_put_setting_with_missing_value_field_returns_400( - dashboard_server: tuple[str, Queue], + dashboard_server: tuple[AuthedClient, Queue], ) -> None: - base, _ = dashboard_server + client, _ = dashboard_server with pytest.raises(urllib.error.HTTPError) as exc_info: - _put(f"{base}/api/settings/k", {"not_value": 1}) + client.put("/api/settings/k", {"not_value": 1}) assert exc_info.value.code == 400 -def test_put_setting_rejects_invalid_json_body(dashboard_server: tuple[str, Queue]) -> None: - base, _ = dashboard_server +def test_put_setting_rejects_invalid_json_body( + dashboard_server: tuple[AuthedClient, Queue], +) -> None: + client, _ = dashboard_server req = urllib.request.Request( - f"{base}/api/settings/k", + f"{client.base}/api/settings/k", method="PUT", data=b"{not json", - headers={"Content-Type": "application/json"}, + headers={ + "Content-Type": "application/json", + "Cookie": ( + f"taskito_session={client.session.token}; taskito_csrf={client.session.csrf_token}" + ), + "X-CSRF-Token": client.session.csrf_token, + }, ) with pytest.raises(urllib.error.HTTPError) as exc_info: urllib.request.urlopen(req) @@ -174,19 +166,19 @@ def test_put_setting_rejects_invalid_json_body(dashboard_server: tuple[str, Queu def test_delete_setting_returns_true_when_exists( - dashboard_server: tuple[str, Queue], + dashboard_server: tuple[AuthedClient, Queue], ) -> None: - base, queue = dashboard_server + client, queue = dashboard_server queue.set_setting("k", "v") - assert _delete(f"{base}/api/settings/k") == {"deleted": True} + assert client.delete("/api/settings/k") == {"deleted": True} assert queue.get_setting("k") is None def test_delete_missing_setting_returns_false( - dashboard_server: tuple[str, Queue], + dashboard_server: tuple[AuthedClient, Queue], ) -> None: - base, _ = dashboard_server - assert _delete(f"{base}/api/settings/missing") == {"deleted": False} + client, _ = dashboard_server + assert client.delete("/api/settings/missing") == {"deleted": False} def test_settings_persist_across_queue_instances(tmp_path: Path) -> None: From 6f9af71d55aec2cce653eed9dc9b2a605e9e2818 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sun, 17 May 2026 04:16:47 +0530 Subject: [PATCH 02/10] feat(dashboard): add login UI, CSRF handling, and auth gate Login page wraps setup + sign-in forms. AuthGate component bounces unauthenticated visitors at the AppShell boundary; the /login route is the only public client-side path. UserMenu in the header surfaces the current user with a sign-out action. api-client now reads the taskito_csrf cookie and forwards it as X-CSRF-Token on POST/PUT/DELETE and uses credentials: same-origin so session cookies travel with every request. --- dashboard/src/components/layout/header.tsx | 2 + dashboard/src/features/auth/api.ts | 29 +++++ .../features/auth/components/auth-gate.tsx | 46 +++++++ .../features/auth/components/login-form.tsx | 95 ++++++++++++++ .../features/auth/components/setup-form.tsx | 116 ++++++++++++++++++ .../features/auth/components/user-menu.tsx | 59 +++++++++ dashboard/src/features/auth/hooks.ts | 94 ++++++++++++++ dashboard/src/features/auth/index.ts | 22 ++++ dashboard/src/features/auth/types.ts | 32 +++++ dashboard/src/lib/api-client.test.ts | 44 +++++++ dashboard/src/lib/api-client.ts | 36 +++++- dashboard/src/routes/__root.tsx | 21 +++- dashboard/src/routes/login.tsx | 51 ++++++++ 13 files changed, 638 insertions(+), 9 deletions(-) create mode 100644 dashboard/src/features/auth/api.ts create mode 100644 dashboard/src/features/auth/components/auth-gate.tsx create mode 100644 dashboard/src/features/auth/components/login-form.tsx create mode 100644 dashboard/src/features/auth/components/setup-form.tsx create mode 100644 dashboard/src/features/auth/components/user-menu.tsx create mode 100644 dashboard/src/features/auth/hooks.ts create mode 100644 dashboard/src/features/auth/index.ts create mode 100644 dashboard/src/features/auth/types.ts create mode 100644 dashboard/src/routes/login.tsx diff --git a/dashboard/src/components/layout/header.tsx b/dashboard/src/components/layout/header.tsx index a66e8d20..35d06fc3 100644 --- a/dashboard/src/components/layout/header.tsx +++ b/dashboard/src/components/layout/header.tsx @@ -1,5 +1,6 @@ import { Search } from "lucide-react"; import { Button, Kbd } from "@/components/ui"; +import { UserMenu } from "@/features/auth"; import { useCommandPalette } from "@/providers"; import { LastRefreshed } from "./last-refreshed"; import { MobileMenu } from "./mobile-menu"; @@ -37,6 +38,7 @@ export function Header() {
+
); diff --git a/dashboard/src/features/auth/api.ts b/dashboard/src/features/auth/api.ts new file mode 100644 index 00000000..b7d19c50 --- /dev/null +++ b/dashboard/src/features/auth/api.ts @@ -0,0 +1,29 @@ +import { api } from "@/lib/api-client"; +import type { AuthStatus, LoginResponse, SetupResponse, WhoamiResponse } from "./types"; + +export function fetchAuthStatus(signal?: AbortSignal): Promise { + return api.get("/api/auth/status", { signal }); +} + +export function fetchWhoami(signal?: AbortSignal): Promise { + return api.get("/api/auth/whoami", { signal }); +} + +export function login(username: string, password: string): Promise { + return api.post("/api/auth/login", { username, password }); +} + +export function logout(): Promise<{ ok: boolean }> { + return api.post<{ ok: boolean }>("/api/auth/logout"); +} + +export function setup(username: string, password: string): Promise { + return api.post("/api/auth/setup", { username, password }); +} + +export function changePassword(oldPassword: string, newPassword: string): Promise<{ ok: boolean }> { + return api.post<{ ok: boolean }>("/api/auth/change-password", { + old_password: oldPassword, + new_password: newPassword, + }); +} diff --git a/dashboard/src/features/auth/components/auth-gate.tsx b/dashboard/src/features/auth/components/auth-gate.tsx new file mode 100644 index 00000000..4bcafc63 --- /dev/null +++ b/dashboard/src/features/auth/components/auth-gate.tsx @@ -0,0 +1,46 @@ +import { useNavigate } from "@tanstack/react-router"; +import type { ReactNode } from "react"; +import { useEffect } from "react"; +import { Skeleton } from "@/components/ui"; +import { useAuthStatus, useWhoami } from "../hooks"; + +/** + * Wraps the authenticated portion of the dashboard. + * + * - When setup is required, redirects to ``/login`` (which shows the setup + * form). + * - When the user isn't signed in, redirects to ``/login``. + * - While loading, renders a centered skeleton so the page never flashes + * raw content. + * + * Once a session resolves, children render normally. + */ +export function AuthGate({ children }: { children: ReactNode }) { + const navigate = useNavigate(); + const status = useAuthStatus(); + const whoami = useWhoami(); + + const setupRequired = status.data?.setup_required === true; + const authenticated = !!whoami.data?.user; + const loading = status.isLoading || whoami.isLoading; + + useEffect(() => { + if (loading) return; + if (setupRequired || !authenticated) { + void navigate({ to: "/login" }); + } + }, [loading, setupRequired, authenticated, navigate]); + + if (loading || setupRequired || !authenticated) { + return ( +
+
+ + +
+
+ ); + } + + return <>{children}; +} diff --git a/dashboard/src/features/auth/components/login-form.tsx b/dashboard/src/features/auth/components/login-form.tsx new file mode 100644 index 00000000..3038d809 --- /dev/null +++ b/dashboard/src/features/auth/components/login-form.tsx @@ -0,0 +1,95 @@ +import { useNavigate } from "@tanstack/react-router"; +import { AlertCircle, LogIn } from "lucide-react"; +import { type FormEvent, useState } from "react"; +import { Button } from "@/components/ui"; +import { Input } from "@/components/ui/input"; +import { ApiError } from "@/lib/api-client"; +import { useLogin } from "../hooks"; + +const ERROR_MESSAGES: Record = { + invalid_credentials: "Invalid username or password.", + setup_required: "Dashboard setup is required before login.", +}; + +export function LoginForm() { + const navigate = useNavigate(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const login = useLogin(); + + function onSubmit(event: FormEvent): void { + event.preventDefault(); + login.mutate( + { username, password }, + { + onSuccess: () => { + void navigate({ to: "/" }); + }, + }, + ); + } + + const error = errorMessage(login.error); + const disabled = login.isPending || !username || !password; + + return ( +
+
+

Sign in

+

+ Enter your dashboard credentials to continue. +

+
+ + + {error ? ( +
+ + {error} +
+ ) : null} + +
+ ); +} + +function errorMessage(error: unknown): string | null { + if (!error) return null; + if (error instanceof ApiError) { + const code = + typeof error.body === "object" && error.body && "error" in error.body + ? String((error.body as { error: unknown }).error) + : ""; + return ERROR_MESSAGES[code] ?? error.message ?? "Sign-in failed."; + } + return "Sign-in failed."; +} diff --git a/dashboard/src/features/auth/components/setup-form.tsx b/dashboard/src/features/auth/components/setup-form.tsx new file mode 100644 index 00000000..2a1cd6e3 --- /dev/null +++ b/dashboard/src/features/auth/components/setup-form.tsx @@ -0,0 +1,116 @@ +import { useNavigate } from "@tanstack/react-router"; +import { AlertCircle, ShieldCheck } from "lucide-react"; +import { type FormEvent, useState } from "react"; +import { Button } from "@/components/ui"; +import { Input } from "@/components/ui/input"; +import { ApiError } from "@/lib/api-client"; +import { useLogin, useSetup } from "../hooks"; + +export function SetupForm() { + const navigate = useNavigate(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [formError, setFormError] = useState(null); + const setup = useSetup(); + const login = useLogin(); + + function onSubmit(event: FormEvent): void { + event.preventDefault(); + setFormError(null); + if (password !== confirm) { + setFormError("Passwords don't match."); + return; + } + setup.mutate( + { username, password }, + { + onSuccess: () => { + // Auto-login as the brand-new admin so the user lands on the + // dashboard without an extra hop. + login.mutate( + { username, password }, + { + onSuccess: () => { + void navigate({ to: "/" }); + }, + }, + ); + }, + }, + ); + } + + const pending = setup.isPending || login.isPending; + const disabled = pending || !username || !password || !confirm; + const apiError = errorMessage(setup.error ?? login.error); + const error = formError ?? apiError; + + return ( +
+
+

Create the first admin

+

+ Set up the initial dashboard administrator. You'll be signed in automatically. +

+
+ + + + {error ? ( +
+ + {error} +
+ ) : null} + +
+ ); +} + +function errorMessage(error: unknown): string | null { + if (!error) return null; + if (error instanceof ApiError) return error.message; + return "Setup failed."; +} diff --git a/dashboard/src/features/auth/components/user-menu.tsx b/dashboard/src/features/auth/components/user-menu.tsx new file mode 100644 index 00000000..52b5c13c --- /dev/null +++ b/dashboard/src/features/auth/components/user-menu.tsx @@ -0,0 +1,59 @@ +import { useNavigate } from "@tanstack/react-router"; +import { LogOut, User as UserIcon } from "lucide-react"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui"; +import { useLogout, useWhoami } from "../hooks"; + +export function UserMenu() { + const { data } = useWhoami(); + const navigate = useNavigate(); + const logout = useLogout(); + + if (!data?.user) return null; + + const { username, role } = data.user; + + function onLogout() { + logout.mutate(undefined, { + onSettled: () => { + void navigate({ to: "/login" }); + }, + }); + } + + return ( + + + + + + +
+ {username} + {role} +
+
+ + + Sign out + +
+
+ ); +} diff --git a/dashboard/src/features/auth/hooks.ts b/dashboard/src/features/auth/hooks.ts new file mode 100644 index 00000000..449ed442 --- /dev/null +++ b/dashboard/src/features/auth/hooks.ts @@ -0,0 +1,94 @@ +import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ApiError } from "@/lib/api-client"; +import { changePassword, fetchAuthStatus, fetchWhoami, login, logout, setup } from "./api"; +import type { WhoamiResponse } from "./types"; + +export const AUTH_STATUS_KEY = ["auth", "status"] as const; +export const WHOAMI_KEY = ["auth", "whoami"] as const; + +export function authStatusQuery() { + return queryOptions({ + queryKey: AUTH_STATUS_KEY, + queryFn: ({ signal }) => fetchAuthStatus(signal), + staleTime: 60_000, + }); +} + +/** + * Resolve the current session. ``data`` is ``null`` when no session is + * active (the server returns 401, which we trap so the rest of the app can + * test for ``data === null`` without a try/catch). + */ +export function whoamiQuery() { + return queryOptions({ + queryKey: WHOAMI_KEY, + queryFn: async ({ signal }): Promise => { + try { + return await fetchWhoami(signal); + } catch (e) { + if (e instanceof ApiError && (e.status === 401 || e.status === 404)) { + return null; + } + throw e; + } + }, + staleTime: 30_000, + retry: (failureCount, error) => { + if (error instanceof ApiError && error.status >= 400 && error.status < 500) { + return false; + } + return failureCount < 2; + }, + }); +} + +export function useAuthStatus() { + return useQuery(authStatusQuery()); +} + +export function useWhoami() { + return useQuery(whoamiQuery()); +} + +export function useLogin() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ username, password }: { username: string; password: string }) => + login(username, password), + onSuccess: async () => { + await qc.invalidateQueries({ queryKey: WHOAMI_KEY }); + await qc.invalidateQueries({ queryKey: AUTH_STATUS_KEY }); + }, + }); +} + +export function useLogout() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: logout, + onSettled: async () => { + qc.setQueryData(WHOAMI_KEY, null); + // Drop every cached query — there will be no further data to show + // until the user logs back in. + qc.clear(); + }, + }); +} + +export function useSetup() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ username, password }: { username: string; password: string }) => + setup(username, password), + onSuccess: async () => { + await qc.invalidateQueries({ queryKey: AUTH_STATUS_KEY }); + }, + }); +} + +export function useChangePassword() { + return useMutation({ + mutationFn: ({ oldPassword, newPassword }: { oldPassword: string; newPassword: string }) => + changePassword(oldPassword, newPassword), + }); +} diff --git a/dashboard/src/features/auth/index.ts b/dashboard/src/features/auth/index.ts new file mode 100644 index 00000000..259cdc02 --- /dev/null +++ b/dashboard/src/features/auth/index.ts @@ -0,0 +1,22 @@ +export { AuthGate } from "./components/auth-gate"; +export { LoginForm } from "./components/login-form"; +export { SetupForm } from "./components/setup-form"; +export { UserMenu } from "./components/user-menu"; +export { + authStatusQuery, + useAuthStatus, + useChangePassword, + useLogin, + useLogout, + useSetup, + useWhoami, + whoamiQuery, +} from "./hooks"; +export type { + AuthSession, + AuthStatus, + AuthUser, + LoginResponse, + SetupResponse, + WhoamiResponse, +} from "./types"; diff --git a/dashboard/src/features/auth/types.ts b/dashboard/src/features/auth/types.ts new file mode 100644 index 00000000..4596f6e5 --- /dev/null +++ b/dashboard/src/features/auth/types.ts @@ -0,0 +1,32 @@ +export interface AuthUser { + username: string; + role: string; + created_at: number; + last_login_at: number | null; +} + +export interface AuthSession { + username: string; + role: string; + expires_at: number; + csrf_token: string; +} + +export interface LoginResponse { + user: AuthUser; + session: AuthSession; +} + +export interface SetupResponse { + user: AuthUser; +} + +export interface AuthStatus { + setup_required: boolean; +} + +export interface WhoamiResponse { + user: AuthUser; + csrf_token: string; + expires_at: number; +} diff --git a/dashboard/src/lib/api-client.test.ts b/dashboard/src/lib/api-client.test.ts index 920bc969..6c82d8a6 100644 --- a/dashboard/src/lib/api-client.test.ts +++ b/dashboard/src/lib/api-client.test.ts @@ -162,3 +162,47 @@ describe("ApiError", () => { expect(err).toBeInstanceOf(Error); }); }); + +describe("CSRF cookie forwarding", () => { + beforeEach(() => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ ok: true })); + // Vitest's default env is node; api-client falls back to "no cookie" when + // ``document`` is undefined, so stub a minimal document object here. + vi.stubGlobal("document", { cookie: "taskito_csrf=test-csrf-token; path=/" }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("attaches X-CSRF-Token to POST when the cookie is set", async () => { + await api.post("/api/auth/logout"); + const [, init] = vi.mocked(globalThis.fetch).mock.calls[0]!; + expect(init?.headers).toMatchObject({ "X-CSRF-Token": "test-csrf-token" }); + }); + + it("attaches X-CSRF-Token to PUT", async () => { + await api.put("/api/settings/k", { value: "v" }); + const [, init] = vi.mocked(globalThis.fetch).mock.calls[0]!; + expect(init?.headers).toMatchObject({ "X-CSRF-Token": "test-csrf-token" }); + }); + + it("attaches X-CSRF-Token to DELETE", async () => { + await api.delete("/api/settings/k"); + const [, init] = vi.mocked(globalThis.fetch).mock.calls[0]!; + expect(init?.headers).toMatchObject({ "X-CSRF-Token": "test-csrf-token" }); + }); + + it("does NOT attach X-CSRF-Token to GET", async () => { + await api.get("/api/stats"); + const [, init] = vi.mocked(globalThis.fetch).mock.calls[0]!; + expect(init?.headers).not.toHaveProperty("X-CSRF-Token"); + }); + + it("uses same-origin credentials so cookies are sent", async () => { + await api.get("/api/stats"); + const [, init] = vi.mocked(globalThis.fetch).mock.calls[0]!; + expect(init?.credentials).toBe("same-origin"); + }); +}); diff --git a/dashboard/src/lib/api-client.ts b/dashboard/src/lib/api-client.ts index 8624c301..72499a56 100644 --- a/dashboard/src/lib/api-client.ts +++ b/dashboard/src/lib/api-client.ts @@ -1,5 +1,8 @@ const API_BASE = import.meta.env.VITE_API_BASE ?? ""; +const CSRF_COOKIE = "taskito_csrf"; +const CSRF_HEADER = "X-CSRF-Token"; + export class ApiError extends Error { readonly status: number; readonly body: unknown; @@ -26,6 +29,25 @@ function buildUrl(path: string, params?: Query): string { return query ? `${url}?${query}` : url; } +/** + * Read a cookie by name from ``document.cookie``. Returns ``undefined`` if + * the cookie isn't set or we're running in an environment without + * ``document`` (e.g. unit tests via jsdom may omit it). + */ +export function readCookie(name: string): string | undefined { + if (typeof document === "undefined") return undefined; + for (const part of document.cookie.split(";")) { + const [k, v] = part.trim().split("="); + if (k === name) return v; + } + return undefined; +} + +function withCsrf(headers: Record): Record { + const csrf = readCookie(CSRF_COOKIE); + return csrf ? { ...headers, [CSRF_HEADER]: csrf } : headers; +} + async function parse(response: Response): Promise { const contentType = response.headers.get("content-type") ?? ""; const payload: unknown = contentType.includes("application/json") @@ -53,6 +75,7 @@ export const api = { const response = await fetch(buildUrl(path, options.params), { method: "GET", signal: options.signal, + credentials: "same-origin", headers: { Accept: "application/json", ...options.headers }, }); return parse(response); @@ -62,11 +85,12 @@ export const api = { const response = await fetch(buildUrl(path, options.params), { method: "POST", signal: options.signal, - headers: { + credentials: "same-origin", + headers: withCsrf({ Accept: "application/json", ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...options.headers, - }, + }), body: body === undefined ? undefined : JSON.stringify(body), }); return parse(response); @@ -76,11 +100,12 @@ export const api = { const response = await fetch(buildUrl(path, options.params), { method: "PUT", signal: options.signal, - headers: { + credentials: "same-origin", + headers: withCsrf({ Accept: "application/json", ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...options.headers, - }, + }), body: body === undefined ? undefined : JSON.stringify(body), }); return parse(response); @@ -90,7 +115,8 @@ export const api = { const response = await fetch(buildUrl(path, options.params), { method: "DELETE", signal: options.signal, - headers: { Accept: "application/json", ...options.headers }, + credentials: "same-origin", + headers: withCsrf({ Accept: "application/json", ...options.headers }), }); return parse(response); }, diff --git a/dashboard/src/routes/__root.tsx b/dashboard/src/routes/__root.tsx index bb4e516f..8296f153 100644 --- a/dashboard/src/routes/__root.tsx +++ b/dashboard/src/routes/__root.tsx @@ -1,8 +1,9 @@ import type { QueryClient } from "@tanstack/react-query"; -import { createRootRouteWithContext, Link, Outlet } from "@tanstack/react-router"; +import { createRootRouteWithContext, Link, Outlet, useLocation } from "@tanstack/react-router"; import { AlertTriangle, ArrowLeft, Home } from "lucide-react"; import { AppShell, BackendOffline } from "@/components/layout"; import { Button, buttonVariants } from "@/components/ui"; +import { AuthGate } from "@/features/auth"; import { cn } from "@/lib/cn"; import { isBackendUnreachable } from "@/lib/errors"; @@ -16,11 +17,23 @@ export const Route = createRootRouteWithContext()({ notFoundComponent: NotFoundView, }); +/** + * Public routes that render without the AppShell or the auth gate. The + * login route handles its own redirect when a session is already active. + */ +const UNAUTHED_ROUTES = new Set(["/login"]); + function RootLayout() { + const { pathname } = useLocation(); + if (UNAUTHED_ROUTES.has(pathname)) { + return ; + } return ( - - - + + + + + ); } diff --git a/dashboard/src/routes/login.tsx b/dashboard/src/routes/login.tsx new file mode 100644 index 00000000..cdbb321b --- /dev/null +++ b/dashboard/src/routes/login.tsx @@ -0,0 +1,51 @@ +import { createFileRoute, Navigate, useRouter } from "@tanstack/react-router"; +import { AlertOctagon } from "lucide-react"; +import { LoginForm, SetupForm, useAuthStatus, useWhoami } from "@/features/auth"; + +export const Route = createFileRoute("/login")({ + component: LoginPage, +}); + +/** + * Standalone auth route — no AppShell wrapping. Shows the setup form when + * no users exist yet, the login form otherwise. Logged-in visitors are + * bounced back to the dashboard root. + */ +function LoginPage() { + const router = useRouter(); + const status = useAuthStatus(); + const whoami = useWhoami(); + + if (whoami.data?.user) { + return ; + } + + const loading = status.isLoading || whoami.isLoading; + + return ( +
+
+
+
+ +
+ taskito +
+ {loading ? ( +
Loading…
+ ) : status.data?.setup_required ? ( + + ) : ( + + )} + +
+
+ ); +} From ae0325f2f58b9a86db1ef3f2def1cff05d95cd08 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sun, 17 May 2026 07:51:00 +0530 Subject: [PATCH 03/10] feat(webhooks): persistent subscriptions + CRUD dashboard UI (Phase 2) (#169) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(webhooks): persist subscriptions and add CRUD endpoints Webhook subscriptions are stored as JSON in the dashboard_settings table, so they survive restarts and propagate across every worker pointed at the same backend. WebhookManager reloads its in-memory snapshot on every CRUD write. Each subscription supports an optional per-task filter alongside event-type filtering, configurable retry policy, and HMAC signing with a rotatable secret. An SSRF guard rejects loopback / RFC1918 / link-local destinations unless TASKITO_WEBHOOKS_ALLOW_PRIVATE is set. The dashboard exposes list / get / create / update / delete / rotate-secret / send-test endpoints plus a public GET /api/event-types listing. * feat(dashboard): add Webhooks page with full CRUD UI New /webhooks route lists configured subscriptions and surfaces a create dialog with event-type multi-select, optional per-task filter input, and auto-generated signing secret. Each row gets a dropdown menu for send-test, enable/disable toggle, secret rotation (with confirmation gate), and delete. Newly minted secrets are shown once through a reveal-and-copy card that hides the raw value on close. * feat(webhooks): persist delivery log + replay UI (Phase 3) (#170) * feat(webhooks): persist delivery log with replay support Every webhook attempt now lands in a per-subscription JSON list under ``webhooks:deliveries:{sub_id}`` in the dashboard settings store, capped at 200 entries per webhook with FIFO eviction. Records carry the final HTTP status, response body (truncated to 2 KiB), latency, attempt count, and any transport-level error so operators can debug failures without leaving the dashboard. The dispatcher gains GET /api/webhooks/{id}/deliveries (with status/event/limit/offset filters), GET /api/webhooks/{id}/deliveries/ {delivery_id}, and POST /api/webhooks/{id}/deliveries/{delivery_id}/ replay — replay re-fires the original payload synchronously and records the outcome as a fresh delivery. * feat(dashboard): add deliveries route with filter + replay New ``/webhooks/{id}/deliveries`` route surfaces the persisted delivery log per webhook. Rows show timestamp, event, status badge, response code, latency, and attempts; clicking a row opens a dialog with the full payload and (truncated) response body. Each row carries a Replay button — and so does the row-actions menu on the main webhooks list ("View deliveries"). Status filter lets operators narrow to delivered/ failed/dead quickly. * feat(overrides): runtime task & queue overrides (Phase 4) (#171) * feat(overrides): persist per-task and per-queue runtime overrides Operators can now tune retry policy, concurrency, rate limit, timeout, priority, and pause state per task — and rate limit, concurrency, pause state per queue — without code changes. Overrides live as JSON entries under ``overrides:task:*`` and ``overrides:queue:*`` keys in the dashboard settings store, and merge into PyTaskConfig / queue_configs at worker startup. Pause state additionally flips the existing paused_queues mechanism so it takes effect on a running worker. Queue gains ``set_task_override`` / ``clear_task_override`` / ``set_queue_override`` / ``clear_queue_override`` plus discovery APIs ``registered_tasks()`` / ``registered_queues()`` that surface decorator defaults, overrides, and effective values for the dashboard. * feat(dashboard): add Tasks page with override editor New /tasks route lists every registered task with its decorator defaults, any active runtime override, and the effective values. Edit-button opens a side sheet with a form for rate_limit, max_concurrent, max_retries, timeout, priority, and a paused toggle. Empty inputs mean "inherit decorator default"; the clear-override button removes the row entirely. Effective values that differ from decorator defaults are highlighted in accent. * feat(middleware): per-task middleware toggles (Phase 5) (#172) * feat(middleware): toggle middleware per task from dashboard TaskMiddleware now carries a stable ``name`` (defaulting to the fully- qualified class path so it survives restarts; overridable on a subclass). Per-task disable lists live under ``middleware:disabled:`` in the dashboard settings store, consulted by ``_get_middleware_chain`` at every invocation — turning a middleware off for one task takes effect on the next job, no worker restart needed. Queue gains ``list_middleware()``, ``disable_middleware_for_task``, ``enable_middleware_for_task``, ``clear_middleware_disables``. New endpoints: GET /api/middleware (discovery), GET/DELETE /api/tasks/{task}/middleware, PUT /api/tasks/{task}/middleware/{mw_name}. * feat(dashboard): add Middleware tab to task editor The task override side-sheet now has two tabs: Overrides (existing form) and Middleware. The Middleware tab lists every middleware that fires for the selected task with an enabled/disabled toggle each. Toggling a middleware off takes effect on the next job — no worker restart required. --- dashboard/src/components/layout/sidebar.tsx | 7 +- dashboard/src/features/tasks/api.ts | 26 ++ .../tasks/components/middleware-toggles.tsx | 99 +++++ .../tasks/components/task-list-table.tsx | 132 ++++++ .../tasks/components/task-override-form.tsx | 237 +++++++++++ dashboard/src/features/tasks/hooks.ts | 100 +++++ dashboard/src/features/tasks/index.ts | 19 + dashboard/src/features/tasks/types.ts | 41 ++ dashboard/src/features/webhooks/api.ts | 77 ++++ .../components/create-webhook-dialog.tsx | 169 ++++++++ .../components/delivery-list-table.tsx | 183 +++++++++ .../components/event-type-multi-select.tsx | 108 +++++ .../webhooks/components/secret-reveal.tsx | 64 +++ .../webhooks/components/task-filter-input.tsx | 81 ++++ .../components/webhook-list-table.tsx | 111 +++++ .../components/webhook-row-actions.tsx | 151 +++++++ dashboard/src/features/webhooks/hooks.ts | 175 ++++++++ dashboard/src/features/webhooks/index.ts | 33 ++ dashboard/src/features/webhooks/types.ts | 93 +++++ dashboard/src/routes/tasks.tsx | 31 ++ .../src/routes/webhooks.$id.deliveries.tsx | 86 ++++ dashboard/src/routes/webhooks.tsx | 32 ++ py_src/taskito/app.py | 6 +- py_src/taskito/dashboard/delivery_store.py | 208 ++++++++++ .../taskito/dashboard/handlers/middleware.py | 62 +++ .../taskito/dashboard/handlers/overrides.py | 95 +++++ .../dashboard/handlers/webhook_deliveries.py | 111 +++++ py_src/taskito/dashboard/handlers/webhooks.py | 241 +++++++++++ py_src/taskito/dashboard/middleware_store.py | 88 ++++ py_src/taskito/dashboard/overrides_store.py | 341 ++++++++++++++++ py_src/taskito/dashboard/routes.py | 79 ++++ py_src/taskito/dashboard/server.py | 40 ++ py_src/taskito/dashboard/url_safety.py | 97 +++++ py_src/taskito/dashboard/webhook_store.py | 204 ++++++++++ py_src/taskito/middleware.py | 8 + py_src/taskito/mixins/__init__.py | 4 + py_src/taskito/mixins/decorators.py | 14 +- py_src/taskito/mixins/events.py | 79 +++- py_src/taskito/mixins/lifecycle.py | 20 +- py_src/taskito/mixins/middleware_admin.py | 70 ++++ py_src/taskito/mixins/overrides.py | 151 +++++++ py_src/taskito/webhooks.py | 263 ++++++++++-- tests/dashboard/test_middleware_toggles.py | 234 +++++++++++ tests/dashboard/test_task_overrides.py | 234 +++++++++++ tests/dashboard/test_webhook_deliveries.py | 303 ++++++++++++++ tests/dashboard/test_webhooks_endpoints.py | 378 ++++++++++++++++++ 46 files changed, 5334 insertions(+), 51 deletions(-) create mode 100644 dashboard/src/features/tasks/api.ts create mode 100644 dashboard/src/features/tasks/components/middleware-toggles.tsx create mode 100644 dashboard/src/features/tasks/components/task-list-table.tsx create mode 100644 dashboard/src/features/tasks/components/task-override-form.tsx create mode 100644 dashboard/src/features/tasks/hooks.ts create mode 100644 dashboard/src/features/tasks/index.ts create mode 100644 dashboard/src/features/tasks/types.ts create mode 100644 dashboard/src/features/webhooks/api.ts create mode 100644 dashboard/src/features/webhooks/components/create-webhook-dialog.tsx create mode 100644 dashboard/src/features/webhooks/components/delivery-list-table.tsx create mode 100644 dashboard/src/features/webhooks/components/event-type-multi-select.tsx create mode 100644 dashboard/src/features/webhooks/components/secret-reveal.tsx create mode 100644 dashboard/src/features/webhooks/components/task-filter-input.tsx create mode 100644 dashboard/src/features/webhooks/components/webhook-list-table.tsx create mode 100644 dashboard/src/features/webhooks/components/webhook-row-actions.tsx create mode 100644 dashboard/src/features/webhooks/hooks.ts create mode 100644 dashboard/src/features/webhooks/index.ts create mode 100644 dashboard/src/features/webhooks/types.ts create mode 100644 dashboard/src/routes/tasks.tsx create mode 100644 dashboard/src/routes/webhooks.$id.deliveries.tsx create mode 100644 dashboard/src/routes/webhooks.tsx create mode 100644 py_src/taskito/dashboard/delivery_store.py create mode 100644 py_src/taskito/dashboard/handlers/middleware.py create mode 100644 py_src/taskito/dashboard/handlers/overrides.py create mode 100644 py_src/taskito/dashboard/handlers/webhook_deliveries.py create mode 100644 py_src/taskito/dashboard/handlers/webhooks.py create mode 100644 py_src/taskito/dashboard/middleware_store.py create mode 100644 py_src/taskito/dashboard/overrides_store.py create mode 100644 py_src/taskito/dashboard/url_safety.py create mode 100644 py_src/taskito/dashboard/webhook_store.py create mode 100644 py_src/taskito/mixins/middleware_admin.py create mode 100644 py_src/taskito/mixins/overrides.py create mode 100644 tests/dashboard/test_middleware_toggles.py create mode 100644 tests/dashboard/test_task_overrides.py create mode 100644 tests/dashboard/test_webhook_deliveries.py create mode 100644 tests/dashboard/test_webhooks_endpoints.py diff --git a/dashboard/src/components/layout/sidebar.tsx b/dashboard/src/components/layout/sidebar.tsx index f3ddd131..5f9e209d 100644 --- a/dashboard/src/components/layout/sidebar.tsx +++ b/dashboard/src/components/layout/sidebar.tsx @@ -14,6 +14,7 @@ import { Server, Settings2, Skull, + Webhook as WebhookIcon, } from "lucide-react"; import { useBranding, useExternalLinks } from "@/features/settings"; import { cn } from "@/lib/cn"; @@ -57,7 +58,11 @@ const NAV: NavGroup[] = [ }, { title: "Configuration", - items: [{ to: "/settings", label: "Settings", icon: Cog }], + items: [ + { to: "/tasks", label: "Tasks", icon: ListTree }, + { to: "/webhooks", label: "Webhooks", icon: WebhookIcon }, + { to: "/settings", label: "Settings", icon: Cog }, + ], }, ]; diff --git a/dashboard/src/features/tasks/api.ts b/dashboard/src/features/tasks/api.ts new file mode 100644 index 00000000..e1232fdd --- /dev/null +++ b/dashboard/src/features/tasks/api.ts @@ -0,0 +1,26 @@ +import { api } from "@/lib/api-client"; +import type { QueueEntry, QueueOverridePatch, TaskEntry, TaskOverridePatch } from "./types"; + +export function listTasks(signal?: AbortSignal): Promise { + return api.get("/api/tasks", { signal }); +} + +export function listQueues(signal?: AbortSignal): Promise { + return api.get("/api/queues", { signal }); +} + +export function putTaskOverride(name: string, patch: TaskOverridePatch): Promise { + return api.put(`/api/tasks/${encodeURIComponent(name)}/override`, patch); +} + +export function clearTaskOverride(name: string): Promise<{ cleared: boolean }> { + return api.delete<{ cleared: boolean }>(`/api/tasks/${encodeURIComponent(name)}/override`); +} + +export function putQueueOverride(name: string, patch: QueueOverridePatch): Promise { + return api.put(`/api/queues/${encodeURIComponent(name)}/override`, patch); +} + +export function clearQueueOverride(name: string): Promise<{ cleared: boolean }> { + return api.delete<{ cleared: boolean }>(`/api/queues/${encodeURIComponent(name)}/override`); +} diff --git a/dashboard/src/features/tasks/components/middleware-toggles.tsx b/dashboard/src/features/tasks/components/middleware-toggles.tsx new file mode 100644 index 00000000..c61241d7 --- /dev/null +++ b/dashboard/src/features/tasks/components/middleware-toggles.tsx @@ -0,0 +1,99 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Power } from "lucide-react"; +import { toast } from "sonner"; +import { ErrorState, Skeleton } from "@/components/ui"; +import { api } from "@/lib/api-client"; + +interface TaskMiddlewareEntry { + name: string; + class_path: string; + disabled: boolean; + effective: boolean; +} + +interface TaskMiddlewareResponse { + task: string; + middleware: TaskMiddlewareEntry[]; +} + +interface Props { + taskName: string; +} + +const queryKey = (task: string) => ["tasks", task, "middleware"] as const; + +export function MiddlewareToggles({ taskName }: Props) { + const qc = useQueryClient(); + const query = useQuery({ + queryKey: queryKey(taskName), + queryFn: ({ signal }) => + api.get(`/api/tasks/${encodeURIComponent(taskName)}/middleware`, { + signal, + }), + }); + + const mutation = useMutation({ + mutationFn: ({ mwName, enabled }: { mwName: string; enabled: boolean }) => + api.put( + `/api/tasks/${encodeURIComponent(taskName)}/middleware/${encodeURIComponent(mwName)}`, + { enabled }, + ), + onSuccess: async () => { + await qc.invalidateQueries({ queryKey: queryKey(taskName) }); + }, + onError: () => toast.error("Failed to update middleware"), + }); + + if (query.isLoading) { + return ; + } + if (query.error) { + return ( + + ); + } + const entries = query.data?.middleware ?? []; + if (entries.length === 0) { + return ( +
+ No middleware registered for this task. +
+ ); + } + + return ( +
    + {entries.map((entry) => { + const enabled = !entry.disabled; + return ( +
  • +
    +
    {entry.name}
    +
    {entry.class_path}
    +
    + +
  • + ); + })} +
+ ); +} diff --git a/dashboard/src/features/tasks/components/task-list-table.tsx b/dashboard/src/features/tasks/components/task-list-table.tsx new file mode 100644 index 00000000..4c69e32d --- /dev/null +++ b/dashboard/src/features/tasks/components/task-list-table.tsx @@ -0,0 +1,132 @@ +import { ListTree } from "lucide-react"; +import { useState } from "react"; +import { + Badge, + Button, + EmptyState, + Sheet, + SheetContent, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui"; +import type { TaskEntry } from "../types"; +import { TaskOverrideForm } from "./task-override-form"; + +interface Props { + tasks: TaskEntry[]; +} + +export function TaskListTable({ tasks }: Props) { + const [editing, setEditing] = useState(null); + + if (tasks.length === 0) { + return ( + + ); + } + + return ( + <> +
+ + + + Task + Queue + Rate limit + Concurrency + Retries + Timeout + Override + + + + + {tasks.map((task) => ( + + {task.name} + + {task.queue} + + + (v == null ? "—" : String(v))} + /> + + + (v == null ? "—" : String(v))} + /> + + + String(v)} + /> + + + `${v}s`} + /> + + + {task.paused ? ( + Paused + ) : task.override ? ( + Override + ) : ( + Default + )} + + + + + + ))} + +
+
+ + !open && setEditing(null)}> + + {editing ? setEditing(null)} /> : null} + + + + ); +} + +interface CellProps { + effective: T; + decoratorDefault: T; + formatter: (v: T) => string; +} + +function EffectiveCell({ effective, decoratorDefault, formatter }: CellProps) { + const overridden = effective !== decoratorDefault; + return ( + + {formatter(effective)} + + ); +} diff --git a/dashboard/src/features/tasks/components/task-override-form.tsx b/dashboard/src/features/tasks/components/task-override-form.tsx new file mode 100644 index 00000000..5e962b5b --- /dev/null +++ b/dashboard/src/features/tasks/components/task-override-form.tsx @@ -0,0 +1,237 @@ +import { Save, Trash2 } from "lucide-react"; +import { type FormEvent, useState } from "react"; +import { Button, Input, Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui"; +import { useClearTaskOverride, useSetTaskOverride } from "../hooks"; +import type { TaskEntry, TaskOverridePatch } from "../types"; +import { MiddlewareToggles } from "./middleware-toggles"; + +interface Props { + task: TaskEntry; + onDone?: () => void; +} + +/** + * Side-panel form for editing a task's overrides. Empty inputs mean + * "inherit the decorator default" (the override field is omitted / + * cleared); a non-empty value overrides the default. Submit applies the + * change; ``Clear`` removes the override entirely. + */ +export function TaskOverrideForm({ task, onDone }: Props) { + const setOverride = useSetTaskOverride(); + const clearOverride = useClearTaskOverride(); + + const o = task.override ?? {}; + const [rateLimit, setRateLimit] = useState(o.rate_limit ?? ""); + const [maxConcurrent, setMaxConcurrent] = useState( + o.max_concurrent != null ? String(o.max_concurrent) : "", + ); + const [maxRetries, setMaxRetries] = useState(o.max_retries != null ? String(o.max_retries) : ""); + const [timeout, setTimeoutValue] = useState(o.timeout != null ? String(o.timeout) : ""); + const [priority, setPriority] = useState(o.priority != null ? String(o.priority) : ""); + const [paused, setPaused] = useState(o.paused ?? false); + + function buildPatch(): TaskOverridePatch | null { + const patch: TaskOverridePatch = {}; + const numOr = (raw: string, name: keyof TaskOverridePatch) => { + if (raw === "") { + patch[name] = null as never; + } else { + const v = Number(raw); + if (!Number.isFinite(v)) return false; + (patch as Record)[name] = v; + } + return true; + }; + patch.rate_limit = rateLimit ? rateLimit : null; + if (!numOr(maxConcurrent, "max_concurrent")) return null; + if (!numOr(maxRetries, "max_retries")) return null; + if (!numOr(timeout, "timeout")) return null; + if (!numOr(priority, "priority")) return null; + patch.paused = paused; + return patch; + } + + function onSubmit(event: FormEvent): void { + event.preventDefault(); + const patch = buildPatch(); + if (!patch) return; + setOverride.mutate({ name: task.name, patch }, { onSuccess: () => onDone?.() }); + } + + return ( +
+
+

{task.name}

+

Queue · {task.queue}

+
+ + + Overrides + Middleware + + + clearOverride.mutate(task.name, { onSuccess: () => onDone?.() })} + /> + + + + + +
+ ); +} + +interface OverrideFormProps { + task: TaskEntry; + onSubmit: (e: FormEvent) => void; + rateLimit: string; + setRateLimit: (v: string) => void; + maxConcurrent: string; + setMaxConcurrent: (v: string) => void; + maxRetries: string; + setMaxRetries: (v: string) => void; + timeoutValue: string; + setTimeoutValue: (v: string) => void; + priority: string; + setPriority: (v: string) => void; + paused: boolean; + setPaused: (v: boolean) => void; + saving: boolean; + clearing: boolean; + onClear: () => void; +} + +function OverrideForm({ + task, + onSubmit, + rateLimit, + setRateLimit, + maxConcurrent, + setMaxConcurrent, + maxRetries, + setMaxRetries, + timeoutValue, + setTimeoutValue, + priority, + setPriority, + paused, + setPaused, + saving, + clearing, + onClear, +}: OverrideFormProps) { + return ( +
+

+ Overrides apply on the next worker restart; pausing takes effect immediately. +

+ + + + + + +
+ + +
+ + ); +} + +interface FieldProps { + id: string; + label: string; + value: string; + onChange: (v: string) => void; + defaultValue: string; + type: "text" | "number"; + placeholder?: string; +} + +function NumberField({ id, label, value, onChange, defaultValue, type, placeholder }: FieldProps) { + return ( + + ); +} diff --git a/dashboard/src/features/tasks/hooks.ts b/dashboard/src/features/tasks/hooks.ts new file mode 100644 index 00000000..2e91188f --- /dev/null +++ b/dashboard/src/features/tasks/hooks.ts @@ -0,0 +1,100 @@ +import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { ApiError } from "@/lib/api-client"; +import { + clearQueueOverride, + clearTaskOverride, + listQueues, + listTasks, + putQueueOverride, + putTaskOverride, +} from "./api"; +import type { QueueOverridePatch, TaskOverridePatch } from "./types"; + +const TASKS_KEY = ["tasks"] as const; +const QUEUES_KEY = ["queues-overrides"] as const; + +function describeError(error: unknown): string | undefined { + if (error instanceof ApiError && error.status >= 400 && error.status < 500) { + return error.message; + } + return undefined; +} + +export function tasksQuery() { + return queryOptions({ + queryKey: TASKS_KEY, + queryFn: ({ signal }) => listTasks(signal), + }); +} + +export function queuesQuery() { + return queryOptions({ + queryKey: QUEUES_KEY, + queryFn: ({ signal }) => listQueues(signal), + }); +} + +export function useTasks() { + return useQuery(tasksQuery()); +} + +export function useQueues() { + return useQuery(queuesQuery()); +} + +export function useSetTaskOverride() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ name, patch }: { name: string; patch: TaskOverridePatch }) => + putTaskOverride(name, patch), + onSuccess: async () => { + await qc.invalidateQueries({ queryKey: TASKS_KEY }); + toast.success("Override saved", { + description: "Applied on next worker restart.", + }); + }, + onError: (error) => + toast.error("Failed to save override", { description: describeError(error) }), + }); +} + +export function useClearTaskOverride() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (name: string) => clearTaskOverride(name), + onSuccess: async () => { + await qc.invalidateQueries({ queryKey: TASKS_KEY }); + toast.success("Override cleared"); + }, + onError: (error) => + toast.error("Failed to clear override", { description: describeError(error) }), + }); +} + +export function useSetQueueOverride() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ name, patch }: { name: string; patch: QueueOverridePatch }) => + putQueueOverride(name, patch), + onSuccess: async () => { + await qc.invalidateQueries({ queryKey: QUEUES_KEY }); + toast.success("Queue override saved"); + }, + onError: (error) => + toast.error("Failed to save queue override", { description: describeError(error) }), + }); +} + +export function useClearQueueOverride() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (name: string) => clearQueueOverride(name), + onSuccess: async () => { + await qc.invalidateQueries({ queryKey: QUEUES_KEY }); + toast.success("Queue override cleared"); + }, + onError: (error) => + toast.error("Failed to clear queue override", { description: describeError(error) }), + }); +} diff --git a/dashboard/src/features/tasks/index.ts b/dashboard/src/features/tasks/index.ts new file mode 100644 index 00000000..e9eae073 --- /dev/null +++ b/dashboard/src/features/tasks/index.ts @@ -0,0 +1,19 @@ +export { TaskListTable } from "./components/task-list-table"; +export { TaskOverrideForm } from "./components/task-override-form"; +export { + queuesQuery, + tasksQuery, + useClearQueueOverride, + useClearTaskOverride, + useQueues, + useSetQueueOverride, + useSetTaskOverride, + useTasks, +} from "./hooks"; +export type { + QueueEntry, + QueueOverridePatch, + TaskDefaults, + TaskEntry, + TaskOverridePatch, +} from "./types"; diff --git a/dashboard/src/features/tasks/types.ts b/dashboard/src/features/tasks/types.ts new file mode 100644 index 00000000..01b46cb7 --- /dev/null +++ b/dashboard/src/features/tasks/types.ts @@ -0,0 +1,41 @@ +export interface TaskDefaults { + max_retries: number; + retry_backoff: number; + timeout: number; + priority: number; + rate_limit: string | null; + max_concurrent: number | null; +} + +export interface TaskOverridePatch { + rate_limit?: string | null; + max_concurrent?: number | null; + max_retries?: number | null; + retry_backoff?: number | null; + timeout?: number | null; + priority?: number | null; + paused?: boolean; +} + +export interface TaskEntry { + name: string; + queue: string; + defaults: TaskDefaults; + override: TaskOverridePatch | null; + effective: TaskDefaults; + paused: boolean; +} + +export interface QueueOverridePatch { + rate_limit?: string | null; + max_concurrent?: number | null; + paused?: boolean; +} + +export interface QueueEntry { + name: string; + defaults: Record; + override: QueueOverridePatch | null; + effective: Record; + paused: boolean; +} diff --git a/dashboard/src/features/webhooks/api.ts b/dashboard/src/features/webhooks/api.ts new file mode 100644 index 00000000..e5e0be1d --- /dev/null +++ b/dashboard/src/features/webhooks/api.ts @@ -0,0 +1,77 @@ +import { api } from "@/lib/api-client"; +import type { + CreateWebhookInput, + DeliveryListPage, + DeliveryStatus, + ReplayDeliveryResult, + RotateSecretResult, + TestWebhookResult, + UpdateWebhookInput, + Webhook, + WebhookDelivery, +} from "./types"; + +export function listWebhooks(signal?: AbortSignal): Promise { + return api.get("/api/webhooks", { signal }); +} + +export function getWebhook(id: string, signal?: AbortSignal): Promise { + return api.get(`/api/webhooks/${id}`, { signal }); +} + +export function createWebhook(input: CreateWebhookInput): Promise { + return api.post("/api/webhooks", input); +} + +export function updateWebhook(id: string, input: UpdateWebhookInput): Promise { + return api.put(`/api/webhooks/${id}`, input); +} + +export function deleteWebhook(id: string): Promise<{ deleted: true }> { + return api.delete<{ deleted: true }>(`/api/webhooks/${id}`); +} + +export function rotateWebhookSecret(id: string): Promise { + return api.post(`/api/webhooks/${id}/rotate-secret`); +} + +export function testWebhook(id: string): Promise { + return api.post(`/api/webhooks/${id}/test`); +} + +export function listEventTypes(signal?: AbortSignal): Promise { + return api.get("/api/event-types", { signal }); +} + +export function listDeliveries( + subscriptionId: string, + options: { status?: DeliveryStatus; limit?: number; offset?: number; signal?: AbortSignal } = {}, +): Promise { + return api.get(`/api/webhooks/${subscriptionId}/deliveries`, { + signal: options.signal, + params: { + status: options.status, + limit: options.limit, + offset: options.offset, + }, + }); +} + +export function getDelivery( + subscriptionId: string, + deliveryId: string, + signal?: AbortSignal, +): Promise { + return api.get(`/api/webhooks/${subscriptionId}/deliveries/${deliveryId}`, { + signal, + }); +} + +export function replayDelivery( + subscriptionId: string, + deliveryId: string, +): Promise { + return api.post( + `/api/webhooks/${subscriptionId}/deliveries/${deliveryId}/replay`, + ); +} diff --git a/dashboard/src/features/webhooks/components/create-webhook-dialog.tsx b/dashboard/src/features/webhooks/components/create-webhook-dialog.tsx new file mode 100644 index 00000000..9e5eece3 --- /dev/null +++ b/dashboard/src/features/webhooks/components/create-webhook-dialog.tsx @@ -0,0 +1,169 @@ +import { AlertCircle, Plus } from "lucide-react"; +import { type FormEvent, useState } from "react"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, + Input, +} from "@/components/ui"; +import { ApiError } from "@/lib/api-client"; +import { useCreateWebhook } from "../hooks"; +import type { Webhook } from "../types"; +import { EventTypeMultiSelect } from "./event-type-multi-select"; +import { SecretReveal } from "./secret-reveal"; +import { TaskFilterInput } from "./task-filter-input"; + +export function CreateWebhookDialog() { + const [open, setOpen] = useState(false); + const [url, setUrl] = useState(""); + const [description, setDescription] = useState(""); + const [events, setEvents] = useState([]); + const [taskFilter, setTaskFilter] = useState(null); + const [generateSecret, setGenerateSecret] = useState(true); + const [createdWebhook, setCreatedWebhook] = useState(null); + const create = useCreateWebhook(); + + function reset() { + setUrl(""); + setDescription(""); + setEvents([]); + setTaskFilter(null); + setGenerateSecret(true); + setCreatedWebhook(null); + create.reset(); + } + + function onOpenChange(next: boolean) { + if (!next) reset(); + setOpen(next); + } + + function onSubmit(event: FormEvent): void { + event.preventDefault(); + create.mutate( + { + url, + description: description || null, + events, + task_filter: taskFilter, + generate_secret: generateSecret, + }, + { onSuccess: (webhook) => setCreatedWebhook(webhook) }, + ); + } + + const errorMessage = + create.error instanceof ApiError + ? create.error.message + : create.error + ? "Failed to create webhook." + : null; + + return ( + + + + + + {createdWebhook ? ( + onOpenChange(false)} /> + ) : ( +
+ + New webhook + + Subscribe an HTTP endpoint to job lifecycle events. + + + + +
+ Events + + + Leave empty to subscribe to every event. + +
+ + + {errorMessage ? ( +
+ + {errorMessage} +
+ ) : null} + + + + + + )} +
+
+ ); +} + +function SuccessView({ webhook, onDone }: { webhook: Webhook; onDone: () => void }) { + return ( +
+ + Webhook created + + Deliveries will start immediately for the events you selected. + + +
+
URL
+
{webhook.url}
+
+ {webhook.secret ? : null} + + + +
+ ); +} diff --git a/dashboard/src/features/webhooks/components/delivery-list-table.tsx b/dashboard/src/features/webhooks/components/delivery-list-table.tsx new file mode 100644 index 00000000..10b0f566 --- /dev/null +++ b/dashboard/src/features/webhooks/components/delivery-list-table.tsx @@ -0,0 +1,183 @@ +import { History, RotateCcw } from "lucide-react"; +import { useState } from "react"; +import { + Badge, + Button, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + EmptyState, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui"; +import { formatRelative } from "@/lib/time"; +import { useReplayDelivery } from "../hooks"; +import type { DeliveryStatus, WebhookDelivery } from "../types"; + +interface Props { + subscriptionId: string; + deliveries: WebhookDelivery[]; +} + +function statusTone(status: DeliveryStatus): "success" | "danger" | "warning" | "neutral" { + if (status === "delivered") return "success"; + if (status === "dead") return "danger"; + if (status === "failed") return "warning"; + return "neutral"; +} + +export function DeliveryListTable({ subscriptionId, deliveries }: Props) { + const [inspecting, setInspecting] = useState(null); + const replay = useReplayDelivery(subscriptionId); + + if (deliveries.length === 0) { + return ( + + ); + } + + return ( + <> +
+ + + + When + Event + Status + Code + Latency + Attempts + + + + + {deliveries.map((delivery) => ( + setInspecting(delivery)} + > + + {formatRelative(delivery.created_at)} + + {delivery.event} + + {delivery.status} + + {delivery.response_code ?? "—"} + + {delivery.latency_ms !== null ? `${delivery.latency_ms} ms` : "—"} + + + {delivery.attempts} + + e.stopPropagation()}> + + + + ))} + +
+
+ + !open && setInspecting(null)}> + + {inspecting ? ( + <> + + Delivery details + + {inspecting.event} ·{" "} + {inspecting.status} + + + +
+ +
+ + ) : null} +
+
+ + ); +} + +function DeliveryDetail({ delivery }: { delivery: WebhookDelivery }) { + return ( +
+ + + + {delivery.error ? ( + + {delivery.error} + + } + /> + ) : null} +
+
Payload
+
+          {JSON.stringify(delivery.payload, null, 2)}
+        
+
+ {delivery.response_body ? ( +
+
+ Response body (truncated) +
+
+            {delivery.response_body}
+          
+
+ ) : null} +
+ ); +} + +function Row({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/dashboard/src/features/webhooks/components/event-type-multi-select.tsx b/dashboard/src/features/webhooks/components/event-type-multi-select.tsx new file mode 100644 index 00000000..7f2716b1 --- /dev/null +++ b/dashboard/src/features/webhooks/components/event-type-multi-select.tsx @@ -0,0 +1,108 @@ +import { Check, ChevronDown } from "lucide-react"; +import { useState } from "react"; +import { + Badge, + Button, + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui"; +import { cn } from "@/lib/cn"; +import { useEventTypes } from "../hooks"; + +interface Props { + value: string[]; + onChange: (next: string[]) => void; + placeholder?: string; + /** When ``true``, an empty array means "all events" and is rendered as a hint. */ + allowAll?: boolean; +} + +export function EventTypeMultiSelect({ + value, + onChange, + placeholder = "All events", + allowAll = true, +}: Props) { + const { data: events = [] } = useEventTypes(); + const [open, setOpen] = useState(false); + + function toggle(event: string) { + if (value.includes(event)) { + onChange(value.filter((e) => e !== event)); + } else { + onChange([...value, event]); + } + } + + const label = + value.length === 0 + ? allowAll + ? placeholder + : "Select events…" + : `${value.length} event${value.length === 1 ? "" : "s"} selected`; + + return ( +
+
+ + {open ? ( +
+ + + + No events match. + + {events.map((event) => { + const selected = value.includes(event); + return ( + toggle(event)} + className="cursor-pointer" + > + + {event} + + ); + })} + + + +
+ ) : null} +
+ {value.length > 0 ? ( +
+ {value.map((event) => ( + + {event} + + + ))} +
+ ) : null} +
+ ); +} diff --git a/dashboard/src/features/webhooks/components/secret-reveal.tsx b/dashboard/src/features/webhooks/components/secret-reveal.tsx new file mode 100644 index 00000000..4005b4aa --- /dev/null +++ b/dashboard/src/features/webhooks/components/secret-reveal.tsx @@ -0,0 +1,64 @@ +import { Check, Copy, Eye, EyeOff, KeyRound } from "lucide-react"; +import { useState } from "react"; +import { Button } from "@/components/ui"; + +interface Props { + secret: string; + hint?: string; +} + +/** + * One-shot secret display. Shows a masked value, lets the user reveal and + * copy it, and reminds them that the secret won't be shown again. Used by + * the create response and the rotate-secret response. + */ +export function SecretReveal({ secret, hint }: Props) { + const [shown, setShown] = useState(false); + const [copied, setCopied] = useState(false); + + async function copyToClipboard() { + try { + await navigator.clipboard.writeText(secret); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // Clipboard write can fail (e.g. http context); the user can still + // select-and-copy the visible value. + } + } + + return ( +
+
+ + {hint ?? "Signing secret"} +
+
+ + {shown ? secret : "•".repeat(Math.min(secret.length, 48))} + + + +
+

+ Store this securely — it will not be shown again. +

+
+ ); +} diff --git a/dashboard/src/features/webhooks/components/task-filter-input.tsx b/dashboard/src/features/webhooks/components/task-filter-input.tsx new file mode 100644 index 00000000..1b5c821f --- /dev/null +++ b/dashboard/src/features/webhooks/components/task-filter-input.tsx @@ -0,0 +1,81 @@ +import { X } from "lucide-react"; +import { type KeyboardEvent, useState } from "react"; +import { Badge, Input } from "@/components/ui"; + +interface Props { + value: string[] | null; + onChange: (next: string[] | null) => void; +} + +/** + * Free-form task name list input. ``null`` means "deliver for every task"; + * an empty array means "deliver for no task" (effectively disabled). + * + * Tasks are added by typing a name and pressing Enter, comma, or space. + */ +export function TaskFilterInput({ value, onChange }: Props) { + const [draft, setDraft] = useState(""); + const enabled = value !== null; + const tasks = value ?? []; + + function commitDraft() { + const trimmed = draft.trim(); + if (!trimmed) return; + if (!tasks.includes(trimmed)) onChange([...tasks, trimmed]); + setDraft(""); + } + + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Enter" || event.key === "," || event.key === " ") { + event.preventDefault(); + commitDraft(); + } else if (event.key === "Backspace" && !draft && tasks.length > 0) { + onChange(tasks.slice(0, -1)); + } + } + + function remove(task: string) { + onChange(tasks.filter((t) => t !== task)); + } + + return ( +
+ + {enabled ? ( + <> + setDraft(e.target.value)} + onKeyDown={onKeyDown} + onBlur={commitDraft} + /> + {tasks.length > 0 ? ( +
+ {tasks.map((task) => ( + + {task} + + + ))} +
+ ) : null} + + ) : null} +
+ ); +} diff --git a/dashboard/src/features/webhooks/components/webhook-list-table.tsx b/dashboard/src/features/webhooks/components/webhook-list-table.tsx new file mode 100644 index 00000000..966d87fb --- /dev/null +++ b/dashboard/src/features/webhooks/components/webhook-list-table.tsx @@ -0,0 +1,111 @@ +import { Webhook as WebhookIcon } from "lucide-react"; +import { + Badge, + EmptyState, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui"; +import type { Webhook } from "../types"; +import { WebhookRowActions } from "./webhook-row-actions"; + +interface Props { + webhooks: Webhook[]; +} + +export function WebhookListTable({ webhooks }: Props) { + if (webhooks.length === 0) { + return ( + + ); + } + + return ( +
+ + + + URL + Events + Task filter + Retries + Status + + + + + {webhooks.map((wh) => ( + + +
+ {wh.url} + {wh.description ? ( + {wh.description} + ) : null} +
+
+ + {wh.events.length === 0 ? ( + All events + ) : ( +
+ {wh.events.slice(0, 3).map((event) => ( + + {event} + + ))} + {wh.events.length > 3 ? ( + + +{wh.events.length - 3} more + + ) : null} +
+ )} +
+ + {wh.task_filter === null ? ( + All tasks + ) : wh.task_filter.length === 0 ? ( + Disabled + ) : ( +
+ {wh.task_filter.slice(0, 2).map((task) => ( + + {task} + + ))} + {wh.task_filter.length > 2 ? ( + + +{wh.task_filter.length - 2} + + ) : null} +
+ )} +
+ + {wh.max_retries}× / {wh.timeout_seconds}s + + + {wh.enabled ? ( + Enabled + ) : ( + Disabled + )} + + + + +
+ ))} +
+
+
+ ); +} diff --git a/dashboard/src/features/webhooks/components/webhook-row-actions.tsx b/dashboard/src/features/webhooks/components/webhook-row-actions.tsx new file mode 100644 index 00000000..69d300a3 --- /dev/null +++ b/dashboard/src/features/webhooks/components/webhook-row-actions.tsx @@ -0,0 +1,151 @@ +import { Link } from "@tanstack/react-router"; +import { + Eye, + History, + MoreHorizontal, + Power, + PowerOff, + RotateCcw, + Send, + Trash2, +} from "lucide-react"; +import { useState } from "react"; +import { + Button, + ConfirmDialog, + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui"; +import { DestructiveConfirmDialog } from "@/components/ui/destructive-confirm-dialog"; +import { useDeleteWebhook, useRotateSecret, useTestWebhook, useUpdateWebhook } from "../hooks"; +import type { Webhook } from "../types"; +import { SecretReveal } from "./secret-reveal"; + +interface Props { + webhook: Webhook; +} + +export function WebhookRowActions({ webhook }: Props) { + const update = useUpdateWebhook(); + const remove = useDeleteWebhook(); + const rotate = useRotateSecret(); + const test = useTestWebhook(); + + const [confirmDelete, setConfirmDelete] = useState(false); + const [confirmRotate, setConfirmRotate] = useState(false); + const [revealedSecret, setRevealedSecret] = useState(null); + + function onToggleEnabled() { + update.mutate({ + id: webhook.id, + input: { enabled: !webhook.enabled }, + }); + } + + function onRotate() { + rotate.mutate(webhook.id, { + onSuccess: (result) => { + setRevealedSecret(result.secret); + }, + }); + } + + return ( + <> + + + + + + + + View deliveries + + + test.mutate(webhook.id)} + disabled={test.isPending || !webhook.enabled} + > + Send test + + + {webhook.enabled ? ( + <> + Disable + + ) : ( + <> + Enable + + )} + + setConfirmRotate(true)}> + Rotate secret + + + setConfirmDelete(true)} + className="text-danger focus:text-danger" + > + Delete + + + + + { + await remove.mutateAsync(webhook.id); + }} + /> + + { + setConfirmRotate(false); + onRotate(); + }} + /> + + !open && setRevealedSecret(null)} + > + + + New signing secret + Configure your receiver with this value. + + {revealedSecret ? : null} + + + + + ); +} diff --git a/dashboard/src/features/webhooks/hooks.ts b/dashboard/src/features/webhooks/hooks.ts new file mode 100644 index 00000000..89570afe --- /dev/null +++ b/dashboard/src/features/webhooks/hooks.ts @@ -0,0 +1,175 @@ +import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { ApiError } from "@/lib/api-client"; +import { + createWebhook, + deleteWebhook, + getWebhook, + listDeliveries, + listEventTypes, + listWebhooks, + replayDelivery, + rotateWebhookSecret, + testWebhook, + updateWebhook, +} from "./api"; +import type { CreateWebhookInput, DeliveryStatus, UpdateWebhookInput, Webhook } from "./types"; + +const KEY = ["webhooks"] as const; +const EVENT_TYPES_KEY = ["webhooks", "event-types"] as const; + +function describeError(error: unknown): string | undefined { + if (error instanceof ApiError && error.status >= 400 && error.status < 500) { + return error.message; + } + return undefined; +} + +export function webhooksQuery() { + return queryOptions({ + queryKey: KEY, + queryFn: ({ signal }) => listWebhooks(signal), + }); +} + +export function webhookQuery(id: string) { + return queryOptions({ + queryKey: [...KEY, id], + queryFn: ({ signal }) => getWebhook(id, signal), + }); +} + +export function eventTypesQuery() { + return queryOptions({ + queryKey: EVENT_TYPES_KEY, + queryFn: ({ signal }) => listEventTypes(signal), + staleTime: 5 * 60 * 1000, + }); +} + +export function useWebhooks() { + return useQuery(webhooksQuery()); +} + +export function useEventTypes() { + return useQuery(eventTypesQuery()); +} + +export function useCreateWebhook() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (input: CreateWebhookInput) => createWebhook(input), + onSuccess: async () => { + await qc.invalidateQueries({ queryKey: KEY }); + toast.success("Webhook created"); + }, + onError: (error) => + toast.error("Failed to create webhook", { description: describeError(error) }), + }); +} + +export function useUpdateWebhook() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, input }: { id: string; input: UpdateWebhookInput }) => + updateWebhook(id, input), + onMutate: async ({ id, input }) => { + await qc.cancelQueries({ queryKey: KEY }); + const prev = qc.getQueryData(KEY); + if (prev) { + qc.setQueryData( + KEY, + prev.map((w) => (w.id === id ? { ...w, ...input } : w)), + ); + } + return { prev }; + }, + onError: (error, _vars, context) => { + if (context?.prev) qc.setQueryData(KEY, context.prev); + toast.error("Failed to update webhook", { description: describeError(error) }); + }, + onSettled: async () => { + await qc.invalidateQueries({ queryKey: KEY }); + }, + }); +} + +export function useDeleteWebhook() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => deleteWebhook(id), + onSuccess: async () => { + await qc.invalidateQueries({ queryKey: KEY }); + toast.success("Webhook deleted"); + }, + onError: (error) => + toast.error("Failed to delete webhook", { description: describeError(error) }), + }); +} + +export function useRotateSecret() { + return useMutation({ + mutationFn: (id: string) => rotateWebhookSecret(id), + onError: (error) => + toast.error("Failed to rotate secret", { description: describeError(error) }), + }); +} + +export function deliveriesQuery( + subscriptionId: string, + options: { status?: DeliveryStatus; limit?: number; offset?: number } = {}, +) { + return queryOptions({ + queryKey: [...KEY, subscriptionId, "deliveries", options] as const, + queryFn: ({ signal }) => listDeliveries(subscriptionId, { ...options, signal }), + }); +} + +export function useDeliveries( + subscriptionId: string, + options: { status?: DeliveryStatus; limit?: number; offset?: number } = {}, +) { + return useQuery(deliveriesQuery(subscriptionId, options)); +} + +export function useReplayDelivery(subscriptionId: string) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (deliveryId: string) => replayDelivery(subscriptionId, deliveryId), + onSuccess: async (result) => { + await qc.invalidateQueries({ queryKey: [...KEY, subscriptionId, "deliveries"] }); + if (result.delivered) { + toast.success("Delivery replayed", { + description: `Endpoint returned ${result.status}`, + }); + } else { + toast.error("Replay failed", { + description: result.status + ? `Endpoint returned ${result.status}` + : "No response received from endpoint", + }); + } + }, + onError: (error) => toast.error("Replay failed", { description: describeError(error) }), + }); +} + +export function useTestWebhook() { + return useMutation({ + mutationFn: (id: string) => testWebhook(id), + onSuccess: (result) => { + if (result.delivered) { + toast.success("Test event delivered", { + description: `Endpoint returned ${result.status}`, + }); + } else { + toast.error("Test event failed", { + description: result.status + ? `Endpoint returned ${result.status}` + : "No response received from endpoint", + }); + } + }, + onError: (error) => toast.error("Test event failed", { description: describeError(error) }), + }); +} diff --git a/dashboard/src/features/webhooks/index.ts b/dashboard/src/features/webhooks/index.ts new file mode 100644 index 00000000..d1b93614 --- /dev/null +++ b/dashboard/src/features/webhooks/index.ts @@ -0,0 +1,33 @@ +export { CreateWebhookDialog } from "./components/create-webhook-dialog"; +export { DeliveryListTable } from "./components/delivery-list-table"; +export { EventTypeMultiSelect } from "./components/event-type-multi-select"; +export { SecretReveal } from "./components/secret-reveal"; +export { TaskFilterInput } from "./components/task-filter-input"; +export { WebhookListTable } from "./components/webhook-list-table"; +export { WebhookRowActions } from "./components/webhook-row-actions"; +export { + deliveriesQuery, + eventTypesQuery, + useCreateWebhook, + useDeleteWebhook, + useDeliveries, + useEventTypes, + useReplayDelivery, + useRotateSecret, + useTestWebhook, + useUpdateWebhook, + useWebhooks, + webhookQuery, + webhooksQuery, +} from "./hooks"; +export type { + CreateWebhookInput, + DeliveryListPage, + DeliveryStatus, + ReplayDeliveryResult, + RotateSecretResult, + TestWebhookResult, + UpdateWebhookInput, + Webhook, + WebhookDelivery, +} from "./types"; diff --git a/dashboard/src/features/webhooks/types.ts b/dashboard/src/features/webhooks/types.ts new file mode 100644 index 00000000..a48e90cc --- /dev/null +++ b/dashboard/src/features/webhooks/types.ts @@ -0,0 +1,93 @@ +/** + * Shape of a persisted webhook subscription returned by the dashboard API. + * + * The ``secret`` field is only present on the response to the *create* and + * *rotate-secret* endpoints — every other endpoint redacts it and exposes + * only ``has_secret`` so the raw value can't leak in repeated reads. + */ +export interface Webhook { + id: string; + url: string; + events: string[]; + task_filter: string[] | null; + headers: Record; + has_secret: boolean; + secret?: string; + max_retries: number; + timeout_seconds: number; + retry_backoff: number; + enabled: boolean; + description: string | null; + created_at: number; + updated_at: number; +} + +export interface CreateWebhookInput { + url: string; + events?: string[]; + task_filter?: string[] | null; + headers?: Record; + secret?: string | null; + generate_secret?: boolean; + max_retries?: number; + timeout_seconds?: number; + retry_backoff?: number; + description?: string | null; +} + +export type UpdateWebhookInput = Partial< + Pick< + Webhook, + | "url" + | "events" + | "task_filter" + | "headers" + | "max_retries" + | "timeout_seconds" + | "retry_backoff" + | "enabled" + | "description" + > +>; + +export interface TestWebhookResult { + status: number | null; + delivered: boolean; +} + +export interface RotateSecretResult { + id: string; + secret: string; +} + +export type DeliveryStatus = "delivered" | "failed" | "dead" | "pending"; + +export interface WebhookDelivery { + id: string; + subscription_id: string; + event: string; + payload: Record; + task_name: string | null; + job_id: string | null; + status: DeliveryStatus; + attempts: number; + response_code: number | null; + response_body: string | null; + latency_ms: number | null; + error: string | null; + created_at: number; + completed_at: number | null; +} + +export interface DeliveryListPage { + items: WebhookDelivery[]; + total: number; + limit: number; + offset: number; +} + +export interface ReplayDeliveryResult { + replayed_of: string; + status: number | null; + delivered: boolean; +} diff --git a/dashboard/src/routes/tasks.tsx b/dashboard/src/routes/tasks.tsx new file mode 100644 index 00000000..1465ba9d --- /dev/null +++ b/dashboard/src/routes/tasks.tsx @@ -0,0 +1,31 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { PageHeader } from "@/components/layout/page-header"; +import { ErrorState, Skeleton } from "@/components/ui"; +import { TaskListTable, useTasks } from "@/features/tasks"; + +export const Route = createFileRoute("/tasks")({ + component: TasksPage, +}); + +function TasksPage() { + const { data, isLoading, error } = useTasks(); + + return ( +
+ + {isLoading ? ( + + ) : error ? ( + + ) : ( + + )} +
+ ); +} diff --git a/dashboard/src/routes/webhooks.$id.deliveries.tsx b/dashboard/src/routes/webhooks.$id.deliveries.tsx new file mode 100644 index 00000000..cf1052d4 --- /dev/null +++ b/dashboard/src/routes/webhooks.$id.deliveries.tsx @@ -0,0 +1,86 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { ArrowLeft } from "lucide-react"; +import { useState } from "react"; +import { PageHeader } from "@/components/layout/page-header"; +import { + Button, + ErrorState, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Skeleton, +} from "@/components/ui"; +import type { DeliveryStatus } from "@/features/webhooks"; +import { DeliveryListTable, useDeliveries, useWebhooks } from "@/features/webhooks"; + +export const Route = createFileRoute("/webhooks/$id/deliveries")({ + component: DeliveriesPage, +}); + +const STATUSES: { label: string; value: DeliveryStatus | "all" }[] = [ + { label: "All statuses", value: "all" }, + { label: "Delivered", value: "delivered" }, + { label: "Failed", value: "failed" }, + { label: "Dead", value: "dead" }, +]; + +function DeliveriesPage() { + const { id } = Route.useParams(); + const [status, setStatus] = useState("all"); + + const webhooks = useWebhooks(); + const webhook = webhooks.data?.find((w) => w.id === id); + + const { data, isLoading, error, refetch } = useDeliveries(id, { + status: status === "all" ? undefined : status, + limit: 100, + }); + + return ( +
+ + + + + + +
+ } + /> + {isLoading ? ( + + ) : error ? ( + + ) : ( + + )} + + ); +} diff --git a/dashboard/src/routes/webhooks.tsx b/dashboard/src/routes/webhooks.tsx new file mode 100644 index 00000000..5a220ae5 --- /dev/null +++ b/dashboard/src/routes/webhooks.tsx @@ -0,0 +1,32 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { PageHeader } from "@/components/layout/page-header"; +import { ErrorState, Skeleton } from "@/components/ui"; +import { CreateWebhookDialog, useWebhooks, WebhookListTable } from "@/features/webhooks"; + +export const Route = createFileRoute("/webhooks")({ + component: WebhooksPage, +}); + +function WebhooksPage() { + const { data, isLoading, error } = useWebhooks(); + + return ( +
+ } + /> + {isLoading ? ( + + ) : error ? ( + + ) : ( + + )} +
+ ); +} diff --git a/py_src/taskito/app.py b/py_src/taskito/app.py index 8f87ff73..c8273c78 100644 --- a/py_src/taskito/app.py +++ b/py_src/taskito/app.py @@ -38,7 +38,9 @@ QueueInspectionMixin, QueueLifecycleMixin, QueueLockMixin, + QueueMiddlewareAdminMixin, QueueOperationsMixin, + QueueOverridesMixin, QueuePredicateMixin, QueueResourceMixin, QueueRuntimeConfigMixin, @@ -83,6 +85,8 @@ class Queue( QueueInspectionMixin, QueueOperationsMixin, QueueLockMixin, + QueueMiddlewareAdminMixin, + QueueOverridesMixin, QueueSettingsMixin, QueueWorkflowMixin, AsyncQueueMixin, @@ -223,7 +227,7 @@ def __init__( self._drain_timeout = drain_timeout self._queue_configs: dict[str, dict[str, Any]] = {} self._event_bus = EventBus(max_workers=event_workers) - self._webhook_manager = WebhookManager() + self._webhook_manager = WebhookManager(queue_ref=self) # Proxy handlers self._proxy_registry = ProxyRegistry() diff --git a/py_src/taskito/dashboard/delivery_store.py b/py_src/taskito/dashboard/delivery_store.py new file mode 100644 index 00000000..0efc8d98 --- /dev/null +++ b/py_src/taskito/dashboard/delivery_store.py @@ -0,0 +1,208 @@ +"""Persistent webhook delivery log. + +Each subscription gets its own JSON list under the key +``webhooks:deliveries:{subscription_id}`` in the ``dashboard_settings`` +table. The store is append-only with FIFO eviction once the per-webhook +cap is hit (default 200 entries) — enough to debug recent activity +without unbounded growth. + +The structure: + + [ + { + "id": "uuid", + "subscription_id": "sub-uuid", + "event": "job.completed", + "task_name": "send_email" | null, + "job_id": "abc123" | null, + "payload": {...}, + "status": "delivered" | "failed" | "dead", + "attempts": 3, + "response_code": 200 | null, + "response_body": "..." | null, + "latency_ms": 42, + "error": "..." | null, + "created_at": 1234567890000, + "completed_at": 1234567890420 + }, + ... + ] + +Records are inserted in chronological order; listing reverses for newest-first. +""" + +from __future__ import annotations + +import json +import logging +import time +import uuid +from dataclasses import asdict, dataclass, field +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from taskito.app import Queue + + +DELIVERY_PREFIX = "webhooks:deliveries:" +DEFAULT_MAX_PER_WEBHOOK = 200 +RESPONSE_BODY_MAX_BYTES = 2048 + +logger = logging.getLogger("taskito.dashboard.deliveries") + + +@dataclass +class DeliveryRecord: + """A single attempted webhook delivery.""" + + id: str + subscription_id: str + event: str + payload: dict[str, Any] + task_name: str | None = None + job_id: str | None = None + status: str = "pending" # "delivered" | "failed" | "dead" | "pending" + attempts: int = 0 + response_code: int | None = None + response_body: str | None = None + latency_ms: int | None = None + error: str | None = None + created_at: int = field(default_factory=lambda: int(time.time() * 1000)) + completed_at: int | None = None + + @classmethod + def from_row(cls, row: dict[str, Any]) -> DeliveryRecord: + return cls( + id=str(row["id"]), + subscription_id=str(row["subscription_id"]), + event=str(row["event"]), + payload=dict(row.get("payload") or {}), + task_name=row.get("task_name"), + job_id=row.get("job_id"), + status=str(row.get("status", "pending")), + attempts=int(row.get("attempts", 0)), + response_code=row.get("response_code"), + response_body=row.get("response_body"), + latency_ms=row.get("latency_ms"), + error=row.get("error"), + created_at=int(row.get("created_at", 0)), + completed_at=row.get("completed_at"), + ) + + +def _new_id() -> str: + return uuid.uuid4().hex + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +def _truncate(body: str | None, *, max_bytes: int = RESPONSE_BODY_MAX_BYTES) -> str | None: + if body is None: + return None + encoded = body.encode("utf-8", errors="replace") + if len(encoded) <= max_bytes: + return body + return encoded[:max_bytes].decode("utf-8", errors="replace") + "…" + + +class DeliveryStore: + """List/insert/update delivery records keyed by subscription id.""" + + def __init__(self, queue: Queue, *, max_per_webhook: int = DEFAULT_MAX_PER_WEBHOOK) -> None: + self._queue = queue + self._max = max_per_webhook + + # ── Internal ──────────────────────────────────────────────── + + def _key(self, subscription_id: str) -> str: + return DELIVERY_PREFIX + subscription_id + + def _load(self, subscription_id: str) -> list[dict[str, Any]]: + raw = self._queue.get_setting(self._key(subscription_id)) + if not raw: + return [] + try: + data = json.loads(raw) + except json.JSONDecodeError: + logger.warning("delivery log for %s is corrupt; resetting", subscription_id) + return [] + return data if isinstance(data, list) else [] + + def _save(self, subscription_id: str, rows: list[dict[str, Any]]) -> None: + self._queue.set_setting( + self._key(subscription_id), + json.dumps(rows, separators=(",", ":")), + ) + + # ── Public API ───────────────────────────────────────────── + + def record_attempt( + self, + subscription_id: str, + event: str, + payload: dict[str, Any], + *, + status: str, + attempts: int, + response_code: int | None = None, + response_body: str | None = None, + latency_ms: int | None = None, + error: str | None = None, + task_name: str | None = None, + job_id: str | None = None, + ) -> DeliveryRecord: + """Append a delivery row and trim to the per-webhook cap.""" + now = _now_ms() + record = DeliveryRecord( + id=_new_id(), + subscription_id=subscription_id, + event=event, + payload=payload, + task_name=task_name, + job_id=job_id, + status=status, + attempts=attempts, + response_code=response_code, + response_body=_truncate(response_body), + latency_ms=latency_ms, + error=error, + created_at=now, + completed_at=now if status != "pending" else None, + ) + rows = self._load(subscription_id) + rows.append(asdict(record)) + if len(rows) > self._max: + rows = rows[-self._max :] + self._save(subscription_id, rows) + return record + + def list_for( + self, + subscription_id: str, + *, + status: str | None = None, + event: str | None = None, + limit: int = 50, + offset: int = 0, + ) -> list[DeliveryRecord]: + rows = list(reversed(self._load(subscription_id))) # newest first + if status: + rows = [r for r in rows if r.get("status") == status] + if event: + rows = [r for r in rows if r.get("event") == event] + page = rows[offset : offset + limit] + return [DeliveryRecord.from_row(r) for r in page] + + def get(self, subscription_id: str, delivery_id: str) -> DeliveryRecord | None: + for row in self._load(subscription_id): + if row.get("id") == delivery_id: + return DeliveryRecord.from_row(row) + return None + + def delete_for(self, subscription_id: str) -> bool: + return self._queue.delete_setting(self._key(subscription_id)) + + def count_for(self, subscription_id: str) -> int: + return len(self._load(subscription_id)) diff --git a/py_src/taskito/dashboard/handlers/middleware.py b/py_src/taskito/dashboard/handlers/middleware.py new file mode 100644 index 00000000..e0fd85b3 --- /dev/null +++ b/py_src/taskito/dashboard/handlers/middleware.py @@ -0,0 +1,62 @@ +"""Middleware discovery + per-task enable/disable endpoints.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from taskito.dashboard.errors import _BadRequest, _NotFound + +if TYPE_CHECKING: + from taskito.app import Queue + + +def handle_list_middleware(queue: Queue, _qs: dict) -> list[dict[str, Any]]: + """Return every registered middleware with its scopes.""" + return queue.list_middleware() + + +def handle_get_task_middleware(queue: Queue, _qs: dict, task_name: str) -> dict[str, Any]: + """Return the middleware chain that fires for ``task_name`` with each + entry's enabled/disabled state.""" + chain = queue._get_middleware_chain(task_name) + disabled = set(queue.get_disabled_middleware_for(task_name)) + # Build the full would-fire chain INCLUDING disabled entries so the UI + # can render every toggle. + base_chain = queue._global_middleware + queue._task_middleware.get(task_name, []) + entries: list[dict[str, Any]] = [] + chain_names = {getattr(mw, "name", "") for mw in chain} + for mw in base_chain: + name = getattr(mw, "name", "") or f"{type(mw).__module__}.{type(mw).__qualname__}" + entries.append( + { + "name": name, + "class_path": f"{type(mw).__module__}.{type(mw).__qualname__}", + "disabled": name in disabled, + "effective": name in chain_names, + } + ) + return {"task": task_name, "middleware": entries} + + +def handle_put_task_middleware(queue: Queue, body: dict, ids: tuple[str, str]) -> dict[str, Any]: + task_name, mw_name = ids + if not isinstance(body, dict) or "enabled" not in body: + raise _BadRequest('body must include {"enabled": bool}') + if not isinstance(body["enabled"], bool): + raise _BadRequest("'enabled' must be a boolean") + # Confirm the middleware exists in the relevant chain so a typo doesn't + # silently write a no-op disable entry. + base_chain = queue._global_middleware + queue._task_middleware.get(task_name, []) + names = {getattr(mw, "name", "") for mw in base_chain} + if mw_name not in names: + raise _NotFound(f"middleware '{mw_name}' is not registered on task '{task_name}'") + if body["enabled"]: + new = queue.enable_middleware_for_task(task_name, mw_name) + else: + new = queue.disable_middleware_for_task(task_name, mw_name) + return {"task": task_name, "disabled": new} + + +def handle_delete_task_middleware(queue: Queue, task_name: str) -> dict[str, bool]: + """Clear ALL disables for a task — every middleware fires again.""" + return {"cleared": queue.clear_middleware_disables(task_name)} diff --git a/py_src/taskito/dashboard/handlers/overrides.py b/py_src/taskito/dashboard/handlers/overrides.py new file mode 100644 index 00000000..c125441c --- /dev/null +++ b/py_src/taskito/dashboard/handlers/overrides.py @@ -0,0 +1,95 @@ +"""Task & queue override endpoints.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import TYPE_CHECKING, Any + +from taskito.dashboard.errors import _BadRequest, _NotFound +from taskito.dashboard.overrides_store import ( + QUEUE_OVERRIDE_FIELDS, + TASK_OVERRIDE_FIELDS, + OverridesStore, +) + +if TYPE_CHECKING: + from taskito.app import Queue + + +def handle_list_tasks(queue: Queue, _qs: dict) -> list[dict[str, Any]]: + """Return every registered task with decorator defaults + active override.""" + return queue.registered_tasks() + + +def handle_list_queues(queue: Queue, _qs: dict) -> list[dict[str, Any]]: + return queue.registered_queues() + + +def _coerce_override_body(body: Any, allowed: frozenset[str]) -> dict[str, Any]: + if not isinstance(body, dict): + raise _BadRequest("body must be a JSON object") + unknown = set(body) - allowed + if unknown: + raise _BadRequest( + f"unknown override fields: {sorted(unknown)}; allowed: {sorted(allowed)}" + ) + return body + + +# ── Task override endpoints ─────────────────────────────────────────── + + +def handle_get_task_override(queue: Queue, _qs: dict, task_name: str) -> dict[str, Any]: + override = OverridesStore(queue).get_task(task_name) + if override is None: + raise _NotFound(f"no override set for task '{task_name}'") + return asdict(override) + + +def handle_put_task_override(queue: Queue, body: dict, task_name: str) -> dict[str, Any]: + fields = _coerce_override_body(body, TASK_OVERRIDE_FIELDS) + try: + override = OverridesStore(queue).set_task(task_name, fields) + except ValueError as e: + raise _BadRequest(str(e)) from None + return asdict(override) + + +def handle_delete_task_override(queue: Queue, task_name: str) -> dict[str, bool]: + removed = OverridesStore(queue).clear_task(task_name) + return {"cleared": removed} + + +# ── Queue override endpoints ────────────────────────────────────────── + + +def handle_get_queue_override(queue: Queue, _qs: dict, queue_name: str) -> dict[str, Any]: + override = OverridesStore(queue).get_queue(queue_name) + if override is None: + raise _NotFound(f"no override set for queue '{queue_name}'") + return asdict(override) + + +def handle_put_queue_override(queue: Queue, body: dict, queue_name: str) -> dict[str, Any]: + fields = _coerce_override_body(body, QUEUE_OVERRIDE_FIELDS) + try: + override = OverridesStore(queue).set_queue(queue_name, fields) + except ValueError as e: + raise _BadRequest(str(e)) from None + # Reflect "paused" immediately by touching the paused_queues store + # (this state DOES propagate to a running worker — independent of the + # static override consumed at worker startup). + if "paused" in fields: + try: + if fields["paused"]: + queue.pause(queue_name) + else: + queue.resume(queue_name) + except Exception: # pragma: no cover - safety net only + pass + return asdict(override) + + +def handle_delete_queue_override(queue: Queue, queue_name: str) -> dict[str, bool]: + removed = OverridesStore(queue).clear_queue(queue_name) + return {"cleared": removed} diff --git a/py_src/taskito/dashboard/handlers/webhook_deliveries.py b/py_src/taskito/dashboard/handlers/webhook_deliveries.py new file mode 100644 index 00000000..aa5bbe49 --- /dev/null +++ b/py_src/taskito/dashboard/handlers/webhook_deliveries.py @@ -0,0 +1,111 @@ +"""Webhook delivery log endpoints (list / get / replay).""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import TYPE_CHECKING, Any + +from taskito.dashboard.delivery_store import DeliveryRecord, DeliveryStore +from taskito.dashboard.errors import _BadRequest, _NotFound +from taskito.dashboard.webhook_store import WebhookSubscriptionStore + +if TYPE_CHECKING: + from taskito.app import Queue + + +_MAX_PAGE_SIZE = 200 + + +def _serialize(record: DeliveryRecord) -> dict[str, Any]: + return asdict(record) + + +def _parse_int_param(qs: dict, name: str, default: int, *, minimum: int = 0) -> int: + raw = qs.get(name, [None])[0] + if raw is None or raw == "": + return default + try: + value = int(raw) + except ValueError: + raise _BadRequest(f"{name} must be an integer") from None + if value < minimum: + raise _BadRequest(f"{name} must be >= {minimum}") + return value + + +def _ensure_subscription(queue: Queue, subscription_id: str) -> None: + sub = WebhookSubscriptionStore(queue).get(subscription_id) + if sub is None: + raise _NotFound(f"webhook '{subscription_id}' not found") + + +def handle_list_deliveries(queue: Queue, qs: dict, subscription_id: str) -> dict[str, Any]: + """List recent deliveries for a subscription. Supports ``status``, + ``event``, ``limit``, and ``offset`` query parameters.""" + _ensure_subscription(queue, subscription_id) + + status = qs.get("status", [None])[0] + if status is not None and status not in {"delivered", "failed", "dead", "pending"}: + raise _BadRequest("status must be one of: delivered, failed, dead, pending") + event = qs.get("event", [None])[0] + + limit = min(_parse_int_param(qs, "limit", 50, minimum=1), _MAX_PAGE_SIZE) + offset = _parse_int_param(qs, "offset", 0) + + store = DeliveryStore(queue) + items = store.list_for(subscription_id, status=status, event=event, limit=limit, offset=offset) + return { + "items": [_serialize(r) for r in items], + "limit": limit, + "offset": offset, + "total": store.count_for(subscription_id), + } + + +def handle_get_delivery( + queue: Queue, _qs: dict, sub_and_delivery_id: tuple[str, str] +) -> dict[str, Any]: + subscription_id, delivery_id = sub_and_delivery_id + record = DeliveryStore(queue).get(subscription_id, delivery_id) + if record is None: + raise _NotFound(f"delivery '{delivery_id}' not found") + return _serialize(record) + + +def handle_replay_delivery(queue: Queue, sub_and_delivery_id: tuple[str, str]) -> dict[str, Any]: + """Re-enqueue a stored delivery's original payload as a fresh attempt. + + The replay creates a NEW delivery record on top of the existing one + so the audit trail is preserved. Returns the new delivery's id and + the synchronous HTTP status from the first attempt. + """ + subscription_id, delivery_id = sub_and_delivery_id + sub = WebhookSubscriptionStore(queue).get(subscription_id) + if sub is None: + raise _NotFound(f"webhook '{subscription_id}' not found") + record = DeliveryStore(queue).get(subscription_id, delivery_id) + if record is None: + raise _NotFound(f"delivery '{delivery_id}' not found") + + from taskito.webhooks import WebhookManager + + runtime = WebhookManager._subscription_to_runtime(sub) + payload = {**record.payload, "replay_of": record.id} + status = queue._webhook_manager.deliver_now(runtime, payload) + # deliver_now does NOT write to the log. Record a replay entry so the + # operator can see it appear in the deliveries list. + DeliveryStore(queue).record_attempt( + subscription_id, + event=str(payload.get("event", record.event)), + payload=payload, + status="delivered" if status is not None and status < 400 else "failed", + attempts=1, + response_code=status, + task_name=record.task_name, + job_id=record.job_id, + ) + return { + "replayed_of": record.id, + "status": status, + "delivered": status is not None and status < 400, + } diff --git a/py_src/taskito/dashboard/handlers/webhooks.py b/py_src/taskito/dashboard/handlers/webhooks.py new file mode 100644 index 00000000..47a3abcb --- /dev/null +++ b/py_src/taskito/dashboard/handlers/webhooks.py @@ -0,0 +1,241 @@ +"""Webhook subscription CRUD endpoints.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import TYPE_CHECKING, Any + +from taskito.dashboard.errors import _BadRequest, _NotFound +from taskito.dashboard.url_safety import UnsafeWebhookUrl, validate_webhook_url +from taskito.dashboard.webhook_store import ( + WebhookSubscription, + WebhookSubscriptionStore, + generate_secret, +) +from taskito.events import EventType + +if TYPE_CHECKING: + from taskito.app import Queue + + +_VALID_EVENT_VALUES = frozenset(e.value for e in EventType) + + +# ── Serialization ───────────────────────────────────────────────────── + + +def _serialize( + subscription: WebhookSubscription, *, reveal_secret: bool = False +) -> dict[str, Any]: + """Convert to a JSON-safe dict. The raw secret is redacted unless the + caller is ``reveal_secret``-ing (used by the create and rotate endpoints, + which need to surface the value to the user exactly once).""" + row = asdict(subscription) + secret = row.pop("secret", None) + row["has_secret"] = bool(secret) + if reveal_secret and secret: + row["secret"] = secret + return row + + +# ── Validation helpers ──────────────────────────────────────────────── + + +def _require_str(body: dict, key: str) -> str: + value = body.get(key) + if not isinstance(value, str) or not value: + raise _BadRequest(f"missing or empty field '{key}'") + return value + + +def _coerce_event_list(value: Any) -> list[str]: + if value is None: + return [] + if not isinstance(value, list): + raise _BadRequest("events must be a list of event type strings") + events: list[str] = [] + for item in value: + if not isinstance(item, str): + raise _BadRequest("events must contain only strings") + if item not in _VALID_EVENT_VALUES: + raise _BadRequest(f"unknown event type {item!r}") + events.append(item) + return events + + +def _coerce_task_filter(value: Any) -> list[str] | None: + if value is None: + return None + if not isinstance(value, list): + raise _BadRequest("task_filter must be a list of task names or null") + out: list[str] = [] + for item in value: + if not isinstance(item, str) or not item: + raise _BadRequest("task_filter entries must be non-empty strings") + out.append(item) + return out + + +def _coerce_headers(value: Any) -> dict[str, str]: + if value is None: + return {} + if not isinstance(value, dict): + raise _BadRequest("headers must be an object of string→string") + out: dict[str, str] = {} + for k, v in value.items(): + if not isinstance(k, str) or not isinstance(v, str): + raise _BadRequest("headers must map strings to strings") + out[k] = v + return out + + +def _coerce_positive_int(value: Any, name: str, default: int) -> int: + if value is None: + return default + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + raise _BadRequest(f"{name} must be a non-negative integer") + return value + + +def _coerce_positive_float(value: Any, name: str, default: float) -> float: + if value is None: + return default + if isinstance(value, bool) or not isinstance(value, (int, float)) or value <= 0: + raise _BadRequest(f"{name} must be a positive number") + return float(value) + + +# ── Handlers ────────────────────────────────────────────────────────── + + +def handle_list_webhooks(queue: Queue, _qs: dict) -> list[dict[str, Any]]: + return [_serialize(s) for s in WebhookSubscriptionStore(queue).list_all()] + + +def handle_get_webhook(queue: Queue, _qs: dict, subscription_id: str) -> dict[str, Any]: + sub = WebhookSubscriptionStore(queue).get(subscription_id) + if sub is None: + raise _NotFound(f"webhook '{subscription_id}' not found") + return _serialize(sub) + + +def handle_create_webhook(queue: Queue, body: dict) -> dict[str, Any]: + if not isinstance(body, dict): + raise _BadRequest("body must be a JSON object") + url = _require_str(body, "url") + try: + validate_webhook_url(url) + except UnsafeWebhookUrl as e: + raise _BadRequest(str(e)) from None + + events = _coerce_event_list(body.get("events")) + task_filter = _coerce_task_filter(body.get("task_filter")) + headers = _coerce_headers(body.get("headers")) + max_retries = _coerce_positive_int(body.get("max_retries"), "max_retries", 3) + timeout_seconds = _coerce_positive_float(body.get("timeout_seconds"), "timeout_seconds", 10.0) + retry_backoff = _coerce_positive_float(body.get("retry_backoff"), "retry_backoff", 2.0) + + secret = body.get("secret") + if secret is not None and not isinstance(secret, str): + raise _BadRequest("secret must be a string or null") + if body.get("generate_secret"): + secret = generate_secret() + + description = body.get("description") + if description is not None and not isinstance(description, str): + raise _BadRequest("description must be a string or null") + + sub = queue.add_webhook( + url=url, + events=[EventType(v) for v in events] if events else None, + headers=headers, + secret=secret, + max_retries=max_retries, + timeout=timeout_seconds, + retry_backoff=retry_backoff, + task_filter=task_filter, + description=description, + ) + return _serialize(sub, reveal_secret=True) + + +def handle_update_webhook(queue: Queue, body: dict, subscription_id: str) -> dict[str, Any]: + if not isinstance(body, dict): + raise _BadRequest("body must be a JSON object") + sub = WebhookSubscriptionStore(queue).get(subscription_id) + if sub is None: + raise _NotFound(f"webhook '{subscription_id}' not found") + + changes: dict[str, Any] = {} + if "url" in body: + url = _require_str(body, "url") + try: + validate_webhook_url(url) + except UnsafeWebhookUrl as e: + raise _BadRequest(str(e)) from None + changes["url"] = url + if "events" in body: + changes["events"] = _coerce_event_list(body["events"]) + if "task_filter" in body: + changes["task_filter"] = _coerce_task_filter(body["task_filter"]) + if "headers" in body: + changes["headers"] = _coerce_headers(body["headers"]) + if "max_retries" in body: + changes["max_retries"] = _coerce_positive_int(body["max_retries"], "max_retries", 3) + if "timeout_seconds" in body: + changes["timeout_seconds"] = _coerce_positive_float( + body["timeout_seconds"], "timeout_seconds", 10.0 + ) + if "retry_backoff" in body: + changes["retry_backoff"] = _coerce_positive_float( + body["retry_backoff"], "retry_backoff", 2.0 + ) + if "enabled" in body: + if not isinstance(body["enabled"], bool): + raise _BadRequest("enabled must be a boolean") + changes["enabled"] = body["enabled"] + if "description" in body: + description = body["description"] + if description is not None and not isinstance(description, str): + raise _BadRequest("description must be a string or null") + changes["description"] = description + + updated = queue.update_webhook(subscription_id, **changes) + return _serialize(updated) + + +def handle_delete_webhook(queue: Queue, subscription_id: str) -> dict[str, bool]: + removed = queue.remove_webhook(subscription_id) + if not removed: + raise _NotFound(f"webhook '{subscription_id}' not found") + return {"deleted": True} + + +def handle_rotate_secret(queue: Queue, subscription_id: str) -> dict[str, Any]: + if WebhookSubscriptionStore(queue).get(subscription_id) is None: + raise _NotFound(f"webhook '{subscription_id}' not found") + secret = queue.rotate_webhook_secret(subscription_id) + return {"id": subscription_id, "secret": secret} + + +def handle_test_webhook(queue: Queue, subscription_id: str) -> dict[str, Any]: + """Synchronously POST a synthetic event and return the result inline.""" + sub = WebhookSubscriptionStore(queue).get(subscription_id) + if sub is None: + raise _NotFound(f"webhook '{subscription_id}' not found") + + from taskito.webhooks import WebhookManager + + runtime = WebhookManager._subscription_to_runtime(sub) + payload = { + "event": "test.ping", + "task_name": None, + "subscription_id": sub.id, + "message": "synthetic test event from dashboard", + } + status = queue._webhook_manager.deliver_now(runtime, payload) + return {"status": status, "delivered": status is not None and status < 400} + + +def handle_list_event_types(_queue: Queue, _qs: dict) -> list[str]: + return sorted(e.value for e in EventType) diff --git a/py_src/taskito/dashboard/middleware_store.py b/py_src/taskito/dashboard/middleware_store.py new file mode 100644 index 00000000..0c2554b7 --- /dev/null +++ b/py_src/taskito/dashboard/middleware_store.py @@ -0,0 +1,88 @@ +"""Per-task middleware disable list. + +Operators turn individual middlewares off for individual tasks from the +dashboard. The disable list is persisted under +``middleware:disabled:`` as a JSON array of middleware names, +read by :meth:`~taskito.mixins.decorators.QueueDecoratorMixin._get_middleware_chain` +at every task invocation so changes take effect immediately on the next +job without a worker restart. +""" + +from __future__ import annotations + +import json +import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from taskito.app import Queue + + +DISABLE_PREFIX = "middleware:disabled:" + +logger = logging.getLogger("taskito.dashboard.middleware") + + +def _parse(raw: str | None) -> list[str]: + if not raw: + return [] + try: + data = json.loads(raw) + except json.JSONDecodeError: + logger.warning("middleware disable list is not valid JSON; treating as empty") + return [] + if not isinstance(data, list): + return [] + return [str(x) for x in data if isinstance(x, str)] + + +class MiddlewareDisableStore: + """List/set/clear per-task middleware disables.""" + + def __init__(self, queue: Queue) -> None: + self._queue = queue + + def _key(self, task_name: str) -> str: + return DISABLE_PREFIX + task_name + + def list_all(self) -> dict[str, list[str]]: + """Return ``{task_name: [disabled_mw_name, ...]}`` for every task that + has at least one disabled middleware.""" + out: dict[str, list[str]] = {} + for key, raw in self._queue.list_settings().items(): + if not key.startswith(DISABLE_PREFIX): + continue + task_name = key[len(DISABLE_PREFIX) :] + names = _parse(raw) + if names: + out[task_name] = names + return out + + def get_for(self, task_name: str) -> list[str]: + return _parse(self._queue.get_setting(self._key(task_name))) + + def is_disabled(self, task_name: str, mw_name: str) -> bool: + return mw_name in self.get_for(task_name) + + def set_disabled(self, task_name: str, mw_name: str, disabled: bool) -> list[str]: + """Flip a middleware on/off for a task and return the new disable list.""" + if not task_name: + raise ValueError("task_name must not be empty") + if not mw_name: + raise ValueError("mw_name must not be empty") + current = self.get_for(task_name) + if disabled: + if mw_name not in current: + current.append(mw_name) + else: + current = [n for n in current if n != mw_name] + if current: + self._queue.set_setting( + self._key(task_name), json.dumps(current, separators=(",", ":")) + ) + else: + self._queue.delete_setting(self._key(task_name)) + return current + + def clear_for(self, task_name: str) -> bool: + return self._queue.delete_setting(self._key(task_name)) diff --git a/py_src/taskito/dashboard/overrides_store.py b/py_src/taskito/dashboard/overrides_store.py new file mode 100644 index 00000000..d5d70f1b --- /dev/null +++ b/py_src/taskito/dashboard/overrides_store.py @@ -0,0 +1,341 @@ +"""Persistent task & queue runtime overrides. + +Operators tune individual task or queue behaviour (rate limits, concurrency +caps, retry policy, timeouts, priority, paused state) at runtime via the +dashboard. The decorator-declared values become the *defaults* — any override +recorded here wins. + +Storage layout in ``dashboard_settings``: + +- ``overrides:task:`` — JSON of overridden fields for that task +- ``overrides:queue:`` — JSON of overridden fields for that queue + +Overrides are applied at worker startup (see +:meth:`taskito.mixins.lifecycle.QueueLifecycleMixin.start_worker`). +Changes to the store DO NOT take effect on a running worker until it is +restarted — the dashboard surfaces this so operators aren't surprised. + +The contract is intentionally minimal: only the fields below can be +overridden. The store rejects anything else so a typo can't write garbage +through the dashboard. +""" + +from __future__ import annotations + +import json +import logging +import time +from dataclasses import asdict, dataclass +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from taskito.app import Queue + + +TASK_PREFIX = "overrides:task:" +QUEUE_PREFIX = "overrides:queue:" + +logger = logging.getLogger("taskito.dashboard.overrides") + + +# ── Allowed override fields ──────────────────────────────────────────── + + +TASK_OVERRIDE_FIELDS: frozenset[str] = frozenset( + { + "rate_limit", + "max_concurrent", + "max_retries", + "retry_backoff", + "timeout", + "priority", + "paused", + } +) + +QUEUE_OVERRIDE_FIELDS: frozenset[str] = frozenset( + { + "rate_limit", + "max_concurrent", + "paused", + } +) + + +# ── Data classes ─────────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class TaskOverride: + """An operator-set override for a registered task.""" + + task_name: str + rate_limit: str | None = None + max_concurrent: int | None = None + max_retries: int | None = None + retry_backoff: float | None = None + timeout: int | None = None + priority: int | None = None + paused: bool = False + updated_at: int = 0 + + def as_patch(self) -> dict[str, Any]: + """Return a dict of only the non-default fields (those the operator + actually set). The empty/default values are NOT patched onto the + underlying ``PyTaskConfig`` — they continue to use the decorator + value.""" + patch: dict[str, Any] = {} + for field in TASK_OVERRIDE_FIELDS: + if field == "paused": + continue # handled separately; not a PyTaskConfig field + value = getattr(self, field) + if value is not None: + patch[field] = value + return patch + + +@dataclass(frozen=True) +class QueueOverride: + """An operator-set override for a queue.""" + + queue_name: str + rate_limit: str | None = None + max_concurrent: int | None = None + paused: bool = False + updated_at: int = 0 + + +# ── Validation ───────────────────────────────────────────────────────── + + +def _validate_task_fields(fields: dict[str, Any]) -> None: + unknown = set(fields) - TASK_OVERRIDE_FIELDS + if unknown: + raise ValueError(f"unknown task override fields: {sorted(unknown)}") + _validate_rate_limit(fields.get("rate_limit")) + _validate_max_concurrent(fields.get("max_concurrent")) + _validate_int_field(fields, "max_retries", minimum=0) + _validate_float_field(fields, "retry_backoff", minimum=0) + _validate_int_field(fields, "timeout", minimum=1) + _validate_int_field(fields, "priority") + _validate_bool_field(fields, "paused") + + +def _validate_queue_fields(fields: dict[str, Any]) -> None: + unknown = set(fields) - QUEUE_OVERRIDE_FIELDS + if unknown: + raise ValueError(f"unknown queue override fields: {sorted(unknown)}") + _validate_rate_limit(fields.get("rate_limit")) + _validate_max_concurrent(fields.get("max_concurrent")) + _validate_bool_field(fields, "paused") + + +def _validate_rate_limit(value: Any) -> None: + if value is None: + return + if not isinstance(value, str) or not value: + raise ValueError("rate_limit must be a non-empty string like '100/m'") + # Cheap shape check; rate-limit parsing happens in Rust. + if "/" not in value: + raise ValueError("rate_limit must contain a unit, e.g. '10/s', '100/m', '3600/h'") + + +def _validate_max_concurrent(value: Any) -> None: + if value is None: + return + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + raise ValueError("max_concurrent must be a non-negative integer") + + +def _validate_int_field(fields: dict[str, Any], name: str, *, minimum: int | None = None) -> None: + value = fields.get(name) + if value is None: + return + if not isinstance(value, int) or isinstance(value, bool): + raise ValueError(f"{name} must be an integer") + if minimum is not None and value < minimum: + raise ValueError(f"{name} must be >= {minimum}") + + +def _validate_float_field( + fields: dict[str, Any], name: str, *, minimum: float | None = None +) -> None: + value = fields.get(name) + if value is None: + return + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"{name} must be a number") + if minimum is not None and value < minimum: + raise ValueError(f"{name} must be >= {minimum}") + + +def _validate_bool_field(fields: dict[str, Any], name: str) -> None: + value = fields.get(name) + if value is not None and not isinstance(value, bool): + raise ValueError(f"{name} must be a boolean") + + +# ── Store ────────────────────────────────────────────────────────────── + + +def _now() -> int: + return int(time.time()) + + +def _parse_json(raw: str | None) -> dict[str, Any]: + if not raw: + return {} + try: + data = json.loads(raw) + except json.JSONDecodeError: + logger.warning("overrides entry is not valid JSON; treating as empty") + return {} + return data if isinstance(data, dict) else {} + + +class OverridesStore: + """CRUD for per-task and per-queue runtime overrides.""" + + def __init__(self, queue: Queue) -> None: + self._queue = queue + + # ── Tasks ────────────────────────────────────────────────── + + def list_tasks(self) -> dict[str, TaskOverride]: + """Return ``{task_name: TaskOverride}`` for every task with an override.""" + out: dict[str, TaskOverride] = {} + for key, raw in self._queue.list_settings().items(): + if not key.startswith(TASK_PREFIX): + continue + task_name = key[len(TASK_PREFIX) :] + out[task_name] = self._row_to_task(task_name, _parse_json(raw)) + return out + + def get_task(self, task_name: str) -> TaskOverride | None: + raw = self._queue.get_setting(TASK_PREFIX + task_name) + if not raw: + return None + return self._row_to_task(task_name, _parse_json(raw)) + + def set_task(self, task_name: str, fields: dict[str, Any]) -> TaskOverride: + _validate_task_fields(fields) + if not task_name: + raise ValueError("task_name must not be empty") + existing = self.get_task(task_name) + merged: dict[str, Any] = {} + if existing is not None: + merged.update({k: v for k, v in asdict(existing).items() if v is not None}) + merged.pop("task_name", None) + merged.pop("updated_at", None) + for k, v in fields.items(): + if v is None: + merged.pop(k, None) + else: + merged[k] = v + merged["updated_at"] = _now() + self._queue.set_setting(TASK_PREFIX + task_name, json.dumps(merged, separators=(",", ":"))) + return self._row_to_task(task_name, merged) + + def clear_task(self, task_name: str) -> bool: + return self._queue.delete_setting(TASK_PREFIX + task_name) + + @staticmethod + def _row_to_task(task_name: str, row: dict[str, Any]) -> TaskOverride: + return TaskOverride( + task_name=task_name, + rate_limit=row.get("rate_limit"), + max_concurrent=row.get("max_concurrent"), + max_retries=row.get("max_retries"), + retry_backoff=row.get("retry_backoff"), + timeout=row.get("timeout"), + priority=row.get("priority"), + paused=bool(row.get("paused", False)), + updated_at=int(row.get("updated_at", 0)), + ) + + # ── Queues ───────────────────────────────────────────────── + + def list_queues(self) -> dict[str, QueueOverride]: + out: dict[str, QueueOverride] = {} + for key, raw in self._queue.list_settings().items(): + if not key.startswith(QUEUE_PREFIX): + continue + queue_name = key[len(QUEUE_PREFIX) :] + out[queue_name] = self._row_to_queue(queue_name, _parse_json(raw)) + return out + + def get_queue(self, queue_name: str) -> QueueOverride | None: + raw = self._queue.get_setting(QUEUE_PREFIX + queue_name) + if not raw: + return None + return self._row_to_queue(queue_name, _parse_json(raw)) + + def set_queue(self, queue_name: str, fields: dict[str, Any]) -> QueueOverride: + _validate_queue_fields(fields) + if not queue_name: + raise ValueError("queue_name must not be empty") + existing = self.get_queue(queue_name) + merged: dict[str, Any] = {} + if existing is not None: + merged.update({k: v for k, v in asdict(existing).items() if v is not None}) + merged.pop("queue_name", None) + merged.pop("updated_at", None) + for k, v in fields.items(): + if v is None: + merged.pop(k, None) + else: + merged[k] = v + merged["updated_at"] = _now() + self._queue.set_setting( + QUEUE_PREFIX + queue_name, json.dumps(merged, separators=(",", ":")) + ) + return self._row_to_queue(queue_name, merged) + + def clear_queue(self, queue_name: str) -> bool: + return self._queue.delete_setting(QUEUE_PREFIX + queue_name) + + @staticmethod + def _row_to_queue(queue_name: str, row: dict[str, Any]) -> QueueOverride: + return QueueOverride( + queue_name=queue_name, + rate_limit=row.get("rate_limit"), + max_concurrent=row.get("max_concurrent"), + paused=bool(row.get("paused", False)), + updated_at=int(row.get("updated_at", 0)), + ) + + # ── Apply (used at worker startup) ───────────────────────── + + def apply_task_overrides(self, configs: list[Any]) -> list[str]: + """Mutate each :class:`PyTaskConfig` in ``configs`` with any matching + task override. Returns a list of task names that are paused (so the + caller can skip enqueuing them). + """ + overrides = self.list_tasks() + paused: list[str] = [] + for config in configs: + override = overrides.get(config.name) + if override is None: + continue + for field, value in override.as_patch().items(): + if hasattr(config, field): + setattr(config, field, value) + if override.paused: + paused.append(config.name) + return paused + + def apply_queue_overrides( + self, queue_configs: dict[str, dict[str, Any]] + ) -> dict[str, dict[str, Any]]: + """Merge queue overrides into ``queue_configs``. Returns the merged + dict (a copy).""" + merged: dict[str, dict[str, Any]] = {k: dict(v) for k, v in queue_configs.items()} + for queue_name, override in self.list_queues().items(): + slot = merged.setdefault(queue_name, {}) + if override.rate_limit is not None: + slot["rate_limit"] = override.rate_limit + if override.max_concurrent is not None: + slot["max_concurrent"] = override.max_concurrent + if override.paused: + slot["paused"] = True + return merged diff --git a/py_src/taskito/dashboard/routes.py b/py_src/taskito/dashboard/routes.py index 29a3eefd..a4ab7937 100644 --- a/py_src/taskito/dashboard/routes.py +++ b/py_src/taskito/dashboard/routes.py @@ -38,6 +38,22 @@ ) from taskito.dashboard.handlers.logs import _handle_logs from taskito.dashboard.handlers.metrics import _handle_metrics, _handle_metrics_timeseries +from taskito.dashboard.handlers.middleware import ( + handle_delete_task_middleware, + handle_get_task_middleware, + handle_list_middleware, + handle_put_task_middleware, +) +from taskito.dashboard.handlers.overrides import ( + handle_delete_queue_override, + handle_delete_task_override, + handle_get_queue_override, + handle_get_task_override, + handle_list_queues, + handle_list_tasks, + handle_put_queue_override, + handle_put_task_override, +) from taskito.dashboard.handlers.queues import _handle_stats_queues from taskito.dashboard.handlers.scaler import build_scaler_response from taskito.dashboard.handlers.settings import ( @@ -46,6 +62,21 @@ _handle_list_settings, _handle_set_setting, ) +from taskito.dashboard.handlers.webhook_deliveries import ( + handle_get_delivery, + handle_list_deliveries, + handle_replay_delivery, +) +from taskito.dashboard.handlers.webhooks import ( + handle_create_webhook, + handle_delete_webhook, + handle_get_webhook, + handle_list_event_types, + handle_list_webhooks, + handle_rotate_secret, + handle_test_webhook, + handle_update_webhook, +) # ── Auth-exempt paths ────────────────────────────────────────────────── # @@ -88,6 +119,11 @@ "/api/scaler": lambda q, qs: build_scaler_response(q, queue_name=qs.get("queue", [None])[0]), "/api/settings": _handle_list_settings, "/api/auth/status": handle_auth_status, + "/api/webhooks": handle_list_webhooks, + "/api/event-types": handle_list_event_types, + "/api/tasks": handle_list_tasks, + "/api/queues": handle_list_queues, + "/api/middleware": handle_list_middleware, } # ── Parameterized GET routes: regex → handler(queue, qs, captured_id) ── @@ -102,6 +138,22 @@ (re.compile(r"^/api/jobs/([^/]+)/dag$"), lambda q, qs, jid: q.job_dag(jid)), (re.compile(r"^/api/jobs/([^/]+)$"), _handle_get_job), (re.compile(r"^/api/settings/(.+)$"), _handle_get_setting), + ( + re.compile(r"^/api/webhooks/([^/]+)/deliveries$"), + handle_list_deliveries, + ), + (re.compile(r"^/api/webhooks/([^/]+)$"), handle_get_webhook), + (re.compile(r"^/api/tasks/([^/]+)/override$"), handle_get_task_override), + (re.compile(r"^/api/queues/([^/]+)/override$"), handle_get_queue_override), + (re.compile(r"^/api/tasks/([^/]+)/middleware$"), handle_get_task_middleware), +] + +# GET routes with 2 captured groups (handler signature: queue, qs, (g1, g2)) +GET_PARAM2_ROUTES: list[tuple[re.Pattern, Any]] = [ + ( + re.compile(r"^/api/webhooks/([^/]+)/deliveries/([^/]+)$"), + handle_get_delivery, + ), ] # ── Exact-match POST routes: path → handler(queue) → JSON data ── @@ -113,6 +165,7 @@ POST_BODY_ROUTES: dict[str, Any] = { "/api/auth/login": handle_login, "/api/auth/setup": handle_setup, + "/api/webhooks": handle_create_webhook, } # Auth-context POST routes: path → handler(queue, ctx) — no body @@ -146,16 +199,42 @@ re.compile(r"^/api/queues/([^/]+)/resume$"), lambda q, n: (q.resume(n), {"resumed": n})[1], ), + (re.compile(r"^/api/webhooks/([^/]+)/test$"), handle_test_webhook), + (re.compile(r"^/api/webhooks/([^/]+)/rotate-secret$"), handle_rotate_secret), +] + +# Routes with two captures (sub_id + delivery_id) — handled by the POST +# dispatcher when patterns yield 2 groups. +POST_PARAM2_ROUTES: list[tuple[re.Pattern, Any]] = [ + ( + re.compile(r"^/api/webhooks/([^/]+)/deliveries/([^/]+)/replay$"), + handle_replay_delivery, + ), ] # ── Parameterized PUT routes: regex → handler(queue, body, captured_id) ── PUT_PARAM_ROUTES: list[tuple[re.Pattern, Any]] = [ (re.compile(r"^/api/settings/(.+)$"), _handle_set_setting), + (re.compile(r"^/api/webhooks/([^/]+)$"), handle_update_webhook), + (re.compile(r"^/api/tasks/([^/]+)/override$"), handle_put_task_override), + (re.compile(r"^/api/queues/([^/]+)/override$"), handle_put_queue_override), +] + +# PUT routes with 2 captured groups (handler signature: queue, body, (g1, g2)) +PUT_PARAM2_ROUTES: list[tuple[re.Pattern, Any]] = [ + ( + re.compile(r"^/api/tasks/([^/]+)/middleware/([^/]+)$"), + handle_put_task_middleware, + ), ] # ── Parameterized DELETE routes: regex → handler(queue, captured_id) ── DELETE_PARAM_ROUTES: list[tuple[re.Pattern, Any]] = [ (re.compile(r"^/api/settings/(.+)$"), _handle_delete_setting), + (re.compile(r"^/api/webhooks/([^/]+)$"), handle_delete_webhook), + (re.compile(r"^/api/tasks/([^/]+)/override$"), handle_delete_task_override), + (re.compile(r"^/api/queues/([^/]+)/override$"), handle_delete_queue_override), + (re.compile(r"^/api/tasks/([^/]+)/middleware$"), handle_delete_task_middleware), ] diff --git a/py_src/taskito/dashboard/server.py b/py_src/taskito/dashboard/server.py index bb009985..2aa17539 100644 --- a/py_src/taskito/dashboard/server.py +++ b/py_src/taskito/dashboard/server.py @@ -33,14 +33,17 @@ AUTH_CONTEXT_POST_PATHS, DELETE_PARAM_ROUTES, GET_CTX_ROUTES, + GET_PARAM2_ROUTES, GET_PARAM_ROUTES, GET_ROUTES, POST_BODY_ROUTES, POST_CTX_BODY_ROUTES, POST_CTX_ROUTES, + POST_PARAM2_ROUTES, POST_PARAM_ROUTES, POST_ROUTES, PUBLIC_PATHS, + PUT_PARAM2_ROUTES, PUT_PARAM_ROUTES, is_csrf_exempt, is_state_changing_method, @@ -183,6 +186,15 @@ def _handle_get(self) -> None: ) return + for pattern, param_handler in GET_PARAM2_ROUTES: + m = pattern.match(path) + if m: + self._dispatch_with_handler( + param_handler, + lambda h, m=m: h(queue, qs, (m.group(1), m.group(2))), + ) + return + if path == "/health": self._json_response(check_health()) elif path == "/readiness": @@ -240,12 +252,29 @@ def _handle_post(self) -> None: self._dispatch_with_handler(handler, lambda h: h(queue)) return + body_handler = POST_BODY_ROUTES.get(path) + if body_handler: + body = self._read_json_body() + if body is None: + return + self._dispatch_with_handler(body_handler, lambda h, body=body: h(queue, body)) + return + for pattern, param_handler in POST_PARAM_ROUTES: m = pattern.match(path) if m: self._dispatch_with_handler(param_handler, lambda h, m=m: h(queue, m.group(1))) return + for pattern, param_handler in POST_PARAM2_ROUTES: + m = pattern.match(path) + if m: + self._dispatch_with_handler( + param_handler, + lambda h, m=m: h(queue, (m.group(1), m.group(2))), + ) + return + self._json_response({"error": "Not found"}, status=404) def _handle_put(self) -> None: @@ -264,6 +293,17 @@ def _handle_put(self) -> None: param_handler, lambda h, m=m, body=body: h(queue, body, m.group(1)) ) return + for pattern, param_handler in PUT_PARAM2_ROUTES: + m = pattern.match(path) + if m: + body = self._read_json_body() + if body is None: + return + self._dispatch_with_handler( + param_handler, + lambda h, m=m, body=body: h(queue, body, (m.group(1), m.group(2))), + ) + return self._json_response({"error": "Not found"}, status=404) def _handle_delete(self) -> None: diff --git a/py_src/taskito/dashboard/url_safety.py b/py_src/taskito/dashboard/url_safety.py new file mode 100644 index 00000000..b39be8e8 --- /dev/null +++ b/py_src/taskito/dashboard/url_safety.py @@ -0,0 +1,97 @@ +"""Outbound URL safety checks for dashboard-configured webhooks. + +We refuse to deliver to loopback, link-local, and RFC1918 addresses by +default — an operator who can write to ``dashboard_settings`` could +otherwise turn the worker into an SSRF proxy. The ``TASKITO_WEBHOOKS_ALLOW_PRIVATE`` +environment variable disables the guard for local development. +""" + +from __future__ import annotations + +import ipaddress +import os +import socket +import urllib.parse + +# Hostnames that always resolve to loopback / never-leave-this-host regardless +# of DNS, but might be missed by a strict ``ipaddress.is_private`` check. +_BLOCKED_HOSTNAME_SUFFIXES = ( + ".localhost", + ".local", + ".internal", + ".intranet", + ".lan", + ".private", +) +_BLOCKED_HOSTNAMES = frozenset( + {"localhost", "localhost.localdomain", "ip6-localhost", "ip6-loopback"} +) + +_ALLOW_ENV_VAR = "TASKITO_WEBHOOKS_ALLOW_PRIVATE" + + +class UnsafeWebhookUrl(ValueError): + """Raised when a webhook URL targets an address we won't deliver to.""" + + +def _is_private_ip(ip: str) -> bool: + try: + address = ipaddress.ip_address(ip) + except ValueError: + return False + return ( + address.is_private + or address.is_loopback + or address.is_link_local + or address.is_multicast + or address.is_reserved + or address.is_unspecified + ) + + +def _hostname_is_blocked(hostname: str) -> bool: + lowered = hostname.lower() + if lowered in _BLOCKED_HOSTNAMES: + return True + return any(lowered.endswith(suffix) for suffix in _BLOCKED_HOSTNAME_SUFFIXES) + + +def validate_webhook_url(url: str) -> None: + """Reject ``url`` if it targets a private/loopback/link-local destination. + + Set ``TASKITO_WEBHOOKS_ALLOW_PRIVATE=1`` in the environment to disable + the guard (intended for local development against ``http://localhost``). + + Raises: + UnsafeWebhookUrl: on scheme other than http/https, missing host, or + a host that resolves to a private/loopback IP. + """ + parsed = urllib.parse.urlparse(url) + if parsed.scheme not in ("http", "https"): + raise UnsafeWebhookUrl(f"URL scheme must be http or https, got {parsed.scheme!r}") + if not parsed.hostname: + raise UnsafeWebhookUrl("URL must include a hostname") + + if os.environ.get(_ALLOW_ENV_VAR): + return + + hostname = parsed.hostname + if _hostname_is_blocked(hostname): + raise UnsafeWebhookUrl(f"URL host {hostname!r} resolves to a private network") + + # Literal IPs are checked directly; named hosts are resolved. + try: + ipaddress.ip_address(hostname) + addresses: list[str] = [hostname] + except ValueError: + try: + addresses = [ + str(info[4][0]) + for info in socket.getaddrinfo(hostname, None, type=socket.SOCK_STREAM) + ] + except OSError as e: + raise UnsafeWebhookUrl(f"could not resolve {hostname!r}: {e}") from None + + for ip in addresses: + if _is_private_ip(ip): + raise UnsafeWebhookUrl(f"URL host {hostname!r} resolves to private address {ip}") diff --git a/py_src/taskito/dashboard/webhook_store.py b/py_src/taskito/dashboard/webhook_store.py new file mode 100644 index 00000000..7793d697 --- /dev/null +++ b/py_src/taskito/dashboard/webhook_store.py @@ -0,0 +1,204 @@ +"""Persistent webhook subscription store. + +Webhook subscriptions are stored as a JSON list under the +``webhooks:subscriptions`` key in the ``dashboard_settings`` table. This +gives us cross-backend persistence (SQLite, Postgres, Redis) without +adding new tables, while keeping the data structured enough for the +dashboard CRUD UI. + +Each entry is fully described by :class:`WebhookSubscription`. The +``secret`` field stores the HMAC signing secret in plaintext (the +storage backend is already trusted with everything else taskito +persists); the dashboard API NEVER returns the raw secret — only a +``has_secret`` indicator. Use :meth:`WebhookSubscriptionStore.rotate_secret` +to generate a new value and surface it once on rotation. +""" + +from __future__ import annotations + +import json +import logging +import secrets +import time +import uuid +from dataclasses import asdict, dataclass, field, replace +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from taskito.app import Queue + + +SUBSCRIPTIONS_KEY = "webhooks:subscriptions" +SECRET_BYTES = 32 + +logger = logging.getLogger("taskito.dashboard.webhooks") + + +@dataclass(frozen=True) +class WebhookSubscription: + """A single persisted webhook subscription.""" + + id: str + url: str + events: list[str] = field(default_factory=list) # empty = all + task_filter: list[str] | None = None # None = all tasks + headers: dict[str, str] = field(default_factory=dict) + secret: str | None = None + max_retries: int = 3 + timeout_seconds: float = 10.0 + retry_backoff: float = 2.0 + enabled: bool = True + description: str | None = None + created_at: int = 0 + updated_at: int = 0 + + def matches(self, event: str, task_name: str | None) -> bool: + """Return True iff this subscription should fire for the event.""" + if not self.enabled: + return False + if self.events and event not in self.events: + return False + return not (self.task_filter is not None and task_name not in self.task_filter) + + +def _new_id() -> str: + return uuid.uuid4().hex + + +def _now() -> int: + return int(time.time()) + + +def generate_secret() -> str: + """Return a fresh URL-safe webhook signing secret.""" + return secrets.token_urlsafe(SECRET_BYTES) + + +class WebhookSubscriptionStore: + """CRUD for webhook subscriptions backed by ``Queue``'s settings store.""" + + def __init__(self, queue: Queue) -> None: + self._queue = queue + + # ── Internal load/save ─────────────────────────────────────── + + def _load_raw(self) -> list[dict[str, Any]]: + raw = self._queue.get_setting(SUBSCRIPTIONS_KEY) + if not raw: + return [] + try: + data = json.loads(raw) + except json.JSONDecodeError: + logger.warning("webhooks:subscriptions is not valid JSON; treating as empty") + return [] + return data if isinstance(data, list) else [] + + def _save_raw(self, items: list[dict[str, Any]]) -> None: + self._queue.set_setting(SUBSCRIPTIONS_KEY, json.dumps(items, separators=(",", ":"))) + + @staticmethod + def _row_to_subscription(row: dict[str, Any]) -> WebhookSubscription: + return WebhookSubscription( + id=str(row["id"]), + url=str(row["url"]), + events=list(row.get("events") or []), + task_filter=(list(row["task_filter"]) if row.get("task_filter") is not None else None), + headers=dict(row.get("headers") or {}), + secret=row.get("secret"), + max_retries=int(row.get("max_retries", 3)), + timeout_seconds=float(row.get("timeout_seconds", 10.0)), + retry_backoff=float(row.get("retry_backoff", 2.0)), + enabled=bool(row.get("enabled", True)), + description=row.get("description"), + created_at=int(row.get("created_at", 0)), + updated_at=int(row.get("updated_at", 0)), + ) + + # ── Public API ─────────────────────────────────────────────── + + def list_all(self) -> list[WebhookSubscription]: + return [self._row_to_subscription(r) for r in self._load_raw()] + + def get(self, subscription_id: str) -> WebhookSubscription | None: + for row in self._load_raw(): + if row.get("id") == subscription_id: + return self._row_to_subscription(row) + return None + + def create( + self, + *, + url: str, + events: list[str] | None = None, + task_filter: list[str] | None = None, + headers: dict[str, str] | None = None, + secret: str | None = None, + max_retries: int = 3, + timeout_seconds: float = 10.0, + retry_backoff: float = 2.0, + enabled: bool = True, + description: str | None = None, + ) -> WebhookSubscription: + now = _now() + sub = WebhookSubscription( + id=_new_id(), + url=url, + events=list(events or []), + task_filter=list(task_filter) if task_filter is not None else None, + headers=dict(headers or {}), + secret=secret, + max_retries=max_retries, + timeout_seconds=timeout_seconds, + retry_backoff=retry_backoff, + enabled=enabled, + description=description, + created_at=now, + updated_at=now, + ) + rows = self._load_raw() + rows.append(asdict(sub)) + self._save_raw(rows) + return sub + + def update(self, subscription_id: str, **changes: Any) -> WebhookSubscription: + """Patch a subscription. Pass only the fields you want to change. + + Raises ``KeyError`` if the subscription does not exist. + """ + rows = self._load_raw() + for idx, row in enumerate(rows): + if row.get("id") != subscription_id: + continue + existing = self._row_to_subscription(row) + allowed = { + "url", + "events", + "task_filter", + "headers", + "secret", + "max_retries", + "timeout_seconds", + "retry_backoff", + "enabled", + "description", + } + patch = {k: v for k, v in changes.items() if k in allowed} + updated = replace(existing, updated_at=_now(), **patch) + rows[idx] = asdict(updated) + self._save_raw(rows) + return updated + raise KeyError(subscription_id) + + def delete(self, subscription_id: str) -> bool: + rows = self._load_raw() + remaining = [r for r in rows if r.get("id") != subscription_id] + if len(remaining) == len(rows): + return False + self._save_raw(remaining) + return True + + def rotate_secret(self, subscription_id: str) -> str: + """Generate a fresh secret for a subscription. Returns the new value.""" + secret = generate_secret() + self.update(subscription_id, secret=secret) + return secret diff --git a/py_src/taskito/middleware.py b/py_src/taskito/middleware.py index 86506413..077ff33f 100644 --- a/py_src/taskito/middleware.py +++ b/py_src/taskito/middleware.py @@ -55,12 +55,20 @@ def after(self, ctx, result, error): print(f"Finished {ctx.task_name}: {status}") """ + #: Stable identifier used to refer to this middleware from the dashboard + #: when toggling it on/off per task. Defaults to the class' fully-qualified + #: name so it survives restarts. Override on a subclass to pin a + #: shorter / more user-facing name. + name: str = "" + def __init__( self, *, predicate: Predicate | Callable[..., Any] | None = None, ) -> None: self._predicate = coerce_predicate(predicate) + if not type(self).name: + type(self).name = f"{type(self).__module__}.{type(self).__qualname__}" def _should_apply(self, ctx: JobContext | None, task_name: str = "") -> bool: """Decide whether this middleware's hooks should fire for ``ctx``. diff --git a/py_src/taskito/mixins/__init__.py b/py_src/taskito/mixins/__init__.py index f9b07baa..2d54c056 100644 --- a/py_src/taskito/mixins/__init__.py +++ b/py_src/taskito/mixins/__init__.py @@ -5,7 +5,9 @@ from taskito.mixins.inspection import QueueInspectionMixin from taskito.mixins.lifecycle import QueueLifecycleMixin from taskito.mixins.locks import QueueLockMixin +from taskito.mixins.middleware_admin import QueueMiddlewareAdminMixin from taskito.mixins.operations import QueueOperationsMixin +from taskito.mixins.overrides import QueueOverridesMixin from taskito.mixins.predicates import QueuePredicateMixin from taskito.mixins.resources import QueueResourceMixin from taskito.mixins.runtime_config import QueueRuntimeConfigMixin @@ -17,7 +19,9 @@ "QueueInspectionMixin", "QueueLifecycleMixin", "QueueLockMixin", + "QueueMiddlewareAdminMixin", "QueueOperationsMixin", + "QueueOverridesMixin", "QueuePredicateMixin", "QueueResourceMixin", "QueueRuntimeConfigMixin", diff --git a/py_src/taskito/mixins/decorators.py b/py_src/taskito/mixins/decorators.py index 671e9a2f..e33c940b 100644 --- a/py_src/taskito/mixins/decorators.py +++ b/py_src/taskito/mixins/decorators.py @@ -16,6 +16,7 @@ from taskito._taskito import PyTaskConfig from taskito.async_support.helpers import run_maybe_async from taskito.context import _clear_context, current_job +from taskito.dashboard.middleware_store import MiddlewareDisableStore from taskito.events import EventType from taskito.exceptions import TaskCancelledError from taskito.inject import Inject, _InjectAlias @@ -111,9 +112,18 @@ class QueueDecoratorMixin: _apply_dispatch_predicate: Callable[..., None] def _get_middleware_chain(self, task_name: str) -> list[TaskMiddleware]: - """Get the combined global + per-task middleware list.""" + """Get the combined global + per-task middleware list, minus any + middleware the operator has disabled for this task from the dashboard.""" per_task = self._task_middleware.get(task_name, []) - return self._global_middleware + per_task + chain = self._global_middleware + per_task + try: + disabled = MiddlewareDisableStore(self).get_for(task_name) # type: ignore[arg-type] + except Exception: # pragma: no cover - storage read failure is non-fatal + disabled = [] + if not disabled: + return chain + disabled_set = set(disabled) + return [mw for mw in chain if getattr(mw, "name", "") not in disabled_set] def _wrap_task( self, fn: Callable, task_name: str, soft_timeout: float | None = None diff --git a/py_src/taskito/mixins/events.py b/py_src/taskito/mixins/events.py index 936c4c15..1aa6e6c6 100644 --- a/py_src/taskito/mixins/events.py +++ b/py_src/taskito/mixins/events.py @@ -5,6 +5,9 @@ from collections.abc import Callable from typing import TYPE_CHECKING, Any +from taskito.dashboard.url_safety import validate_webhook_url +from taskito.dashboard.webhook_store import WebhookSubscription, WebhookSubscriptionStore + if TYPE_CHECKING: from taskito.events import EventBus, EventType from taskito.webhooks import WebhookManager @@ -30,6 +33,8 @@ def on_event(self, event_type: EventType, callback: Callable[..., Any]) -> None: """ self._event_bus.on(event_type, callback) + # ── Webhook subscriptions (persistent) ──────────────────────── + def add_webhook( self, url: str, @@ -39,24 +44,74 @@ def add_webhook( max_retries: int = 3, timeout: float = 10.0, retry_backoff: float = 2.0, - ) -> None: + task_filter: list[str] | None = None, + description: str | None = None, + ) -> WebhookSubscription: """Register a webhook endpoint for job events. + Persisted through the dashboard settings store, so the subscription + survives restarts and is shared across every worker pointed at the + same backend. + Args: url: URL to POST event payloads to. - events: Event types to subscribe to (None = all). + events: Event types to subscribe to (``None`` = all). headers: Extra HTTP headers. - secret: HMAC-SHA256 signing secret. - max_retries: Maximum delivery attempts (default 3). - timeout: HTTP request timeout in seconds (default 10.0). - retry_backoff: Base for exponential backoff between retries (default 2.0). + secret: HMAC-SHA256 signing secret. Stored as plaintext; rotate + via :meth:`rotate_webhook_secret`. + max_retries: Maximum delivery attempts. + timeout: HTTP request timeout in seconds. + retry_backoff: Base for exponential backoff between retries. + task_filter: When set, deliver only when the event's + ``task_name`` is in this list. + description: Free-form label shown in the dashboard. + + Returns: + The persisted :class:`WebhookSubscription`. """ - self._webhook_manager.add_webhook( - url, - events, - headers, - secret, + validate_webhook_url(url) + store = WebhookSubscriptionStore(self) # type: ignore[arg-type] + sub = store.create( + url=url, + events=[e.value for e in events] if events else None, + task_filter=task_filter, + headers=headers, + secret=secret, max_retries=max_retries, - timeout=timeout, + timeout_seconds=timeout, retry_backoff=retry_backoff, + description=description, ) + self._webhook_manager.reload() + return sub + + def list_webhooks(self) -> list[WebhookSubscription]: + """Return every persisted webhook subscription.""" + return WebhookSubscriptionStore(self).list_all() # type: ignore[arg-type] + + def get_webhook(self, subscription_id: str) -> WebhookSubscription | None: + return WebhookSubscriptionStore(self).get(subscription_id) # type: ignore[arg-type] + + def update_webhook(self, subscription_id: str, **changes: Any) -> WebhookSubscription: + """Patch fields of an existing subscription. Reloads the manager.""" + if "url" in changes: + validate_webhook_url(changes["url"]) + store = WebhookSubscriptionStore(self) # type: ignore[arg-type] + updated = store.update(subscription_id, **changes) + self._webhook_manager.reload() + return updated + + def remove_webhook(self, subscription_id: str) -> bool: + """Delete a subscription. Returns ``True`` if it existed.""" + store = WebhookSubscriptionStore(self) # type: ignore[arg-type] + removed = store.delete(subscription_id) + if removed: + self._webhook_manager.reload() + return removed + + def rotate_webhook_secret(self, subscription_id: str) -> str: + """Generate a fresh signing secret. Returns the new value.""" + store = WebhookSubscriptionStore(self) # type: ignore[arg-type] + secret = store.rotate_secret(subscription_id) + self._webhook_manager.reload() + return secret diff --git a/py_src/taskito/mixins/lifecycle.py b/py_src/taskito/mixins/lifecycle.py index 874d5539..9b912e73 100644 --- a/py_src/taskito/mixins/lifecycle.py +++ b/py_src/taskito/mixins/lifecycle.py @@ -16,6 +16,7 @@ import taskito from taskito._taskito import PyQueue, PyTaskConfig from taskito.context import _set_queue_ref +from taskito.dashboard.overrides_store import OverridesStore from taskito.events import EventType from taskito.log_config import configure as configure_logging from taskito.log_config import restore_asyncio_pipe_noise, silence_asyncio_pipe_noise @@ -231,7 +232,24 @@ def sighup_handler(signum: int, frame: Any) -> None: ) try: - queue_configs_json = json.dumps(self._queue_configs) if self._queue_configs else None + overrides = OverridesStore(self) # type: ignore[arg-type] + # Mutate the in-memory PyTaskConfig list so the Rust scheduler + # sees the override values; merge queue-level overrides into + # the JSON blob passed to run_worker. Paused tasks/queues get + # their pause state propagated to the existing paused_queues + # mechanism for tasks-by-queue, but per-task pause is left to + # the application-level guard in enqueue (out of scope here). + paused_tasks = overrides.apply_task_overrides(self._task_configs) + if paused_tasks: + logger.info("Paused task overrides in effect: %s", paused_tasks) + merged_queue_configs = overrides.apply_queue_overrides(self._queue_configs) + for queue_name, slot in merged_queue_configs.items(): + if slot.get("paused"): + try: + self.pause(queue_name) # type: ignore[attr-defined] + except Exception: + logger.exception("Failed to apply paused state for queue %s", queue_name) + queue_configs_json = json.dumps(merged_queue_configs) if merged_queue_configs else None self._inner.run_worker( task_registry=self._task_registry, task_configs=self._task_configs, diff --git a/py_src/taskito/mixins/middleware_admin.py b/py_src/taskito/mixins/middleware_admin.py new file mode 100644 index 00000000..dd9af800 --- /dev/null +++ b/py_src/taskito/mixins/middleware_admin.py @@ -0,0 +1,70 @@ +"""Middleware discovery and per-task disable management on :class:`Queue`.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from taskito.dashboard.middleware_store import MiddlewareDisableStore + +if TYPE_CHECKING: + from taskito.middleware import TaskMiddleware + + +class QueueMiddlewareAdminMixin: + """Discovery + per-task enable/disable for registered middlewares.""" + + _global_middleware: list[TaskMiddleware] + _task_middleware: dict[str, list[TaskMiddleware]] + + # ── Discovery ────────────────────────────────────────────────── + + def list_middleware(self) -> list[dict[str, Any]]: + """Return every registered middleware (global + per-task) with its + name, source ("global" or task name), and Python class path. The + ``name`` is the value the disable list keys on.""" + seen: dict[str, dict[str, Any]] = {} + for mw in self._global_middleware: + name = getattr(mw, "name", "") or f"{type(mw).__module__}.{type(mw).__qualname__}" + seen.setdefault( + name, + { + "name": name, + "class_path": f"{type(mw).__module__}.{type(mw).__qualname__}", + "scopes": [], + }, + )["scopes"].append({"kind": "global"}) + for task_name, mws in self._task_middleware.items(): + for mw in mws: + name = getattr(mw, "name", "") or f"{type(mw).__module__}.{type(mw).__qualname__}" + entry = seen.setdefault( + name, + { + "name": name, + "class_path": f"{type(mw).__module__}.{type(mw).__qualname__}", + "scopes": [], + }, + ) + entry["scopes"].append({"kind": "task", "task": task_name}) + return sorted(seen.values(), key=lambda x: x["name"]) + + # ── Disable management ───────────────────────────────────────── + + def list_middleware_disables(self) -> dict[str, list[str]]: + """Return every task that has at least one disabled middleware.""" + return MiddlewareDisableStore(self).list_all() # type: ignore[arg-type] + + def get_disabled_middleware_for(self, task_name: str) -> list[str]: + return MiddlewareDisableStore(self).get_for(task_name) # type: ignore[arg-type] + + def disable_middleware_for_task(self, task_name: str, mw_name: str) -> list[str]: + return MiddlewareDisableStore(self).set_disabled( # type: ignore[arg-type] + task_name, mw_name, disabled=True + ) + + def enable_middleware_for_task(self, task_name: str, mw_name: str) -> list[str]: + return MiddlewareDisableStore(self).set_disabled( # type: ignore[arg-type] + task_name, mw_name, disabled=False + ) + + def clear_middleware_disables(self, task_name: str) -> bool: + return MiddlewareDisableStore(self).clear_for(task_name) # type: ignore[arg-type] diff --git a/py_src/taskito/mixins/overrides.py b/py_src/taskito/mixins/overrides.py new file mode 100644 index 00000000..aae9aceb --- /dev/null +++ b/py_src/taskito/mixins/overrides.py @@ -0,0 +1,151 @@ +"""Task & queue runtime override management on :class:`taskito.app.Queue`. + +These knobs let operators tune retry policy, concurrency caps, rate +limits, timeouts, priority, and pause/resume state without touching +code. Overrides land in the dashboard settings store and apply on the +next worker startup. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from taskito.dashboard.overrides_store import ( + OverridesStore, + QueueOverride, + TaskOverride, +) + +if TYPE_CHECKING: + from taskito._taskito import PyTaskConfig + + +class QueueOverridesMixin: + """CRUD for task + queue overrides, plus a task-discovery API for the UI.""" + + _task_configs: list[PyTaskConfig] + _queue_configs: dict[str, dict[str, Any]] + + # ── Task overrides ───────────────────────────────────────────── + + def list_task_overrides(self) -> dict[str, TaskOverride]: + """Return every persisted task override keyed by task name.""" + return OverridesStore(self).list_tasks() # type: ignore[arg-type] + + def get_task_override(self, task_name: str) -> TaskOverride | None: + return OverridesStore(self).get_task(task_name) # type: ignore[arg-type] + + def set_task_override(self, task_name: str, **fields: Any) -> TaskOverride: + """Set or update an override. Pass ``None`` for a field to clear it. + + Allowed fields: ``rate_limit``, ``max_concurrent``, ``max_retries``, + ``retry_backoff``, ``timeout``, ``priority``, ``paused``. + """ + return OverridesStore(self).set_task(task_name, fields) # type: ignore[arg-type] + + def clear_task_override(self, task_name: str) -> bool: + return OverridesStore(self).clear_task(task_name) # type: ignore[arg-type] + + # ── Queue overrides ──────────────────────────────────────────── + + def list_queue_overrides(self) -> dict[str, QueueOverride]: + return OverridesStore(self).list_queues() # type: ignore[arg-type] + + def get_queue_override(self, queue_name: str) -> QueueOverride | None: + return OverridesStore(self).get_queue(queue_name) # type: ignore[arg-type] + + def set_queue_override(self, queue_name: str, **fields: Any) -> QueueOverride: + """Set or update a queue override. Allowed fields: ``rate_limit``, + ``max_concurrent``, ``paused``.""" + return OverridesStore(self).set_queue(queue_name, fields) # type: ignore[arg-type] + + def clear_queue_override(self, queue_name: str) -> bool: + return OverridesStore(self).clear_queue(queue_name) # type: ignore[arg-type] + + # ── Task discovery (for the dashboard) ───────────────────────── + + def registered_tasks(self) -> list[dict[str, Any]]: + """Return every registered task with its decorator defaults and any + active override. Each entry contains: + + - ``name``, ``queue``, ``priority`` + - ``defaults``: the decorator-declared values + - ``override``: the override fields (or ``None`` if no override exists) + - ``effective``: the values that will be used on the next worker start + """ + overrides = self.list_task_overrides() + out: list[dict[str, Any]] = [] + for config in self._task_configs: + defaults = { + "max_retries": config.max_retries, + "retry_backoff": config.retry_backoff, + "timeout": config.timeout, + "priority": config.priority, + "rate_limit": config.rate_limit, + "max_concurrent": config.max_concurrent, + } + override = overrides.get(config.name) + override_dict: dict[str, Any] | None + if override is None: + override_dict = None + effective = dict(defaults) + paused = False + else: + patch = override.as_patch() + override_dict = dict(patch) + if override.paused: + override_dict["paused"] = True + effective = {**defaults, **patch} + paused = override.paused + out.append( + { + "name": config.name, + "queue": config.queue, + "defaults": defaults, + "override": override_dict, + "effective": effective, + "paused": paused, + } + ) + return out + + def registered_queues(self) -> list[dict[str, Any]]: + """Return every queue mentioned by a task config plus any + configured-from-Python queue, with its current overrides + paused + state.""" + queue_names: set[str] = set() + queue_names.update(self._queue_configs.keys()) + for config in self._task_configs: + queue_names.add(config.queue) + overrides = self.list_queue_overrides() + paused_set = set( + self.paused_queues() # type: ignore[attr-defined] + ) + out: list[dict[str, Any]] = [] + for name in sorted(queue_names): + base = dict(self._queue_configs.get(name, {})) + override = overrides.get(name) + override_dict: dict[str, Any] | None + if override is None: + override_dict = None + effective = dict(base) + else: + patch: dict[str, Any] = {} + if override.rate_limit is not None: + patch["rate_limit"] = override.rate_limit + if override.max_concurrent is not None: + patch["max_concurrent"] = override.max_concurrent + override_dict = dict(patch) + if override.paused: + override_dict["paused"] = True + effective = {**base, **patch} + out.append( + { + "name": name, + "defaults": base, + "override": override_dict, + "effective": effective, + "paused": name in paused_set or (override.paused if override else False), + } + ) + return out diff --git a/py_src/taskito/webhooks.py b/py_src/taskito/webhooks.py index 9eb9109d..95daac18 100644 --- a/py_src/taskito/webhooks.py +++ b/py_src/taskito/webhooks.py @@ -1,4 +1,17 @@ -"""Webhook delivery for job events.""" +"""Webhook delivery for job events. + +The manager keeps an in-memory snapshot of the active subscriptions for +fast dispatch and rehydrates that snapshot from +:class:`~taskito.dashboard.webhook_store.WebhookSubscriptionStore` on +start (and on demand via :meth:`reload`). All add/update/delete writes +go through the DB-backed store so changes survive restarts and propagate +to every worker. + +In-memory subscriptions registered through the legacy +``add_webhook(url, ...)`` API continue to work but are not persisted — +that path is kept for backward compatibility with code that constructs +a ``Queue`` without a settings store yet (rare in practice). +""" from __future__ import annotations @@ -9,12 +22,18 @@ import queue import threading import time +import urllib.error import urllib.parse import urllib.request -from typing import Any +from typing import TYPE_CHECKING, Any +from taskito.dashboard.delivery_store import DeliveryStore from taskito.events import EventType +if TYPE_CHECKING: + from taskito.app import Queue + from taskito.dashboard.webhook_store import WebhookSubscription + logger = logging.getLogger("taskito.webhooks") @@ -22,14 +41,54 @@ class WebhookManager: """Delivers webhook POST requests for job events. Uses a background daemon thread with a queue for non-blocking delivery. - Each webhook is retried up to 3 times with exponential backoff. + Each webhook is retried up to its configured ``max_retries`` with + exponential backoff. """ - def __init__(self) -> None: + def __init__(self, queue_ref: Queue | None = None) -> None: + # ``queue_ref`` is the parent :class:`taskito.app.Queue`. Optional + # so legacy in-process tests can construct a bare manager. + self._queue: Queue | None = queue_ref + # In-memory subscription list. Each entry is a dict shaped like a + # legacy ``add_webhook`` call so both code paths share a single + # delivery loop. self._webhooks: list[dict[str, Any]] = [] - self._queue: queue.Queue[tuple[dict[str, Any], dict[str, Any]]] = queue.Queue() + self._delivery_queue: queue.Queue[tuple[dict[str, Any], dict[str, Any]]] = queue.Queue() self._thread: threading.Thread | None = None - self._thread_lock = threading.Lock() + self._lock = threading.Lock() + if queue_ref is not None: + self.reload() + + # ── Snapshot management ─────────────────────────────────────── + + def reload(self) -> None: + """Refresh the in-memory snapshot from the persistent store.""" + if self._queue is None: + return + from taskito.dashboard.webhook_store import WebhookSubscriptionStore + + store = WebhookSubscriptionStore(self._queue) + snapshot = [self._subscription_to_runtime(s) for s in store.list_all()] + with self._lock: + self._webhooks = snapshot + self._ensure_thread() + + @staticmethod + def _subscription_to_runtime(sub: WebhookSubscription) -> dict[str, Any]: + return { + "subscription_id": sub.id, + "url": sub.url, + "events": set(sub.events) if sub.events else None, + "task_filter": set(sub.task_filter) if sub.task_filter is not None else None, + "headers": dict(sub.headers), + "secret": sub.secret.encode() if sub.secret else None, + "max_retries": sub.max_retries, + "timeout": sub.timeout_seconds, + "retry_backoff": sub.retry_backoff, + "enabled": sub.enabled, + } + + # ── Public API (legacy + new) ───────────────────────────────── def add_webhook( self, @@ -41,44 +100,60 @@ def add_webhook( timeout: float = 10.0, retry_backoff: float = 2.0, ) -> None: - """Register a webhook endpoint. - - Args: - url: URL to POST event payloads to. - events: List of event types to subscribe to. None means all events. - headers: Extra HTTP headers to include. - secret: HMAC-SHA256 signing secret for the ``X-Taskito-Signature`` header. - max_retries: Maximum delivery attempts (default 3). - timeout: HTTP request timeout in seconds (default 10.0). - retry_backoff: Base for exponential backoff between retries (default 2.0). + """Register a webhook endpoint (in-memory; not persisted). + + Prefer :meth:`Queue.create_webhook` for new code — it persists + through the dashboard-managed store and survives restarts. """ parsed = urllib.parse.urlparse(url) if parsed.scheme not in ("http", "https"): raise ValueError(f"Webhook URL must use http:// or https://, got {parsed.scheme!r}") - with self._thread_lock: + with self._lock: self._webhooks.append( { + "subscription_id": None, "url": url, "events": {e.value for e in events} if events else None, + "task_filter": None, "headers": headers or {}, "secret": secret.encode() if secret else None, "max_retries": max_retries, "timeout": timeout, "retry_backoff": retry_backoff, + "enabled": True, } ) self._ensure_thread() def notify(self, event_type: EventType, payload: dict[str, Any]) -> None: """Queue an event for delivery to matching webhooks.""" - with self._thread_lock: + with self._lock: webhooks = list(self._webhooks) + task_name = payload.get("task_name") + wire_event = event_type.value for wh in webhooks: - if wh["events"] is None or event_type.value in wh["events"]: - self._queue.put((wh, {"event": event_type.value, **payload})) + if not wh.get("enabled", True): + continue + if wh["events"] is not None and wire_event not in wh["events"]: + continue + task_filter = wh.get("task_filter") + if task_filter is not None and task_name not in task_filter: + continue + self._delivery_queue.put((wh, {"event": wire_event, **payload})) + + def deliver_now(self, wh: dict[str, Any], payload: dict[str, Any]) -> int | None: + """Synchronously deliver one payload. Returns the final HTTP status or + ``None`` if every attempt failed at the transport level. + + Used by the dashboard "send test event" endpoint so the operator + sees the result inline. Does NOT add to the retry queue. + """ + return self._send(wh, payload, write_to_log=False) + + # ── Delivery loop ───────────────────────────────────────────── def _ensure_thread(self) -> None: - with self._thread_lock: + with self._lock: if self._thread is None or not self._thread.is_alive(): self._thread = threading.Thread( target=self._deliver_loop, daemon=True, name="taskito-webhooks" @@ -88,14 +163,25 @@ def _ensure_thread(self) -> None: def _deliver_loop(self) -> None: while True: try: - wh, payload = self._queue.get(timeout=10) + wh, payload = self._delivery_queue.get(timeout=10) self._send(wh, payload) except queue.Empty: continue except Exception: logger.exception("Webhook delivery error") - def _send(self, wh: dict[str, Any], payload: dict[str, Any]) -> None: + def _send( + self, wh: dict[str, Any], payload: dict[str, Any], *, write_to_log: bool = True + ) -> int | None: + """Deliver ``payload`` to ``wh`` with retries. Returns the last HTTP + status code observed (after retries) or ``None`` if every attempt + failed at the transport level. + + When ``write_to_log`` is true AND the subscription is persisted + (``wh["subscription_id"]`` is not ``None``), a record of the final + outcome is appended to the delivery log so the dashboard can + replay it later. + """ body = json.dumps(payload, default=str).encode("utf-8") headers: dict[str, str] = { @@ -111,25 +197,134 @@ def _send(self, wh: dict[str, Any], payload: dict[str, Any]) -> None: timeout: float = wh.get("timeout", 10.0) retry_backoff: float = wh.get("retry_backoff", 2.0) + last_status: int | None = None + last_response_body: str | None = None + last_error: str | None = None + started_at = time.monotonic() + attempt_count = 0 + for attempt in range(max_retries): + attempt_count = attempt + 1 try: req = urllib.request.Request(wh["url"], data=body, headers=headers, method="POST") with urllib.request.urlopen(req, timeout=timeout) as resp: - if resp.status < 400: - return - if resp.status < 500: + last_status = int(resp.status) + last_response_body = self._read_response_body(resp) + if last_status < 400: + self._record( + wh, + payload, + status="delivered", + attempts=attempt_count, + response_code=last_status, + response_body=last_response_body, + latency_ms=int((time.monotonic() - started_at) * 1000), + write_to_log=write_to_log, + ) + return last_status + if write_to_log: + logger.warning( + "Webhook %s returned server error %d", wh["url"], resp.status + ) + except urllib.error.HTTPError as e: + last_status = e.code + last_response_body = self._read_response_body(e) + if e.code < 500: + if write_to_log: logger.warning( "Webhook %s returned client error %d, not retrying", wh["url"], - resp.status, + e.code, ) - return - logger.warning("Webhook %s returned server error %d", wh["url"], resp.status) - except Exception: - logger.debug("Webhook %s attempt %d failed", wh["url"], attempt + 1, exc_info=True) + self._record( + wh, + payload, + status="failed", + attempts=attempt_count, + response_code=last_status, + response_body=last_response_body, + latency_ms=int((time.monotonic() - started_at) * 1000), + write_to_log=write_to_log, + ) + return e.code + if write_to_log: + logger.warning("Webhook %s returned server error %d", wh["url"], e.code) + except Exception as e: + last_error = f"{type(e).__name__}: {e}" + if write_to_log: + logger.debug( + "Webhook %s attempt %d failed", + wh["url"], + attempt + 1, + exc_info=True, + ) if attempt == max_retries - 1: - logger.warning( - "Webhook delivery failed after %d attempts: %s", max_retries, wh["url"] - ) + if write_to_log: + logger.warning( + "Webhook delivery failed after %d attempts: %s", + max_retries, + wh["url"], + ) else: time.sleep(retry_backoff**attempt) + + # Out of retries — record as dead. + self._record( + wh, + payload, + status="dead", + attempts=attempt_count, + response_code=last_status, + response_body=last_response_body, + latency_ms=int((time.monotonic() - started_at) * 1000), + error=last_error, + write_to_log=write_to_log, + ) + return last_status + + def _record( + self, + wh: dict[str, Any], + payload: dict[str, Any], + *, + status: str, + attempts: int, + response_code: int | None = None, + response_body: str | None = None, + latency_ms: int | None = None, + error: str | None = None, + write_to_log: bool = True, + ) -> None: + """Persist a delivery outcome to the dashboard log.""" + if not write_to_log: + return + subscription_id = wh.get("subscription_id") + if not subscription_id or self._queue is None: + return + try: + DeliveryStore(self._queue).record_attempt( + subscription_id, + event=str(payload.get("event", "")), + payload=payload, + status=status, + attempts=attempts, + response_code=response_code, + response_body=response_body, + latency_ms=latency_ms, + error=error, + task_name=payload.get("task_name"), + job_id=payload.get("job_id"), + ) + except Exception: + logger.exception("Failed to record webhook delivery") + + @staticmethod + def _read_response_body(resp: Any) -> str | None: + """Read up to a few KiB from a response/HTTPError object.""" + try: + data = resp.read(4096) # limit even before truncation in DeliveryStore + except Exception: + return None + if not data: + return None + return str(data.decode("utf-8", errors="replace")) diff --git a/tests/dashboard/test_middleware_toggles.py b/tests/dashboard/test_middleware_toggles.py new file mode 100644 index 00000000..6713f098 --- /dev/null +++ b/tests/dashboard/test_middleware_toggles.py @@ -0,0 +1,234 @@ +"""Tests for per-task middleware enable/disable from the dashboard.""" + +from __future__ import annotations + +import threading +import urllib.error +from collections.abc import Generator +from http.server import ThreadingHTTPServer +from pathlib import Path +from typing import Any + +import pytest + +from taskito import Queue +from taskito.context import JobContext +from taskito.dashboard import _make_handler +from taskito.dashboard._testing import AuthedClient, seed_admin_and_session +from taskito.dashboard.middleware_store import MiddlewareDisableStore +from taskito.middleware import TaskMiddleware + + +class RecordingMiddleware(TaskMiddleware): + """Captures every ``before`` invocation so the test can assert which + tasks the middleware fired for.""" + + name = "test.recording" + + def __init__(self) -> None: + super().__init__() + self.invocations: list[str] = [] + + def before(self, ctx: JobContext) -> None: + self.invocations.append(ctx.task_name) + + +class OtherMiddleware(TaskMiddleware): + name = "test.other" + + def __init__(self) -> None: + super().__init__() + self.invocations: list[str] = [] + + def before(self, ctx: JobContext) -> None: + self.invocations.append(ctx.task_name) + + +@pytest.fixture +def middleware_pair() -> tuple[RecordingMiddleware, OtherMiddleware]: + return RecordingMiddleware(), OtherMiddleware() + + +@pytest.fixture +def queue(tmp_path: Path, middleware_pair: tuple[RecordingMiddleware, OtherMiddleware]) -> Queue: + rec, other = middleware_pair + q = Queue(db_path=str(tmp_path / "mw.db"), middleware=[rec, other]) + + @q.task() + def alpha() -> str: + return "a" + + @q.task() + def beta() -> str: + return "b" + + return q + + +@pytest.fixture +def dashboard(queue: Queue) -> Generator[tuple[AuthedClient, Queue]]: + handler = _make_handler(queue) + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + session = seed_admin_and_session(queue) + client = AuthedClient(base=f"http://127.0.0.1:{server.server_address[1]}", session=session) + try: + yield client, queue + finally: + server.shutdown() + + +# ── Store ────────────────────────────────────────────────────────────── + + +def test_store_starts_empty(queue: Queue) -> None: + store = MiddlewareDisableStore(queue) + assert store.list_all() == {} + assert store.get_for("alpha") == [] + + +def test_set_disabled_adds_and_removes(queue: Queue) -> None: + store = MiddlewareDisableStore(queue) + store.set_disabled("alpha", "test.other", True) + assert store.get_for("alpha") == ["test.other"] + # Idempotent — same disable twice still has just one entry. + store.set_disabled("alpha", "test.other", True) + assert store.get_for("alpha") == ["test.other"] + # Re-enable clears just that one. + store.set_disabled("alpha", "test.other", False) + assert store.get_for("alpha") == [] + + +def test_clear_for_drops_setting_key(queue: Queue) -> None: + store = MiddlewareDisableStore(queue) + store.set_disabled("alpha", "test.other", True) + assert store.clear_for("alpha") is True + assert store.clear_for("alpha") is False + assert store.get_for("alpha") == [] + + +# ── Wiring into the middleware chain ────────────────────────────────── + + +def test_chain_skips_disabled_middleware(queue: Queue) -> None: + """``_get_middleware_chain`` returns a chain that respects the disable + list at lookup time — no worker restart required.""" + full = queue._get_middleware_chain("alpha") + assert {mw.name for mw in full} == {"test.recording", "test.other"} + queue.disable_middleware_for_task("alpha", "test.other") + filtered = queue._get_middleware_chain("alpha") + assert {mw.name for mw in filtered} == {"test.recording"} + # Other tasks unaffected. + assert {mw.name for mw in queue._get_middleware_chain("beta")} == { + "test.recording", + "test.other", + } + + +def test_clear_re_enables_all(queue: Queue) -> None: + queue.disable_middleware_for_task("alpha", "test.other") + queue.disable_middleware_for_task("alpha", "test.recording") + assert queue._get_middleware_chain("alpha") == [] + queue.clear_middleware_disables("alpha") + assert len(queue._get_middleware_chain("alpha")) == 2 + + +# ── Discovery ───────────────────────────────────────────────────────── + + +def test_list_middleware_reports_globals(queue: Queue) -> None: + items = queue.list_middleware() + names = {item["name"] for item in items} + assert {"test.recording", "test.other"} <= names + for entry in items: + assert any(scope["kind"] == "global" for scope in entry["scopes"]) + + +# ── HTTP endpoints ──────────────────────────────────────────────────── + + +def test_list_middleware_endpoint(dashboard: tuple[AuthedClient, Queue]) -> None: + client, _ = dashboard + items = client.get("/api/middleware") + names = {item["name"] for item in items} + assert {"test.recording", "test.other"} <= names + + +def test_get_task_middleware_endpoint(dashboard: tuple[AuthedClient, Queue]) -> None: + client, _ = dashboard + result = client.get("/api/tasks/alpha/middleware") + by_name = {entry["name"]: entry for entry in result["middleware"]} + assert by_name["test.recording"]["disabled"] is False + assert by_name["test.recording"]["effective"] is True + + +def test_put_task_middleware_disables(dashboard: tuple[AuthedClient, Queue]) -> None: + client, queue = dashboard + name = next(c.name for c in queue._task_configs if c.name.endswith("alpha")) + result = client.put(f"/api/tasks/{name}/middleware/test.other", {"enabled": False}) + assert "test.other" in result["disabled"] + # Reflected in the chain. + chain_names = {mw.name for mw in queue._get_middleware_chain(name)} + assert "test.other" not in chain_names + # Re-enabling clears it. + client.put(f"/api/tasks/{name}/middleware/test.other", {"enabled": True}) + chain_names = {mw.name for mw in queue._get_middleware_chain(name)} + assert "test.other" in chain_names + + +def test_put_task_middleware_rejects_unknown_middleware( + dashboard: tuple[AuthedClient, Queue], +) -> None: + client, queue = dashboard + name = next(c.name for c in queue._task_configs if c.name.endswith("alpha")) + with pytest.raises(urllib.error.HTTPError) as exc_info: + client.put(f"/api/tasks/{name}/middleware/not.a.real.mw", {"enabled": False}) + assert exc_info.value.code == 404 + + +def test_put_task_middleware_rejects_bad_body( + dashboard: tuple[AuthedClient, Queue], +) -> None: + client, queue = dashboard + name = next(c.name for c in queue._task_configs if c.name.endswith("alpha")) + with pytest.raises(urllib.error.HTTPError) as exc_info: + client.put(f"/api/tasks/{name}/middleware/test.other", {"enabled": "yes"}) + assert exc_info.value.code == 400 + + +def test_delete_task_middleware_clears_all( + dashboard: tuple[AuthedClient, Queue], +) -> None: + client, queue = dashboard + name = next(c.name for c in queue._task_configs if c.name.endswith("alpha")) + client.put(f"/api/tasks/{name}/middleware/test.other", {"enabled": False}) + client.put(f"/api/tasks/{name}/middleware/test.recording", {"enabled": False}) + assert queue._get_middleware_chain(name) == [] + result = client.delete(f"/api/tasks/{name}/middleware") + assert result == {"cleared": True} + assert len(queue._get_middleware_chain(name)) == 2 + + +# ── End-to-end: disabled middleware doesn't fire ───────────────────── + + +def test_disabled_middleware_does_not_fire( + queue: Queue, + middleware_pair: tuple[RecordingMiddleware, OtherMiddleware], + poll_until: Any, +) -> None: + rec, other = middleware_pair + alpha_name = next(c.name for c in queue._task_configs if c.name.endswith("alpha")) + queue.disable_middleware_for_task(alpha_name, "test.other") + + thread = threading.Thread(target=queue.run_worker, daemon=True) + thread.start() + try: + queue.enqueue(alpha_name) + poll_until(lambda: alpha_name in rec.invocations, message="task didn't run") + finally: + queue._inner.request_shutdown() + thread.join(timeout=5) + + assert alpha_name in rec.invocations # global fired + assert alpha_name not in other.invocations # disabled for this task diff --git a/tests/dashboard/test_task_overrides.py b/tests/dashboard/test_task_overrides.py new file mode 100644 index 00000000..ba01e2f9 --- /dev/null +++ b/tests/dashboard/test_task_overrides.py @@ -0,0 +1,234 @@ +"""Tests for task & queue runtime overrides.""" + +from __future__ import annotations + +import threading +import urllib.error +from collections.abc import Generator +from http.server import ThreadingHTTPServer +from pathlib import Path + +import pytest + +from taskito import Queue +from taskito.dashboard import _make_handler +from taskito.dashboard._testing import AuthedClient, seed_admin_and_session +from taskito.dashboard.overrides_store import OverridesStore + + +@pytest.fixture +def queue(tmp_path: Path) -> Queue: + q = Queue(db_path=str(tmp_path / "overrides.db")) + + @q.task(queue="default", max_retries=3, timeout=300) + def send_email(to: str) -> str: + return to + + @q.task(queue="email", max_retries=5, rate_limit="100/m", max_concurrent=10) + def deliver(message: str) -> str: + return message + + return q + + +@pytest.fixture +def dashboard(queue: Queue) -> Generator[tuple[AuthedClient, Queue]]: + handler = _make_handler(queue) + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + session = seed_admin_and_session(queue) + client = AuthedClient(base=f"http://127.0.0.1:{server.server_address[1]}", session=session) + try: + yield client, queue + finally: + server.shutdown() + + +# ── Store ────────────────────────────────────────────────────────────── + + +def test_overrides_store_starts_empty(queue: Queue) -> None: + store = OverridesStore(queue) + assert store.list_tasks() == {} + assert store.list_queues() == {} + + +def test_set_task_override_persists(queue: Queue) -> None: + store = OverridesStore(queue) + override = store.set_task("foo", {"max_retries": 7, "rate_limit": "50/s"}) + assert override.max_retries == 7 + assert override.rate_limit == "50/s" + fetched = store.get_task("foo") + assert fetched is not None and fetched.max_retries == 7 + + +def test_set_task_override_validates(queue: Queue) -> None: + store = OverridesStore(queue) + with pytest.raises(ValueError, match="rate_limit"): + store.set_task("foo", {"rate_limit": "no-slash"}) + with pytest.raises(ValueError, match="max_concurrent"): + store.set_task("foo", {"max_concurrent": -1}) + with pytest.raises(ValueError, match="unknown task override"): + store.set_task("foo", {"not_a_field": 1}) + + +def test_set_task_override_merges_with_existing(queue: Queue) -> None: + store = OverridesStore(queue) + store.set_task("foo", {"max_retries": 7}) + store.set_task("foo", {"rate_limit": "50/s"}) + merged = store.get_task("foo") + assert merged is not None + assert merged.max_retries == 7 + assert merged.rate_limit == "50/s" + + +def test_set_task_override_clears_field_with_none(queue: Queue) -> None: + store = OverridesStore(queue) + store.set_task("foo", {"max_retries": 7, "rate_limit": "50/s"}) + store.set_task("foo", {"max_retries": None}) + fetched = store.get_task("foo") + assert fetched is not None + assert fetched.max_retries is None + assert fetched.rate_limit == "50/s" + + +def test_clear_task_override(queue: Queue) -> None: + store = OverridesStore(queue) + store.set_task("foo", {"max_retries": 7}) + assert store.clear_task("foo") is True + assert store.clear_task("foo") is False + assert store.get_task("foo") is None + + +def test_queue_override_basics(queue: Queue) -> None: + store = OverridesStore(queue) + store.set_queue("default", {"max_concurrent": 5, "paused": True}) + fetched = store.get_queue("default") + assert fetched is not None + assert fetched.max_concurrent == 5 + assert fetched.paused is True + + +def test_apply_task_overrides_mutates_configs(queue: Queue) -> None: + """Mutating the in-memory PyTaskConfig is what makes overrides reach the + Rust scheduler at worker start.""" + store = OverridesStore(queue) + send_email = next(c for c in queue._task_configs if "send_email" in c.name) + store.set_task(send_email.name, {"max_retries": 99, "rate_limit": "1/s"}) + store.apply_task_overrides(queue._task_configs) + assert send_email.max_retries == 99 + assert send_email.rate_limit == "1/s" + + +def test_apply_task_overrides_reports_paused(queue: Queue) -> None: + store = OverridesStore(queue) + send_email = next(c for c in queue._task_configs if "send_email" in c.name) + store.set_task(send_email.name, {"paused": True}) + paused = store.apply_task_overrides(queue._task_configs) + assert send_email.name in paused + + +def test_apply_queue_overrides_merges(queue: Queue) -> None: + store = OverridesStore(queue) + queue.set_queue_concurrency("email", 10) # configured-from-Python + store.set_queue("email", {"rate_limit": "200/m"}) + merged = store.apply_queue_overrides(queue._queue_configs) + assert merged["email"]["max_concurrent"] == 10 # decorator-set survives + assert merged["email"]["rate_limit"] == "200/m" # override wins + + +# ── Queue.registered_tasks() ────────────────────────────────────────── + + +def test_registered_tasks_lists_defaults_and_overrides(queue: Queue) -> None: + tasks = queue.registered_tasks() + assert len(tasks) == 2 + by_name = {t["name"]: t for t in tasks} + deliver = next(t for n, t in by_name.items() if "deliver" in n) + assert deliver["defaults"]["rate_limit"] == "100/m" + assert deliver["defaults"]["max_retries"] == 5 + assert deliver["override"] is None + assert deliver["effective"]["rate_limit"] == "100/m" + + +def test_registered_tasks_reflects_override(queue: Queue) -> None: + send_email = next(t for t in queue.registered_tasks() if "send_email" in t["name"]) + queue.set_task_override(send_email["name"], max_retries=99) + fresh = next(t for t in queue.registered_tasks() if t["name"] == send_email["name"]) + assert fresh["override"] == {"max_retries": 99} + assert fresh["effective"]["max_retries"] == 99 + assert fresh["defaults"]["max_retries"] == 3 # original decorator value + + +# ── HTTP endpoints ──────────────────────────────────────────────────── + + +def test_list_tasks_endpoint(dashboard: tuple[AuthedClient, Queue]) -> None: + client, _ = dashboard + tasks = client.get("/api/tasks") + assert len(tasks) == 2 + for entry in tasks: + assert "name" in entry and "defaults" in entry and "effective" in entry + + +def test_put_task_override(dashboard: tuple[AuthedClient, Queue]) -> None: + client, queue = dashboard + name = next(c.name for c in queue._task_configs if "send_email" in c.name) + result = client.put( + f"/api/tasks/{name}/override", + {"max_retries": 7, "rate_limit": "50/s"}, + ) + assert result["max_retries"] == 7 + assert result["rate_limit"] == "50/s" + + fetched = client.get(f"/api/tasks/{name}/override") + assert fetched["max_retries"] == 7 + + +def test_put_task_override_rejects_unknown_field( + dashboard: tuple[AuthedClient, Queue], +) -> None: + client, queue = dashboard + name = next(c.name for c in queue._task_configs if "send_email" in c.name) + with pytest.raises(urllib.error.HTTPError) as exc_info: + client.put(f"/api/tasks/{name}/override", {"made_up": 1}) + assert exc_info.value.code == 400 + + +def test_delete_task_override(dashboard: tuple[AuthedClient, Queue]) -> None: + client, queue = dashboard + name = next(c.name for c in queue._task_configs if "send_email" in c.name) + client.put(f"/api/tasks/{name}/override", {"max_retries": 7}) + assert client.delete(f"/api/tasks/{name}/override") == {"cleared": True} + assert client.delete(f"/api/tasks/{name}/override") == {"cleared": False} + + +def test_get_task_override_404_when_none(dashboard: tuple[AuthedClient, Queue]) -> None: + client, _ = dashboard + with pytest.raises(urllib.error.HTTPError) as exc_info: + client.get("/api/tasks/nonexistent/override") + assert exc_info.value.code == 404 + + +def test_put_queue_override_pauses_queue(dashboard: tuple[AuthedClient, Queue]) -> None: + """Pausing via queue override must also update the live paused_queues + state so a running worker stops dequeueing immediately.""" + client, queue = dashboard + client.put("/api/queues/email/override", {"paused": True}) + assert "email" in queue.paused_queues() + client.put("/api/queues/email/override", {"paused": False}) + assert "email" not in queue.paused_queues() + + +def test_list_queues_endpoint(dashboard: tuple[AuthedClient, Queue]) -> None: + client, _ = dashboard + queues = client.get("/api/queues") + names = {q["name"] for q in queues} + assert {"default", "email"} <= names + + +def test_put_queue_override_validates(dashboard: tuple[AuthedClient, Queue]) -> None: + client, _ = dashboard + with pytest.raises(urllib.error.HTTPError) as exc_info: + client.put("/api/queues/default/override", {"max_concurrent": -1}) + assert exc_info.value.code == 400 diff --git a/tests/dashboard/test_webhook_deliveries.py b/tests/dashboard/test_webhook_deliveries.py new file mode 100644 index 00000000..16f5e83c --- /dev/null +++ b/tests/dashboard/test_webhook_deliveries.py @@ -0,0 +1,303 @@ +"""Tests for the webhook delivery log + replay endpoints.""" + +from __future__ import annotations + +import json +import threading +import urllib.error +import urllib.request +from collections.abc import Generator +from http.server import BaseHTTPRequestHandler, HTTPServer, ThreadingHTTPServer +from pathlib import Path +from typing import Any + +import pytest + +from taskito import Queue +from taskito.dashboard import _make_handler +from taskito.dashboard._testing import AuthedClient, seed_admin_and_session +from taskito.dashboard.delivery_store import DeliveryStore +from taskito.events import EventType + + +@pytest.fixture +def queue(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Queue: + monkeypatch.setenv("TASKITO_WEBHOOKS_ALLOW_PRIVATE", "1") + return Queue(db_path=str(tmp_path / "deliveries.db")) + + +@pytest.fixture +def echo_server() -> Generator[tuple[str, list[dict[str, Any]]]]: + """A local server that captures the bodies it receives.""" + received: list[dict[str, Any]] = [] + + class Handler(BaseHTTPRequestHandler): + def do_POST(self) -> None: + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length) + received.append({"body": json.loads(body), "headers": dict(self.headers)}) + self.send_response(200) + self.end_headers() + self.wfile.write(b"ok") + + def log_message(self, *args: Any) -> None: + pass + + server = HTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + try: + yield f"http://127.0.0.1:{server.server_address[1]}", received + finally: + server.shutdown() + + +@pytest.fixture +def fail_server() -> Generator[str]: + """Always returns 500 to exercise the dead-letter path.""" + + class Handler(BaseHTTPRequestHandler): + def do_POST(self) -> None: + self.send_response(500) + self.end_headers() + self.wfile.write(b"server error") + + def log_message(self, *args: Any) -> None: + pass + + server = HTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + try: + yield f"http://127.0.0.1:{server.server_address[1]}" + finally: + server.shutdown() + + +@pytest.fixture +def dashboard(queue: Queue) -> Generator[tuple[AuthedClient, Queue]]: + handler = _make_handler(queue) + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + session = seed_admin_and_session(queue) + client = AuthedClient(base=f"http://127.0.0.1:{server.server_address[1]}", session=session) + try: + yield client, queue + finally: + server.shutdown() + + +# ── DeliveryStore ────────────────────────────────────────────────────── + + +def test_delivery_store_starts_empty(queue: Queue) -> None: + store = DeliveryStore(queue) + assert store.list_for("missing") == [] + assert store.count_for("missing") == 0 + + +def test_record_attempt_appends(queue: Queue) -> None: + store = DeliveryStore(queue) + record = store.record_attempt( + "sub1", + event="job.completed", + payload={"job_id": "x"}, + status="delivered", + attempts=1, + response_code=200, + latency_ms=10, + ) + assert record.subscription_id == "sub1" + assert record.status == "delivered" + assert store.count_for("sub1") == 1 + listed = store.list_for("sub1") + assert len(listed) == 1 + assert listed[0].id == record.id + + +def test_record_attempt_caps_history(queue: Queue) -> None: + store = DeliveryStore(queue, max_per_webhook=3) + for i in range(5): + store.record_attempt( + "sub1", + event="job.completed", + payload={"job_id": str(i)}, + status="delivered", + attempts=1, + ) + items = store.list_for("sub1") + assert len(items) == 3 + # Newest first; oldest (i=0, i=1) evicted. + assert items[0].payload["job_id"] == "4" + assert items[-1].payload["job_id"] == "2" + + +def test_record_attempt_truncates_response_body(queue: Queue) -> None: + store = DeliveryStore(queue) + big = "x" * 100_000 + record = store.record_attempt( + "sub1", + event="job.completed", + payload={}, + status="failed", + attempts=1, + response_body=big, + ) + assert record.response_body is not None + assert len(record.response_body.encode("utf-8")) <= 2048 + 4 # +ellipsis + + +def test_list_for_filters_by_status_and_event(queue: Queue) -> None: + store = DeliveryStore(queue) + store.record_attempt("sub1", event="job.completed", payload={}, status="delivered", attempts=1) + store.record_attempt("sub1", event="job.failed", payload={}, status="failed", attempts=1) + store.record_attempt("sub1", event="job.completed", payload={}, status="failed", attempts=1) + + delivered = store.list_for("sub1", status="delivered") + assert len(delivered) == 1 + failed = store.list_for("sub1", status="failed") + assert len(failed) == 2 + completed_event = store.list_for("sub1", event="job.completed") + assert len(completed_event) == 2 + + +# ── End-to-end delivery recording ────────────────────────────────────── + + +def test_successful_delivery_recorded( + queue: Queue, echo_server: tuple[str, list[dict[str, Any]]], poll_until: Any +) -> None: + url, _ = echo_server + sub = queue.add_webhook(url, events=[EventType.JOB_COMPLETED]) + queue._webhook_manager.notify(EventType.JOB_COMPLETED, {"job_id": "abc"}) + poll_until( + lambda: DeliveryStore(queue).count_for(sub.id) >= 1, + message="delivery not recorded", + ) + items = DeliveryStore(queue).list_for(sub.id) + assert len(items) == 1 + assert items[0].status == "delivered" + assert items[0].response_code == 200 + assert items[0].latency_ms is not None + + +def test_failed_delivery_marked_dead(queue: Queue, fail_server: str, poll_until: Any) -> None: + sub = queue.add_webhook(fail_server, events=[EventType.JOB_FAILED], max_retries=2) + queue._webhook_manager.notify(EventType.JOB_FAILED, {"job_id": "x", "error": "boom"}) + poll_until( + lambda: DeliveryStore(queue).count_for(sub.id) >= 1, + message="delivery never recorded", + ) + items = DeliveryStore(queue).list_for(sub.id) + assert len(items) == 1 + assert items[0].status == "dead" + assert items[0].attempts == 2 + assert items[0].response_code == 500 + + +# ── Dashboard endpoints ──────────────────────────────────────────────── + + +def test_list_deliveries_endpoint( + dashboard: tuple[AuthedClient, Queue], + echo_server: tuple[str, list[dict[str, Any]]], + poll_until: Any, +) -> None: + client, queue = dashboard + url, _ = echo_server + sub = queue.add_webhook(url, events=[EventType.JOB_COMPLETED]) + queue._webhook_manager.notify(EventType.JOB_COMPLETED, {"job_id": "1"}) + poll_until(lambda: DeliveryStore(queue).count_for(sub.id) >= 1) + + page = client.get(f"/api/webhooks/{sub.id}/deliveries") + assert page["total"] == 1 + assert page["items"][0]["status"] == "delivered" + + +def test_list_deliveries_filters_by_status( + dashboard: tuple[AuthedClient, Queue], fail_server: str, poll_until: Any +) -> None: + client, queue = dashboard + sub = queue.add_webhook(fail_server, max_retries=1) + queue._webhook_manager.notify(EventType.JOB_COMPLETED, {"job_id": "1"}) + poll_until(lambda: DeliveryStore(queue).count_for(sub.id) >= 1) + + only_failed = client.get(f"/api/webhooks/{sub.id}/deliveries?status=dead") + assert only_failed["total"] >= 1 + assert all(r["status"] == "dead" for r in only_failed["items"]) + + delivered = client.get(f"/api/webhooks/{sub.id}/deliveries?status=delivered") + assert delivered["items"] == [] + + +def test_get_delivery_endpoint( + dashboard: tuple[AuthedClient, Queue], + echo_server: tuple[str, list[dict[str, Any]]], + poll_until: Any, +) -> None: + client, queue = dashboard + url, _ = echo_server + sub = queue.add_webhook(url) + queue._webhook_manager.notify(EventType.JOB_COMPLETED, {"job_id": "x"}) + poll_until(lambda: DeliveryStore(queue).count_for(sub.id) >= 1) + record_id = DeliveryStore(queue).list_for(sub.id)[0].id + + record = client.get(f"/api/webhooks/{sub.id}/deliveries/{record_id}") + assert record["id"] == record_id + assert record["status"] == "delivered" + + +def test_replay_delivery_endpoint( + dashboard: tuple[AuthedClient, Queue], + echo_server: tuple[str, list[dict[str, Any]]], + poll_until: Any, +) -> None: + client, queue = dashboard + url, received = echo_server + sub = queue.add_webhook(url) + queue._webhook_manager.notify(EventType.JOB_COMPLETED, {"job_id": "x"}) + poll_until(lambda: len(received) >= 1) + poll_until(lambda: DeliveryStore(queue).count_for(sub.id) >= 1) + delivery_id = DeliveryStore(queue).list_for(sub.id)[0].id + + result = client.post(f"/api/webhooks/{sub.id}/deliveries/{delivery_id}/replay") + assert result["delivered"] is True + assert result["status"] == 200 + assert result["replayed_of"] == delivery_id + + # Replay produces a NEW delivery record AND a new POST. + poll_until(lambda: len(received) >= 2) + poll_until(lambda: DeliveryStore(queue).count_for(sub.id) >= 2) + items = DeliveryStore(queue).list_for(sub.id) + assert any(r.payload.get("replay_of") == delivery_id for r in items) + + +def test_list_deliveries_404_for_unknown_subscription( + dashboard: tuple[AuthedClient, Queue], +) -> None: + client, _ = dashboard + with pytest.raises(urllib.error.HTTPError) as exc_info: + client.get("/api/webhooks/nope/deliveries") + assert exc_info.value.code == 404 + + +def test_get_delivery_404_when_missing( + dashboard: tuple[AuthedClient, Queue], + echo_server: tuple[str, list[dict[str, Any]]], +) -> None: + client, queue = dashboard + url, _ = echo_server + sub = queue.add_webhook(url) + with pytest.raises(urllib.error.HTTPError) as exc_info: + client.get(f"/api/webhooks/{sub.id}/deliveries/nonexistent") + assert exc_info.value.code == 404 + + +def test_list_deliveries_rejects_bad_status( + dashboard: tuple[AuthedClient, Queue], + echo_server: tuple[str, list[dict[str, Any]]], +) -> None: + client, queue = dashboard + url, _ = echo_server + sub = queue.add_webhook(url) + with pytest.raises(urllib.error.HTTPError) as exc_info: + client.get(f"/api/webhooks/{sub.id}/deliveries?status=not-real") + assert exc_info.value.code == 400 diff --git a/tests/dashboard/test_webhooks_endpoints.py b/tests/dashboard/test_webhooks_endpoints.py new file mode 100644 index 00000000..a28cb7fc --- /dev/null +++ b/tests/dashboard/test_webhooks_endpoints.py @@ -0,0 +1,378 @@ +"""Tests for the persistent webhook subscription store + dashboard CRUD endpoints.""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import threading +import urllib.error +import urllib.request +from collections.abc import Generator +from http.server import BaseHTTPRequestHandler, HTTPServer, ThreadingHTTPServer +from pathlib import Path +from typing import Any + +import pytest + +from taskito import Queue +from taskito.dashboard import _make_handler +from taskito.dashboard._testing import AuthedClient, seed_admin_and_session +from taskito.dashboard.url_safety import UnsafeWebhookUrl, validate_webhook_url +from taskito.dashboard.webhook_store import WebhookSubscriptionStore +from taskito.events import EventType + +# ── Fixtures ─────────────────────────────────────────────────────────── + + +@pytest.fixture +def queue(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Queue: + # Tests in this file create webhooks against 127.0.0.1 servers, which the + # SSRF guard would otherwise reject. + monkeypatch.setenv("TASKITO_WEBHOOKS_ALLOW_PRIVATE", "1") + return Queue(db_path=str(tmp_path / "webhooks.db")) + + +@pytest.fixture +def echo_server() -> Generator[tuple[str, list[dict[str, Any]]]]: + """A local HTTP server that captures incoming webhook bodies.""" + received: list[dict[str, Any]] = [] + + class Handler(BaseHTTPRequestHandler): + def do_POST(self) -> None: + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length) + received.append({"body": json.loads(body), "headers": dict(self.headers)}) + self.send_response(200) + self.end_headers() + + def log_message(self, *args: Any) -> None: + pass + + server = HTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_address[1]}", received + finally: + server.shutdown() + + +@pytest.fixture +def dashboard(queue: Queue) -> Generator[tuple[AuthedClient, Queue]]: + handler = _make_handler(queue) + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + session = seed_admin_and_session(queue) + client = AuthedClient(base=f"http://127.0.0.1:{server.server_address[1]}", session=session) + try: + yield client, queue + finally: + server.shutdown() + + +# ── SSRF guard ───────────────────────────────────────────────────────── + + +def test_url_safety_rejects_loopback(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("TASKITO_WEBHOOKS_ALLOW_PRIVATE", raising=False) + with pytest.raises(UnsafeWebhookUrl): + validate_webhook_url("http://127.0.0.1:8080/x") + with pytest.raises(UnsafeWebhookUrl): + validate_webhook_url("http://localhost/x") + with pytest.raises(UnsafeWebhookUrl): + validate_webhook_url("http://something.internal/x") + + +def test_url_safety_rejects_private_ranges(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("TASKITO_WEBHOOKS_ALLOW_PRIVATE", raising=False) + with pytest.raises(UnsafeWebhookUrl): + validate_webhook_url("http://10.0.0.5/x") + with pytest.raises(UnsafeWebhookUrl): + validate_webhook_url("http://192.168.1.1/x") + with pytest.raises(UnsafeWebhookUrl): + validate_webhook_url("http://169.254.169.254/latest/meta-data") + + +def test_url_safety_rejects_bad_scheme(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("TASKITO_WEBHOOKS_ALLOW_PRIVATE", raising=False) + with pytest.raises(UnsafeWebhookUrl): + validate_webhook_url("ftp://example.com/x") + with pytest.raises(UnsafeWebhookUrl): + validate_webhook_url("javascript:alert(1)") + + +def test_url_safety_allows_private_with_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("TASKITO_WEBHOOKS_ALLOW_PRIVATE", "1") + # No exception + validate_webhook_url("http://127.0.0.1:8080/x") + validate_webhook_url("http://10.0.0.5/x") + + +# ── Store / Python API ───────────────────────────────────────────────── + + +def test_store_starts_empty(queue: Queue) -> None: + assert WebhookSubscriptionStore(queue).list_all() == [] + + +def test_create_and_get_subscription(queue: Queue) -> None: + sub = queue.add_webhook( + "http://127.0.0.1:9999/x", events=[EventType.JOB_FAILED], secret="topsecret" + ) + fetched = queue.get_webhook(sub.id) + assert fetched is not None + assert fetched.url == "http://127.0.0.1:9999/x" + assert fetched.events == ["job.failed"] + assert fetched.secret == "topsecret" + + +def test_subscriptions_persist_across_queue_instances(tmp_path: Path) -> None: + """A fresh Queue against the same DB sees prior subscriptions.""" + import os + + os.environ["TASKITO_WEBHOOKS_ALLOW_PRIVATE"] = "1" + try: + db = str(tmp_path / "persist.db") + q1 = Queue(db_path=db) + sub = q1.add_webhook("http://127.0.0.1:9999/x") + + q2 = Queue(db_path=db) + all_subs = q2.list_webhooks() + assert any(s.id == sub.id for s in all_subs) + finally: + del os.environ["TASKITO_WEBHOOKS_ALLOW_PRIVATE"] + + +def test_update_webhook(queue: Queue) -> None: + sub = queue.add_webhook("http://127.0.0.1:9999/x", max_retries=3) + updated = queue.update_webhook(sub.id, max_retries=7, enabled=False) + assert updated.max_retries == 7 + assert updated.enabled is False + fresh = queue.get_webhook(sub.id) + assert fresh is not None and fresh.max_retries == 7 + + +def test_remove_webhook(queue: Queue) -> None: + sub = queue.add_webhook("http://127.0.0.1:9999/x") + assert queue.remove_webhook(sub.id) is True + assert queue.remove_webhook(sub.id) is False + assert queue.get_webhook(sub.id) is None + + +def test_rotate_secret(queue: Queue) -> None: + sub = queue.add_webhook("http://127.0.0.1:9999/x", secret="old") + new_secret = queue.rotate_webhook_secret(sub.id) + assert new_secret != "old" + fresh = queue.get_webhook(sub.id) + assert fresh is not None and fresh.secret == new_secret + + +def test_disabled_webhook_does_not_deliver( + queue: Queue, echo_server: tuple[str, list[dict[str, Any]]], poll_until: Any +) -> None: + url, received = echo_server + sub = queue.add_webhook(url, events=[EventType.JOB_COMPLETED]) + queue.update_webhook(sub.id, enabled=False) + queue._webhook_manager.notify(EventType.JOB_COMPLETED, {"job_id": "1"}) + # Give the dispatcher a chance. + import time + + time.sleep(0.3) + assert received == [] + + +def test_task_filter_restricts_delivery( + queue: Queue, echo_server: tuple[str, list[dict[str, Any]]], poll_until: Any +) -> None: + url, received = echo_server + queue.add_webhook(url, task_filter=["only_me"]) + queue._webhook_manager.notify(EventType.JOB_COMPLETED, {"job_id": "1", "task_name": "other"}) + queue._webhook_manager.notify(EventType.JOB_COMPLETED, {"job_id": "2", "task_name": "only_me"}) + poll_until(lambda: len(received) >= 1, message="task-filtered webhook not delivered") + assert len(received) == 1 + assert received[0]["body"]["task_name"] == "only_me" + + +def test_manager_reload_picks_up_new_subscription( + queue: Queue, echo_server: tuple[str, list[dict[str, Any]]], poll_until: Any +) -> None: + """Subscriptions written by another worker show up after reload.""" + url, received = echo_server + # Bypass the Queue API and write directly to the store to simulate a peer. + WebhookSubscriptionStore(queue).create(url=url) + queue._webhook_manager.reload() + queue._webhook_manager.notify(EventType.JOB_COMPLETED, {"job_id": "1"}) + poll_until(lambda: len(received) >= 1, message="reloaded webhook not delivered") + + +def test_subscription_secret_signs_payload( + queue: Queue, echo_server: tuple[str, list[dict[str, Any]]], poll_until: Any +) -> None: + url, received = echo_server + sub = queue.add_webhook(url, secret="signing-key") + queue._webhook_manager.notify(EventType.JOB_COMPLETED, {"job_id": "x"}) + poll_until(lambda: len(received) >= 1) + + sig_header = received[0]["headers"].get("X-Taskito-Signature") + assert sig_header is not None + body_bytes = json.dumps(received[0]["body"], default=str).encode("utf-8") + expected = hmac.new(b"signing-key", body_bytes, hashlib.sha256).hexdigest() + assert sig_header == f"sha256={expected}" + assert sub.secret == "signing-key" + + +# ── Dashboard HTTP endpoints ────────────────────────────────────────── + + +def test_list_webhooks_returns_empty(dashboard: tuple[AuthedClient, Queue]) -> None: + client, _ = dashboard + assert client.get("/api/webhooks") == [] + + +def test_event_types_listing(dashboard: tuple[AuthedClient, Queue]) -> None: + client, _ = dashboard + events = client.get("/api/event-types") + assert "job.completed" in events + assert "job.failed" in events + assert sorted(events) == events # always sorted + + +def test_create_webhook_endpoint( + dashboard: tuple[AuthedClient, Queue], echo_server: tuple[str, list[dict[str, Any]]] +) -> None: + client, _queue = dashboard + url, _ = echo_server + created = client.post( + "/api/webhooks", + { + "url": url, + "events": ["job.failed"], + "task_filter": ["send_email"], + "max_retries": 5, + "description": "ops failures", + "generate_secret": True, + }, + ) + assert created["url"] == url + assert created["events"] == ["job.failed"] + assert created["task_filter"] == ["send_email"] + assert created["max_retries"] == 5 + # Secret is revealed exactly once on create. + assert "secret" in created + assert created["has_secret"] is True + + listed = client.get("/api/webhooks") + assert len(listed) == 1 + # ``secret`` is redacted from list/get responses. + assert "secret" not in listed[0] + assert listed[0]["has_secret"] is True + + +def test_create_webhook_rejects_unsafe_url(dashboard: tuple[AuthedClient, Queue]) -> None: + client, _ = dashboard + # The fixture has TASKITO_WEBHOOKS_ALLOW_PRIVATE=1; remove it for this test only. + import os + + saved = os.environ.pop("TASKITO_WEBHOOKS_ALLOW_PRIVATE", None) + try: + with pytest.raises(urllib.error.HTTPError) as exc_info: + client.post("/api/webhooks", {"url": "http://127.0.0.1/x"}) + assert exc_info.value.code == 400 + finally: + if saved is not None: + os.environ["TASKITO_WEBHOOKS_ALLOW_PRIVATE"] = saved + + +def test_create_webhook_rejects_unknown_event( + dashboard: tuple[AuthedClient, Queue], echo_server: tuple[str, list[dict[str, Any]]] +) -> None: + client, _ = dashboard + url, _ = echo_server + with pytest.raises(urllib.error.HTTPError) as exc_info: + client.post("/api/webhooks", {"url": url, "events": ["not.a.real.event"]}) + assert exc_info.value.code == 400 + + +def test_update_webhook_endpoint( + dashboard: tuple[AuthedClient, Queue], echo_server: tuple[str, list[dict[str, Any]]] +) -> None: + client, _ = dashboard + url, _ = echo_server + created = client.post("/api/webhooks", {"url": url}) + + updated = client.put( + f"/api/webhooks/{created['id']}", + {"max_retries": 10, "enabled": False, "description": "paused"}, + ) + assert updated["max_retries"] == 10 + assert updated["enabled"] is False + assert updated["description"] == "paused" + + +def test_delete_webhook_endpoint( + dashboard: tuple[AuthedClient, Queue], echo_server: tuple[str, list[dict[str, Any]]] +) -> None: + client, _ = dashboard + url, _ = echo_server + created = client.post("/api/webhooks", {"url": url}) + assert client.delete(f"/api/webhooks/{created['id']}") == {"deleted": True} + with pytest.raises(urllib.error.HTTPError) as exc_info: + client.delete(f"/api/webhooks/{created['id']}") + assert exc_info.value.code == 404 + + +def test_rotate_secret_endpoint( + dashboard: tuple[AuthedClient, Queue], echo_server: tuple[str, list[dict[str, Any]]] +) -> None: + client, _ = dashboard + url, _ = echo_server + created = client.post("/api/webhooks", {"url": url, "secret": "old"}) + rotated = client.post(f"/api/webhooks/{created['id']}/rotate-secret") + assert rotated["secret"] != "old" + assert rotated["id"] == created["id"] + + +def test_test_webhook_endpoint_returns_status( + dashboard: tuple[AuthedClient, Queue], echo_server: tuple[str, list[dict[str, Any]]] +) -> None: + client, _ = dashboard + url, received = echo_server + created = client.post("/api/webhooks", {"url": url}) + result = client.post(f"/api/webhooks/{created['id']}/test") + assert result["delivered"] is True + assert result["status"] == 200 + # A test event landed at the echo server. + assert any(r["body"].get("event") == "test.ping" for r in received) + + +def test_test_webhook_endpoint_reports_failure( + dashboard: tuple[AuthedClient, Queue], +) -> None: + """When the target server returns 4xx, the test endpoint surfaces it.""" + received_count = [0] + + class FailHandler(BaseHTTPRequestHandler): + def do_POST(self) -> None: + received_count[0] += 1 + self.send_response(418) + self.end_headers() + + def log_message(self, *args: Any) -> None: + pass + + server = HTTPServer(("127.0.0.1", 0), FailHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + client, _ = dashboard + created = client.post( + "/api/webhooks", + {"url": f"http://127.0.0.1:{server.server_address[1]}/x"}, + ) + result = client.post(f"/api/webhooks/{created['id']}/test") + assert result["delivered"] is False + assert result["status"] == 418 + finally: + server.shutdown() From c74ea63ad553135bc2b7f5b5d59520122a8a143d Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sun, 17 May 2026 09:20:49 +0530 Subject: [PATCH 04/10] fix(webhooks): only start delivery thread when subscriptions exist WebhookManager.reload() unconditionally spawned a daemon thread on every Queue construction, leaking a thread per test and exhausting the macOS runner's thread limit (Resource temporarily unavailable / can't start new thread) by the time the workflow suite ran. --- py_src/taskito/webhooks.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/py_src/taskito/webhooks.py b/py_src/taskito/webhooks.py index 95daac18..1afd2a43 100644 --- a/py_src/taskito/webhooks.py +++ b/py_src/taskito/webhooks.py @@ -71,7 +71,8 @@ def reload(self) -> None: snapshot = [self._subscription_to_runtime(s) for s in store.list_all()] with self._lock: self._webhooks = snapshot - self._ensure_thread() + if snapshot: + self._ensure_thread() @staticmethod def _subscription_to_runtime(sub: WebhookSubscription) -> dict[str, Any]: From 79ef0bb5cd1a28b99ef300a205da8aa4af96c92f Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sun, 17 May 2026 09:30:26 +0530 Subject: [PATCH 05/10] docs(dashboard): customization guides + screenshots (Phases 1-5) (#173) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(dashboard): URL-decode regex captures and unnest deliveries route The HTTP server captured ``[^/]+`` from request paths and used the match verbatim as a settings key / task name. Browser clients run ``encodeURIComponent`` on names containing ``<``, ``>``, ``/`` etc., so the per-task middleware disable list was being written under one key (encoded) and looked up under another (decoded). The chain filter then never matched and the toggle silently no-op'd. Decode every captured group with ``urllib.parse.unquote`` before it reaches a handler. Regression-tested in ``test_put_task_middleware_handles_url_encoded_name``. Also rename ``webhooks.\$id.deliveries.tsx`` to ``webhooks_.\$id.deliveries.tsx`` so the route is a flat sibling of ``/webhooks`` instead of a nested child — without the underscore, TanStack Router required an ```` on the parent, which the list page doesn't render, so the deliveries page never mounted. * chore(docs): add reproducible screenshot capture pipeline ``scripts/capture_docs_screenshots.py`` seeds a fresh queue with deterministic demo data (admin, sample tasks/queues, three webhook subscriptions with mixed delivery outcomes, a task override, a middleware disable), boots the dashboard on a random port, and walks through every screen with headless Chrome via Playwright. Output lands under ``docs/public/screenshots/dashboard/`` so the MDX side can reference stable paths. ``scripts/optimize_screenshots.py`` follows up with Pillow-based palette quantisation, shaving the bundle by ~71% (2.3 MB → 675 KB) with no visible quality loss on the UI-dominated screenshots. Playwright lives behind a new ``docs`` optional dependency (``uv sync --extra docs``) so contributors who never touch docs don't pay the install cost. * docs(dashboard): customization guides + screenshots for phases 1-5 Two new pages and a rewrite of the existing webhooks guide cover every piece of customisation that landed across PRs #168-#172: - ``observability/dashboard-auth`` — first-run setup, env-var bootstrap, session cookies, CSRF model, headless usage, SSRF guard - ``observability/task-overrides`` — per-task and per-queue runtime knobs, the "next restart vs. takes effect now" timing model, and the middleware on/off matrix - ``extensibility/events-webhooks`` — rewritten to cover both the dashboard CRUD flow and the Python API, including delivery log, replay, secret rotation, and the SSRF guard The existing ``observability/dashboard`` page is refreshed: the outdated "no auth" callout is gone, the page table now lists Webhooks and Tasks, and the walkthrough section is restructured into screen groups so it's easy to skim. The REST API reference (``observability/dashboard-api``) gains sections for Auth, Webhooks, Webhook Deliveries, Tasks & Overrides, Queue Overrides, and Middleware, plus a refreshed lead paragraph (auth + CSRF apply to every route, no more ``Access-Control-Allow-Origin: *``). 12 screenshots under ``docs/public/screenshots/dashboard/`` illustrate the auth flow, every customisation page, and the side-sheet editors — all produced by the new reproducible capture pipeline. * docs: update dashboard screenshots for task edit middleware and task list * chore: remove unused Makefile for documentation generation --- Makefile | 9 - ...eries.tsx => webhooks_.$id.deliveries.tsx} | 2 +- .../guides/extensibility/events-webhooks.mdx | 336 +++++++----- .../guides/observability/dashboard-api.mdx | 386 +++++++++++--- .../guides/observability/dashboard-auth.mdx | 158 ++++++ .../docs/guides/observability/dashboard.mdx | 281 +++++----- .../docs/guides/observability/index.mdx | 2 + .../docs/guides/observability/meta.json | 10 +- .../guides/observability/task-overrides.mdx | 222 ++++++++ .../screenshots/dashboard/auth-login.png | Bin 0 -> 20006 bytes .../screenshots/dashboard/auth-setup.png | Bin 0 -> 26648 bytes docs/public/screenshots/dashboard/jobs.png | Bin 0 -> 62734 bytes .../public/screenshots/dashboard/overview.png | Bin 0 -> 68973 bytes docs/public/screenshots/dashboard/queues.png | Bin 0 -> 54124 bytes .../dashboard/task-edit-middleware.png | Bin 0 -> 49300 bytes .../dashboard/task-edit-overrides.png | Bin 0 -> 56794 bytes .../screenshots/dashboard/tasks-list.png | Bin 0 -> 68635 bytes .../dashboard/webhook-create-dialog.png | Bin 0 -> 62437 bytes .../dashboard/webhook-deliveries.png | Bin 0 -> 94751 bytes .../screenshots/dashboard/webhooks-list.png | Bin 0 -> 78647 bytes docs/public/screenshots/dashboard/workers.png | Bin 0 -> 53454 bytes py_src/taskito/dashboard/server.py | 25 +- pyproject.toml | 2 + scripts/capture_docs_screenshots.py | 482 ++++++++++++++++++ scripts/optimize_screenshots.py | 73 +++ tests/dashboard/test_middleware_toggles.py | 22 + 26 files changed, 1631 insertions(+), 379 deletions(-) delete mode 100644 Makefile rename dashboard/src/routes/{webhooks.$id.deliveries.tsx => webhooks_.$id.deliveries.tsx} (97%) create mode 100644 docs/content/docs/guides/observability/dashboard-auth.mdx create mode 100644 docs/content/docs/guides/observability/task-overrides.mdx create mode 100644 docs/public/screenshots/dashboard/auth-login.png create mode 100644 docs/public/screenshots/dashboard/auth-setup.png create mode 100644 docs/public/screenshots/dashboard/jobs.png create mode 100644 docs/public/screenshots/dashboard/overview.png create mode 100644 docs/public/screenshots/dashboard/queues.png create mode 100644 docs/public/screenshots/dashboard/task-edit-middleware.png create mode 100644 docs/public/screenshots/dashboard/task-edit-overrides.png create mode 100644 docs/public/screenshots/dashboard/tasks-list.png create mode 100644 docs/public/screenshots/dashboard/webhook-create-dialog.png create mode 100644 docs/public/screenshots/dashboard/webhook-deliveries.png create mode 100644 docs/public/screenshots/dashboard/webhooks-list.png create mode 100644 docs/public/screenshots/dashboard/workers.png create mode 100644 scripts/capture_docs_screenshots.py create mode 100644 scripts/optimize_screenshots.py diff --git a/Makefile b/Makefile deleted file mode 100644 index c74ddd12..00000000 --- a/Makefile +++ /dev/null @@ -1,9 +0,0 @@ -.PHONY: docs docs-serve - -docs: - uv run zensical build - @echo "Copying markdown sources..." - cd docs && find . -name '*.md' -exec install -D {} ../site/_sources/{} \; - -docs-serve: - uv run zensical serve diff --git a/dashboard/src/routes/webhooks.$id.deliveries.tsx b/dashboard/src/routes/webhooks_.$id.deliveries.tsx similarity index 97% rename from dashboard/src/routes/webhooks.$id.deliveries.tsx rename to dashboard/src/routes/webhooks_.$id.deliveries.tsx index cf1052d4..37d52c59 100644 --- a/dashboard/src/routes/webhooks.$id.deliveries.tsx +++ b/dashboard/src/routes/webhooks_.$id.deliveries.tsx @@ -15,7 +15,7 @@ import { import type { DeliveryStatus } from "@/features/webhooks"; import { DeliveryListTable, useDeliveries, useWebhooks } from "@/features/webhooks"; -export const Route = createFileRoute("/webhooks/$id/deliveries")({ +export const Route = createFileRoute("/webhooks_/$id/deliveries")({ component: DeliveriesPage, }); diff --git a/docs/content/docs/guides/extensibility/events-webhooks.mdx b/docs/content/docs/guides/extensibility/events-webhooks.mdx index 03d13d74..d2e4775b 100644 --- a/docs/content/docs/guides/extensibility/events-webhooks.mdx +++ b/docs/content/docs/guides/extensibility/events-webhooks.mdx @@ -1,17 +1,28 @@ --- title: Events & Webhooks -description: "In-process event bus and HMAC-signed HTTP webhooks for job and worker lifecycle events." +description: "In-process event bus, dashboard-managed HMAC-signed webhooks, persistent delivery log, and replay." --- -taskito includes an in-process event bus and webhook delivery system for -reacting to job lifecycle events. +import { Callout } from "fumadocs-ui/components/callout"; +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; + +taskito has two complementary primitives for reacting to job lifecycle: + +1. **In-process event bus** — `queue.on_event()` registers Python callbacks + dispatched in a thread pool. Same process, lowest latency, no + serialization, no HTTP. +2. **Webhooks** — HMAC-signed HTTP POSTs to external endpoints, managed + from the dashboard (or the Python API), with a persistent delivery + log and one-click replay. + +This guide covers both, starting with the events that drive them. ## Event types The `EventType` enum defines all available lifecycle events: -| Event | Fired when | Payload fields | -|-------|------------|----------------| +| Event | Fires when | Payload fields | +|---|---|---| | `JOB_ENQUEUED` | A job is added to the queue | `job_id`, `task_name`, `queue` | | `JOB_COMPLETED` | A job finishes successfully | `job_id`, `task_name`, `queue` | | `JOB_FAILED` | A job raises an exception (before retry) | `job_id`, `task_name`, `queue`, `error` | @@ -26,18 +37,11 @@ The `EventType` enum defines all available lifecycle events: | `QUEUE_PAUSED` | A named queue is paused | `queue` | | `QUEUE_RESUMED` | A paused queue is resumed | `queue` | -`JOB_RETRYING`, `JOB_DEAD`, and `JOB_CANCELLED` are emitted by the Rust -result handler immediately after the scheduler records the outcome. -Middleware hooks (`on_retry`, `on_dead_letter`, `on_cancel`) are called in -the same result-handling pass, after the event fires. +## In-process listeners -`QUEUE_PAUSED` and `QUEUE_RESUMED` are emitted synchronously by -`queue.pause()` and `queue.resume()` after the queue state is written to -storage. - -## Registering listeners - -Use `queue.on_event()` to subscribe a callback to a specific event type: +Use `queue.on_event()` to subscribe a callback. Callbacks run in a +`ThreadPoolExecutor` so they never block the worker, and exceptions are +logged but don't affect job processing. ```python from taskito import Queue @@ -45,66 +49,97 @@ from taskito.events import EventType queue = Queue(db_path="tasks.db") -def on_failure(event_type: EventType, payload: dict): +def on_failure(event_type: EventType, payload: dict) -> None: print(f"Job {payload['job_id']} failed: {payload.get('error')}") queue.on_event(EventType.JOB_FAILED, on_failure) ``` -### Callback signature +Configure the pool size via `Queue(event_workers=N)` (default 4) if +your callbacks are slow. + +## Webhooks (dashboard-managed) -All callbacks receive two arguments: +Webhook subscriptions are first-class **persisted** resources — survive +restarts, propagate across every worker pointed at the same backend, +and are fully manageable from the dashboard. The same surface is +available programmatically via `queue.add_webhook()` / +`list_webhooks()` / `update_webhook()` / etc. -- `event_type` (`EventType`) — the event that occurred -- `payload` (`dict`) — event details including `job_id`, `task_name`, `queue`, and event-specific fields +### Configure from the dashboard -### Async delivery +The Webhooks page (sidebar → Configuration → Webhooks) lists every +subscription with its URL, event filter, optional task filter, retry +policy, and status. -Callbacks are dispatched asynchronously in a `ThreadPoolExecutor`. The -thread pool size defaults to 4 and can be configured via -`Queue(event_workers=N)`. This means: +![Webhooks page with three subscriptions](/screenshots/dashboard/webhooks-list.png) -- Callbacks never block the worker -- Exceptions in callbacks are logged but do not affect job processing -- Callbacks may execute slightly after the event occurs +Click **+ New webhook** to add an endpoint. The dialog walks you +through URL, optional description, the event-type multi-select, an +optional per-task filter, and a checkbox to auto-generate an +HMAC-SHA256 signing secret. -## Webhooks +![New webhook dialog](/screenshots/dashboard/webhook-create-dialog.png) -For external systems, register webhook URLs to receive HTTP POST requests -on job events. +After save, the new secret is shown **once** in a copy-and-reveal card +— treat it like an API key. The same flow applies when you rotate the +secret later from the row-actions menu. -### Registering a webhook +### Per-row actions + +Each row has a "⋯" menu: + +| Action | Effect | +|---|---| +| **View deliveries** | Open the persistent delivery log (see below) | +| **Send test** | POST a synthetic `test.ping` event synchronously and toast the result | +| **Enable / Disable** | Flip the active flag without losing the configuration | +| **Rotate secret** | Generate a new HMAC secret. Confirm dialog prevents accidents | +| **Delete** | Type-to-confirm destructive dialog removes the subscription | + +### Configure from Python + +The same operations are available programmatically: ```python -queue.add_webhook( - url="https://example.com/hooks/taskito", +from taskito import Queue +from taskito.events import EventType + +queue = Queue(db_path="tasks.db") + +sub = queue.add_webhook( + url="https://hooks.example.com/ops-failures", events=[EventType.JOB_FAILED, EventType.JOB_DEAD], - headers={"Authorization": "Bearer mytoken"}, - secret="my-signing-secret", + secret="whsec_my_signing_secret", + description="Page ops on permanent failures", + max_retries=5, + timeout=8.0, + task_filter=["myapp.tasks.send_email"], # optional per-task gate ) +print(sub.id) # use this to update / remove later + +queue.update_webhook(sub.id, enabled=False) +queue.rotate_webhook_secret(sub.id) +queue.remove_webhook(sub.id) ``` | Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `url` | `str` | — | URL to POST event payloads to (must be `http://` or `https://`) | +|---|---|---|---| +| `url` | `str` | — | http/https URL. SSRF-guarded — see below | | `events` | `list[EventType] \| None` | `None` | Event types to subscribe to. `None` means all events | -| `headers` | `dict[str, str] \| None` | `None` | Extra HTTP headers to include in requests | -| `secret` | `str \| None` | `None` | HMAC-SHA256 signing secret | +| `task_filter` | `list[str] \| None` | `None` | Restrict to specific task names. `None` means all tasks | +| `headers` | `dict[str, str] \| None` | `None` | Extra HTTP headers (e.g. `Authorization`) | +| `secret` | `str \| None` | `None` | HMAC-SHA256 signing key | | `max_retries` | `int` | `3` | Maximum delivery attempts | | `timeout` | `float` | `10.0` | HTTP request timeout in seconds | | `retry_backoff` | `float` | `2.0` | Base for exponential backoff between retries | +| `description` | `str \| None` | `None` | Free-form label shown in the dashboard | ### HMAC signing -When a `secret` is provided, each webhook request includes an -`X-Taskito-Signature` header: - -``` -X-Taskito-Signature: sha256= -``` - -The signature is computed over the JSON request body using HMAC-SHA256. -Verify it on the receiving end: +When a `secret` is set, every webhook request includes +`X-Taskito-Signature: sha256=`. Verify it on the receiving +end: ```python import hashlib @@ -115,46 +150,93 @@ def verify_signature(body: bytes, signature: str, secret: str) -> bool: return hmac.compare_digest(f"sha256={expected}", signature) ``` -### Retry behavior +The signature is computed over the raw JSON request body. **Verify +before parsing the body** — that way a forged payload never reaches +your business logic. + + + The secret column stores the value as-is in the dashboard settings + table. The DB is already trusted with everything else taskito + persists (job payloads, error tracebacks). If you need at-rest + encryption beyond filesystem-level (e.g. SQLite encrypted with + SQLCipher), the dashboard never returns the raw secret after the + initial create / rotate response — only a ``has_secret`` indicator. + -Failed webhook deliveries are retried with exponential backoff. The number -of attempts, request timeout, and backoff base are configurable per webhook -via `max_retries`, `timeout`, and `retry_backoff`. With the defaults -(`max_retries=3`, `retry_backoff=2.0`): +### SSRF guard + +Outbound webhook URLs are validated before the manager queues any +delivery. The guard rejects: + +- Non-`http` / `https` schemes +- `localhost`, `*.local`, `*.internal`, `*.intranet`, `*.lan`, + `*.private` +- Any address that resolves to a loopback, link-local, RFC1918, + multicast, or unspecified range (including the AWS metadata service + at `169.254.169.254`) + +Set `TASKITO_WEBHOOKS_ALLOW_PRIVATE=1` to lift the guard for local +development. Keep it on in production. + +### Retry behaviour + +Failed webhook deliveries are retried with exponential backoff, +configurable per subscription via `max_retries`, `timeout`, and +`retry_backoff`. With the defaults (`max_retries=3`, +`retry_backoff=2.0`): | Attempt | Delay before next retry | -|---------|------------------------| +|---|---| | 1st retry | 1 second (`2.0 ** 0`) | | 2nd retry | 2 seconds (`2.0 ** 1`) | | 3rd retry | — (final) | -4xx responses are not retried. If all attempts fail, a warning is logged -and the event is dropped. +4xx responses are NOT retried — they're treated as client errors and +the delivery is marked `failed`. 5xx responses retry until exhausted, +at which point the delivery is marked `dead`. -### Event filtering +## Delivery log + replay -Subscribe to specific events or all events: +Every webhook attempt — successful, failed, or dead-lettered — is +recorded under the subscription so you can debug failures without +leaving the dashboard. -```python -# Only failure events -queue.add_webhook( - url="https://slack.example.com/webhook", - events=[EventType.JOB_FAILED, EventType.JOB_DEAD], -) +![Delivery log with mixed outcomes](/screenshots/dashboard/webhook-deliveries.png) -# All events -queue.add_webhook(url="https://monitoring.example.com/events") -``` +Each row carries: + +- **When** — relative timestamp ("2 minutes ago") +- **Event** — the event type the delivery was for (`job.completed`, `job.failed`, etc.) +- **Status** — `delivered` (green), `failed` (yellow), `dead` (red) +- **Code** — final HTTP status returned by the endpoint +- **Latency** — wall time of the final attempt +- **Attempts** — total retry count consumed + +Click any row to inspect the full payload, the truncated response body +(first 2 KiB), and any transport-level error. The **Replay** button +re-fires the stored payload synchronously and records the outcome as a +fresh delivery — the original record is preserved for the audit trail. + +### Retention + +Each subscription keeps the most recent 200 deliveries in a FIFO ring +buffer per webhook (configurable via the +`DeliveryStore(max_per_webhook=N)` constructor). Successful and +failed deliveries are stored uniformly so the replay view always +matches what really happened. ## Examples -### Slack notification on job failure +### Slack-on-failure (in-process listener) + +When the receiver is the same Python process and you don't need +persistence, the event bus is the cheapest path: ```python import requests from taskito.events import EventType -def notify_slack(event_type: EventType, payload: dict): +def notify_slack(event_type: EventType, payload: dict) -> None: requests.post( "https://hooks.slack.com/services/T.../B.../xxx", json={ @@ -168,7 +250,11 @@ queue.on_event(EventType.JOB_FAILED, notify_slack) queue.on_event(EventType.JOB_DEAD, notify_slack) ``` -### Webhook to external monitoring +### Persistent webhook to an external service + +When the receiver is a separate service — auditing, monitoring, a +different team's pipeline — use a webhook so the delivery log is +preserved across restarts: ```python queue.add_webhook( @@ -176,58 +262,73 @@ queue.add_webhook( events=[EventType.JOB_COMPLETED, EventType.JOB_FAILED, EventType.JOB_DEAD], secret="whsec_abc123", headers={"X-Source": "taskito-prod"}, + description="Forward terminal job outcomes to the monitoring service", ) ``` -The monitoring service receives JSON payloads like: +Payload shape received by the endpoint: ```json { - "event": "job.failed", - "job_id": "01H5K6X...", - "task_name": "myapp.tasks.process", - "queue": "default", - "error": "ConnectionError: ..." + "event": "job.failed", + "job_id": "01H5K6X...", + "task_name": "myapp.tasks.process", + "queue": "default", + "error": "ConnectionError: ..." } ``` -### Job completion tracking +### Flask receiver + +A minimal Flask app that receives and verifies taskito webhooks: ```python -from taskito.events import EventType +from flask import Flask, request, abort +import hashlib, hmac -completed_count = 0 +app = Flask(__name__) +WEBHOOK_SECRET = "whsec_my_signing_secret" -def track_completion(event_type: EventType, payload: dict): - global completed_count - completed_count += 1 - if completed_count % 100 == 0: - print(f"Milestone: {completed_count} jobs completed") +@app.route("/hooks/taskito", methods=["POST"]) +def receive_webhook(): + signature = request.headers.get("X-Taskito-Signature", "") + expected = hmac.new( + WEBHOOK_SECRET.encode(), request.data, hashlib.sha256 + ).hexdigest() -queue.on_event(EventType.JOB_COMPLETED, track_completion) + if not hmac.compare_digest(f"sha256={expected}", signature): + abort(401) + + event = request.json + print(f"Received event: {event['event']} for job {event['job_id']}") + return "", 204 ``` -### Database logging for audit trail +### Database audit trail (in-process listener) ```python from taskito.events import EventType -def audit_log(event_type: EventType, payload: dict): +def audit_log(event_type: EventType, payload: dict) -> None: db.execute( "INSERT INTO audit_log (event, job_id, task_name, timestamp) VALUES (?, ?, ?, ?)", (event_type.value, payload["job_id"], payload["task_name"], time.time()), ) -# Subscribe to all important events -for event in [EventType.JOB_ENQUEUED, EventType.JOB_COMPLETED, EventType.JOB_FAILED, EventType.JOB_DEAD]: +for event in [ + EventType.JOB_ENQUEUED, + EventType.JOB_COMPLETED, + EventType.JOB_FAILED, + EventType.JOB_DEAD, +]: queue.on_event(event, audit_log) ``` ## Event ordering -Events fire in the order the scheduler processes results — typically the -order jobs complete. For jobs that complete nearly simultaneously, ordering -is **not guaranteed** across different workers or threads. +Events fire in the order the scheduler processes results — typically +the order jobs complete. For jobs that complete nearly simultaneously, +ordering is **not guaranteed** across different workers or threads. Within a single job's lifecycle, events always fire in this order: @@ -236,50 +337,7 @@ Within a single job's lifecycle, events always fire in this order: 3. `JOB_RETRYING` (if retried, before the next attempt) 4. `JOB_DEAD` (if all retries exhausted) -## Backpressure - -Events are dispatched to a thread pool (default size: 4, configurable via -`event_workers=N`). If callbacks are slow and events arrive faster than -they can be processed, they queue in memory. - -For high-volume event scenarios: - -```python -queue = Queue(event_workers=16) # More threads for slow callbacks -``` +## Reference -If a callback raises an exception, it is logged and the event is dropped — -it does not retry or block other callbacks. - -## Webhook failure - -Webhooks retry with exponential backoff (up to `max_retries`). After all -retries are exhausted, the webhook delivery is **logged and dropped** — -there is no dead-letter queue for webhooks. Monitor webhook failures via -the `on_failure` callback or structured logging. - -### Webhook receiver (Flask) - -A minimal Flask app that receives and verifies taskito webhooks: - -```python -from flask import Flask, request, abort -import hashlib, hmac - -app = Flask(__name__) -WEBHOOK_SECRET = "my-signing-secret" - -@app.route("/hooks/taskito", methods=["POST"]) -def receive_webhook(): - signature = request.headers.get("X-Taskito-Signature", "") - expected = hmac.new( - WEBHOOK_SECRET.encode(), request.data, hashlib.sha256 - ).hexdigest() - - if not hmac.compare_digest(f"sha256={expected}", signature): - abort(401) - - event = request.json - print(f"Received event: {event['event']} for job {event['job_id']}") - return "", 204 -``` +- [Dashboard REST API for webhooks and deliveries](/guides/observability/dashboard-api#webhooks) +- [Dashboard auth — how to call these endpoints from a script](/guides/observability/dashboard-auth) diff --git a/docs/content/docs/guides/observability/dashboard-api.mdx b/docs/content/docs/guides/observability/dashboard-api.mdx index 2e3028ea..1fd63359 100644 --- a/docs/content/docs/guides/observability/dashboard-api.mdx +++ b/docs/content/docs/guides/observability/dashboard-api.mdx @@ -1,10 +1,55 @@ --- title: Dashboard REST API -description: "JSON endpoints for stats, jobs, dead letters, metrics, logs, infrastructure, observability." +description: "JSON endpoints for stats, jobs, dead letters, metrics, logs, infrastructure, observability, webhooks, and runtime overrides." --- -The dashboard exposes a JSON API you can use independently of the UI. All -endpoints return `application/json` with `Access-Control-Allow-Origin: *`. +import { Callout } from "fumadocs-ui/components/callout"; + +The dashboard exposes a JSON API you can use independently of the UI. +All endpoints return `application/json` and live under the same origin +as the dashboard itself. + + + Every `/api/*` endpoint except the public set (`/api/auth/status`, + `/api/auth/login`, `/api/auth/setup`) requires a valid session cookie + obtained from `POST /api/auth/login`. State-changing requests + (POST/PUT/DELETE) additionally require a CSRF header. See + [Dashboard Authentication](/guides/observability/dashboard-auth) for + the login flow and headless usage examples. + + +## Auth + +### `GET /api/auth/status` + +Public. Returns whether the dashboard needs first-run setup. + +```json +{ "setup_required": false } +``` + +### `POST /api/auth/setup` + +Public, but locks itself after the first user is created. Body: +`{"username": "...", "password": "..."}`. Returns the new user. + +### `POST /api/auth/login` + +Body: `{"username": "...", "password": "..."}`. Sets the +`taskito_session` (HttpOnly) and `taskito_csrf` cookies on success. +Returns `400 invalid_credentials` on failure. + +### `POST /api/auth/logout` + +Invalidates the current session and clears cookies. + +### `GET /api/auth/whoami` + +Returns the current user, CSRF token, and expiry. `401` when no session. + +### `POST /api/auth/change-password` + +Body: `{"old_password": "...", "new_password": "..."}`. ## Stats @@ -25,13 +70,8 @@ Queue statistics snapshot. ### `GET /api/stats/queues` -Per-queue statistics. Pass `?queue=name` for a single queue, or omit for all -queues. - -```bash -curl http://localhost:8080/api/stats/queues -curl http://localhost:8080/api/stats/queues?queue=emails -``` +Per-queue statistics. Pass `?queue=name` for a single queue, or omit +for all queues. ## Jobs @@ -51,10 +91,6 @@ Paginated list of jobs with filtering. | `limit` | `int` | `20` | Page size | | `offset` | `int` | `0` | Pagination offset | -```bash -curl http://localhost:8080/api/jobs?status=running&limit=10 -``` - ### `GET /api/jobs/{id}` Full detail for a single job. @@ -79,41 +115,263 @@ Dependency graph for a job (nodes and edges). Cancel a pending job. -```json -{ "cancelled": true } -``` - ### `POST /api/jobs/{id}/replay` Replay a completed or failed job with the same payload. -```json -{ "replay_job_id": "01H5K7Y..." } -``` - ## Dead letters ### `GET /api/dead-letters` -Paginated list of dead letter entries. Supports `limit` and `offset` -parameters. +Paginated list of dead letter entries. Supports `limit` and `offset`. ### `POST /api/dead-letters/{id}/retry` Re-enqueue a dead letter job. +### `POST /api/dead-letters/purge` + +Purge all dead letters. + +## Webhooks + +Full guide: [Events & Webhooks](/guides/extensibility/events-webhooks). + +### `GET /api/webhooks` + +List all subscriptions. The `secret` field is **never** returned — only +a `has_secret` boolean. The secret is only included on the response to +`POST /api/webhooks` (create) and `POST /api/webhooks/{id}/rotate-secret`, +exactly once. + ```json -{ "new_job_id": "01H5K7Y..." } +[ + { + "id": "f00563cbbb1a4200bb461f83d1db47bf", + "url": "https://hooks.example.com/ops-failures", + "events": ["job.failed", "job.dead"], + "task_filter": null, + "headers": {}, + "has_secret": true, + "max_retries": 5, + "timeout_seconds": 8.0, + "retry_backoff": 2.0, + "enabled": true, + "description": "Page ops on permanent failures", + "created_at": 1716000000, + "updated_at": 1716000000 + } +] ``` -### `POST /api/dead-letters/purge` +### `POST /api/webhooks` -Purge all dead letters. +Create a subscription. + +| Field | Type | Description | +|---|---|---| +| `url` | `string` | Required. http/https URL, SSRF-vetted | +| `events` | `string[]` | Event types (`job.failed`, etc.). Empty/missing → all | +| `task_filter` | `string[] \| null` | Restrict to specific task names. `null` → all tasks | +| `headers` | `object` | Extra HTTP headers | +| `secret` | `string \| null` | Explicit signing key | +| `generate_secret` | `bool` | If true, server generates a fresh secret | +| `max_retries` | `int` | Default 3 | +| `timeout_seconds` | `float` | Default 10.0 | +| `retry_backoff` | `float` | Default 2.0 | +| `description` | `string \| null` | Free-form label | + +Response includes the secret **once** if one was set or generated. + +### `GET /api/webhooks/{id}` + +Single subscription (secret redacted). + +### `PUT /api/webhooks/{id}` + +Partial update. Only fields you include are touched. Same field set as +create. + +### `DELETE /api/webhooks/{id}` + +Delete the subscription. + +### `POST /api/webhooks/{id}/test` + +Synchronously POST a synthetic `test.ping` event and return the +result inline. ```json -{ "purged": 42 } +{ "status": 200, "delivered": true } ``` +### `POST /api/webhooks/{id}/rotate-secret` + +Generate a fresh HMAC secret. Returns `{id, secret}` — the only time +the new value is visible. + +### `GET /api/event-types` + +Sorted list of every valid event type value. Used by the dashboard's +event multi-select. + +```json +["job.cancelled", "job.completed", "job.dead", ...] +``` + +## Webhook deliveries + +### `GET /api/webhooks/{id}/deliveries` + +Persistent log of attempts for the subscription. Supports filters: + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `status` | `delivered \| failed \| dead \| pending` | all | Filter by outcome | +| `event` | `string` | all | Filter by event type | +| `limit` | `int` | `50` | Page size (max 200) | +| `offset` | `int` | `0` | Pagination offset | + +```json +{ + "items": [ + { + "id": "01H...", + "subscription_id": "f00563cb...", + "event": "job.failed", + "payload": { "job_id": "...", "task_name": "...", "error": "..." }, + "task_name": "myapp.tasks.process_image", + "job_id": "01H...", + "status": "dead", + "attempts": 3, + "response_code": 500, + "response_body": "Internal Server Error", + "latency_ms": 30000, + "error": null, + "created_at": 1716000000000, + "completed_at": 1716000030000 + } + ], + "total": 1, + "limit": 50, + "offset": 0 +} +``` + +### `GET /api/webhooks/{id}/deliveries/{delivery_id}` + +Single delivery record. + +### `POST /api/webhooks/{id}/deliveries/{delivery_id}/replay` + +Re-fire the stored payload synchronously. Records the outcome as a +fresh delivery on top of the original (audit trail preserved). + +```json +{ "replayed_of": "01H...", "status": 200, "delivered": true } +``` + +## Tasks and overrides + +Full guide: [Task & Queue Overrides](/guides/observability/task-overrides). + +### `GET /api/tasks` + +List every registered task with decorator defaults, override, and +effective values. + +```json +[ + { + "name": "myapp.tasks.send_email", + "queue": "default", + "defaults": { + "max_retries": 3, + "retry_backoff": 1.0, + "timeout": 300, + "priority": 0, + "rate_limit": null, + "max_concurrent": null + }, + "override": { "rate_limit": "200/m", "max_retries": 10 }, + "effective": { + "max_retries": 10, + "retry_backoff": 1.0, + "timeout": 300, + "priority": 0, + "rate_limit": "200/m", + "max_concurrent": null + }, + "paused": false + } +] +``` + +### `GET /api/tasks/{name}/override` + +Single task's override row. `404` if none set. + +### `PUT /api/tasks/{name}/override` + +Upsert the override. Body keys must be in the allow-list: +`rate_limit`, `max_concurrent`, `max_retries`, `retry_backoff`, +`timeout`, `priority`, `paused`. Passing `null` for a field removes +just that field. Unknown fields → `400`. + +### `DELETE /api/tasks/{name}/override` + +Remove the override entirely. Returns `{cleared: bool}`. + +### `GET /api/queues` + +List every queue mentioned by a task config with defaults, override, +effective, and paused state. + +### `GET /api/queues/{name}/override` / `PUT` / `DELETE` + +Same shape as tasks. Allow-list for queue overrides: +`rate_limit`, `max_concurrent`, `paused`. The `paused` flag also flips +the live `paused_queues` table so it takes effect on running workers +immediately. + +## Middleware + +### `GET /api/middleware` + +List every registered middleware (global + per-task) with its scopes. + +```json +[ + { "name": "sentry", "class_path": "myapp.middleware.SentryMiddleware", "scopes": [{"kind": "global"}] } +] +``` + +### `GET /api/tasks/{name}/middleware` + +The middleware chain that fires for a task, with each entry's +`disabled` and `effective` flags. + +```json +{ + "task": "myapp.tasks.send_email", + "middleware": [ + { "name": "demo.logging", "class_path": "...", "disabled": false, "effective": true }, + { "name": "demo.metrics", "class_path": "...", "disabled": true, "effective": false } + ] +} +``` + +### `PUT /api/tasks/{name}/middleware/{mw_name}` + +Body: `{"enabled": bool}`. Returns `{task, disabled: [...]}` reflecting +the new disable list. `404` if the middleware name isn't registered on +the task — typos can't write no-op disables. + +### `DELETE /api/tasks/{name}/middleware` + +Clear all middleware disables for a task — every middleware fires +again. + ## Metrics ### `GET /api/metrics` @@ -129,12 +387,6 @@ Per-task execution metrics. Time-bucketed metrics for charts. -| Parameter | Type | Default | Description | -|---|---|---|---| -| `task` | `string` | all | Filter by task name | -| `since` | `int` | `3600` | Lookback window in seconds | -| `bucket` | `int` | `60` | Bucket size in seconds | - ## Logs ### `GET /api/logs` @@ -166,13 +418,9 @@ Worker resource health and pool status. List paused queue names. -### `POST /api/queues/{name}/pause` - -Pause a queue (jobs stop being dequeued). +### `POST /api/queues/{name}/pause` / `POST /api/queues/{name}/resume` -### `POST /api/queues/{name}/resume` - -Resume a paused queue. +Pause or resume a queue. Takes effect immediately. ## Observability @@ -190,45 +438,57 @@ KEDA-compatible autoscaler payload. Pass `?queue=name` for a specific queue. ### `GET /health` -Liveness check. Always returns `{"status": "ok"}`. +Public liveness check. Always returns `{"status": "ok"}`. ### `GET /readiness` -Readiness check with storage, worker, and resource health. +Public readiness check with storage, worker, and resource health. ### `GET /metrics` -Prometheus metrics endpoint (requires `prometheus-client` package). +Public Prometheus metrics endpoint (requires `prometheus-client` package). + +## Settings + +### `GET /api/settings` + +Dump of every dashboard setting key/value. + +### `GET /api/settings/{key}` / `PUT` / `DELETE` + +Read, set, or delete a single dashboard setting. Used by the dashboard +itself for branding, external links, and integration URLs — but you +can write your own keys here too. Note that this is the same store +where authentication, webhook subscriptions, delivery logs, and +runtime overrides live, all under namespaced prefixes (`auth:*`, +`webhooks:*`, `overrides:*`, etc.). ## Using the API programmatically ```python import requests -# Health check script -stats = requests.get("http://localhost:8080/api/stats").json() +s = requests.Session() +s.post( + "http://localhost:8080/api/auth/login", + json={"username": "admin", "password": "..."}, +) +csrf = s.cookies.get("taskito_csrf") +s.headers["X-CSRF-Token"] = csrf +# Health check script. +stats = s.get("http://localhost:8080/api/stats").json() if stats["dead"] > 0: print(f"WARNING: {stats['dead']} dead letter(s)") -if stats["running"] > 100: - print(f"WARNING: {stats['running']} jobs running, possible backlog") -``` - -```python -# Pause a queue during deployment -requests.post("http://localhost:8080/api/queues/default/pause") +# Tune a task's rate limit during an incident. +s.put( + "http://localhost:8080/api/tasks/myapp.tasks.send_email/override", + json={"rate_limit": "30/m"}, +) +# Pause a queue during deployment. +s.post("http://localhost:8080/api/queues/default/pause") # ... deploy ... - -# Resume after deployment -requests.post("http://localhost:8080/api/queues/default/resume") -``` - -```python -# Retry all dead letters -dead = requests.get("http://localhost:8080/api/dead-letters?limit=100").json() -for entry in dead: - requests.post(f"http://localhost:8080/api/dead-letters/{entry['id']}/retry") - print(f"Retried {entry['task_name']}") +s.post("http://localhost:8080/api/queues/default/resume") ``` diff --git a/docs/content/docs/guides/observability/dashboard-auth.mdx b/docs/content/docs/guides/observability/dashboard-auth.mdx new file mode 100644 index 00000000..38237684 --- /dev/null +++ b/docs/content/docs/guides/observability/dashboard-auth.mdx @@ -0,0 +1,158 @@ +--- +title: Dashboard Authentication +description: "Session-based login, CSRF, env-var bootstrap, and the setup-required flow." +--- + +import { Callout } from "fumadocs-ui/components/callout"; +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; + +The taskito dashboard is auth-gated by default. Until the first admin +exists, every protected API route returns `503 setup_required` and the +SPA shows the one-time setup form. Once an admin is registered the +dashboard requires a valid session cookie on every API call and a CSRF +token on every state-changing request. + +## How auth works + +- **Users + sessions** live in the existing `dashboard_settings` + key/value table — no new schema, so SQLite, PostgreSQL, and Redis + backends are supported uniformly. +- **Passwords** are hashed with stdlib `hashlib.pbkdf2_hmac` (SHA-256, + 600,000 iterations, 16-byte random salt — the OWASP 2023+ PBKDF2 + baseline). No third-party crypto dependency. +- **Sessions** are stored server-side under + `auth:session:` with a 24-hour TTL. The token rides in + an `HttpOnly` + `SameSite=Strict` cookie named `taskito_session`. +- **CSRF** uses the double-submit pattern: a non-HttpOnly cookie named + `taskito_csrf` carries a per-session token that the SPA reads and + echoes back via the `X-CSRF-Token` header on POST/PUT/DELETE. The + server rejects any state-changing request whose header doesn't match + both the cookie and the session-bound token. + +## First-run setup + +On a fresh database the dashboard refuses to do anything else until an +admin exists. + +![First-run setup form](/screenshots/dashboard/auth-setup.png) + +The form submits to `POST /api/auth/setup`, which is allowed to run +exactly once — it returns `400 setup already complete` after the first +user is created. The new admin is signed in automatically. + +### Bootstrap via environment variables + +For headless deployments (Docker, Kubernetes, systemd) you usually don't +want to visit a browser just to register the first user. Set both env +vars before starting the dashboard: + +```bash +export TASKITO_DASHBOARD_ADMIN_USER=admin +export TASKITO_DASHBOARD_ADMIN_PASSWORD='change-me-on-first-login' +taskito dashboard --app myapp:queue +``` + +The bootstrap is **idempotent** — once a user with that name exists, +subsequent dashboard restarts read the env vars but skip creation. + + + Rotate the password after first login (use ``POST /api/auth/change-password`` + or the future UI). Leaving the env var in your deployment is fine for + recovery, but anyone with access to the env can re-bootstrap a fresh + install — keep it scoped accordingly. + + +## Sign in + +After setup, every visit routes through the sign-in form. + +![Sign-in form](/screenshots/dashboard/auth-login.png) + +```bash +# Login from the CLI — note the cookie jar so subsequent requests +# carry the session. +curl -c jar -X POST http://localhost:8080/api/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"username":"admin","password":"change-me-on-first-login"}' + +# The CSRF token comes back in a non-HttpOnly cookie. Read it and +# echo it on writes. +CSRF=$(grep taskito_csrf jar | awk '{print $7}') +curl -b jar -H "X-CSRF-Token: $CSRF" -X POST \ + http://localhost:8080/api/queues/default/pause +``` + +The browser SPA does this automatically — the `api-client.ts` wrapper +reads `document.cookie` and attaches the header on POST/PUT/DELETE. + +## API surface + +All routes live under `/api/auth/`: + +| Method | Path | What it does | +|---|---|---| +| `GET` | `/api/auth/status` | Public. Returns `{setup_required: bool}` | +| `POST` | `/api/auth/setup` | Public, locks itself after the first user | +| `POST` | `/api/auth/login` | Returns the user + session and sets cookies | +| `POST` | `/api/auth/logout` | Invalidates the current session, clears cookies | +| `GET` | `/api/auth/whoami` | Returns the current user + CSRF token + expiry | +| `POST` | `/api/auth/change-password` | Requires the current password | + +Every other route under `/api/` is auth-gated. Public exceptions: +`/health`, `/readiness`, `/metrics` (Prometheus), and the static SPA +assets. + +## Headless requests + +The same endpoints work for any HTTP client — Slack bots, +deployment scripts, custom dashboards. The minimal workflow: + +1. `POST /api/auth/login` — save the `Set-Cookie` values +2. For every read: send the `taskito_session` cookie +3. For every write: send the `taskito_session` cookie + `taskito_csrf` + cookie + matching `X-CSRF-Token` header + +```python +import requests + +s = requests.Session() +s.post( + "http://localhost:8080/api/auth/login", + json={"username": "admin", "password": "..."}, +) +csrf = s.cookies.get("taskito_csrf") +s.headers["X-CSRF-Token"] = csrf + +# Reads — no CSRF needed. +stats = s.get("http://localhost:8080/api/stats").json() + +# Writes — CSRF auto-attached via session headers. +s.post("http://localhost:8080/api/queues/default/pause") +``` + +## SSRF guard for outbound URLs + +Webhook URLs entered through the dashboard are vetted before any +delivery happens. By default the server rejects: + +- Non-`http`/`https` schemes +- `localhost`, `*.localhost`, `*.local`, `*.internal`, `*.intranet`, + `*.lan`, `*.private` +- Resolved addresses in any RFC1918 / loopback / link-local / + multicast range (including the AWS metadata service at + `169.254.169.254`) + +Set `TASKITO_WEBHOOKS_ALLOW_PRIVATE=1` to disable the guard for local +development against `http://localhost`. Production should keep the +guard on. + +## Limitations + +- **One role** today (`admin`). Read-only viewers and per-route + permissions are planned; the column already exists on the user + record. +- **No SSO / OIDC** out of the box. Put the dashboard behind a reverse + proxy (oauth2-proxy, Cloudflare Access) if your team uses SSO; the + built-in auth then becomes a fallback for service accounts. +- **Password rotation** has an endpoint but no UI yet — invoke + `POST /api/auth/change-password` directly. diff --git a/docs/content/docs/guides/observability/dashboard.mdx b/docs/content/docs/guides/observability/dashboard.mdx index 192500b2..e19fc825 100644 --- a/docs/content/docs/guides/observability/dashboard.mdx +++ b/docs/content/docs/guides/observability/dashboard.mdx @@ -1,15 +1,18 @@ --- title: Web Dashboard -description: "Zero-dependency built-in web UI for browsing jobs, metrics, workers, and managing the queue." +description: "Zero-dependency built-in web UI for browsing jobs, configuring webhooks, tuning per-task runtime limits, and managing the queue." --- import { Callout } from "fumadocs-ui/components/callout"; import { Tab, Tabs } from "fumadocs-ui/components/tabs"; taskito ships with a built-in web dashboard for monitoring jobs, inspecting -dead letters, and managing your task queue in real time. The dashboard is a -single-page application served directly from the Python package — **zero -extra dependencies required**. +dead letters, configuring webhooks, tuning per-task runtime limits, and +managing your task queue in real time. The dashboard is a single-page +application served directly from the Python package — **zero extra +dependencies required**. + +![Overview page with stats cards, throughput chart, and recent activity](/screenshots/dashboard/overview.png) ## Launching the dashboard @@ -51,8 +54,8 @@ taskito dashboard --app myapp:queue --host 0.0.0.0 --port 9000 ``` - The dashboard reads directly from the same SQLite database as the worker. - You can run them side by side without any coordination: + The dashboard reads directly from the same database as the worker. You + can run them side by side without any coordination: ```bash # Terminal 1 @@ -63,172 +66,128 @@ taskito dashboard --app myapp:queue --host 0.0.0.0 --port 9000 ``` -## Dashboard features - -The dashboard is a React + Vite + TypeScript SPA routed via TanStack Router, -styled with Tailwind v4 and shadcn/ui, and shipped as hash-busted multi-file -assets under `py_src/taskito/static/dashboard/`. - -### Design - -- **Dark and light mode** — Toggle between themes via the sun/moon button in the header. Preference is stored in `localStorage` and follows the system scheme by default. -- **Auto-refresh** — Configurable refresh interval (2s, 5s, 10s, or off) via the header dropdown. All pages auto-refresh at the selected interval; TanStack Query handles caching and background revalidation. -- **Command palette** — `⌘K` / `Ctrl+K` opens a cmdk palette for route navigation and common actions. -- **Icons** — Lucide icons throughout for visual clarity. -- **Toast notifications** — Every action shows a success or error toast via sonner. Optimistic mutations update the UI immediately and roll back on error. -- **Destructive confirms** — Irreversible actions (purge, retry all) use a type-to-confirm dialog. -- **Loading states** — Skeleton screens for tables and cards, error boundaries with retry. -- **Responsive layout** — Sidebar navigation with grouped sections (Monitoring, Infrastructure, Advanced). The main content area scrolls independently. - -### Pages - -| Page | Description | -|---|---| -| **Overview** | Stats cards with status icons, throughput sparkline chart, recent jobs table | -| **Jobs** | Filterable job listing (status, queue, task, metadata, error, date range) with pagination | -| **Job Detail** | Full job info, error history, task logs, replay history, dependency DAG visualization | -| **Metrics** | Per-task performance table (avg, P50, P95, P99) with timeseries chart and time range selector | -| **Logs** | Structured task execution logs with task/level filters | -| **Workers** | Worker cards with heartbeat status, queue assignments, and tags | -| **Queues** | Per-queue stats (pending/running), pause and resume controls | -| **Resources** | Worker DI runtime status — health, scope, init duration, pool stats, dependencies | -| **Circuit Breakers** | Automatic failure protection state (closed/open/half_open), thresholds, cooldowns | -| **Dead Letters** | Failed jobs that exhausted retries — retry individual entries or purge all | -| **System** | Proxy reconstruction and interception strategy metrics | - - - The built SPA ships inside the Python wheel under - `py_src/taskito/static/dashboard/` and is served by the Python dashboard - process. No Node.js, no pnpm, no CDN at runtime — just `pip install - taskito`. Node.js and pnpm are only needed by contributors rebuilding the - dashboard source in `dashboard/`. + + On a fresh database the dashboard refuses every API request with + ``503 setup_required`` until you create the first admin. See + [Authentication](/guides/observability/dashboard-auth) for the full + flow, including the env-var bootstrap path useful for managed + deployments. -## Tutorial - -This walkthrough covers every dashboard page and how to use it. - -### Step 1: start the dashboard - -Start a worker and the dashboard in two terminals: - -```bash -# Terminal 1 — start the worker -taskito worker --app myapp:queue - -# Terminal 2 — start the dashboard -taskito dashboard --app myapp:queue -``` - -You should see: - -``` -taskito dashboard → http://127.0.0.1:8080 -Press Ctrl+C to stop -``` - -Open `http://localhost:8080` in your browser. - -### Step 2: Overview page - -The first page you see is the **Overview**. It shows: - -- **Stats cards** — Six cards at the top showing pending, running, completed, failed, dead, and cancelled job counts. -- **Throughput chart** — A green sparkline showing jobs processed per second over the last 60 refresh intervals. -- **Recent jobs table** — The 10 most recent jobs. Click any row to open its detail view. - -The stats update automatically based on the refresh interval you select in -the header (default: 5 seconds). +## Pages -### Step 3: browsing and filtering jobs +The dashboard is grouped by intent — Monitoring (what's happening), +Infrastructure (where it runs), Reliability (when it goes wrong), and +Configuration (how to change it): -Click **Jobs** in the sidebar. This page shows: - -- **Stats grid** — Same six stat cards as the overview. -- **Filter panel** — Status dropdown, queue, task, metadata, error text, created-after/before pickers. -- **Results table** — Paginated list showing ID, task, queue, status, priority, progress, retries, and created time. - -Use the **Prev / Next** buttons at the bottom to paginate. - -### Step 4: inspecting a job - -Click any job row to open the **Job Detail** page. The detail card shows: - -- A colored top border matching the job status (green for complete, red for failed, etc.) -- Full job ID, status badge, task name, queue, priority, progress bar, retries, timestamps -- **Error** field (if the job failed) displayed in a red-highlighted box -- Unique key and metadata (if set) - -**Actions:** - -- **Cancel Job** — Visible only for pending jobs. Sends a cancel request and shows a toast. -- **Replay** — Re-enqueue the job with the same payload. Navigates to the new job's detail page. - -**Sections below the detail card:** Error History, Task Logs, Replay -History, and a Dependency Graph visualization for jobs with dependencies. - -### Step 5: monitoring metrics +| Group | Page | What it does | +|---|---|---| +| Monitoring | **Overview** | Stats cards, throughput sparkline, queue-by-queue table | +| Monitoring | **Jobs** | Filterable job listing (status, queue, task, metadata, error, date range) | +| Monitoring | **Job Detail** | Full job info, error history, task logs, replay history, dependency DAG | +| Monitoring | **Metrics** | Per-task performance (avg, P50, P95, P99) with timeseries chart | +| Monitoring | **Logs** | Structured task execution logs with task/level filters | +| Infrastructure | **Queues** | Per-queue stats, pause and resume controls | +| Infrastructure | **Workers** | Worker cards with heartbeat status and queue assignments | +| Infrastructure | **Resources** | Worker DI runtime status — health, scope, init duration | +| Reliability | **Dead Letters** | Failed jobs that exhausted retries — retry or purge | +| Reliability | **Circuit Breakers** | Automatic failure protection state, thresholds, cooldowns | +| Reliability | **System** | Proxy reconstruction and interception strategy metrics | +| Configuration | **Tasks** | Decorator defaults + runtime overrides per task ([guide](/guides/observability/task-overrides)) | +| Configuration | **Webhooks** | HTTP event subscriptions with delivery history + replay ([guide](/guides/extensibility/events-webhooks)) | +| Configuration | **Settings** | Dashboard branding, external links, integrations | + +The full REST API surface is documented at +[Dashboard REST API](/guides/observability/dashboard-api). + +## Design + +The dashboard is a React 19 + Vite 8 + TypeScript SPA routed via TanStack +Router, styled with Tailwind v4 and shadcn/ui, and shipped as +hash-busted multi-file assets under `py_src/taskito/static/dashboard/`. + +- **Dark and light mode** — Toggle via the sun/moon button in the header. + Preference is stored in `localStorage` and follows the system scheme by + default. +- **Auto-refresh** — Configurable interval (2s, 5s, 10s, or off) via the + header dropdown. TanStack Query handles caching and background + revalidation. +- **Command palette** — `⌘K` / `Ctrl+K` opens a `cmdk` palette for route + navigation. +- **Toast notifications** — Every action shows a success or error toast. + Optimistic mutations update the UI immediately and roll back on error. +- **Destructive confirms** — Irreversible actions (purge, delete) use a + type-to-confirm dialog. +- **Loading + error states** — Skeleton screens for tables and cards; + error boundaries with retry. -Click **Metrics** in the sidebar. This page shows a time-range selector (1h -/ 6h / 24h), a stacked bar chart of success/failure counts per time bucket, -and a per-task table with avg / P50 / P95 / P99 / min / max latency. + + The built SPA ships inside the Python wheel under + `py_src/taskito/static/dashboard/` and is served by the Python + dashboard process. No Node.js, no pnpm, no CDN at runtime — just + `pip install taskito`. Node.js and pnpm are only needed by + contributors rebuilding the dashboard source. + -### Step 6: viewing logs +## Walkthrough -Click **Logs** in the sidebar. Filter by task name or level. Each log entry -shows time, level badge, task name, job ID, message, and structured extra -data. +### Sign in -### Step 7: workers +On the first visit you'll see the setup form. After you create the +first admin, every subsequent visit shows the sign-in form. -Click **Workers**. Each active worker is displayed as a card showing the -green dot for liveness, worker ID, queues consumed, last heartbeat, -registration time, and tags. +![First-run setup form for the initial admin](/screenshots/dashboard/auth-setup.png) -### Step 8: managing queues +See [Authentication](/guides/observability/dashboard-auth) for the env +var-based bootstrap (`TASKITO_DASHBOARD_ADMIN_USER` / +`TASKITO_DASHBOARD_ADMIN_PASSWORD`) and the CSRF model. -Click **Queues**. Per-queue table with pending/running counts, pause/resume -buttons, and status badges. +### Browse jobs and dig into one - - Pausing a queue prevents the scheduler from dequeuing new jobs from it. - Jobs already running will complete normally. Enqueuing new jobs still - works — they'll be picked up when the queue is resumed. - +The **Jobs** page shows a filterable, paginated table. Filters live in +the sidebar panel: status, queue, task name, metadata search, error +text, date range. Click any row to open the detail view with the full +job state, error history, task logs, replay history, and a dependency +DAG for jobs with relationships. -### Step 9: resources +![Jobs page with filter panel and paginated list](/screenshots/dashboard/jobs.png) -Click **Resources**. Shows registered worker DI runtime entries (name, -scope, health, init duration, recreations, dependencies, pool stats). +### Configure webhooks -### Step 10: circuit breakers +The **Webhooks** page lists every HTTP endpoint subscribed to job +events. Add new endpoints with the **+ New webhook** button. Each row +has a dropdown menu — send a test event, enable/disable, rotate the +signing secret, or view the delivery history. Full guide: +[Events & Webhooks](/guides/extensibility/events-webhooks). -Click **Circuit Breakers**. State badge (closed/open/half_open), failure -count, threshold, window, cooldown. +![Webhooks page with three subscriptions in different states](/screenshots/dashboard/webhooks-list.png) -### Step 11: dead letter queue +### Tune per-task limits -Click **Dead Letters**. Retry individual entries with the **Retry** button, -or purge all with the type-to-confirm **Purge All** in the header. +The **Tasks** page lists every registered task with its decorator +defaults and any active runtime override. Click **Edit** to open a +side sheet with two tabs: **Overrides** (rate limit, concurrency, +retries, timeout, priority, paused) and **Middleware** (toggle each +middleware on or off for the task). Full guide: +[Task & Queue Overrides](/guides/observability/task-overrides). -### Step 12: system internals +![Tasks page with one task overridden in accent](/screenshots/dashboard/tasks-list.png) -Click **System**. Two tables: Proxy Reconstruction (per-handler metrics) -and Interception (per-strategy metrics). +### Manage queues -### Step 13: switching themes +The **Queues** page lists every queue mentioned by a registered task, +showing pending/running counts and the current pause state. Pause and +resume buttons take effect immediately on the running worker. -Click the sun/moon icon in the top-right of the header. +![Queues page with per-queue controls](/screenshots/dashboard/queues.png) -### Step 14: changing refresh rate +### Inspect workers -Use the **Refresh** dropdown in the header — 2s, 5s, 10s, or off. +The **Workers** page lists every registered worker with heartbeat +status, the queues it consumes from, tags, and registration time. Stale +workers (no heartbeat for 30s) automatically transition to "offline". - - The dashboard also exposes a full JSON API. See the - [Dashboard REST API](/guides/observability/dashboard-api) reference - for all endpoints. - +![Workers page showing a single active worker](/screenshots/dashboard/workers.png) ## Development @@ -253,13 +212,23 @@ pnpm run build automatically from the version pinned in `dashboard/package.json`. -The build produces a static `index.html` plus hashed JS/CSS chunks under -`py_src/taskito/static/dashboard/`. The built assets aren't committed — -release tooling runs `pnpm -C dashboard build` before packaging so the -wheel ships them. +The build produces a static `index.html` plus hashed JS/CSS chunks +under `py_src/taskito/static/dashboard/`. The built assets aren't +committed — release tooling runs `pnpm -C dashboard build` before +packaging so the wheel ships them. - - The dashboard does not include authentication. If you expose it beyond - `localhost`, place it behind a reverse proxy with authentication (e.g. - nginx with basic auth, or an OAuth2 proxy). - +### Regenerating screenshots + +Every dashboard screenshot in this documentation is produced by a +reproducible script that seeds a fresh queue, walks the UI in headless +Chrome via Playwright, and writes PNGs into `docs/public/screenshots/dashboard/`: + +```bash +uv sync --extra docs # one-time +uv run python -m playwright install chromium # one-time +uv run python scripts/capture_docs_screenshots.py +``` + +Pass `--skip-capture` to start the seeded demo dashboard in a browser +without running Playwright — useful when iterating on UI changes +locally. diff --git a/docs/content/docs/guides/observability/index.mdx b/docs/content/docs/guides/observability/index.mdx index 9570dd97..c1153632 100644 --- a/docs/content/docs/guides/observability/index.mdx +++ b/docs/content/docs/guides/observability/index.mdx @@ -10,4 +10,6 @@ Monitor, log, and inspect your task queue in real time. | [Monitoring & Hooks](/guides/observability/monitoring) | Queue stats, progress tracking, worker heartbeat, and alerting hooks | | [Structured Logging](/guides/observability/logging) | Per-task structured logs with automatic context | | [Web Dashboard](/guides/observability/dashboard) | Built-in web UI for browsing jobs, metrics, and worker status | +| [Dashboard Authentication](/guides/observability/dashboard-auth) | Setup flow, session cookies, CSRF, env-var bootstrap | +| [Task & Queue Overrides](/guides/observability/task-overrides) | Runtime knobs for retry policy, rate limits, concurrency, and middleware toggles | | [Dashboard REST API](/guides/observability/dashboard-api) | Programmatic access to all dashboard data via REST endpoints | diff --git a/docs/content/docs/guides/observability/meta.json b/docs/content/docs/guides/observability/meta.json index 474b3748..145ef7f2 100644 --- a/docs/content/docs/guides/observability/meta.json +++ b/docs/content/docs/guides/observability/meta.json @@ -1,4 +1,12 @@ { "title": "Observability", - "pages": ["monitoring", "logging", "notes", "dashboard", "dashboard-api"] + "pages": [ + "monitoring", + "logging", + "notes", + "dashboard", + "dashboard-auth", + "task-overrides", + "dashboard-api" + ] } diff --git a/docs/content/docs/guides/observability/task-overrides.mdx b/docs/content/docs/guides/observability/task-overrides.mdx new file mode 100644 index 00000000..562000bd --- /dev/null +++ b/docs/content/docs/guides/observability/task-overrides.mdx @@ -0,0 +1,222 @@ +--- +title: Task & Queue Overrides +description: "Tune retry policy, concurrency, rate limits, and middleware per task from the dashboard — without redeploying." +--- + +import { Callout } from "fumadocs-ui/components/callout"; +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; + +The decorator-declared values on `@queue.task(...)` are *defaults*. The +dashboard lets operators override them at runtime — adjust a rate +limit, pause a misbehaving task, lower the retry budget after an +incident — without redeploying. + +Two surfaces: + +- **Task overrides** — per-task knobs: rate limit, concurrency, + retries, retry backoff, timeout, priority, paused. +- **Queue overrides** — per-queue knobs: rate limit, concurrency, + paused. + +Plus a separate but related toggle for **middleware** on a per-task +basis (see [§ Middleware toggles](#middleware-toggles) below). + +## Tasks page + +The **Tasks** page lists every task registered on the live Queue with +its decorator defaults, any active override, and the *effective* value +(default merged with override). Overridden values render in accent so +"which knobs are pinned" is visible at a glance. + +![Tasks page with one override active](/screenshots/dashboard/tasks-list.png) + +Click **Edit** on any row to open the side sheet. The form mirrors +the decorator kwargs: + +![Override side sheet on the send_email task](/screenshots/dashboard/task-edit-overrides.png) + +- **Empty input** → inherit the decorator default +- **Value entered** → override the default +- **Clear override** → remove the row entirely; task falls back to + every decorator value + +| Field | Decorator equivalent | +|---|---| +| Rate limit | `rate_limit="100/m"` | +| Max concurrent | `max_concurrent=10` | +| Max retries | `max_retries=5` | +| Timeout | `timeout=300` (seconds) | +| Priority | `priority=2` | +| Paused | n/a — runtime-only | + +## When changes take effect + +This is the most important thing to internalize: + +| Change | Takes effect | +|---|---| +| Pausing a task | Next worker restart for the rate-limit/concurrency side effects, **but** the paused flag is plumbed through the live `paused_queues` mechanism so the scheduler stops dequeuing immediately for queue-level pauses | +| Pausing a queue | **Immediately** on running workers (writes to `paused_queues`) | +| Rate limit / max concurrent / retries / timeout / priority on a task | **Next worker restart** — the values are baked into `PyTaskConfig` at `run_worker` time and passed to the Rust scheduler | +| Rate limit / max concurrent on a queue | **Next worker restart** — same mechanism, merged into `queue_configs` JSON sent to Rust | +| Middleware on/off per task | **Next job** — middleware lookup happens at every task invocation | + +This split is intentional. Pause is a fast-path safety valve; +retry/rate-limit changes need scheduler buy-in and are deliberately +"restart to apply" so operators have a clear mental model of when the +new values take over. + + + Pulling rate-limit / retries / timeout into the Rust scheduler's + per-poll lookup would let those changes hot-reload too. The + ``PyTaskConfig`` → scheduler path would gain a cache-invalidation + counter (incremented on every override write) the poller checks + before each admission cycle. Until then, restart the worker to apply + changes to those knobs. + + +## Programmatic API + +The dashboard CRUD is a thin shell over the `Queue` API — you can +script overrides the same way: + +```python +from taskito import Queue + +queue = Queue(db_path="tasks.db") + +# Tasks +queue.set_task_override( + "myapp.tasks.send_email", + rate_limit="200/m", + max_retries=10, +) +queue.set_task_override("myapp.tasks.send_email", paused=True) # immediate-ish +queue.clear_task_override("myapp.tasks.send_email") + +# Queues +queue.set_queue_override("email", max_concurrent=5) +queue.set_queue_override("email", paused=True) # immediate +queue.clear_queue_override("email") + +# Discovery — what's registered + what's overridden +for entry in queue.registered_tasks(): + print(entry["name"], entry["effective"]) + +for entry in queue.registered_queues(): + print(entry["name"], entry["effective"]) +``` + +Allowed task override fields: `rate_limit`, `max_concurrent`, +`max_retries`, `retry_backoff`, `timeout`, `priority`, `paused`. + +Allowed queue override fields: `rate_limit`, `max_concurrent`, +`paused`. + +The store validates types and ranges before persisting — a typo (or a +typed-in `-1`) raises `ValueError` rather than writing garbage. The +dashboard handler surfaces the same errors as `400 Bad Request`. + +## Storage + +Overrides live as JSON entries under +`overrides:task:` and `overrides:queue:` keys +in the `dashboard_settings` table. SQLite, PostgreSQL, and Redis +backends all support them uniformly — no new schema. The encoded +JSON only includes fields the operator actually set, so removing a +field by passing `None` shrinks the row rather than leaving stale +data. + +## Middleware toggles + +Middleware are normally global (via `Queue(middleware=[...])`) or +per-task (via `@queue.task(middleware=[...])`). The dashboard adds a +third axis: **temporarily disable a middleware for one task** without +touching code. Useful when: + +- A logging middleware is generating noise for one chatty task +- A retry-policy middleware is interfering with a specific debug job +- You want to A/B compare runs with and without a middleware + +### Toggle from the dashboard + +Open the same side sheet as for overrides and switch to the +**Middleware** tab. Each registered middleware shows up as a pill +button — green for enabled, grey for disabled. + +![Middleware tab with one toggle disabled](/screenshots/dashboard/task-edit-middleware.png) + +Changes take effect on the **next job** — no worker restart required. +The middleware lookup runs at every task invocation, so the next time +the task is dequeued the new chain applies. + +### Naming middleware + +Every `TaskMiddleware` carries a stable `name` attribute that the +disable list keys on. By default the name is the fully-qualified class +path (e.g. `myapp.middleware.LoggingMiddleware`) so it survives +restarts. Override it to pin a shorter, user-facing name: + +```python +from taskito.middleware import TaskMiddleware + +class SentryMiddleware(TaskMiddleware): + name = "sentry" # shows up as "sentry" in the dashboard + + def before(self, ctx): + ... +``` + +The dashboard rejects toggles for unknown middleware names (`404`), so +typos can't silently write no-op disables. + +### Programmatic API + +```python +queue.list_middleware() # [{name, class_path, scopes}, ...] +queue.disable_middleware_for_task("myapp.tasks.send_email", "demo.metrics") +queue.enable_middleware_for_task("myapp.tasks.send_email", "demo.metrics") +queue.clear_middleware_disables("myapp.tasks.send_email") +queue.get_disabled_middleware_for("myapp.tasks.send_email") # ["demo.metrics"] +``` + +## Examples + +### Pause one task without redeploying + +A flaky third-party API is rate-limiting your `send_email` task and +you want to stop new sends while you investigate: + +```python +queue.set_task_override("myapp.tasks.send_email", paused=True) +# ... or from the dashboard: Tasks → send_email → Edit → check "Pause this task" +``` + +Existing in-flight jobs finish normally; nothing new dequeues until +you clear the override. + +### Lower a rate limit during an incident + +Cut `send_email` from 200/m to 30/m while a downstream is recovering: + +```python +queue.set_task_override("myapp.tasks.send_email", rate_limit="30/m") +# Restart the workers for the change to take effect on the scheduler. +``` + +### Disable a heavyweight middleware for one task + +A debug middleware is dumping payloads for every invocation, and you +want to keep it on for everything except your high-volume +`process_image` task: + +```python +queue.disable_middleware_for_task("myapp.tasks.process_image", "debug.payload") +# Takes effect on the next process_image job, no restart needed. +``` + +## Reference + +- [Dashboard REST API: Tasks & overrides](/guides/observability/dashboard-api#tasks-and-overrides) +- [Dashboard REST API: Middleware](/guides/observability/dashboard-api#middleware) +- [Tasks decorator reference](/api-reference/task) diff --git a/docs/public/screenshots/dashboard/auth-login.png b/docs/public/screenshots/dashboard/auth-login.png new file mode 100644 index 0000000000000000000000000000000000000000..fbcfe56da6877f0d32928cabdeff0f89c81ec480 GIT binary patch literal 20006 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 C#TXI* literal 0 HcmV?d00001 diff --git a/docs/public/screenshots/dashboard/auth-setup.png b/docs/public/screenshots/dashboard/auth-setup.png new file mode 100644 index 0000000000000000000000000000000000000000..98c08268957610489912eed5e200a15107ec0673 GIT binary patch literal 26648 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

KgV|sq#nUe$vyA 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

Ur!x2EJH*noyv!4lfp@(}Uyy8RN zT&2Iz@_+B)e8?-rZ;`*Z+P{ngK8FoH>fTz%7I&9M-?~C>sF@>zLOsEG7aFJfF_Qjk zr>8X7qkPTm6-|~$b|VVFw=FIMN|G~KFS2^>1y(ea%{s}i?cQPuQed3MnrYzQ8gkTh z7$JLIO(q|nH>I<##9g0($7a`~CENz%|rFE$;G>*!T*{Fh4;L$^YWhyVyj%5c@9>Pvhg4m6H zh^|%XwSL6VcL77DWVO}u`Qw+I{DyyTJ;3O^iu-m53F zqgRu(J4ap(w#BxIg) zY6XI<{KDLIE`shoA!5dl7#V@eqG+SUG*$WJbyrp9uy9LM6w^RyWa+32I|7m+_L z+MA2xpPxAI)2Pt_7zk9#9~()RR7$*cj*6}I-#dG9xN}(%{Bnn)0DtgI{DN>~yiSC0 zYy4FDUJ04=(H4In?n{n#IR=DQbz2T6Ki7E^3}N{hH)H%GN4)3HvI1(Og2f4|`VO>O z!IBLbeso%j5XQrW+|qNyxV*xoR(AZ4mW;J)7B6Q7Dl_$+&Z~{?CUJdNoQ5v}%JU*B zuC$LS{JCqkBHF=nWH?(#B35r?U>j7a+X^pMzvEjKRJ!_DVV~c8!uSGa&0H>Cja4j0 zFt1qwHe8NT<(BS4>gOIkcV*j1@Q2^o3TOwC(y6OWp40;m3#1KtrmAZFRg+l~!RO@- zotU&)!Q}@lKsjuWvZl3EHKQ$f%*{H;`x{oDYTOoSzAo%$w+9)tC$n(sYdv2td*R|W zxaVz;(&iz-agJ2mbB>KGc}4b?1mU$B+QG?knP^n(y>p9K`vBOOK%ML+>0pM_#2Z4t zD8c+-9#4BOzV7z5PWFjuX*Am)s9eu@cPDUuN0_xpN$xRwu-+N!0==bUA@Jj}Bh!1V zx7BHX-y2$UWVVoVR_ld33UMVe>?4iTu+0s{cvg>y?+#WIA$?}o(&JsCEb1C zbu03StSjq!Z|9NYteZBBbx0;{k0==tYJfDllp{KsDjkv^ubx(>E4Sqp3*bzodLrxg zl!p9TcYS#H!H3jWxb3}G`LDtW9<5Wa(Fr$Ra;QP5pxn$G0(zV$_O~lJMDX~DNLD{l>z0S59EWp)8K|T+&7Xmrn$DCMaJW4 zoBZExaY8@TodARSAEb{zHD>#x5!ngQ$Nx%G`R9f|hrsbUY=^acR zR|mh9m+8%QhQ$CheBZs%2Nfw)Qmx92;*_Eas_^{=Lf00&`%PXy_9O25yk|4y6yEYn zMI>`jA3Vv9{!$j5oy9ps%+^dpOcM<*%=%(%B`-_iFF$x+@6yvcOLYtfNXh}n<+}p* zQ4Vxru_Z})6_T%X%j|Y_0O@t@32cek@GMuXFCU3mY5;VqFHsV4-#?>dcVVa&eb=>vc|(rTfnmDdnGJKmW9zKO>P_UPTNc!=_Ioi_%`3YmGJ zDVxeisFlwJ^caJrOoU+C_LL_COT#H#U2_DBeMB;h#V&<}=}QPsXKbR}fdC7&2M#^4 z(`sx9;`aG1sJ!zFLWxZ^t@E%prhz1nN^@`NAVC-7$+*+vaB)J|u>c$2r`IPSnR!LL z^0;9e6B5shZpJ&w|I`?P%45%#bM939gd5t_39vX9S|$I!O9vb;oSE|+d<8@`=SV>> zu+P*w$?HWM^GC9LM@m_V5D>QN;)88>G?}y41#JWaTkrZd%cK1#AYo_u;LxoUlqoe0 zB{^vj`$aMld*w>#Zo{KzfPTXe$kL&jiW^g}b`aL%(LrMuwl2RR?1mgVlNaDg!`V=1 z*>R&JQJGV7WhW7bk|F0a=$(%G%IG9D!QPD>TWcuVgRUC&k?Q!MDX0gh5b=3_2-LCe zaip>Kx?ChClRaFciqhx`wBbDztwC=;wDO$5j%CYzTD}50?Hy+nSyml!`ic}`A=hHc z+?P^zgd*3Ax#WObvxo$b5LPTO3vtYa5hX!A>-Kk?2-g($KuuXSJIreiw?UeoVAsIH zj>~K@z8@h2Q4EHKi+@$$9C$WQJ@2K*5hCkncyk&B$*>rjh%AP=@P2?v3b|O6h&Uh* zQ6{};;1=(~+2t5rW8l5E62j5|=(n42N2T^%y?P~iroFwLoN={X*UL)VjFALSq~uWM zenIfZa#Y9%?oKqAbH6q7ksXk*>Uj#z8GN}u``<$ozi07pX8-$Pj6eV6mU#Ga_a8LM cdTp*sl1Wr4={y+%fRA&h;r~@}%I(hI0Q1ggi~s-t literal 0 HcmV?d00001 diff --git a/docs/public/screenshots/dashboard/overview.png b/docs/public/screenshots/dashboard/overview.png new file mode 100644 index 0000000000000000000000000000000000000000..ef6e3a385ed4ab967a19e400e884ae5a9b46db06 GIT binary patch literal 68973 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
+

Sign in

- Enter your dashboard credentials to continue. + {passwordEnabled + ? "Enter your dashboard credentials to continue." + : "Choose a provider to continue."}

- - - {error ? ( + + {hasOAuth ? ( +
+ {oauthProviders.map((provider) => ( + + ))} +
+ ) : null} + + {hasOAuth && passwordEnabled ? ( +
+
+ or sign in with password +
+
+ ) : null} + + {passwordEnabled ? ( + + + + {error ? ( +
+ + {error} +
+ ) : null} + + + ) : null} + + {!passwordEnabled && !hasOAuth ? (
- {error} + + No login methods are configured. Set{" "} + TASKITO_DASHBOARD_PASSWORD_AUTH_ENABLED=true or configure an OAuth + provider. +
) : null} - - +
); } diff --git a/dashboard/src/features/auth/components/oauth-button.tsx b/dashboard/src/features/auth/components/oauth-button.tsx new file mode 100644 index 00000000..e5f53bcd --- /dev/null +++ b/dashboard/src/features/auth/components/oauth-button.tsx @@ -0,0 +1,85 @@ +import { KeyRound } from "lucide-react"; +import { oauthStartUrl } from "../api"; +import type { AuthProvider } from "../types"; + +interface OAuthButtonProps { + provider: AuthProvider; + /** Path to send the user to after a successful login. Validated server-side. */ + next?: string; +} + +/** "Sign in with X" button — renders as a plain anchor so the browser + * follows the 302 from ``/api/auth/oauth/start/{slot}`` natively. + * + * Styling matches the dashboard's design system without depending on the + * primary :class:`Button` component (we need anchor semantics, not button). + */ +export function OAuthButton({ provider, next }: OAuthButtonProps) { + return ( +
+ + Continue with {provider.label} + + ); +} + +function ProviderIcon({ type }: { type: AuthProvider["type"] }) { + if (type === "google") { + return ; + } + if (type === "github") { + return ; + } + // Generic OIDC — operator-configured SSO. + return ; +} + +/** Official Google "G" mark — inlined SVG so we don't pull in a brand-asset + * dependency. Matches Google's brand guidelines for sign-in buttons. + */ +function GoogleGlyph() { + return ( + + Google + + + + + + ); +} + +/** GitHub Octocat (Mark) — inlined so we don't depend on a brand-icon set + * that might drop it (lucide-react 1.x removed brand icons). + */ +function GitHubGlyph() { + return ( + + GitHub + + + ); +} diff --git a/dashboard/src/features/auth/hooks.ts b/dashboard/src/features/auth/hooks.ts index 449ed442..b1481a35 100644 --- a/dashboard/src/features/auth/hooks.ts +++ b/dashboard/src/features/auth/hooks.ts @@ -1,10 +1,19 @@ import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ApiError } from "@/lib/api-client"; -import { changePassword, fetchAuthStatus, fetchWhoami, login, logout, setup } from "./api"; +import { + changePassword, + fetchAuthStatus, + fetchProviders, + fetchWhoami, + login, + logout, + setup, +} from "./api"; import type { WhoamiResponse } from "./types"; export const AUTH_STATUS_KEY = ["auth", "status"] as const; export const WHOAMI_KEY = ["auth", "whoami"] as const; +export const PROVIDERS_KEY = ["auth", "providers"] as const; export function authStatusQuery() { return queryOptions({ @@ -42,6 +51,15 @@ export function whoamiQuery() { }); } +/** List of OAuth providers exposed by the server. */ +export function providersQuery() { + return queryOptions({ + queryKey: PROVIDERS_KEY, + queryFn: ({ signal }) => fetchProviders(signal), + staleTime: 60_000, + }); +} + export function useAuthStatus() { return useQuery(authStatusQuery()); } @@ -50,6 +68,10 @@ export function useWhoami() { return useQuery(whoamiQuery()); } +export function useAuthProviders() { + return useQuery(providersQuery()); +} + export function useLogin() { const qc = useQueryClient(); return useMutation({ diff --git a/dashboard/src/features/auth/index.ts b/dashboard/src/features/auth/index.ts index 259cdc02..40babe48 100644 --- a/dashboard/src/features/auth/index.ts +++ b/dashboard/src/features/auth/index.ts @@ -1,9 +1,12 @@ export { AuthGate } from "./components/auth-gate"; export { LoginForm } from "./components/login-form"; +export { OAuthButton } from "./components/oauth-button"; export { SetupForm } from "./components/setup-form"; export { UserMenu } from "./components/user-menu"; export { authStatusQuery, + providersQuery, + useAuthProviders, useAuthStatus, useChangePassword, useLogin, @@ -13,10 +16,12 @@ export { whoamiQuery, } from "./hooks"; export type { + AuthProvider, AuthSession, AuthStatus, AuthUser, LoginResponse, + ProvidersResponse, SetupResponse, WhoamiResponse, } from "./types"; diff --git a/dashboard/src/features/auth/types.ts b/dashboard/src/features/auth/types.ts index 4596f6e5..e61b6328 100644 --- a/dashboard/src/features/auth/types.ts +++ b/dashboard/src/features/auth/types.ts @@ -30,3 +30,18 @@ export interface WhoamiResponse { csrf_token: string; expires_at: number; } + +/** One entry in the providers listing response. */ +export interface AuthProvider { + /** Stable URL-safe identifier used in the callback path. */ + slot: string; + /** Human-readable button label. */ + label: string; + /** Provider type, drives which icon is rendered. */ + type: "google" | "github" | "oidc"; +} + +export interface ProvidersResponse { + password_enabled: boolean; + providers: AuthProvider[]; +} diff --git a/docs/content/docs/guides/observability/dashboard-auth.mdx b/docs/content/docs/guides/observability/dashboard-auth.mdx index 38237684..95964778 100644 --- a/docs/content/docs/guides/observability/dashboard-auth.mdx +++ b/docs/content/docs/guides/observability/dashboard-auth.mdx @@ -146,13 +146,18 @@ Set `TASKITO_WEBHOOKS_ALLOW_PRIVATE=1` to disable the guard for local development against `http://localhost`. Production should keep the guard on. +## SSO / OAuth login + +Native sign-in with Google, GitHub, and any OIDC-compliant provider +(Okta, Auth0, Keycloak, Microsoft Entra) is available alongside +password auth — see [Dashboard SSO (OAuth & OIDC)](./dashboard-oauth). +Operators can mix-and-match providers or run an OAuth-only deployment +by setting `TASKITO_DASHBOARD_PASSWORD_AUTH_ENABLED=false`. + ## Limitations - **One role** today (`admin`). Read-only viewers and per-route permissions are planned; the column already exists on the user record. -- **No SSO / OIDC** out of the box. Put the dashboard behind a reverse - proxy (oauth2-proxy, Cloudflare Access) if your team uses SSO; the - built-in auth then becomes a fallback for service accounts. - **Password rotation** has an endpoint but no UI yet — invoke `POST /api/auth/change-password` directly. diff --git a/docs/content/docs/guides/observability/dashboard-oauth.mdx b/docs/content/docs/guides/observability/dashboard-oauth.mdx new file mode 100644 index 00000000..a43310cf --- /dev/null +++ b/docs/content/docs/guides/observability/dashboard-oauth.mdx @@ -0,0 +1,288 @@ +--- +title: Dashboard SSO (OAuth & OIDC) +description: "Sign in with Google, GitHub, or any OIDC provider. Per-domain / per-org allowlists, OAuth-only mode." +--- + +import { Callout } from "fumadocs-ui/components/callout"; +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; + +The dashboard ships native sign-in for **Google**, **GitHub**, and any +**OIDC-compliant** provider (Okta, Auth0, Keycloak, Microsoft Entra, Dex, +…). Multiple OIDC providers can run side-by-side, each rendered as its +own button on the login screen. + +OAuth is **off by default**. Setting any provider's env vars turns it +on; password login remains enabled unless you opt out explicitly. + + + OAuth requires the `authlib` extra: + + ```bash + pip install 'taskito[oauth]' + # or with uv: + uv pip install 'taskito[oauth]' + ``` + + Skip this if you only use password login. + + +## How it works + +``` +Browser Dashboard Provider + │ │ │ + │ GET /login │ │ + ├─────────────────►│ │ + │ GET /api/auth/providers │ + ├─────────────────►│ │ + │ {providers, password_enabled} │ + │◄─────────────────┤ │ + │ click "Continue with Google" │ + │ GET /api/auth/oauth/start/google │ + ├─────────────────►│ mint state+nonce+PKCE + │ │ persist state row │ + │ 302 Location: │ + │◄─────────────────┤ │ + │ GET ─────────────────────► + │ user consents on Google │ + │◄──────────────────────────────────────┤ + │ GET /api/auth/oauth/callback/google?code=…&state=… + ├─────────────────►│ validate state │ + │ │ POST /token │ + │ ├───────────────────►│ + │ │ {id_token, access_token} + │ │◄───────────────────┤ + │ │ verify JWKS / nonce / aud / iss + │ │ check allowlist │ + │ │ get_or_create User │ + │ │ create Session │ + │ 302 Location: / + taskito_session cookie + taskito_csrf cookie + │◄─────────────────┤ │ +``` + +State is **single-use** and **time-bounded** (5-min default TTL). PKCE +S256, OIDC nonce, ID-token signature (via the provider's JWKS), +`iss` / `aud` / `exp` are all enforced server-side. + +## Quick start: Google login + +1. **Create an OAuth client.** Visit the + [Google Cloud Console → APIs & Services → Credentials](https://console.cloud.google.com/apis/credentials), + create an OAuth 2.0 Client ID of type *Web application*, and register + the callback URL: + + ``` + https://taskito.your-company.com/api/auth/oauth/callback/google + ``` + + (For local development, `http://localhost:8000/api/auth/oauth/callback/google` + works without HTTPS.) + +2. **Set env vars** before starting the dashboard: + + ```bash + export TASKITO_DASHBOARD_OAUTH_REDIRECT_BASE_URL=https://taskito.your-company.com + export TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_ID=...apps.googleusercontent.com + export TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_SECRET=... + # Restrict logins to your Google Workspace domain: + export TASKITO_DASHBOARD_OAUTH_GOOGLE_ALLOWED_DOMAINS=your-company.com + ``` + +3. **Start the dashboard.** The login screen now shows a "Continue with + Google" button above the password form. + +## GitHub login + +GitHub is OAuth2-only (no OIDC), so the dashboard hits `/user` and +`/user/emails` to derive an identity. Org membership is verified via +`/orgs/{org}/members/{login}`. + +1. Create a [GitHub OAuth App](https://github.com/settings/developers). + Set the *Authorization callback URL* to + `https://taskito.your-company.com/api/auth/oauth/callback/github`. + +2. Env vars: + + ```bash + export TASKITO_DASHBOARD_OAUTH_GITHUB_CLIENT_ID=Iv1.xxxxx + export TASKITO_DASHBOARD_OAUTH_GITHUB_CLIENT_SECRET=... + # Restrict logins to members of these GitHub orgs: + export TASKITO_DASHBOARD_OAUTH_GITHUB_ALLOWED_ORGS=your-org,partner-org + ``` + +When `ALLOWED_ORGS` is set the OAuth scope automatically expands to +include `read:org` so the membership endpoint returns reliable results +for private orgs. Users who consent without the additional scope are +rejected at the allowlist gate. + + + GitHub accounts that have no `verified=true` primary email + (returned by `GET /user/emails`) are always assigned the `viewer` + role, even if listed in `TASKITO_DASHBOARD_OAUTH_ADMIN_EMAILS`. This + prevents privilege escalation via spoofed email claims. + + +## Generic OIDC (Okta, Auth0, Keycloak, Microsoft, …) + +Generic OIDC providers are configured as **named slots**. Each slot has +its own callback URL, own user namespace, and own button on the login +screen. + +```bash +export TASKITO_DASHBOARD_OAUTH_REDIRECT_BASE_URL=https://taskito.your-company.com + +# List the slots first. +export TASKITO_DASHBOARD_OAUTH_OIDC_PROVIDERS=okta,microsoft + +# Then per-slot config (slot name uppercase, separators normalised to _). +export TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_CLIENT_ID=... +export TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_CLIENT_SECRET=... +export TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_DISCOVERY_URL=https://acme.okta.com/.well-known/openid-configuration +export TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_LABEL="Acme SSO" +export TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_ALLOWED_DOMAINS=your-company.com + +export TASKITO_DASHBOARD_OAUTH_OIDC_MICROSOFT_CLIENT_ID=... +export TASKITO_DASHBOARD_OAUTH_OIDC_MICROSOFT_CLIENT_SECRET=... +export TASKITO_DASHBOARD_OAUTH_OIDC_MICROSOFT_DISCOVERY_URL=https://login.microsoftonline.com//v2.0/.well-known/openid-configuration +export TASKITO_DASHBOARD_OAUTH_OIDC_MICROSOFT_LABEL="Microsoft 365" +``` + +The callback URL for each slot is +`{REDIRECT_BASE_URL}/api/auth/oauth/callback/{slot}` — register that +exact URL with your IdP. + +Slot names must match `^[a-z][a-z0-9_-]{0,31}$` and must not collide +with `google` / `github` (the built-ins). The Taskito user generated +for an OIDC login is namespaced as `{slot}:{sub}`, so two different +Okta tenants stay distinct users even when subjects overlap. + +## Role assignment for OAuth users + +The first time someone signs in via OAuth, the dashboard decides their +role using this rule: + +1. **`TASKITO_DASHBOARD_OAUTH_ADMIN_EMAILS` match** — case-insensitive + match against a verified email → role `admin`. +2. **Empty user table fallback** — if no users (password or OAuth) exist + yet, the first OAuth user with a verified email becomes `admin`. +3. **Everyone else** → role `viewer`. + +```bash +export TASKITO_DASHBOARD_OAUTH_ADMIN_EMAILS=alice@your-company.com,bob@your-company.com +``` + +Once a user is created, their role is **not** re-evaluated on subsequent +logins (you can change it from the dashboard or via the API). Their +`email` and `display_name` are refreshed from each new login's claims. + +## OAuth-only mode + +To disable password login entirely: + +```bash +export TASKITO_DASHBOARD_PASSWORD_AUTH_ENABLED=false +``` + +The dashboard refuses to start in OAuth-only mode if no provider is +configured (you'd have no way to log in). The login page hides the +username/password form and renders only provider buttons. + +## Allowlist semantics + +| Provider | Allowlist scope | Where it's checked | +|---|---|---| +| Google | `ALLOWED_DOMAINS` — the email domain (lowercased) must be in this list. Required: `email_verified=true`. | Server-side after JWKS verification of the ID token. | +| GitHub | `ALLOWED_ORGS` — user must be a member of at least one listed org. | `GET /orgs/{org}/members/{login}` returning 204. | +| Generic OIDC | `ALLOWED_DOMAINS` — same as Google. | Server-side after ID-token JWKS verification. | + +An **empty** allowlist means "any account from this provider is welcome" +— useful for personal projects but never appropriate for a production +deployment. Configure at least the admin-email list, and ideally a +domain/org allowlist too. + + + Allowlists are not editable from the dashboard UI. Changes require + restarting the server with new env values. This keeps the security + surface in one place (your deployment config) and avoids drift across + the operator's GitOps and the database. + + +## Security model + +| Control | Implementation | +|---|---| +| **PKCE** | S256 challenge derived from a 32-byte random verifier, per RFC 7636. Required by OAuth 2.1 and most providers in 2026. | +| **State** | 32-byte URL-safe random, stored server-side in `auth:oauth_state:` with a 5-min TTL. **Single-use** — deleted on first read. | +| **Nonce** | 16-byte random, embedded in the OIDC authorize request, verified against the ID-token `nonce` claim. Replay protection. | +| **ID-token signature** | Verified against the provider's JWKS (fetched from the discovery doc and cached per-provider). | +| **iss / aud / exp** | All validated; 60-second clock skew tolerance for `exp`. | +| **Open redirect** | The `next` query param is validated against `is_safe_redirect` — relative paths only, no scheme, no `//`. Falls back to `/`. | +| **HTTPS required** | `redirect_base_url` must be `https://` unless the host is `localhost` / `127.0.0.1`. Misconfiguration aborts startup. | +| **Provider tokens** | Never persisted. Only the verified identity flows into the Taskito session. | +| **Cross-provider linking** | Disabled by design. A given `(slot, subject)` always maps to one user. Two different providers with the same email = two different users. | + +## API surface + +| Method | Path | What it does | +|---|---|---| +| `GET` | `/api/auth/providers` | Public. Returns `{password_enabled, providers: [{slot, label, type}]}` for the login UI. | +| `GET` | `/api/auth/oauth/start/{slot}` | Public. Mints state, 302s to the provider's authorize URL. Accepts `?next=/path` (validated). | +| `GET` | `/api/auth/oauth/callback/{slot}` | Public. Validates state, exchanges code, enforces allowlist, creates/refreshes the user, sets cookies, 302s to `next`. | + +The callback uses the same `taskito_session` + `taskito_csrf` cookies +as password login — every other dashboard route works identically once +you're signed in. + +## Troubleshooting + +**"oauth_state_invalid"** — the state row expired (5-min window) or +already consumed. The user pressed back / refresh after the provider +redirect; have them start over. + +**"oauth_identity_failed: id_token issuer mismatch"** — the +`TASKITO_DASHBOARD_OAUTH_OIDC__DISCOVERY_URL` points to a +different issuer than what the IdP signed. Check the `issuer` field in +the discovery doc. + +**"oauth_allowlist_denied"** — the user authenticated successfully but +isn't in your allowlist. Either widen the allowlist or remove it. + +**Provider button doesn't appear** — `GET /api/auth/providers` returns +the list the UI renders. If the button is missing, check the server +logs for an env-var parse error at startup. The dashboard falls back to +password-only auth (logged at WARN) when env parsing fails. + +**"redirect_uri_mismatch" from the provider** — the callback URL you +registered with the provider doesn't match `{REDIRECT_BASE_URL}/api/auth/oauth/callback/{slot}`. +The trailing slash and the slot value must match exactly. + +## Env var reference + +```bash +# Required when any provider is configured. +TASKITO_DASHBOARD_OAUTH_REDIRECT_BASE_URL=https://taskito.company.com + +# Google. +TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_ID=... +TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_SECRET=... +TASKITO_DASHBOARD_OAUTH_GOOGLE_ALLOWED_DOMAINS=company.com,partner.com # optional + +# GitHub. +TASKITO_DASHBOARD_OAUTH_GITHUB_CLIENT_ID=Iv1.xxxxx +TASKITO_DASHBOARD_OAUTH_GITHUB_CLIENT_SECRET=... +TASKITO_DASHBOARD_OAUTH_GITHUB_ALLOWED_ORGS=org1,org2 # optional + +# Generic OIDC — list slots, then config each one. +TASKITO_DASHBOARD_OAUTH_OIDC_PROVIDERS=okta,microsoft +TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_CLIENT_ID=... +TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_CLIENT_SECRET=... +TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_DISCOVERY_URL=https://acme.okta.com/.well-known/openid-configuration +TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_LABEL=Acme SSO # optional +TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_ALLOWED_DOMAINS=company.com # optional + +# Role bootstrap. +TASKITO_DASHBOARD_OAUTH_ADMIN_EMAILS=alice@company.com,bob@company.com # optional + +# Disable password login (OAuth-only mode). Defaults to true. +TASKITO_DASHBOARD_PASSWORD_AUTH_ENABLED=false # optional +``` diff --git a/docs/content/docs/guides/observability/meta.json b/docs/content/docs/guides/observability/meta.json index 145ef7f2..bdaa19d7 100644 --- a/docs/content/docs/guides/observability/meta.json +++ b/docs/content/docs/guides/observability/meta.json @@ -6,6 +6,7 @@ "notes", "dashboard", "dashboard-auth", + "dashboard-oauth", "task-overrides", "dashboard-api" ] diff --git a/py_src/taskito/dashboard/auth.py b/py_src/taskito/dashboard/auth.py index 0c57f03d..a7f2b6b2 100644 --- a/py_src/taskito/dashboard/auth.py +++ b/py_src/taskito/dashboard/auth.py @@ -57,6 +57,10 @@ PASSWORD_MAX_LEN = 256 VALID_ROLES = frozenset({"admin", "viewer"}) +# Sentinel prefix used in ``password_hash`` for OAuth-only users so +# ``verify_password`` can short-circuit-reject any password attempt. +OAUTH_PASSWORD_HASH_PREFIX = "oauth:" + # ── Password hashing ─────────────────────────────────────────────────── @@ -77,6 +81,10 @@ def hash_password(password: str) -> str: def verify_password(password: str, encoded: str) -> bool: """Constant-time verify a password against the encoded hash.""" + # Sentinel for OAuth-only users — they have no real password and must + # never authenticate via the password endpoint. + if encoded.startswith(OAUTH_PASSWORD_HASH_PREFIX): + return False try: scheme, iters_str, salt_hex, hash_hex = encoded.split("$") except ValueError: @@ -106,13 +114,24 @@ def generate_session_token() -> str: @dataclass(frozen=True) class User: - """A persisted dashboard user.""" + """A persisted dashboard user. + + ``email`` and ``display_name`` are populated for users created via the + OAuth flow; for password users they are typically ``None`` until set + by an admin. + """ username: str password_hash: str role: str created_at: int last_login_at: int | None = None + email: str | None = None + display_name: str | None = None + + @property + def is_oauth(self) -> bool: + return self.password_hash.startswith(OAUTH_PASSWORD_HASH_PREFIX) @dataclass(frozen=True) @@ -154,6 +173,33 @@ def _validate_role(role: str) -> None: raise ValueError(f"role must be one of {sorted(VALID_ROLES)}") +def _oauth_bootstrap_role( + *, + email: str | None, + email_verified: bool, + admin_emails: tuple[str, ...], + user_table_empty: bool, +) -> str: + """Decide the role for a freshly-created OAuth user. + + Order: any path to ``admin`` requires a verified email (defence against + spoofed claims). If an explicit admin list is configured, only listed + emails get ``admin`` — the first-user-wins fallback is skipped. With no + admin list, the very first user (empty table) gets ``admin``, everyone + else gets ``viewer``. + """ + if not email_verified or not email: + return "viewer" + normalised = email.lower() + if admin_emails: + if normalised in {e.lower() for e in admin_emails}: + return "admin" + return "viewer" + if user_table_empty: + return "admin" + return "viewer" + + # ── Auth store ───────────────────────────────────────────────────────── @@ -243,13 +289,66 @@ def _row_to_user(username: str, row: dict[str, object] | None) -> User: assert row is not None created_raw = row["created_at"] last_raw = row.get("last_login_at") + email_raw = row.get("email") + name_raw = row.get("display_name") return User( username=username, password_hash=str(row["password_hash"]), role=str(row["role"]), created_at=int(created_raw) if isinstance(created_raw, (int, float, str)) else 0, last_login_at=(int(last_raw) if isinstance(last_raw, (int, float, str)) else None), + email=str(email_raw) if isinstance(email_raw, str) and email_raw else None, + display_name=str(name_raw) if isinstance(name_raw, str) and name_raw else None, + ) + + # ── OAuth users ──────────────────────────────────────────────── + + def get_or_create_oauth_user( + self, + slot: str, + subject: str, + email: str | None, + name: str | None, + email_verified: bool, + admin_emails: tuple[str, ...] = (), + ) -> User: + """Look up or create the User row backing an OAuth identity. + + Username is ``f"{slot}:{subject}"``. On first sight, the role is + assigned by :func:`_oauth_bootstrap_role`. On subsequent logins, + the role is left alone but ``email`` / ``display_name`` are refreshed + from the latest provider claims. + """ + username = f"{slot}:{subject}" + users = self._load_users() + existing = users.get(username) + if existing is not None: + if email and existing.get("email") != email: + existing["email"] = email + if name and existing.get("display_name") != name: + existing["display_name"] = name + existing["last_login_at"] = int(time.time()) + users[username] = existing + self._save_users(users) + return self._row_to_user(username, existing) + + role = _oauth_bootstrap_role( + email=email, + email_verified=email_verified, + admin_emails=admin_emails, + user_table_empty=not users, ) + now = int(time.time()) + users[username] = { + "password_hash": f"{OAUTH_PASSWORD_HASH_PREFIX}{slot}", + "role": role, + "created_at": now, + "last_login_at": now, + "email": email, + "display_name": name, + } + self._save_users(users) + return self._row_to_user(username, users[username]) # ── Sessions ─────────────────────────────────────────────────── diff --git a/py_src/taskito/dashboard/handlers/auth.py b/py_src/taskito/dashboard/handlers/auth.py index 5b6744db..e9c4b299 100644 --- a/py_src/taskito/dashboard/handlers/auth.py +++ b/py_src/taskito/dashboard/handlers/auth.py @@ -37,11 +37,13 @@ def _serialize_session(session: Any) -> dict[str, Any]: } -def handle_auth_status(queue: Queue, _qs: dict) -> dict[str, bool]: +def handle_auth_status(queue: Queue, _qs: dict) -> dict[str, Any]: """Public endpoint: tells the SPA whether setup is required. Returns ``{setup_required: bool}``. The SPA uses this on cold-load to - decide between showing the setup page and the login page. + decide between showing the setup page and the login page. Provider + listing is fetched separately via ``GET /api/auth/providers`` so this + endpoint stays free of any OAuth dependency. """ return {"setup_required": AuthStore(queue).count_users() == 0} diff --git a/py_src/taskito/dashboard/handlers/oauth.py b/py_src/taskito/dashboard/handlers/oauth.py new file mode 100644 index 00000000..a9b35570 --- /dev/null +++ b/py_src/taskito/dashboard/handlers/oauth.py @@ -0,0 +1,115 @@ +"""HTTP handlers for the OAuth login flow. + +These handlers are not JSON-producing like the rest of ``handlers/`` — +they emit 302 redirects (and, on a successful callback, set the session +cookies). The server wires them into ``_handle_get`` directly rather +than through the generic JSON dispatcher. + +The handlers themselves are network-IO-free aside from what the wrapped +:class:`OAuthFlow` does internally. They translate provider/flow +exceptions to dashboard ``_BadRequest`` / ``_NotFound`` for the server's +error machinery to pick up, and they return :class:`OAuthRedirect` — +a tiny adapter type that tells the server "emit 302 to URL, optionally +with these cookies attached". +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from taskito.dashboard.errors import _BadRequest, _NotFound +from taskito.dashboard.oauth.identity import ( + AllowlistDenied, + IdentityFetchError, + ProviderNotConfigured, + StateValidationError, +) + +if TYPE_CHECKING: + from taskito.app import Queue + from taskito.dashboard.auth import Session + from taskito.dashboard.oauth.flow import OAuthFlow + + +@dataclass(frozen=True) +class OAuthRedirect: + """Server adapter: emit ``302 Location: url``. + + ``session`` is set on a successful callback so the server can attach + the same ``taskito_session`` + ``taskito_csrf`` cookies it sets for + password login. On the ``/start`` redirect ``session`` is ``None``. + """ + + url: str + session: Session | None = None + status: int = 302 + + +def handle_providers(queue: Queue, _qs: dict, flow: OAuthFlow | None) -> dict: + """List configured providers + whether password auth is enabled. + + Returns ``{password_enabled: bool, providers: [{slot, label, type}]}``. + Always callable; returns ``providers: []`` when OAuth is not configured. + """ + if flow is None: + return {"password_enabled": True, "providers": []} + return { + "password_enabled": flow.password_auth_enabled, + "providers": flow.providers_listing(), + } + + +def handle_start( + queue: Queue, + qs: dict[str, list[str]], + slot: str, + flow: OAuthFlow | None, +) -> OAuthRedirect: + """Begin an OAuth login: mint state, return a 302 to the provider URL.""" + if flow is None: + raise _NotFound("oauth_not_configured") + next_values = qs.get("next") or [] + next_url = next_values[0] if next_values else None + try: + provider_url = flow.start(slot, next_url) + except ProviderNotConfigured as e: + raise _NotFound(str(e)) from None + return OAuthRedirect(url=provider_url) + + +def handle_callback( + queue: Queue, + qs: dict[str, list[str]], + slot: str, + flow: OAuthFlow | None, +) -> OAuthRedirect: + """Land an OAuth login: verify state, create a session, redirect home. + + The returned :class:`OAuthRedirect` carries the new :class:`Session`; + the server attaches the standard ``taskito_session`` + ``taskito_csrf`` + cookies before sending the 302. + """ + if flow is None: + raise _NotFound("oauth_not_configured") + + def _first(name: str) -> str | None: + values = qs.get(name) or [] + return values[0] if values else None + + code = _first("code") + state_token = _first("state") + error = _first("error") + try: + session, next_url = flow.handle_callback( + slot, code=code, state_token=state_token, error=error + ) + except ProviderNotConfigured as e: + raise _NotFound(str(e)) from None + except StateValidationError as e: + raise _BadRequest(f"oauth_state_invalid: {e}") from None + except IdentityFetchError as e: + raise _BadRequest(f"oauth_identity_failed: {e}") from None + except AllowlistDenied as e: + raise _BadRequest(f"oauth_allowlist_denied: {e}") from None + return OAuthRedirect(url=next_url, session=session) diff --git a/py_src/taskito/dashboard/oauth/__init__.py b/py_src/taskito/dashboard/oauth/__init__.py new file mode 100644 index 00000000..28be1e4b --- /dev/null +++ b/py_src/taskito/dashboard/oauth/__init__.py @@ -0,0 +1,46 @@ +"""OAuth2 / OIDC support for the Taskito dashboard. + +Adds Google, GitHub, and one-or-more generic OIDC providers (Okta, Auth0, +Keycloak, Microsoft Entra, …) alongside the existing password login. Auth +state continues to live in ``dashboard_settings``; OAuth users are stored +in the same ``auth:users`` blob as password users, with a sentinel +``password_hash`` prefix (``oauth:{slot}``) that ``verify_password`` +refuses. +""" + +from __future__ import annotations + +from taskito.dashboard.oauth.config import ( + GitHubConfig, + GoogleConfig, + OAuthConfig, + OAuthConfigError, + OIDCConfig, +) +from taskito.dashboard.oauth.identity import ( + AllowlistDenied, + IdentityFetchError, + OAuthError, + OAuthProvider, + ProviderIdentity, + ProviderNotConfigured, + StateValidationError, +) +from taskito.dashboard.oauth.state_store import OAuthState, OAuthStateStore + +__all__ = [ + "AllowlistDenied", + "GitHubConfig", + "GoogleConfig", + "IdentityFetchError", + "OAuthConfig", + "OAuthConfigError", + "OAuthError", + "OAuthProvider", + "OAuthState", + "OAuthStateStore", + "OIDCConfig", + "ProviderIdentity", + "ProviderNotConfigured", + "StateValidationError", +] diff --git a/py_src/taskito/dashboard/oauth/config.py b/py_src/taskito/dashboard/oauth/config.py new file mode 100644 index 00000000..dfc7977f --- /dev/null +++ b/py_src/taskito/dashboard/oauth/config.py @@ -0,0 +1,257 @@ +"""Operator-facing OAuth configuration and env-var parsing. + +All settings come from environment variables (or an equivalent +:class:`OAuthConfig` instance passed programmatically — used by tests). +Secrets are never stored in the dashboard settings DB. + +See ``docs/content/docs/dashboard/oauth.mdx`` for the full env-var +reference. +""" + +from __future__ import annotations + +import os +import re +import urllib.parse +from collections.abc import Mapping +from dataclasses import dataclass, field + + +class OAuthConfigError(ValueError): + """Raised when env-var configuration is invalid.""" + + +SLOT_RE = re.compile(r"^[a-z][a-z0-9_-]{0,31}$") +RESERVED_SLOTS = frozenset({"google", "github"}) + +# Hostnames where http:// is accepted for ``redirect_base_url`` (dev only). +_LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"}) + + +def _split_csv(raw: str | None) -> tuple[str, ...]: + if not raw: + return () + return tuple(part.strip() for part in raw.split(",") if part.strip()) + + +@dataclass(frozen=True) +class GoogleConfig: + client_id: str + client_secret: str + allowed_domains: tuple[str, ...] = () + + slot: str = "google" + label: str = "Google" + type: str = "google" + + +@dataclass(frozen=True) +class GitHubConfig: + client_id: str + client_secret: str + allowed_orgs: tuple[str, ...] = () + + slot: str = "github" + label: str = "GitHub" + type: str = "github" + + +@dataclass(frozen=True) +class OIDCConfig: + slot: str + client_id: str + client_secret: str + discovery_url: str + allowed_domains: tuple[str, ...] = () + label: str = "" + type: str = "oidc" + + def __post_init__(self) -> None: + if not SLOT_RE.match(self.slot): + raise OAuthConfigError(f"OIDC slot {self.slot!r} must match {SLOT_RE.pattern}") + if self.slot in RESERVED_SLOTS: + raise OAuthConfigError(f"OIDC slot {self.slot!r} collides with built-in provider") + if not self.discovery_url: + raise OAuthConfigError(f"OIDC slot {self.slot!r}: discovery_url is required") + + +@dataclass(frozen=True) +class OAuthConfig: + """Top-level OAuth configuration. + + ``redirect_base_url`` is the public origin the dashboard is served at — + every callback URL is built from it (``{redirect_base_url}/api/auth/oauth/callback/{slot}``). + OAuth is considered disabled if no provider is configured. + """ + + redirect_base_url: str + google: GoogleConfig | None = None + github: GitHubConfig | None = None + oidc: tuple[OIDCConfig, ...] = () + password_auth_enabled: bool = True + admin_emails: tuple[str, ...] = field(default=()) + + def __post_init__(self) -> None: + _validate_redirect_base_url(self.redirect_base_url) + + @property + def is_enabled(self) -> bool: + return self.google is not None or self.github is not None or bool(self.oidc) + + def providers(self) -> tuple[GoogleConfig | GitHubConfig | OIDCConfig, ...]: + """Configured providers in display order (Google, GitHub, then OIDC slots).""" + out: list[GoogleConfig | GitHubConfig | OIDCConfig] = [] + if self.google is not None: + out.append(self.google) + if self.github is not None: + out.append(self.github) + out.extend(self.oidc) + return tuple(out) + + def find_provider(self, slot: str) -> GoogleConfig | GitHubConfig | OIDCConfig | None: + for p in self.providers(): + if p.slot == slot: + return p + return None + + def callback_url(self, slot: str) -> str: + return f"{self.redirect_base_url.rstrip('/')}/api/auth/oauth/callback/{slot}" + + +def _validate_redirect_base_url(url: str) -> None: + if not url: + raise OAuthConfigError("redirect_base_url must be set when OAuth is enabled") + parsed = urllib.parse.urlparse(url) + if parsed.scheme not in ("http", "https"): + raise OAuthConfigError(f"redirect_base_url must be http(s), got {parsed.scheme!r}") + if not parsed.hostname: + raise OAuthConfigError("redirect_base_url must include a hostname") + if parsed.scheme == "http" and parsed.hostname not in _LOCAL_HOSTS: + raise OAuthConfigError( + f"redirect_base_url must use https for non-local hosts (got http://{parsed.hostname})" + ) + + +def from_env(environ: Mapping[str, str] | None = None) -> OAuthConfig | None: + """Parse :class:`OAuthConfig` from the environment. + + Returns ``None`` when neither ``TASKITO_DASHBOARD_OAUTH_REDIRECT_BASE_URL`` + nor any provider client-id env var is set — i.e. OAuth is not configured. + Raises :class:`OAuthConfigError` if some but not all required vars are set + for a configured provider (fail-fast on partial configuration). + """ + env = environ if environ is not None else os.environ + + base_url = env.get("TASKITO_DASHBOARD_OAUTH_REDIRECT_BASE_URL", "").strip() + google_id = env.get("TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_ID", "").strip() + github_id = env.get("TASKITO_DASHBOARD_OAUTH_GITHUB_CLIENT_ID", "").strip() + oidc_slots_raw = env.get("TASKITO_DASHBOARD_OAUTH_OIDC_PROVIDERS", "").strip() + + any_provider_signal = bool(google_id or github_id or oidc_slots_raw) + if not any_provider_signal and not base_url: + return None + if any_provider_signal and not base_url: + raise OAuthConfigError( + "TASKITO_DASHBOARD_OAUTH_REDIRECT_BASE_URL must be set when any " + "OAuth provider is configured" + ) + + google = _parse_google(env) if google_id else None + github = _parse_github(env) if github_id else None + oidc = _parse_oidc_slots(env, oidc_slots_raw) + password_enabled = _parse_bool( + env.get("TASKITO_DASHBOARD_PASSWORD_AUTH_ENABLED", "true"), default=True + ) + admin_emails = _split_csv(env.get("TASKITO_DASHBOARD_OAUTH_ADMIN_EMAILS")) + + config = OAuthConfig( + redirect_base_url=base_url, + google=google, + github=github, + oidc=oidc, + password_auth_enabled=password_enabled, + admin_emails=admin_emails, + ) + + if not config.is_enabled and not password_enabled: + raise OAuthConfigError( + "password auth disabled but no OAuth providers configured — no way to log in" + ) + + return config + + +def _parse_google(env: Mapping[str, str]) -> GoogleConfig: + cid = env.get("TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_ID", "").strip() + secret = env.get("TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_SECRET", "").strip() + if not secret: + raise OAuthConfigError( + "TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_SECRET is required when google client_id is set" + ) + return GoogleConfig( + client_id=cid, + client_secret=secret, + allowed_domains=_split_csv(env.get("TASKITO_DASHBOARD_OAUTH_GOOGLE_ALLOWED_DOMAINS")), + ) + + +def _parse_github(env: Mapping[str, str]) -> GitHubConfig: + cid = env.get("TASKITO_DASHBOARD_OAUTH_GITHUB_CLIENT_ID", "").strip() + secret = env.get("TASKITO_DASHBOARD_OAUTH_GITHUB_CLIENT_SECRET", "").strip() + if not secret: + raise OAuthConfigError( + "TASKITO_DASHBOARD_OAUTH_GITHUB_CLIENT_SECRET is required when github client_id is set" + ) + return GitHubConfig( + client_id=cid, + client_secret=secret, + allowed_orgs=_split_csv(env.get("TASKITO_DASHBOARD_OAUTH_GITHUB_ALLOWED_ORGS")), + ) + + +def _parse_oidc_slots(env: Mapping[str, str], slots_raw: str) -> tuple[OIDCConfig, ...]: + slot_names = _split_csv(slots_raw) + if not slot_names: + return () + out: list[OIDCConfig] = [] + seen: set[str] = set() + for raw_slot in slot_names: + slot = raw_slot.lower() + if slot in seen: + raise OAuthConfigError( + f"OIDC slot {slot!r} listed twice in TASKITO_DASHBOARD_OAUTH_OIDC_PROVIDERS" + ) + seen.add(slot) + out.append(_parse_oidc_slot(env, slot)) + return tuple(out) + + +def _parse_oidc_slot(env: Mapping[str, str], slot: str) -> OIDCConfig: + prefix = f"TASKITO_DASHBOARD_OAUTH_OIDC_{slot.upper().replace('-', '_')}" + cid = env.get(f"{prefix}_CLIENT_ID", "").strip() + secret = env.get(f"{prefix}_CLIENT_SECRET", "").strip() + discovery = env.get(f"{prefix}_DISCOVERY_URL", "").strip() + if not cid or not secret or not discovery: + raise OAuthConfigError( + f"OIDC slot {slot!r} requires {prefix}_CLIENT_ID, _CLIENT_SECRET, and _DISCOVERY_URL" + ) + default_label = slot.replace("-", " ").replace("_", " ").title() + label = env.get(f"{prefix}_LABEL", "").strip() or default_label + allowed = _split_csv(env.get(f"{prefix}_ALLOWED_DOMAINS")) + return OIDCConfig( + slot=slot, + client_id=cid, + client_secret=secret, + discovery_url=discovery, + allowed_domains=allowed, + label=label, + ) + + +def _parse_bool(raw: str, *, default: bool) -> bool: + lowered = raw.strip().lower() + if lowered in ("1", "true", "yes", "on"): + return True + if lowered in ("0", "false", "no", "off"): + return False + return default diff --git a/py_src/taskito/dashboard/oauth/flow.py b/py_src/taskito/dashboard/oauth/flow.py new file mode 100644 index 00000000..aacf8e71 --- /dev/null +++ b/py_src/taskito/dashboard/oauth/flow.py @@ -0,0 +1,167 @@ +"""End-to-end OAuth flow orchestration. + +:class:`OAuthFlow` is the seam between the HTTP handler layer and the +provider implementations. It owns the registry of configured providers, +the state store, and the :class:`AuthStore` integration. Handlers call +``start()`` to mint a redirect URL and ``handle_callback()`` to land a +session. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from taskito.dashboard.auth import AuthStore +from taskito.dashboard.oauth.config import ( + GitHubConfig, + GoogleConfig, + OAuthConfig, + OIDCConfig, +) +from taskito.dashboard.oauth.identity import ( + IdentityFetchError, + OAuthProvider, + ProviderNotConfigured, + StateValidationError, +) +from taskito.dashboard.oauth.pkce import s256_challenge +from taskito.dashboard.oauth.providers import ( + GenericOIDCProvider, + GitHubProvider, + GoogleProvider, +) +from taskito.dashboard.oauth.state_store import OAuthStateStore +from taskito.dashboard.url_safety import is_safe_redirect + +if TYPE_CHECKING: + from taskito.app import Queue + from taskito.dashboard.auth import Session + + +def build_providers( + config: OAuthConfig, +) -> dict[str, OAuthProvider]: + """Instantiate one provider per configured slot, keyed by slot.""" + registry: dict[str, OAuthProvider] = {} + for entry in config.providers(): + if isinstance(entry, GoogleConfig): + registry[entry.slot] = GoogleProvider(entry) + elif isinstance(entry, GitHubConfig): + registry[entry.slot] = GitHubProvider(entry) + elif isinstance(entry, OIDCConfig): + registry[entry.slot] = GenericOIDCProvider(entry) + return registry + + +class OAuthFlow: + """Ties together config, providers, state store, and the auth store.""" + + def __init__( + self, + queue: Queue, + config: OAuthConfig, + *, + providers: dict[str, OAuthProvider] | None = None, + state_store: OAuthStateStore | None = None, + ) -> None: + self._queue = queue + self._config = config + self._providers: dict[str, OAuthProvider] = ( + providers if providers is not None else build_providers(config) + ) + self._state_store = state_store or OAuthStateStore(queue) + + # ── Introspection ──────────────────────────────────────────────── + + @property + def password_auth_enabled(self) -> bool: + return self._config.password_auth_enabled + + def has_provider(self, slot: str) -> bool: + return slot in self._providers + + def providers_listing(self) -> list[dict[str, str]]: + """Compact provider summary for the login UI (no secrets).""" + return [ + {"slot": p.slot, "label": p.label, "type": p.type} for p in self._providers.values() + ] + + # ── Flow ───────────────────────────────────────────────────────── + + def start(self, slot: str, next_url: str | None) -> str: + """Mint a state row and return the provider's authorize URL. + + ``next_url`` is sanitised against :func:`is_safe_redirect` and falls + back to ``"/"`` if it fails the check. + """ + provider = self._require_provider(slot) + safe_next = next_url if next_url and is_safe_redirect(next_url) else "/" + state = self._state_store.create(slot=slot, next_url=safe_next) + challenge = s256_challenge(state.code_verifier) + return provider.authorization_url( + state=state.state, + nonce=state.nonce, + code_challenge=challenge, + redirect_uri=self._config.callback_url(slot), + ) + + def handle_callback( + self, + slot: str, + *, + code: str | None, + state_token: str | None, + error: str | None, + ) -> tuple[Session, str]: + """Exchange ``code`` for an identity and create a session. + + Returns ``(session, next_url)`` on success. Raises: + + - :class:`StateValidationError` for missing/expired/replayed state + - :class:`IdentityFetchError` for any token / userinfo / claim issue + - :class:`AllowlistDenied` if the identity is outside the allowlist + """ + if error: + raise IdentityFetchError(f"provider returned error: {error}") + if not code or not state_token: + raise StateValidationError("missing code or state parameter") + + row = self._state_store.consume(state_token) + if row is None: + raise StateValidationError("state is invalid, expired, or already used") + if row.slot != slot: + raise StateValidationError("state slot does not match callback slot") + + provider = self._require_provider(slot) + identity = provider.exchange_code( + code=code, + code_verifier=row.code_verifier, + redirect_uri=self._config.callback_url(slot), + expected_nonce=row.nonce, + ) + provider.check_allowlist(identity) + + store = AuthStore(self._queue) + user = store.get_or_create_oauth_user( + slot=identity.slot, + subject=identity.subject, + email=identity.email, + name=identity.name, + email_verified=identity.email_verified, + admin_emails=self._config.admin_emails, + ) + session = store.create_session(user) + return session, row.next_url + + # ── Maintenance ────────────────────────────────────────────────── + + def prune_state(self) -> int: + return self._state_store.prune_expired() + + # ── Internal ───────────────────────────────────────────────────── + + def _require_provider(self, slot: str) -> OAuthProvider: + provider = self._providers.get(slot) + if provider is None: + raise ProviderNotConfigured(f"OAuth provider {slot!r} is not configured") + return provider diff --git a/py_src/taskito/dashboard/oauth/identity.py b/py_src/taskito/dashboard/oauth/identity.py new file mode 100644 index 00000000..59f906ef --- /dev/null +++ b/py_src/taskito/dashboard/oauth/identity.py @@ -0,0 +1,89 @@ +"""Identity types and the provider contract. + +A :class:`ProviderIdentity` is the canonical shape of "who just logged in" +that every provider must return. The :class:`OAuthProvider` protocol is +the contract every concrete provider (Google, GitHub, generic OIDC) +satisfies. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + + +class OAuthError(Exception): + """Base class for any OAuth-flow error surfaced to the handler layer.""" + + +class StateValidationError(OAuthError): + """Raised when the callback state is missing, expired, replayed, or forged.""" + + +class IdentityFetchError(OAuthError): + """Raised when the provider returns an error during token / userinfo fetch.""" + + +class AllowlistDenied(OAuthError): + """Raised when a verified identity is rejected by a configured allowlist.""" + + +class ProviderNotConfigured(OAuthError): + """Raised when a request references an OAuth slot that is not registered.""" + + +@dataclass(frozen=True) +class ProviderIdentity: + """Normalised identity returned by every provider after a successful flow. + + ``slot`` is the registry key (``google``, ``github``, or the operator- + chosen OIDC slot name). ``subject`` is the provider's stable unique ID + for the user (``sub`` claim, GitHub ``id``); never the email, which + can change. Both together form the Taskito username ``f"{slot}:{subject}"``. + """ + + slot: str + subject: str + email: str | None + email_verified: bool + name: str | None = None + picture: str | None = None + + +class OAuthProvider(Protocol): + """Contract every OAuth provider implementation must satisfy.""" + + slot: str + """URL-safe unique identifier used in the callback path.""" + + label: str + """Human-readable button label rendered by the dashboard.""" + + type: str + """One of ``"google"``, ``"github"``, ``"oidc"`` — chooses the icon.""" + + def authorization_url( + self, + *, + state: str, + nonce: str, + code_challenge: str, + redirect_uri: str, + ) -> str: + """Build the provider-side authorize URL the browser is redirected to.""" + ... + + def exchange_code( + self, + *, + code: str, + code_verifier: str, + redirect_uri: str, + expected_nonce: str | None, + ) -> ProviderIdentity: + """Exchange the auth code for an identity, raising on any failure.""" + ... + + def check_allowlist(self, identity: ProviderIdentity) -> None: + """Raise :class:`AllowlistDenied` if the identity is not permitted.""" + ... diff --git a/py_src/taskito/dashboard/oauth/pkce.py b/py_src/taskito/dashboard/oauth/pkce.py new file mode 100644 index 00000000..1d5f7f00 --- /dev/null +++ b/py_src/taskito/dashboard/oauth/pkce.py @@ -0,0 +1,16 @@ +"""PKCE S256 code-challenge derivation.""" + +from __future__ import annotations + +import base64 +import hashlib + + +def s256_challenge(verifier: str) -> str: + """Return the S256 code-challenge for ``verifier`` per RFC 7636. + + The challenge is ``base64url(sha256(verifier))`` with trailing ``=`` + padding stripped, matching every OAuth provider's PKCE implementation. + """ + digest = hashlib.sha256(verifier.encode("ascii")).digest() + return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") diff --git a/py_src/taskito/dashboard/oauth/providers.py b/py_src/taskito/dashboard/oauth/providers.py new file mode 100644 index 00000000..f88588b2 --- /dev/null +++ b/py_src/taskito/dashboard/oauth/providers.py @@ -0,0 +1,411 @@ +"""Concrete provider implementations: Google, GitHub, generic OIDC. + +Every provider satisfies :class:`OAuthProvider`. The split between +``exchange_code`` (network IO + claim normalisation) and +``check_allowlist`` (pure-data permission check) is deliberate so tests +can drive either path in isolation. + +Tests stub the network boundary via the ``_fetch_token`` and HTTP +session attributes on each provider. +""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING, Any +from urllib.parse import urlencode + +import requests +from authlib.integrations.requests_client import OAuth2Session +from joserfc import jwt +from joserfc.errors import JoseError +from joserfc.jwk import KeySet + +from taskito.dashboard.oauth.identity import ( + AllowlistDenied, + IdentityFetchError, + ProviderIdentity, +) + +if TYPE_CHECKING: + from taskito.dashboard.oauth.config import ( + GitHubConfig, + GoogleConfig, + OIDCConfig, + ) + + +GOOGLE_DISCOVERY_URL = "https://accounts.google.com/.well-known/openid-configuration" +GITHUB_AUTH_URL = "https://github.com/login/oauth/authorize" +GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token" +GITHUB_API_BASE = "https://api.github.com" + +_HTTP_TIMEOUT = 10.0 + + +def _email_domain(email: str | None) -> str | None: + if not email or "@" not in email: + return None + return email.rsplit("@", 1)[-1].lower() + + +def _audience_matches(aud: Any, client_id: str) -> bool: + if isinstance(aud, str): + return aud == client_id + if isinstance(aud, list): + return client_id in aud + return False + + +# ── OIDC provider (shared logic for Google + generic OIDC) ───────────── + + +class _OIDCProviderBase: + """Shared OIDC machinery: discovery, JWKS caching, ID-token decoding.""" + + slot: str + label: str + type: str + client_id: str + client_secret: str + discovery_url: str + scope: str = "openid email profile" + + def __init__(self, *, http: requests.Session | None = None) -> None: + self._http = http or requests.Session() + self._discovery: dict[str, Any] | None = None + self._jwks: dict[str, Any] | None = None + + # Sub-classes override / extend `_extra_auth_params` to add hints. + def _extra_auth_params(self) -> dict[str, str]: + return {} + + def _get_discovery(self) -> dict[str, Any]: + if self._discovery is None: + resp = self._http.get(self.discovery_url, timeout=_HTTP_TIMEOUT) + resp.raise_for_status() + self._discovery = resp.json() + return self._discovery + + def _get_jwks(self) -> dict[str, Any]: + if self._jwks is None: + resp = self._http.get(self._get_discovery()["jwks_uri"], timeout=_HTTP_TIMEOUT) + resp.raise_for_status() + self._jwks = resp.json() + return self._jwks + + def authorization_url( + self, + *, + state: str, + nonce: str, + code_challenge: str, + redirect_uri: str, + ) -> str: + params: dict[str, str] = { + "response_type": "code", + "client_id": self.client_id, + "redirect_uri": redirect_uri, + "scope": self.scope, + "state": state, + "nonce": nonce, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + } + params.update(self._extra_auth_params()) + return f"{self._get_discovery()['authorization_endpoint']}?{urlencode(params)}" + + def _fetch_token( + self, + *, + code: str, + code_verifier: str, + redirect_uri: str, + ) -> dict[str, Any]: + """POST the auth code to the token endpoint. Returns the raw token dict. + + Isolated so tests can stub it without involving Authlib's HTTP stack. + """ + client = OAuth2Session( + client_id=self.client_id, + client_secret=self.client_secret, + ) + try: + token = client.fetch_token( + self._get_discovery()["token_endpoint"], + code=code, + code_verifier=code_verifier, + redirect_uri=redirect_uri, + grant_type="authorization_code", + ) + except Exception as e: + raise IdentityFetchError(f"token exchange failed: {e}") from e + return dict(token) + + def exchange_code( + self, + *, + code: str, + code_verifier: str, + redirect_uri: str, + expected_nonce: str | None, + ) -> ProviderIdentity: + token = self._fetch_token( + code=code, code_verifier=code_verifier, redirect_uri=redirect_uri + ) + id_token = token.get("id_token") + if not id_token: + raise IdentityFetchError("no id_token in token response") + + try: + key_set = KeySet.import_key_set(self._get_jwks()) # type: ignore[arg-type] + decoded = jwt.decode(id_token, key_set) + claims = decoded.claims + except JoseError as e: + raise IdentityFetchError(f"id_token validation failed: {e}") from e + + issuer = self._get_discovery().get("issuer") + if issuer and claims.get("iss") != issuer: + raise IdentityFetchError( + f"id_token issuer mismatch: expected {issuer!r}, got {claims.get('iss')!r}" + ) + if not _audience_matches(claims.get("aud"), self.client_id): + raise IdentityFetchError(f"id_token audience mismatch: {claims.get('aud')!r}") + if expected_nonce is not None and claims.get("nonce") != expected_nonce: + raise IdentityFetchError("id_token nonce mismatch") + + exp = claims.get("exp") + if isinstance(exp, (int, float)) and exp < int(time.time()) - 60: + # 60s clock skew tolerance. + raise IdentityFetchError("id_token expired") + + sub = claims.get("sub") + if not sub: + raise IdentityFetchError("id_token missing 'sub' claim") + + return ProviderIdentity( + slot=self.slot, + subject=str(sub), + email=claims.get("email"), + email_verified=bool(claims.get("email_verified")), + name=claims.get("name"), + picture=claims.get("picture"), + ) + + +class GoogleProvider(_OIDCProviderBase): + slot = "google" + type = "google" + discovery_url = GOOGLE_DISCOVERY_URL + + def __init__(self, config: GoogleConfig, *, http: requests.Session | None = None) -> None: + super().__init__(http=http) + self.config = config + self.label = config.label + self.client_id = config.client_id + self.client_secret = config.client_secret + + def _extra_auth_params(self) -> dict[str, str]: + params = {"prompt": "select_account"} + # When exactly one domain is allowlisted, pass it as ``hd`` so Google + # pre-selects the right account. This is a UX hint only — the real + # enforcement happens in ``check_allowlist``. + if len(self.config.allowed_domains) == 1: + params["hd"] = self.config.allowed_domains[0] + return params + + def check_allowlist(self, identity: ProviderIdentity) -> None: + if not self.config.allowed_domains: + return + if not identity.email or not identity.email_verified: + raise AllowlistDenied("verified email required for domain check") + domain = _email_domain(identity.email) + allowed = {d.lower() for d in self.config.allowed_domains} + if domain not in allowed: + raise AllowlistDenied(f"email domain {domain!r} is not in the allowed domains list") + + +class GenericOIDCProvider(_OIDCProviderBase): + type = "oidc" + + def __init__(self, config: OIDCConfig, *, http: requests.Session | None = None) -> None: + super().__init__(http=http) + self.config = config + self.slot = config.slot + self.label = config.label or config.slot.title() + self.client_id = config.client_id + self.client_secret = config.client_secret + self.discovery_url = config.discovery_url + + def check_allowlist(self, identity: ProviderIdentity) -> None: + if not self.config.allowed_domains: + return + if not identity.email or not identity.email_verified: + raise AllowlistDenied("verified email required for domain check") + domain = _email_domain(identity.email) + allowed = {d.lower() for d in self.config.allowed_domains} + if domain not in allowed: + raise AllowlistDenied(f"email domain {domain!r} is not in the allowed domains list") + + +# ── GitHub (OAuth2-only, no OIDC) ────────────────────────────────────── + + +class GitHubProvider: + slot = "github" + type = "github" + scope = "read:user user:email" + + def __init__(self, config: GitHubConfig, *, http: requests.Session | None = None) -> None: + self.config = config + self.label = config.label + self._http = http or requests.Session() + + def authorization_url( + self, + *, + state: str, + nonce: str, + code_challenge: str, + redirect_uri: str, + ) -> str: + # GitHub does not implement OIDC: ``nonce`` is unused. PKCE is honoured + # — GitHub added support for it in 2023. + params = { + "client_id": self.config.client_id, + "redirect_uri": redirect_uri, + "scope": self.scope, + "state": state, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + "allow_signup": "false", + } + # Request read:org scope when allowlist is configured, so the + # membership endpoint returns reliable results. + if self.config.allowed_orgs: + params["scope"] = self.scope + " read:org" + return f"{GITHUB_AUTH_URL}?{urlencode(params)}" + + def _fetch_token( + self, + *, + code: str, + code_verifier: str, + redirect_uri: str, + ) -> dict[str, Any]: + client = OAuth2Session( + client_id=self.config.client_id, + client_secret=self.config.client_secret, + ) + try: + token = client.fetch_token( + GITHUB_TOKEN_URL, + code=code, + code_verifier=code_verifier, + redirect_uri=redirect_uri, + grant_type="authorization_code", + # GitHub returns form-encoded by default; ask for JSON. + headers={"Accept": "application/json"}, + ) + except Exception as e: + raise IdentityFetchError(f"token exchange failed: {e}") from e + return dict(token) + + def _api_get(self, path: str, access_token: str) -> Any: + resp = self._http.get( + f"{GITHUB_API_BASE}{path}", + headers={ + "Authorization": f"Bearer {access_token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + timeout=_HTTP_TIMEOUT, + ) + if resp.status_code >= 400 and resp.status_code != 404: + raise IdentityFetchError( + f"GitHub API {path} returned {resp.status_code}: {resp.text[:200]}" + ) + return resp + + def exchange_code( + self, + *, + code: str, + code_verifier: str, + redirect_uri: str, + expected_nonce: str | None, + ) -> ProviderIdentity: + token = self._fetch_token( + code=code, code_verifier=code_verifier, redirect_uri=redirect_uri + ) + access_token = token.get("access_token") + if not access_token: + raise IdentityFetchError("no access_token in token response") + + user_resp = self._api_get("/user", access_token) + if user_resp.status_code != 200: + raise IdentityFetchError(f"GET /user failed: {user_resp.status_code}") + user = user_resp.json() + gh_id = user.get("id") + login = user.get("login") + if gh_id is None or not login: + raise IdentityFetchError("GitHub /user response missing 'id' or 'login'") + + primary_email, verified = self._primary_email(access_token) + + # Org membership requires the access token, so we enforce it here + # rather than in ``check_allowlist`` (which is a no-op for GitHub). + # Any denial raises :class:`AllowlistDenied` straight through. + self._verify_org_membership(access_token, str(login)) + + return ProviderIdentity( + slot=self.slot, + subject=str(gh_id), + email=primary_email, + email_verified=verified, + name=user.get("name") or user.get("login"), + picture=user.get("avatar_url"), + ) + + def _primary_email(self, access_token: str) -> tuple[str | None, bool]: + """Return ``(primary_verified_email_or_None, verified_flag)``. + + Falls back to ``None`` if no verified primary exists. We never trust + an unverified email for any access decision. + """ + resp = self._api_get("/user/emails", access_token) + if resp.status_code != 200: + return None, False + for entry in resp.json(): + if entry.get("primary") and entry.get("verified"): + return entry.get("email"), True + return None, False + + def _verify_org_membership(self, access_token: str, login: str) -> None: + if not self.config.allowed_orgs: + return + for org in self.config.allowed_orgs: + resp = self._http.get( + f"{GITHUB_API_BASE}/orgs/{org}/members/{login}", + headers={ + "Authorization": f"Bearer {access_token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + timeout=_HTTP_TIMEOUT, + ) + if resp.status_code == 204: + return + if resp.status_code not in (302, 404): + raise IdentityFetchError(f"GitHub org membership check failed: {resp.status_code}") + raise AllowlistDenied( + f"user is not a member of any allowed GitHub org " + f"({', '.join(self.config.allowed_orgs)})" + ) + + def check_allowlist(self, identity: ProviderIdentity) -> None: + """No-op — GitHub's org check happens inside :meth:`exchange_code`. + + Required by the :class:`OAuthProvider` protocol for interface symmetry. + """ + return diff --git a/py_src/taskito/dashboard/oauth/state_store.py b/py_src/taskito/dashboard/oauth/state_store.py new file mode 100644 index 00000000..5eb541be --- /dev/null +++ b/py_src/taskito/dashboard/oauth/state_store.py @@ -0,0 +1,137 @@ +"""Short-lived store for in-flight OAuth flows. + +When the dashboard redirects a browser to a provider's ``/authorize`` URL, +we stash the ``state``, ``nonce``, PKCE ``code_verifier``, target slot, +and post-login ``next_url`` server-side, keyed by ``state``. On callback +we look the row up, validate ``state`` (single-use, time-bounded), and +delete it. + +Rows live in ``dashboard_settings`` under the ``auth:oauth_state:`` +key namespace alongside sessions and users, so they work uniformly across +SQLite / Postgres / Redis with no new migrations. +""" + +from __future__ import annotations + +import json +import logging +import secrets +import time +from dataclasses import asdict, dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from taskito.app import Queue + + +logger = logging.getLogger("taskito.dashboard.oauth") + +STATE_PREFIX = "auth:oauth_state:" +DEFAULT_STATE_TTL_SECONDS = 5 * 60 # 5 min — covers consent UX + reasonable network latency. + +STATE_TOKEN_BYTES = 32 +NONCE_BYTES = 16 +CODE_VERIFIER_BYTES = 32 + + +@dataclass(frozen=True) +class OAuthState: + """One in-flight OAuth flow, stored server-side until callback or expiry.""" + + state: str + nonce: str + code_verifier: str + slot: str + next_url: str + created_at: int + expires_at: int + + def is_expired(self, now: int | None = None) -> bool: + return (now if now is not None else int(time.time())) >= self.expires_at + + +def generate_state() -> str: + return secrets.token_urlsafe(STATE_TOKEN_BYTES) + + +def generate_nonce() -> str: + return secrets.token_urlsafe(NONCE_BYTES) + + +def generate_code_verifier() -> str: + # RFC 7636 section 4.1: high-entropy URL-safe string, 43-128 chars. + # 32 bytes yields 43 chars base64url, comfortably above the minimum. + return secrets.token_urlsafe(CODE_VERIFIER_BYTES) + + +class OAuthStateStore: + """Create, consume (read+delete), and prune short-lived OAuth state rows.""" + + def __init__(self, queue: Queue) -> None: + self._queue = queue + + def create( + self, + slot: str, + next_url: str, + ttl_seconds: int = DEFAULT_STATE_TTL_SECONDS, + ) -> OAuthState: + """Mint a fresh state/nonce/verifier triple and persist it.""" + now = int(time.time()) + state = OAuthState( + state=generate_state(), + nonce=generate_nonce(), + code_verifier=generate_code_verifier(), + slot=slot, + next_url=next_url, + created_at=now, + expires_at=now + ttl_seconds, + ) + payload = {k: v for k, v in asdict(state).items() if k != "state"} + self._queue.set_setting( + STATE_PREFIX + state.state, json.dumps(payload, separators=(",", ":")) + ) + return state + + def consume(self, state_token: str) -> OAuthState | None: + """Look up ``state_token`` and atomically delete it. Returns ``None`` + if the row is missing, malformed, or expired. Single-use — the row + is always deleted, so a replayed state never re-validates. + """ + if not state_token: + return None + key = STATE_PREFIX + state_token + raw = self._queue.get_setting(key) + if not raw: + return None + # Always delete first so any subsequent request with the same state + # sees a missing row, even if parsing fails below. + self._queue.delete_setting(key) + try: + data = json.loads(raw) + except json.JSONDecodeError: + return None + try: + row = OAuthState(state=state_token, **data) + except TypeError: + return None + if row.is_expired(): + return None + return row + + def prune_expired(self) -> int: + """Best-effort sweep of expired state rows. Returns count removed.""" + now = int(time.time()) + removed = 0 + for key, value in self._queue.list_settings().items(): + if not key.startswith(STATE_PREFIX): + continue + try: + data = json.loads(value) + expires_at = int(data.get("expires_at", 0)) + except (json.JSONDecodeError, TypeError, ValueError): + continue + if expires_at <= now: + self._queue.delete_setting(key) + removed += 1 + return removed diff --git a/py_src/taskito/dashboard/routes.py b/py_src/taskito/dashboard/routes.py index a4ab7937..70342500 100644 --- a/py_src/taskito/dashboard/routes.py +++ b/py_src/taskito/dashboard/routes.py @@ -87,12 +87,26 @@ "/api/auth/status", "/api/auth/login", "/api/auth/setup", + "/api/auth/providers", "/health", "/readiness", "/metrics", } ) +# Path prefixes that bypass auth — used by the OAuth flow whose paths +# contain a provider slot in the URL (e.g. ``/api/auth/oauth/start/google``). +PUBLIC_PATH_PREFIXES: tuple[str, ...] = ( + "/api/auth/oauth/start/", + "/api/auth/oauth/callback/", +) + + +def is_public_path(path: str) -> bool: + """Whether ``path`` should bypass the session/CSRF gate.""" + return path in PUBLIC_PATHS or any(path.startswith(p) for p in PUBLIC_PATH_PREFIXES) + + # Paths handled directly by the server (live outside the regular dispatch # tables because they take a RequestContext as well as the queue). AUTH_CONTEXT_GET_PATHS: frozenset[str] = frozenset({"/api/auth/whoami"}) diff --git a/py_src/taskito/dashboard/server.py b/py_src/taskito/dashboard/server.py index bdb56eda..f5d239ae 100644 --- a/py_src/taskito/dashboard/server.py +++ b/py_src/taskito/dashboard/server.py @@ -22,6 +22,16 @@ bootstrap_admin_from_env, ) from taskito.dashboard.errors import _BadRequest, _NotFound +from taskito.dashboard.handlers.oauth import ( + OAuthRedirect, + handle_providers, +) +from taskito.dashboard.handlers.oauth import ( + handle_callback as handle_oauth_callback, +) +from taskito.dashboard.handlers.oauth import ( + handle_start as handle_oauth_start, +) from taskito.dashboard.request_context import ( CSRF_COOKIE, SESSION_COOKIE, @@ -42,10 +52,10 @@ POST_PARAM2_ROUTES, POST_PARAM_ROUTES, POST_ROUTES, - PUBLIC_PATHS, PUT_PARAM2_ROUTES, PUT_PARAM_ROUTES, is_csrf_exempt, + is_public_path, is_state_changing_method, ) from taskito.dashboard.static import ( @@ -59,6 +69,7 @@ if TYPE_CHECKING: from taskito.app import Queue + from taskito.dashboard.oauth.flow import OAuthFlow logger = logging.getLogger("taskito.dashboard") @@ -80,12 +91,27 @@ def _safe_path(path: str) -> str: return path.translate(_LOG_UNSAFE_CHARS)[:_LOG_PATH_MAX] +def _session_cookies(session: Any) -> tuple[str, ...]: + """Build the standard ``Set-Cookie`` headers for a freshly-created session. + + Used by both password login and OAuth callback so the cookie shape + stays in lockstep across login methods. + """ + return ( + f"{SESSION_COOKIE}={session.token}; HttpOnly; SameSite=Strict; Path=/; " + f"Max-Age={DEFAULT_SESSION_TTL_SECONDS}", + f"{CSRF_COOKIE}={session.csrf_token}; SameSite=Strict; Path=/; " + f"Max-Age={DEFAULT_SESSION_TTL_SECONDS}", + ) + + def serve_dashboard( queue: Queue, host: str = "127.0.0.1", port: int = 8080, *, static_assets: StaticAssets | None = None, + oauth_flow: OAuthFlow | None = None, ) -> None: """Start the dashboard HTTP server (blocking). @@ -96,9 +122,14 @@ def serve_dashboard( static_assets: Override the default SPA asset source. Mainly a test seam; downstream embedders can also use it to ship a customised dashboard bundle from a different location. + oauth_flow: Configured :class:`OAuthFlow` to enable social login. + When unset, OAuth endpoints respond 404 and the providers list + is empty. """ bootstrap_admin_from_env(queue) - handler = _make_handler(queue, static_assets=static_assets) + if oauth_flow is None: + oauth_flow = _build_oauth_flow_from_env(queue) + handler = _make_handler(queue, static_assets=static_assets, oauth_flow=oauth_flow) server = ThreadingHTTPServer((host, port), handler) print(f"taskito dashboard → http://{host}:{port}") print("Press Ctrl+C to stop") @@ -111,7 +142,31 @@ def serve_dashboard( server.server_close() -def _make_handler(queue: Queue, *, static_assets: StaticAssets | None = None) -> type: +def _build_oauth_flow_from_env(queue: Queue) -> OAuthFlow | None: + """Build :class:`OAuthFlow` from environment variables, or ``None``. + + Failures in the env-var config are logged and treated as "OAuth not + configured" — the dashboard still starts with password auth only. + """ + try: + from taskito.dashboard.oauth.config import from_env as oauth_from_env + from taskito.dashboard.oauth.flow import OAuthFlow + + config = oauth_from_env() + if config is None or not config.is_enabled: + return None + return OAuthFlow(queue, config) + except Exception: + logger.exception("OAuth env-var configuration is invalid; OAuth disabled") + return None + + +def _make_handler( + queue: Queue, + *, + static_assets: StaticAssets | None = None, + oauth_flow: OAuthFlow | None = None, +) -> type: """Create a request handler class bound to the given queue.""" assets = static_assets if static_assets is not None else _get_default_assets() @@ -169,6 +224,19 @@ def _handle_get(self) -> None: if denied: return + # ── OAuth flow paths (public, redirect-emitting) ──────── + if path == "/api/auth/providers": + self._dispatch_with_handler(handle_providers, lambda h: h(queue, qs, oauth_flow)) + return + if path.startswith("/api/auth/oauth/start/"): + slot = unquote(path[len("/api/auth/oauth/start/") :]) + self._dispatch_oauth_redirect(handle_oauth_start, queue, qs, slot, oauth_flow) + return + if path.startswith("/api/auth/oauth/callback/"): + slot = unquote(path[len("/api/auth/oauth/callback/") :]) + self._dispatch_oauth_redirect(handle_oauth_callback, queue, qs, slot, oauth_flow) + return + if path in AUTH_CONTEXT_GET_PATHS: self._dispatch_with_handler(GET_CTX_ROUTES.get(path), lambda h: h(queue, ctx)) return @@ -336,13 +404,13 @@ def _authorize(self, path: str, method: str) -> tuple[RequestContext, bool]: # SPA can show the setup page. if ( path.startswith("/api/") - and path not in PUBLIC_PATHS + and not is_public_path(path) and AuthStore(queue).count_users() == 0 ): self._json_response({"error": "setup_required"}, status=503) return ctx, True - if path in PUBLIC_PATHS or not path.startswith("/api/"): + if is_public_path(path) or not path.startswith("/api/"): # CSRF still applies to public state-changing routes that are # NOT exempt — but login/setup are the only public POSTs and # they're exempt. @@ -423,6 +491,33 @@ def _dispatch_with_handler( on_success(result) self._json_response(result) + def _dispatch_oauth_redirect( + self, + handler: Any, + queue: Any, + qs: dict[str, list[str]], + slot: str, + flow: OAuthFlow | None, + ) -> None: + try: + redirect: OAuthRedirect = handler(queue, qs, slot, flow) + except _BadRequest as e: + self._json_response({"error": e.message}, status=400) + return + except _NotFound as e: + self._json_response({"error": e.message}, status=404) + return + cookies: list[str] = [] + if redirect.session is not None: + cookies = list(_session_cookies(redirect.session)) + self.send_response(redirect.status) + self.send_header("Location", redirect.url) + self.send_header("Content-Length", "0") + self.send_header("Cache-Control", "no-store") + for cookie in cookies: + self.send_header("Set-Cookie", cookie) + self.end_headers() + # ── Body / response helpers ───────────────────────────────── def _read_json_body(self) -> Any | None: diff --git a/py_src/taskito/dashboard/url_safety.py b/py_src/taskito/dashboard/url_safety.py index b39be8e8..a0db4e3d 100644 --- a/py_src/taskito/dashboard/url_safety.py +++ b/py_src/taskito/dashboard/url_safety.py @@ -95,3 +95,21 @@ def validate_webhook_url(url: str) -> None: for ip in addresses: if _is_private_ip(ip): raise UnsafeWebhookUrl(f"URL host {hostname!r} resolves to private address {ip}") + + +def is_safe_redirect(path: str | None) -> bool: + """Whether ``path`` is safe to use as a post-login same-origin redirect target. + + Accepts only relative paths rooted at ``/``. Rejects absolute URLs + (``http://evil.com/x``), protocol-relative URLs (``//evil.com/x``), + and anything without a leading slash. Empty / ``None`` is rejected so + the caller can fall back to a default explicitly. + """ + if not path: + return False + if not path.startswith("/"): + return False + if path.startswith("//") or path.startswith("/\\"): + return False + parsed = urllib.parse.urlparse(path) + return not (parsed.scheme or parsed.netloc) diff --git a/py_src/taskito/proxies/handlers/requests_session.py b/py_src/taskito/proxies/handlers/requests_session.py index 489c7ea7..f372776f 100644 --- a/py_src/taskito/proxies/handlers/requests_session.py +++ b/py_src/taskito/proxies/handlers/requests_session.py @@ -5,7 +5,7 @@ from typing import Any try: - import requests # type: ignore[import-untyped] + import requests _HAS_REQUESTS = True except ImportError: diff --git a/pyproject.toml b/pyproject.toml index 29509ccf..9e3e5ba9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,7 @@ flask = ["flask>=3.0"] aws = ["boto3>=1.34"] gcs = ["google-cloud-storage>=2.10"] docs = ["playwright>=1.59"] +oauth = ["authlib>=1.7,<2"] [tool.maturin] manifest-path = "crates/taskito-python/Cargo.toml" @@ -150,3 +151,15 @@ ignore_missing_imports = true module = "click" ignore_missing_imports = true +[[tool.mypy.overrides]] +module = ["authlib", "authlib.*"] +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = ["joserfc", "joserfc.*"] +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = ["requests", "requests.*"] +ignore_missing_imports = true + diff --git a/tests/dashboard/test_auth.py b/tests/dashboard/test_auth.py index c78a73c0..599e6894 100644 --- a/tests/dashboard/test_auth.py +++ b/tests/dashboard/test_auth.py @@ -197,6 +197,141 @@ def test_bootstrap_admin_noop_without_env(queue: Queue, monkeypatch: pytest.Monk assert AuthStore(queue).count_users() == 0 +# ── OAuth users ──────────────────────────────────────────────────────── + + +def test_verify_password_rejects_oauth_sentinel_hash() -> None: + assert verify_password("anything", "oauth:google") is False + assert verify_password("anything", "oauth:okta") is False + + +def test_get_or_create_oauth_user_creates_user_with_admin_when_table_empty( + queue: Queue, +) -> None: + store = AuthStore(queue) + user = store.get_or_create_oauth_user( + slot="google", + subject="1184283742", + email="alice@acme.com", + name="Alice Example", + email_verified=True, + ) + assert user.username == "google:1184283742" + assert user.role == "admin" + assert user.email == "alice@acme.com" + assert user.display_name == "Alice Example" + assert user.is_oauth is True + + +def test_get_or_create_oauth_user_subsequent_user_is_viewer(queue: Queue) -> None: + store = AuthStore(queue) + store.get_or_create_oauth_user( + slot="google", + subject="111", + email="alice@acme.com", + name="Alice", + email_verified=True, + ) + second = store.get_or_create_oauth_user( + slot="google", + subject="222", + email="bob@acme.com", + name="Bob", + email_verified=True, + ) + assert second.role == "viewer" + + +def test_get_or_create_oauth_user_admin_emails_take_precedence(queue: Queue) -> None: + store = AuthStore(queue) + # Pre-seed a password user so the table is not empty. + store.create_user("primary", "hunter2-secret") + listed = store.get_or_create_oauth_user( + slot="google", + subject="111", + email="alice@acme.com", + name="Alice", + email_verified=True, + admin_emails=("alice@acme.com",), + ) + assert listed.role == "admin" + + unlisted = store.get_or_create_oauth_user( + slot="google", + subject="222", + email="eve@evil.com", + name="Eve", + email_verified=True, + admin_emails=("alice@acme.com",), + ) + assert unlisted.role == "viewer" + + +def test_get_or_create_oauth_user_unverified_email_never_gets_admin(queue: Queue) -> None: + store = AuthStore(queue) + # Even on empty table, an unverified email cannot become admin. + user = store.get_or_create_oauth_user( + slot="github", + subject="42", + email="claimed@acme.com", + name=None, + email_verified=False, + admin_emails=("claimed@acme.com",), + ) + assert user.role == "viewer" + + +def test_get_or_create_oauth_user_email_match_is_case_insensitive(queue: Queue) -> None: + store = AuthStore(queue) + store.create_user("primary", "hunter2-secret") + user = store.get_or_create_oauth_user( + slot="google", + subject="123", + email="Alice@ACME.com", + name=None, + email_verified=True, + admin_emails=("alice@acme.com",), + ) + assert user.role == "admin" + + +def test_get_or_create_oauth_user_returning_user_refreshes_display_fields( + queue: Queue, +) -> None: + store = AuthStore(queue) + first = store.get_or_create_oauth_user( + slot="google", + subject="555", + email="alice@acme.com", + name="Alice", + email_verified=True, + ) + again = store.get_or_create_oauth_user( + slot="google", + subject="555", + email="alice-new@acme.com", + name="Alice Renamed", + email_verified=True, + ) + assert again.username == first.username + assert again.email == "alice-new@acme.com" + assert again.display_name == "Alice Renamed" + # Role is not re-evaluated on subsequent logins. + assert again.role == first.role + + +def test_oauth_users_namespace_by_slot(queue: Queue) -> None: + store = AuthStore(queue) + a = store.get_or_create_oauth_user( + slot="okta", subject="abc", email=None, name=None, email_verified=False + ) + b = store.get_or_create_oauth_user( + slot="microsoft", subject="abc", email=None, name=None, email_verified=False + ) + assert a.username != b.username + assert store.count_users() == 2 + + # ── HTTP endpoints ───────────────────────────────────────────────────── diff --git a/tests/dashboard/test_oauth_config.py b/tests/dashboard/test_oauth_config.py new file mode 100644 index 00000000..16cbe5ae --- /dev/null +++ b/tests/dashboard/test_oauth_config.py @@ -0,0 +1,209 @@ +"""Tests for OAuth config parsing from env vars.""" + +from __future__ import annotations + +import pytest + +from taskito.dashboard.oauth.config import ( + GitHubConfig, + GoogleConfig, + OAuthConfig, + OAuthConfigError, + OIDCConfig, + from_env, +) + + +def test_from_env_returns_none_when_unconfigured() -> None: + assert from_env({}) is None + + +def test_from_env_requires_base_url_when_any_provider_set() -> None: + with pytest.raises(OAuthConfigError, match="REDIRECT_BASE_URL"): + from_env( + { + "TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_ID": "gid", + "TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_SECRET": "gsec", + } + ) + + +def test_from_env_parses_google_provider() -> None: + config = from_env( + { + "TASKITO_DASHBOARD_OAUTH_REDIRECT_BASE_URL": "https://taskito.acme.com", + "TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_ID": "gid", + "TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_SECRET": "gsec", + "TASKITO_DASHBOARD_OAUTH_GOOGLE_ALLOWED_DOMAINS": "acme.com, partner.com", + } + ) + assert config is not None + assert config.is_enabled + assert isinstance(config.google, GoogleConfig) + assert config.google.client_id == "gid" + assert config.google.client_secret == "gsec" + assert config.google.allowed_domains == ("acme.com", "partner.com") + assert config.github is None + assert config.oidc == () + + +def test_from_env_partial_google_config_raises() -> None: + with pytest.raises(OAuthConfigError, match="CLIENT_SECRET"): + from_env( + { + "TASKITO_DASHBOARD_OAUTH_REDIRECT_BASE_URL": "https://taskito.acme.com", + "TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_ID": "gid", + } + ) + + +def test_from_env_parses_github_provider() -> None: + config = from_env( + { + "TASKITO_DASHBOARD_OAUTH_REDIRECT_BASE_URL": "https://taskito.acme.com", + "TASKITO_DASHBOARD_OAUTH_GITHUB_CLIENT_ID": "hid", + "TASKITO_DASHBOARD_OAUTH_GITHUB_CLIENT_SECRET": "hsec", + "TASKITO_DASHBOARD_OAUTH_GITHUB_ALLOWED_ORGS": "acme,partner", + } + ) + assert config is not None + assert isinstance(config.github, GitHubConfig) + assert config.github.allowed_orgs == ("acme", "partner") + + +def test_from_env_parses_multiple_oidc_slots() -> None: + config = from_env( + { + "TASKITO_DASHBOARD_OAUTH_REDIRECT_BASE_URL": "https://taskito.acme.com", + "TASKITO_DASHBOARD_OAUTH_OIDC_PROVIDERS": "okta,microsoft", + "TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_CLIENT_ID": "oid", + "TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_CLIENT_SECRET": "osec", + "TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_DISCOVERY_URL": "https://acme.okta.com/.well-known/openid-configuration", + "TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_LABEL": "Acme SSO", + "TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_ALLOWED_DOMAINS": "acme.com", + "TASKITO_DASHBOARD_OAUTH_OIDC_MICROSOFT_CLIENT_ID": "mid", + "TASKITO_DASHBOARD_OAUTH_OIDC_MICROSOFT_CLIENT_SECRET": "msec", + "TASKITO_DASHBOARD_OAUTH_OIDC_MICROSOFT_DISCOVERY_URL": "https://login.microsoftonline.com/x/v2.0/.well-known/openid-configuration", + } + ) + assert config is not None + assert [p.slot for p in config.oidc] == ["okta", "microsoft"] + okta = config.oidc[0] + assert isinstance(okta, OIDCConfig) + assert okta.label == "Acme SSO" + assert okta.allowed_domains == ("acme.com",) + microsoft = config.oidc[1] + assert microsoft.label == "Microsoft" # default = title-cased slot + + +def test_from_env_rejects_duplicate_oidc_slot() -> None: + with pytest.raises(OAuthConfigError, match="twice"): + from_env( + { + "TASKITO_DASHBOARD_OAUTH_REDIRECT_BASE_URL": "https://taskito.acme.com", + "TASKITO_DASHBOARD_OAUTH_OIDC_PROVIDERS": "okta,okta", + "TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_CLIENT_ID": "oid", + "TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_CLIENT_SECRET": "osec", + "TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_DISCOVERY_URL": "https://x/y", + } + ) + + +def test_oidc_slot_must_not_collide_with_reserved_name() -> None: + with pytest.raises(OAuthConfigError, match=r"reserved|collides|built-in"): + OIDCConfig( + slot="google", + client_id="x", + client_secret="y", + discovery_url="https://x/y", + ) + + +def test_oidc_slot_must_be_url_safe() -> None: + with pytest.raises(OAuthConfigError): + OIDCConfig( + slot="Has Spaces", + client_id="x", + client_secret="y", + discovery_url="https://x/y", + ) + + +def test_redirect_base_url_must_be_https_for_remote_hosts() -> None: + with pytest.raises(OAuthConfigError, match="https"): + OAuthConfig(redirect_base_url="http://taskito.acme.com") + + +def test_redirect_base_url_allows_http_for_localhost() -> None: + # No exception. + OAuthConfig(redirect_base_url="http://localhost:8000") + OAuthConfig(redirect_base_url="http://127.0.0.1:8000") + + +def test_password_auth_flag_parses() -> None: + config = from_env( + { + "TASKITO_DASHBOARD_OAUTH_REDIRECT_BASE_URL": "https://taskito.acme.com", + "TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_ID": "gid", + "TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_SECRET": "gsec", + "TASKITO_DASHBOARD_PASSWORD_AUTH_ENABLED": "false", + } + ) + assert config is not None + assert config.password_auth_enabled is False + + +def test_disabling_password_without_providers_is_an_error() -> None: + with pytest.raises(OAuthConfigError, match="no way to log in"): + from_env( + { + "TASKITO_DASHBOARD_OAUTH_REDIRECT_BASE_URL": "https://taskito.acme.com", + "TASKITO_DASHBOARD_PASSWORD_AUTH_ENABLED": "false", + } + ) + + +def test_admin_emails_parsed_as_tuple() -> None: + config = from_env( + { + "TASKITO_DASHBOARD_OAUTH_REDIRECT_BASE_URL": "https://taskito.acme.com", + "TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_ID": "gid", + "TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_SECRET": "gsec", + "TASKITO_DASHBOARD_OAUTH_ADMIN_EMAILS": " alice@acme.com , bob@acme.com ", + } + ) + assert config is not None + assert config.admin_emails == ("alice@acme.com", "bob@acme.com") + + +def test_callback_url_built_from_base_url_and_slot() -> None: + config = from_env( + { + "TASKITO_DASHBOARD_OAUTH_REDIRECT_BASE_URL": "https://taskito.acme.com/", + "TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_ID": "gid", + "TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_SECRET": "gsec", + } + ) + assert config is not None + assert ( + config.callback_url("google") == "https://taskito.acme.com/api/auth/oauth/callback/google" + ) + + +def test_find_provider_returns_matching_slot() -> None: + config = from_env( + { + "TASKITO_DASHBOARD_OAUTH_REDIRECT_BASE_URL": "https://taskito.acme.com", + "TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_ID": "gid", + "TASKITO_DASHBOARD_OAUTH_GOOGLE_CLIENT_SECRET": "gsec", + "TASKITO_DASHBOARD_OAUTH_OIDC_PROVIDERS": "okta", + "TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_CLIENT_ID": "oid", + "TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_CLIENT_SECRET": "osec", + "TASKITO_DASHBOARD_OAUTH_OIDC_OKTA_DISCOVERY_URL": "https://acme.okta.com/.well-known/openid-configuration", + } + ) + assert config is not None + assert config.find_provider("google") is config.google + okta = config.find_provider("okta") + assert isinstance(okta, OIDCConfig) + assert config.find_provider("does-not-exist") is None diff --git a/tests/dashboard/test_oauth_endpoints.py b/tests/dashboard/test_oauth_endpoints.py new file mode 100644 index 00000000..d6b95ba2 --- /dev/null +++ b/tests/dashboard/test_oauth_endpoints.py @@ -0,0 +1,400 @@ +"""HTTP-level integration tests for the OAuth endpoints. + +Spins up a real :class:`ThreadingHTTPServer` with a stubbed +:class:`OAuthFlow` so we can drive the full request → 302-redirect → +cookies path without making real provider calls. +""" + +from __future__ import annotations + +import contextlib +import json +import threading +import urllib.error +import urllib.request +from collections.abc import Callable, Generator +from http.server import ThreadingHTTPServer +from pathlib import Path +from typing import Any + +import pytest + +from taskito import Queue +from taskito.dashboard import _make_handler +from taskito.dashboard.auth import AuthStore +from taskito.dashboard.oauth.config import ( + GitHubConfig, + GoogleConfig, + OAuthConfig, + OIDCConfig, +) +from taskito.dashboard.oauth.flow import OAuthFlow +from taskito.dashboard.oauth.identity import ( + AllowlistDenied, + IdentityFetchError, + ProviderIdentity, +) + + +@pytest.fixture +def queue(tmp_path: Path) -> Queue: + return Queue(db_path=str(tmp_path / "oauth_endpoints.db")) + + +class _FakeProvider: + """Programmable provider used by the integration tests.""" + + def __init__(self, slot: str, *, label: str = "Test", ptype: str = "google") -> None: + self.slot = slot + self.label = label + self.type = ptype + self.identity: ProviderIdentity | None = None + self.allow = True + self.start_called_with: dict[str, str] | None = None + + def authorization_url( + self, + *, + state: str, + nonce: str, + code_challenge: str, + redirect_uri: str, + ) -> str: + self.start_called_with = { + "state": state, + "nonce": nonce, + "code_challenge": code_challenge, + "redirect_uri": redirect_uri, + } + return f"https://idp.example.com/authorize?state={state}" + + def exchange_code( + self, + *, + code: str, + code_verifier: str, + redirect_uri: str, + expected_nonce: str | None, + ) -> ProviderIdentity: + if self.identity is None: + raise IdentityFetchError("no identity configured") + return self.identity + + def check_allowlist(self, identity: ProviderIdentity) -> None: + if not self.allow: + raise AllowlistDenied("denied") + + +@pytest.fixture +def google_provider() -> _FakeProvider: + return _FakeProvider("google", label="Google", ptype="google") + + +@pytest.fixture +def okta_provider() -> _FakeProvider: + return _FakeProvider("okta", label="Acme SSO", ptype="oidc") + + +def _make_flow( + queue: Queue, + providers: dict[str, _FakeProvider], + *, + password_enabled: bool = True, + admin_emails: tuple[str, ...] = (), +) -> OAuthFlow: + google_cfg = GoogleConfig(client_id="gid", client_secret="gsec") + github_cfg = GitHubConfig(client_id="hid", client_secret="hsec") + config = OAuthConfig( + redirect_base_url="http://127.0.0.1", + google=google_cfg if "google" in providers else None, + github=github_cfg if "github" in providers else None, + oidc=tuple( + OIDCConfig( + slot=slot, + client_id="x", + client_secret="y", + discovery_url=f"https://idp/{slot}/.well-known/openid-configuration", + ) + for slot in providers + if slot not in ("google", "github") + ), + password_auth_enabled=password_enabled, + admin_emails=admin_emails, + ) + return OAuthFlow(queue, config, providers=providers) # type: ignore[arg-type] + + +@pytest.fixture +def server_factory( + queue: Queue, +) -> Generator[Callable[[OAuthFlow | None], str]]: + """Spawns dashboard servers with the requested OAuthFlow.""" + handles: list[ThreadingHTTPServer] = [] + + def _factory(flow: OAuthFlow | None) -> str: + handler = _make_handler(queue, oauth_flow=flow) + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + handles.append(server) + port = server.server_address[1] + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return f"http://127.0.0.1:{port}" + + yield _factory + + for server in handles: + server.shutdown() + + +def _get_no_redirect( + url: str, *, cookies: dict[str, str] | None = None +) -> tuple[int, Any, dict[str, list[str]]]: + """GET without following redirects, returning (status, body, headers).""" + + class _NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *_a: Any, **_k: Any) -> None: + return None + + opener = urllib.request.build_opener(_NoRedirect()) + req = urllib.request.Request(url, method="GET") + if cookies: + req.add_header("Cookie", "; ".join(f"{k}={v}" for k, v in cookies.items())) + try: + resp = opener.open(req) + body: Any = None + try: + raw = resp.read() + body = json.loads(raw) if raw else None + except (ValueError, json.JSONDecodeError): + body = None + headers = {k: resp.headers.get_all(k) or [] for k in set(resp.headers.keys())} + return resp.status, body, headers + except urllib.error.HTTPError as e: + body = None + with contextlib.suppress(ValueError, json.JSONDecodeError): + body = json.loads(e.read() or b"{}") + headers = {k: e.headers.get_all(k) or [] for k in set(e.headers.keys())} + return e.code, body, headers + + +def _parse_set_cookies(raw: list[str]) -> dict[str, str]: + out: dict[str, str] = {} + for line in raw: + nv = line.split(";", 1)[0] + if "=" in nv: + name, value = nv.split("=", 1) + out[name.strip()] = value.strip() + return out + + +# ── /api/auth/providers ────────────────────────────────────────────── + + +def test_providers_endpoint_returns_empty_list_when_no_flow( + server_factory: Any, +) -> None: + base = server_factory(None) + status, body, _ = _get_no_redirect(f"{base}/api/auth/providers") + assert status == 200 + assert body == {"password_enabled": True, "providers": []} + + +def test_providers_endpoint_lists_configured_providers( + server_factory: Any, + queue: Queue, + google_provider: _FakeProvider, + okta_provider: _FakeProvider, +) -> None: + flow = _make_flow(queue, {"google": google_provider, "okta": okta_provider}) + base = server_factory(flow) + status, body, _ = _get_no_redirect(f"{base}/api/auth/providers") + assert status == 200 + assert body == { + "password_enabled": True, + "providers": [ + {"slot": "google", "label": "Google", "type": "google"}, + {"slot": "okta", "label": "Acme SSO", "type": "oidc"}, + ], + } + + +def test_providers_endpoint_reflects_password_disabled( + server_factory: Any, queue: Queue, google_provider: _FakeProvider +) -> None: + flow = _make_flow(queue, {"google": google_provider}, password_enabled=False) + base = server_factory(flow) + _, body, _ = _get_no_redirect(f"{base}/api/auth/providers") + assert body["password_enabled"] is False + + +# ── /api/auth/oauth/start/{slot} ───────────────────────────────────── + + +def test_start_returns_302_to_provider( + server_factory: Any, queue: Queue, google_provider: _FakeProvider +) -> None: + flow = _make_flow(queue, {"google": google_provider}) + base = server_factory(flow) + status, _, headers = _get_no_redirect(f"{base}/api/auth/oauth/start/google") + assert status == 302 + locations = headers.get("Location") or [] + assert len(locations) == 1 + assert locations[0].startswith("https://idp.example.com/authorize?state=") + assert google_provider.start_called_with is not None + assert google_provider.start_called_with["redirect_uri"].endswith( + "/api/auth/oauth/callback/google" + ) + + +def test_start_returns_404_for_unknown_slot( + server_factory: Any, queue: Queue, google_provider: _FakeProvider +) -> None: + flow = _make_flow(queue, {"google": google_provider}) + base = server_factory(flow) + status, body, _ = _get_no_redirect(f"{base}/api/auth/oauth/start/azure") + assert status == 404 + assert body is not None and "azure" in body.get("error", "") + + +def test_start_returns_404_when_oauth_not_configured( + server_factory: Any, +) -> None: + base = server_factory(None) + status, body, _ = _get_no_redirect(f"{base}/api/auth/oauth/start/google") + assert status == 404 + assert body is not None + assert body.get("error") == "oauth_not_configured" + + +# ── /api/auth/oauth/callback/{slot} ────────────────────────────────── + + +def test_callback_creates_session_and_sets_cookies( + server_factory: Any, queue: Queue, google_provider: _FakeProvider +) -> None: + google_provider.identity = ProviderIdentity( + slot="google", + subject="118420987654321", + email="alice@acme.com", + email_verified=True, + name="Alice", + ) + flow = _make_flow(queue, {"google": google_provider}) + base = server_factory(flow) + + # First /start to mint state. + start_status, _, headers = _get_no_redirect( + f"{base}/api/auth/oauth/start/google?next=/dashboard" + ) + assert start_status == 302 + location = headers["Location"][0] + state = location.split("state=")[-1] + + cb_status, _, cb_headers = _get_no_redirect( + f"{base}/api/auth/oauth/callback/google?code=abc&state={state}" + ) + assert cb_status == 302 + # Redirected to the safe ``next`` URL. + assert cb_headers["Location"] == ["/dashboard"] + + cookies = _parse_set_cookies(cb_headers.get("Set-Cookie", [])) + assert "taskito_session" in cookies + assert "taskito_csrf" in cookies + assert cookies["taskito_session"] + + # A user was created in the AuthStore with the OAuth username scheme. + user = AuthStore(queue).get_user("google:118420987654321") + assert user is not None + assert user.email == "alice@acme.com" + assert user.is_oauth + + +def test_callback_rejects_unsafe_next_via_fallback_root( + server_factory: Any, queue: Queue, google_provider: _FakeProvider +) -> None: + google_provider.identity = ProviderIdentity( + slot="google", subject="2", email="bob@acme.com", email_verified=True + ) + flow = _make_flow(queue, {"google": google_provider}) + base = server_factory(flow) + _, _, headers = _get_no_redirect( + f"{base}/api/auth/oauth/start/google?next=https://evil.com/take" + ) + state = headers["Location"][0].split("state=")[-1] + _, _, cb_headers = _get_no_redirect( + f"{base}/api/auth/oauth/callback/google?code=abc&state={state}" + ) + # Unsafe next was scrubbed to "/" before being persisted with the state. + assert cb_headers["Location"] == ["/"] + + +def test_callback_replayed_state_is_rejected( + server_factory: Any, queue: Queue, google_provider: _FakeProvider +) -> None: + google_provider.identity = ProviderIdentity( + slot="google", subject="3", email="c@acme.com", email_verified=True + ) + flow = _make_flow(queue, {"google": google_provider}) + base = server_factory(flow) + _, _, headers = _get_no_redirect(f"{base}/api/auth/oauth/start/google") + state = headers["Location"][0].split("state=")[-1] + # First callback succeeds. + first_status, _, _ = _get_no_redirect( + f"{base}/api/auth/oauth/callback/google?code=abc&state={state}" + ) + assert first_status == 302 + # Replay is a 400. + replay_status, body, _ = _get_no_redirect( + f"{base}/api/auth/oauth/callback/google?code=abc&state={state}" + ) + assert replay_status == 400 + assert body is not None + assert "oauth_state_invalid" in body.get("error", "") + + +def test_callback_with_provider_error_returns_400( + server_factory: Any, queue: Queue, google_provider: _FakeProvider +) -> None: + flow = _make_flow(queue, {"google": google_provider}) + base = server_factory(flow) + status, body, _ = _get_no_redirect( + f"{base}/api/auth/oauth/callback/google?error=access_denied" + ) + assert status == 400 + assert body is not None + assert "oauth_state_invalid" in body.get("error", "") or "identity" in body.get("error", "") + + +def test_callback_blocked_by_allowlist( + server_factory: Any, queue: Queue, google_provider: _FakeProvider +) -> None: + google_provider.identity = ProviderIdentity( + slot="google", subject="4", email="eve@evil.com", email_verified=True + ) + google_provider.allow = False + flow = _make_flow(queue, {"google": google_provider}) + base = server_factory(flow) + _, _, headers = _get_no_redirect(f"{base}/api/auth/oauth/start/google") + state = headers["Location"][0].split("state=")[-1] + status, body, _ = _get_no_redirect( + f"{base}/api/auth/oauth/callback/google?code=abc&state={state}" + ) + assert status == 400 + assert body is not None + assert "allowlist_denied" in body.get("error", "") + + +def test_oauth_paths_bypass_setup_required_gate( + server_factory: Any, queue: Queue, google_provider: _FakeProvider +) -> None: + """Even before the first user exists, the OAuth flow paths must answer. + + Otherwise a fresh deployment using OAuth-only mode could never bootstrap. + """ + flow = _make_flow(queue, {"google": google_provider}) + base = server_factory(flow) + assert AuthStore(queue).count_users() == 0 + status, _, _ = _get_no_redirect(f"{base}/api/auth/providers") + assert status == 200 + status, _, _ = _get_no_redirect(f"{base}/api/auth/oauth/start/google") + assert status == 302 diff --git a/tests/dashboard/test_oauth_flow.py b/tests/dashboard/test_oauth_flow.py new file mode 100644 index 00000000..825b9d6c --- /dev/null +++ b/tests/dashboard/test_oauth_flow.py @@ -0,0 +1,208 @@ +"""Tests for :class:`OAuthFlow` — state + provider + auth-store orchestration.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from taskito import Queue +from taskito.dashboard.auth import AuthStore +from taskito.dashboard.oauth.config import GoogleConfig, OAuthConfig +from taskito.dashboard.oauth.flow import OAuthFlow +from taskito.dashboard.oauth.identity import ( + AllowlistDenied, + IdentityFetchError, + ProviderIdentity, + ProviderNotConfigured, + StateValidationError, +) + + +@pytest.fixture +def queue(tmp_path: Path) -> Queue: + return Queue(db_path=str(tmp_path / "oauth_flow.db")) + + +@pytest.fixture +def config() -> OAuthConfig: + return OAuthConfig( + redirect_base_url="https://taskito.example.com", + google=GoogleConfig( + client_id="cid", + client_secret="csec", + allowed_domains=("acme.com",), + ), + admin_emails=("alice@acme.com",), + ) + + +class FakeProvider: + """In-memory provider with programmable identity / allowlist behaviour.""" + + type = "google" + label = "Test" + + def __init__(self, slot: str, identity: ProviderIdentity | None = None) -> None: + self.slot = slot + self.identity = identity + self.allow = True + self.last_authorization_args: dict | None = None + + def authorization_url( + self, + *, + state: str, + nonce: str, + code_challenge: str, + redirect_uri: str, + ) -> str: + self.last_authorization_args = { + "state": state, + "nonce": nonce, + "code_challenge": code_challenge, + "redirect_uri": redirect_uri, + } + return f"https://idp.example.com/authorize?state={state}" + + def exchange_code( + self, + *, + code: str, + code_verifier: str, + redirect_uri: str, + expected_nonce: str | None, + ) -> ProviderIdentity: + if self.identity is None: + raise IdentityFetchError("test stub: no identity configured") + return self.identity + + def check_allowlist(self, identity: ProviderIdentity) -> None: + if not self.allow: + raise AllowlistDenied("test stub: denied") + + +def test_start_returns_provider_url_with_safe_next(queue: Queue, config: OAuthConfig) -> None: + fake = FakeProvider("google") + flow = OAuthFlow(queue, config, providers={"google": fake}) + url = flow.start("google", next_url="/dashboard/jobs") + assert url.startswith("https://idp.example.com/authorize?state=") + args = fake.last_authorization_args + assert args is not None + assert args["redirect_uri"] == "https://taskito.example.com/api/auth/oauth/callback/google" + assert len(args["state"]) >= 32 + + +def test_start_falls_back_to_root_when_next_unsafe(queue: Queue, config: OAuthConfig) -> None: + fake = FakeProvider("google") + flow = OAuthFlow(queue, config, providers={"google": fake}) + flow.start("google", next_url="https://evil.com/x") + # We can't read state.next_url back without inspecting the store — + # but we can confirm the callback rejects it via a separate test. + + +def test_start_raises_for_unknown_slot(queue: Queue, config: OAuthConfig) -> None: + flow = OAuthFlow(queue, config, providers={}) + with pytest.raises(ProviderNotConfigured): + flow.start("nonexistent", next_url="/") + + +def test_handle_callback_creates_user_and_session(queue: Queue, config: OAuthConfig) -> None: + identity = ProviderIdentity( + slot="google", + subject="100200300", + email="alice@acme.com", + email_verified=True, + name="Alice", + ) + fake = FakeProvider("google", identity=identity) + flow = OAuthFlow(queue, config, providers={"google": fake}) + + # Mint state then handle the callback. + flow.start("google", next_url="/dashboard") + state_token = next(iter(_state_tokens(queue))) + + session, next_url = flow.handle_callback( + "google", code="abc", state_token=state_token, error=None + ) + assert session.username == "google:100200300" + assert session.role == "admin" # alice is in admin_emails + assert next_url == "/dashboard" + + # Replay attempt fails because state is single-use. + with pytest.raises(StateValidationError, match="invalid"): + flow.handle_callback("google", code="abc", state_token=state_token, error=None) + + +def test_handle_callback_rejects_slot_mismatch(queue: Queue, config: OAuthConfig) -> None: + identity = ProviderIdentity(slot="google", subject="x", email=None, email_verified=False) + fake = FakeProvider("google", identity=identity) + flow = OAuthFlow(queue, config, providers={"google": fake}) + flow.start("google", next_url="/") + state_token = next(iter(_state_tokens(queue))) + with pytest.raises(StateValidationError, match="slot"): + flow.handle_callback("github", code="abc", state_token=state_token, error=None) + + +def test_handle_callback_propagates_provider_error(queue: Queue, config: OAuthConfig) -> None: + flow = OAuthFlow(queue, config, providers={"google": FakeProvider("google")}) + flow.start("google", next_url="/") + state_token = next(iter(_state_tokens(queue))) + with pytest.raises(IdentityFetchError): + flow.handle_callback("google", code="abc", state_token=state_token, error=None) + + +def test_handle_callback_propagates_allowlist_denied(queue: Queue, config: OAuthConfig) -> None: + identity = ProviderIdentity( + slot="google", + subject="1", + email="eve@evil.com", + email_verified=True, + ) + fake = FakeProvider("google", identity=identity) + fake.allow = False + flow = OAuthFlow(queue, config, providers={"google": fake}) + flow.start("google", next_url="/") + state_token = next(iter(_state_tokens(queue))) + with pytest.raises(AllowlistDenied): + flow.handle_callback("google", code="abc", state_token=state_token, error=None) + + +def test_handle_callback_with_provider_error_raises(queue: Queue, config: OAuthConfig) -> None: + fake = FakeProvider("google") + flow = OAuthFlow(queue, config, providers={"google": fake}) + with pytest.raises(IdentityFetchError, match="provider returned error"): + flow.handle_callback("google", code=None, state_token=None, error="access_denied") + + +def test_providers_listing_returns_visible_metadata(queue: Queue, config: OAuthConfig) -> None: + fake = FakeProvider("google") + flow = OAuthFlow(queue, config, providers={"google": fake}) + listing = flow.providers_listing() + assert listing == [{"slot": "google", "label": "Test", "type": "google"}] + + +def test_admin_emails_promote_first_user(queue: Queue, config: OAuthConfig) -> None: + identity = ProviderIdentity( + slot="google", + subject="alice-sub", + email="alice@acme.com", + email_verified=True, + ) + fake = FakeProvider("google", identity=identity) + flow = OAuthFlow(queue, config, providers={"google": fake}) + flow.start("google", next_url="/") + state_token = next(iter(_state_tokens(queue))) + session, _ = flow.handle_callback("google", code="x", state_token=state_token, error=None) + user = AuthStore(queue).get_user(session.username) + assert user is not None + assert user.role == "admin" + assert user.email == "alice@acme.com" + + +# ── Helpers ────────────────────────────────────────────────────────── + + +def _state_tokens(queue: Queue) -> list[str]: + prefix = "auth:oauth_state:" + return [k[len(prefix) :] for k in queue.list_settings() if k.startswith(prefix)] diff --git a/tests/dashboard/test_oauth_providers.py b/tests/dashboard/test_oauth_providers.py new file mode 100644 index 00000000..0693888e --- /dev/null +++ b/tests/dashboard/test_oauth_providers.py @@ -0,0 +1,574 @@ +"""Unit tests for the concrete OAuth provider implementations. + +These tests stub every HTTP boundary so they run without network access. +The end-to-end "real flow" test lives in ``test_oauth_endpoints.py``. +""" + +from __future__ import annotations + +import json +import time +from typing import Any +from urllib.parse import parse_qs, urlparse + +import pytest +from joserfc import jwt as joserfc_jwt +from joserfc.jwk import RSAKey + +from taskito.dashboard.oauth.config import ( + GitHubConfig, + GoogleConfig, + OIDCConfig, +) +from taskito.dashboard.oauth.identity import ( + AllowlistDenied, + IdentityFetchError, +) +from taskito.dashboard.oauth.providers import ( + GenericOIDCProvider, + GitHubProvider, + GoogleProvider, + _audience_matches, + _email_domain, +) + +# ── HTTP stub helpers ──────────────────────────────────────────────── + + +class StubResponse: + """Minimal stand-in for a ``requests.Response`` object.""" + + def __init__(self, *, status_code: int = 200, payload: Any = None, text: str = "") -> None: + self.status_code = status_code + self._payload = payload + self.text = text or json.dumps(payload) + + def json(self) -> Any: + return self._payload + + def raise_for_status(self) -> None: + if self.status_code >= 400: + raise RuntimeError(f"HTTP {self.status_code}") + + +class StubSession: + """Replaces ``requests.Session`` with a programmable URL → response map.""" + + def __init__(self, routes: dict[str, StubResponse]) -> None: + self._routes = routes + self.calls: list[tuple[str, dict[str, str]]] = [] + + def get(self, url: str, *, headers: dict[str, str] | None = None, **_: Any) -> StubResponse: + self.calls.append((url, headers or {})) + if url in self._routes: + return self._routes[url] + # Wildcard fallback: match by prefix to support .../members/. + for prefix, response in self._routes.items(): + if prefix.endswith("*") and url.startswith(prefix[:-1]): + return response + return StubResponse(status_code=404, payload={"error": "not found"}) + + +# ── Test fixtures ──────────────────────────────────────────────────── + + +@pytest.fixture +def rsa_key() -> RSAKey: + """A fresh RSA keypair used to sign + verify test ID tokens.""" + return RSAKey.generate_key(2048, parameters={"kid": "test-kid"}, private=True) + + +@pytest.fixture +def google_discovery() -> dict[str, str]: + return { + "issuer": "https://accounts.google.com", + "authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth", + "token_endpoint": "https://oauth2.googleapis.com/token", + "jwks_uri": "https://www.googleapis.com/oauth2/v3/certs", + } + + +def _make_google_provider( + *, + allowed_domains: tuple[str, ...] = (), + discovery: dict[str, str], + jwks_payload: dict, +) -> GoogleProvider: + routes = { + "https://accounts.google.com/.well-known/openid-configuration": StubResponse( + payload=discovery + ), + discovery["jwks_uri"]: StubResponse(payload=jwks_payload), + } + provider = GoogleProvider( + GoogleConfig( + client_id="test-client-id", + client_secret="test-client-secret", + allowed_domains=allowed_domains, + ), + http=StubSession(routes), # type: ignore[arg-type] + ) + return provider + + +def _make_id_token( + *, + key: RSAKey, + issuer: str, + audience: str, + subject: str, + email: str, + email_verified: bool, + nonce: str | None, + name: str | None = "Alice Example", + extra_claims: dict[str, Any] | None = None, +) -> str: + claims: dict[str, Any] = { + "iss": issuer, + "aud": audience, + "sub": subject, + "email": email, + "email_verified": email_verified, + "name": name, + "iat": int(time.time()), + "exp": int(time.time()) + 600, + } + if nonce is not None: + claims["nonce"] = nonce + if extra_claims: + claims.update(extra_claims) + header = {"alg": "RS256", "kid": key.kid} + return joserfc_jwt.encode(header, claims, key) + + +# ── Helpers ────────────────────────────────────────────────────────── + + +def test_email_domain_extracts_lowercase() -> None: + assert _email_domain("Alice@ACME.com") == "acme.com" + assert _email_domain(None) is None + assert _email_domain("not-an-email") is None + + +def test_audience_matches_string_and_list() -> None: + assert _audience_matches("cid", "cid") + assert _audience_matches(["cid", "other"], "cid") + assert not _audience_matches("other", "cid") + assert not _audience_matches([], "cid") + assert not _audience_matches(None, "cid") + + +# ── Google: authorization URL ──────────────────────────────────────── + + +def test_google_authorization_url_includes_required_params( + google_discovery: dict[str, str], +) -> None: + provider = _make_google_provider( + discovery=google_discovery, + jwks_payload={"keys": []}, + ) + url = provider.authorization_url( + state="STATE", + nonce="NONCE", + code_challenge="CHALLENGE", + redirect_uri="https://taskito.example.com/api/auth/oauth/callback/google", + ) + parsed = urlparse(url) + qs = parse_qs(parsed.query) + assert parsed.scheme == "https" + assert qs["response_type"] == ["code"] + assert qs["client_id"] == ["test-client-id"] + assert qs["scope"] == ["openid email profile"] + assert qs["state"] == ["STATE"] + assert qs["nonce"] == ["NONCE"] + assert qs["code_challenge"] == ["CHALLENGE"] + assert qs["code_challenge_method"] == ["S256"] + assert qs["prompt"] == ["select_account"] + assert "hd" not in qs # no allowed_domains configured + + +def test_google_authorization_url_sets_hd_hint_for_single_domain( + google_discovery: dict[str, str], +) -> None: + provider = _make_google_provider( + discovery=google_discovery, + jwks_payload={"keys": []}, + allowed_domains=("acme.com",), + ) + url = provider.authorization_url( + state="s", nonce="n", code_challenge="c", redirect_uri="https://x/y" + ) + qs = parse_qs(urlparse(url).query) + assert qs["hd"] == ["acme.com"] + + +def test_google_authorization_url_omits_hd_for_multi_domain( + google_discovery: dict[str, str], +) -> None: + provider = _make_google_provider( + discovery=google_discovery, + jwks_payload={"keys": []}, + allowed_domains=("acme.com", "partner.com"), + ) + url = provider.authorization_url( + state="s", nonce="n", code_challenge="c", redirect_uri="https://x/y" + ) + qs = parse_qs(urlparse(url).query) + assert "hd" not in qs # ambiguous, do not preselect + + +# ── Google: exchange_code → identity ───────────────────────────────── + + +def test_google_exchange_code_returns_identity_for_valid_id_token( + google_discovery: dict[str, str], rsa_key: RSAKey, monkeypatch: pytest.MonkeyPatch +) -> None: + id_token = _make_id_token( + key=rsa_key, + issuer=google_discovery["issuer"], + audience="test-client-id", + subject="118420987654321", + email="alice@acme.com", + email_verified=True, + nonce="EXPECTED_NONCE", + ) + provider = _make_google_provider( + discovery=google_discovery, + jwks_payload={"keys": [rsa_key.as_dict(private=False)]}, + ) + monkeypatch.setattr( + provider, "_fetch_token", lambda **_: {"id_token": id_token, "access_token": "AT"} + ) + identity = provider.exchange_code( + code="abc", + code_verifier="verifier", + redirect_uri="https://x", + expected_nonce="EXPECTED_NONCE", + ) + assert identity.slot == "google" + assert identity.subject == "118420987654321" + assert identity.email == "alice@acme.com" + assert identity.email_verified is True + assert identity.name == "Alice Example" + + +def test_google_exchange_code_rejects_wrong_nonce( + google_discovery: dict[str, str], rsa_key: RSAKey, monkeypatch: pytest.MonkeyPatch +) -> None: + id_token = _make_id_token( + key=rsa_key, + issuer=google_discovery["issuer"], + audience="test-client-id", + subject="111", + email="x@y.com", + email_verified=True, + nonce="WRONG", + ) + provider = _make_google_provider( + discovery=google_discovery, + jwks_payload={"keys": [rsa_key.as_dict(private=False)]}, + ) + monkeypatch.setattr(provider, "_fetch_token", lambda **_: {"id_token": id_token}) + with pytest.raises(IdentityFetchError, match="nonce mismatch"): + provider.exchange_code( + code="abc", code_verifier="v", redirect_uri="https://x", expected_nonce="EXPECTED" + ) + + +def test_google_exchange_code_rejects_wrong_audience( + google_discovery: dict[str, str], rsa_key: RSAKey, monkeypatch: pytest.MonkeyPatch +) -> None: + id_token = _make_id_token( + key=rsa_key, + issuer=google_discovery["issuer"], + audience="DIFFERENT-CLIENT", + subject="111", + email="x@y.com", + email_verified=True, + nonce=None, + ) + provider = _make_google_provider( + discovery=google_discovery, + jwks_payload={"keys": [rsa_key.as_dict(private=False)]}, + ) + monkeypatch.setattr(provider, "_fetch_token", lambda **_: {"id_token": id_token}) + with pytest.raises(IdentityFetchError, match="audience mismatch"): + provider.exchange_code( + code="abc", code_verifier="v", redirect_uri="https://x", expected_nonce=None + ) + + +def test_google_exchange_code_rejects_wrong_issuer( + google_discovery: dict[str, str], rsa_key: RSAKey, monkeypatch: pytest.MonkeyPatch +) -> None: + id_token = _make_id_token( + key=rsa_key, + issuer="https://evil.com", + audience="test-client-id", + subject="111", + email="x@y.com", + email_verified=True, + nonce=None, + ) + provider = _make_google_provider( + discovery=google_discovery, + jwks_payload={"keys": [rsa_key.as_dict(private=False)]}, + ) + monkeypatch.setattr(provider, "_fetch_token", lambda **_: {"id_token": id_token}) + with pytest.raises(IdentityFetchError, match="issuer mismatch"): + provider.exchange_code( + code="abc", code_verifier="v", redirect_uri="https://x", expected_nonce=None + ) + + +def test_google_exchange_code_rejects_missing_id_token( + google_discovery: dict[str, str], monkeypatch: pytest.MonkeyPatch +) -> None: + provider = _make_google_provider( + discovery=google_discovery, + jwks_payload={"keys": []}, + ) + monkeypatch.setattr(provider, "_fetch_token", lambda **_: {"access_token": "AT"}) + with pytest.raises(IdentityFetchError, match="no id_token"): + provider.exchange_code( + code="abc", code_verifier="v", redirect_uri="https://x", expected_nonce=None + ) + + +# ── Google: allowlist ───────────────────────────────────────────────── + + +def test_google_check_allowlist_passes_when_no_restriction( + google_discovery: dict[str, str], +) -> None: + provider = _make_google_provider(discovery=google_discovery, jwks_payload={"keys": []}) + from taskito.dashboard.oauth.identity import ProviderIdentity + + identity = ProviderIdentity(slot="google", subject="x", email="x@y.com", email_verified=True) + # Should not raise. + provider.check_allowlist(identity) + + +def test_google_check_allowlist_rejects_unverified_email( + google_discovery: dict[str, str], +) -> None: + from taskito.dashboard.oauth.identity import ProviderIdentity + + provider = _make_google_provider( + discovery=google_discovery, + jwks_payload={"keys": []}, + allowed_domains=("acme.com",), + ) + with pytest.raises(AllowlistDenied, match="verified email"): + provider.check_allowlist( + ProviderIdentity( + slot="google", + subject="x", + email="user@acme.com", + email_verified=False, + ) + ) + + +def test_google_check_allowlist_rejects_out_of_domain_email( + google_discovery: dict[str, str], +) -> None: + from taskito.dashboard.oauth.identity import ProviderIdentity + + provider = _make_google_provider( + discovery=google_discovery, + jwks_payload={"keys": []}, + allowed_domains=("acme.com",), + ) + with pytest.raises(AllowlistDenied, match="not in the allowed domains"): + provider.check_allowlist( + ProviderIdentity( + slot="google", + subject="x", + email="user@gmail.com", + email_verified=True, + ) + ) + + +def test_google_check_allowlist_accepts_listed_domain( + google_discovery: dict[str, str], +) -> None: + from taskito.dashboard.oauth.identity import ProviderIdentity + + provider = _make_google_provider( + discovery=google_discovery, + jwks_payload={"keys": []}, + allowed_domains=("acme.com",), + ) + provider.check_allowlist( + ProviderIdentity(slot="google", subject="x", email="USER@Acme.COM", email_verified=True) + ) + + +# ── Generic OIDC ────────────────────────────────────────────────────── + + +def test_generic_oidc_uses_provided_discovery_url(rsa_key: RSAKey) -> None: + discovery = { + "issuer": "https://acme.okta.com", + "authorization_endpoint": "https://acme.okta.com/oauth2/authorize", + "token_endpoint": "https://acme.okta.com/oauth2/token", + "jwks_uri": "https://acme.okta.com/oauth2/jwks", + } + routes = { + "https://acme.okta.com/.well-known/openid-configuration": StubResponse(payload=discovery), + discovery["jwks_uri"]: StubResponse(payload={"keys": [rsa_key.as_dict(private=False)]}), + } + provider = GenericOIDCProvider( + OIDCConfig( + slot="okta", + client_id="cid", + client_secret="csec", + discovery_url="https://acme.okta.com/.well-known/openid-configuration", + label="Acme SSO", + ), + http=StubSession(routes), # type: ignore[arg-type] + ) + url = provider.authorization_url( + state="s", nonce="n", code_challenge="c", redirect_uri="https://taskito.x/cb" + ) + assert url.startswith("https://acme.okta.com/oauth2/authorize?") + assert provider.slot == "okta" + assert provider.label == "Acme SSO" + assert provider.type == "oidc" + + +# ── GitHub ──────────────────────────────────────────────────────────── + + +def _gh_provider( + *, + allowed_orgs: tuple[str, ...] = (), + routes: dict[str, StubResponse] | None = None, +) -> GitHubProvider: + return GitHubProvider( + GitHubConfig( + client_id="gh-client", + client_secret="gh-secret", + allowed_orgs=allowed_orgs, + ), + http=StubSession(routes or {}), # type: ignore[arg-type] + ) + + +def test_github_authorization_url_includes_pkce_and_state() -> None: + provider = _gh_provider() + url = provider.authorization_url( + state="STATE", nonce="UNUSED", code_challenge="CHL", redirect_uri="https://x/cb" + ) + parsed = urlparse(url) + qs = parse_qs(parsed.query) + assert parsed.netloc == "github.com" + assert qs["client_id"] == ["gh-client"] + assert qs["state"] == ["STATE"] + assert qs["code_challenge"] == ["CHL"] + assert qs["code_challenge_method"] == ["S256"] + assert "nonce" not in qs # GitHub does not implement OIDC + + +def test_github_authorization_url_adds_read_org_when_allowlist_configured() -> None: + provider = _gh_provider(allowed_orgs=("acme",)) + url = provider.authorization_url( + state="s", nonce="n", code_challenge="c", redirect_uri="https://x/cb" + ) + qs = parse_qs(urlparse(url).query) + assert "read:org" in qs["scope"][0] + + +def test_github_exchange_code_returns_verified_primary_email( + monkeypatch: pytest.MonkeyPatch, +) -> None: + routes = { + "https://api.github.com/user": StubResponse( + payload={"id": 584213, "login": "alice", "name": "Alice", "avatar_url": "https://x/y"} + ), + "https://api.github.com/user/emails": StubResponse( + payload=[ + {"email": "alt@x.com", "primary": False, "verified": True}, + {"email": "alice@acme.com", "primary": True, "verified": True}, + ] + ), + } + provider = _gh_provider(routes=routes) + monkeypatch.setattr(provider, "_fetch_token", lambda **_: {"access_token": "AT"}) + identity = provider.exchange_code( + code="abc", code_verifier="v", redirect_uri="https://x", expected_nonce=None + ) + assert identity.slot == "github" + assert identity.subject == "584213" + assert identity.email == "alice@acme.com" + assert identity.email_verified is True + assert identity.name == "Alice" + + +def test_github_exchange_code_returns_none_email_when_no_verified_primary( + monkeypatch: pytest.MonkeyPatch, +) -> None: + routes = { + "https://api.github.com/user": StubResponse( + payload={"id": 1, "login": "u", "name": None, "avatar_url": None} + ), + "https://api.github.com/user/emails": StubResponse( + payload=[ + {"email": "claimed@x.com", "primary": True, "verified": False}, + ] + ), + } + provider = _gh_provider(routes=routes) + monkeypatch.setattr(provider, "_fetch_token", lambda **_: {"access_token": "AT"}) + identity = provider.exchange_code( + code="abc", code_verifier="v", redirect_uri="https://x", expected_nonce=None + ) + assert identity.email is None + assert identity.email_verified is False + + +def test_github_exchange_code_enforces_org_membership( + monkeypatch: pytest.MonkeyPatch, +) -> None: + routes = { + "https://api.github.com/user": StubResponse( + payload={"id": 1, "login": "alice", "name": "A", "avatar_url": "x"} + ), + "https://api.github.com/user/emails": StubResponse( + payload=[{"email": "a@x.com", "primary": True, "verified": True}] + ), + "https://api.github.com/orgs/acme/members/alice": StubResponse( + status_code=204, payload=None, text="" + ), + } + provider = _gh_provider(allowed_orgs=("acme",), routes=routes) + monkeypatch.setattr(provider, "_fetch_token", lambda **_: {"access_token": "AT"}) + identity = provider.exchange_code( + code="abc", code_verifier="v", redirect_uri="https://x", expected_nonce=None + ) + assert identity.email == "a@x.com" + + +def test_github_exchange_code_rejects_non_member( + monkeypatch: pytest.MonkeyPatch, +) -> None: + routes = { + "https://api.github.com/user": StubResponse( + payload={"id": 1, "login": "eve", "name": "E", "avatar_url": "x"} + ), + "https://api.github.com/user/emails": StubResponse( + payload=[{"email": "e@x.com", "primary": True, "verified": True}] + ), + "https://api.github.com/orgs/acme/members/eve": StubResponse( + status_code=404, payload={"message": "Not Found"} + ), + } + provider = _gh_provider(allowed_orgs=("acme",), routes=routes) + monkeypatch.setattr(provider, "_fetch_token", lambda **_: {"access_token": "AT"}) + with pytest.raises(AllowlistDenied, match="not a member"): + provider.exchange_code( + code="abc", code_verifier="v", redirect_uri="https://x", expected_nonce=None + ) diff --git a/tests/dashboard/test_oauth_state_store.py b/tests/dashboard/test_oauth_state_store.py new file mode 100644 index 00000000..d20d7649 --- /dev/null +++ b/tests/dashboard/test_oauth_state_store.py @@ -0,0 +1,98 @@ +"""Tests for the short-lived OAuth state store.""" + +from __future__ import annotations + +import time +from pathlib import Path + +import pytest + +from taskito import Queue +from taskito.dashboard.oauth.state_store import ( + DEFAULT_STATE_TTL_SECONDS, + STATE_PREFIX, + OAuthStateStore, +) + + +@pytest.fixture +def queue(tmp_path: Path) -> Queue: + return Queue(db_path=str(tmp_path / "oauth_state.db")) + + +def test_create_persists_row_and_returns_state(queue: Queue) -> None: + store = OAuthStateStore(queue) + row = store.create(slot="google", next_url="/dashboard") + + assert row.slot == "google" + assert row.next_url == "/dashboard" + assert len(row.state) >= 32 + assert len(row.nonce) >= 16 + assert len(row.code_verifier) >= 32 + # state, nonce, and verifier must each be unique tokens. + assert row.state != row.nonce != row.code_verifier + # Row is in the settings store under the expected prefix. + assert queue.get_setting(STATE_PREFIX + row.state) is not None + + +def test_consume_returns_row_then_invalidates_it(queue: Queue) -> None: + store = OAuthStateStore(queue) + row = store.create(slot="github", next_url="/") + + first = store.consume(row.state) + assert first is not None + assert first.slot == "github" + assert first.code_verifier == row.code_verifier + + # Second consume is a replay attempt — must fail. + assert store.consume(row.state) is None + + +def test_consume_rejects_empty_and_unknown_tokens(queue: Queue) -> None: + store = OAuthStateStore(queue) + assert store.consume("") is None + assert store.consume("never-issued") is None + + +def test_consume_expired_row_returns_none(queue: Queue) -> None: + store = OAuthStateStore(queue) + row = store.create(slot="google", next_url="/", ttl_seconds=0) + # Even at TTL=0 we deliberately treat the row as immediately expired + # — but it's still single-use (deleted on consume). + assert store.consume(row.state) is None + # Underlying entry is gone after the consume. + assert queue.get_setting(STATE_PREFIX + row.state) is None + + +def test_consume_strips_malformed_rows(queue: Queue) -> None: + store = OAuthStateStore(queue) + # Inject a garbage row directly into the settings store. + queue.set_setting(STATE_PREFIX + "broken", "not-json-{}") + assert store.consume("broken") is None + assert queue.get_setting(STATE_PREFIX + "broken") is None + + +def test_prune_expired_removes_only_old_rows(queue: Queue) -> None: + store = OAuthStateStore(queue) + fresh = store.create(slot="google", next_url="/") + stale = store.create(slot="github", next_url="/", ttl_seconds=0) + + # Simulate the prune sweep. + removed = store.prune_expired() + assert removed >= 1 + # Fresh row survives. + assert queue.get_setting(STATE_PREFIX + fresh.state) is not None + # Stale row is gone. + assert queue.get_setting(STATE_PREFIX + stale.state) is None + + +def test_default_ttl_is_five_minutes() -> None: + assert DEFAULT_STATE_TTL_SECONDS == 300 + + +def test_create_sets_expected_expiry(queue: Queue) -> None: + store = OAuthStateStore(queue) + before = int(time.time()) + row = store.create(slot="google", next_url="/", ttl_seconds=120) + after = int(time.time()) + assert before + 120 <= row.expires_at <= after + 120 diff --git a/tests/dashboard/test_url_safety.py b/tests/dashboard/test_url_safety.py new file mode 100644 index 00000000..c2f4d2da --- /dev/null +++ b/tests/dashboard/test_url_safety.py @@ -0,0 +1,40 @@ +"""Tests for the URL-safety helpers used by the dashboard.""" + +from __future__ import annotations + +import pytest + +from taskito.dashboard.url_safety import is_safe_redirect + + +@pytest.mark.parametrize( + "path", + [ + "/", + "/dashboard", + "/dashboard/jobs", + "/dashboard?tab=overview", + "/dashboard/jobs#section", + ], +) +def test_is_safe_redirect_accepts_relative_paths(path: str) -> None: + assert is_safe_redirect(path) is True + + +@pytest.mark.parametrize( + "path", + [ + "", + None, + "dashboard", # no leading slash + "//evil.com/x", # protocol-relative URL + "/\\evil.com", # backslash variant + "http://evil.com/x", + "https://evil.com/x", + "javascript:alert(1)", + "data:text/html,xss", + "\\\\evil.com", + ], +) +def test_is_safe_redirect_rejects_unsafe(path: str | None) -> None: + assert is_safe_redirect(path) is False From bc3bb8bc18cbc644c42da47a7726fe2ab1e78f56 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sun, 17 May 2026 12:03:01 +0530 Subject: [PATCH 07/10] fix(dashboard): mypy 2.x strictness for oauth providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce an HttpClient Protocol so providers accept any duck-typed session (requests.Session in prod, StubSession in tests) without runtime cast(). This is the actual design fix — the test suppressions were patching a missing abstraction. For the joserfc KeySet.import_key_set call, mypy 2.x widened the stub to accept dict-shaped JWKS while mypy 1.x still requires the explicit KeySetSerialization TypedDict. Suppress both directions with the documented arg-type,unused-ignore dual pattern. Encode the test JWT helper return as an explicit str assignment so the no-any-return check passes under both mypy versions. --- py_src/taskito/dashboard/oauth/providers.py | 46 +++++++++++++++++---- tests/dashboard/test_oauth_providers.py | 9 ++-- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/py_src/taskito/dashboard/oauth/providers.py b/py_src/taskito/dashboard/oauth/providers.py index f88588b2..1df51fe3 100644 --- a/py_src/taskito/dashboard/oauth/providers.py +++ b/py_src/taskito/dashboard/oauth/providers.py @@ -5,14 +5,17 @@ ``check_allowlist`` (pure-data permission check) is deliberate so tests can drive either path in isolation. -Tests stub the network boundary via the ``_fetch_token`` and HTTP -session attributes on each provider. +Providers depend on :class:`HttpClient`, a structural Protocol over +the small subset of ``requests.Session`` they actually use (one ``get`` +method). Production code passes a ``requests.Session``; tests pass an +in-memory stub. The Protocol keeps the provider layer framework-free +and test-friendly without runtime ``cast`` calls at either boundary. """ from __future__ import annotations import time -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Protocol from urllib.parse import urlencode import requests @@ -35,6 +38,29 @@ ) +class HttpResponse(Protocol): + """Minimal response shape a provider consumes from its HTTP client.""" + + status_code: int + text: str + + def json(self) -> Any: ... + + def raise_for_status(self) -> None: ... + + +class HttpClient(Protocol): + """Minimal HTTP client shape — ``requests.Session`` satisfies this.""" + + def get( + self, + url: str, + *, + headers: dict[str, str] | None = ..., + timeout: float = ..., + ) -> HttpResponse: ... + + GOOGLE_DISCOVERY_URL = "https://accounts.google.com/.well-known/openid-configuration" GITHUB_AUTH_URL = "https://github.com/login/oauth/authorize" GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token" @@ -71,7 +97,7 @@ class _OIDCProviderBase: discovery_url: str scope: str = "openid email profile" - def __init__(self, *, http: requests.Session | None = None) -> None: + def __init__(self, *, http: HttpClient | None = None) -> None: self._http = http or requests.Session() self._discovery: dict[str, Any] | None = None self._jwks: dict[str, Any] | None = None @@ -158,7 +184,11 @@ def exchange_code( raise IdentityFetchError("no id_token in token response") try: - key_set = KeySet.import_key_set(self._get_jwks()) # type: ignore[arg-type] + # joserfc's KeySet.import_key_set is typed as accepting its own + # KeySetSerialization TypedDict, but operationally it accepts any + # standard JWKS dict. Mypy 2.x widened the stub; 1.x still + # complains. The dual code suppresses both directions. + key_set = KeySet.import_key_set(self._get_jwks()) # type: ignore[arg-type, unused-ignore] decoded = jwt.decode(id_token, key_set) claims = decoded.claims except JoseError as e: @@ -198,7 +228,7 @@ class GoogleProvider(_OIDCProviderBase): type = "google" discovery_url = GOOGLE_DISCOVERY_URL - def __init__(self, config: GoogleConfig, *, http: requests.Session | None = None) -> None: + def __init__(self, config: GoogleConfig, *, http: HttpClient | None = None) -> None: super().__init__(http=http) self.config = config self.label = config.label @@ -228,7 +258,7 @@ def check_allowlist(self, identity: ProviderIdentity) -> None: class GenericOIDCProvider(_OIDCProviderBase): type = "oidc" - def __init__(self, config: OIDCConfig, *, http: requests.Session | None = None) -> None: + def __init__(self, config: OIDCConfig, *, http: HttpClient | None = None) -> None: super().__init__(http=http) self.config = config self.slot = config.slot @@ -256,7 +286,7 @@ class GitHubProvider: type = "github" scope = "read:user user:email" - def __init__(self, config: GitHubConfig, *, http: requests.Session | None = None) -> None: + def __init__(self, config: GitHubConfig, *, http: HttpClient | None = None) -> None: self.config = config self.label = config.label self._http = http or requests.Session() diff --git a/tests/dashboard/test_oauth_providers.py b/tests/dashboard/test_oauth_providers.py index 0693888e..6d29aa00 100644 --- a/tests/dashboard/test_oauth_providers.py +++ b/tests/dashboard/test_oauth_providers.py @@ -106,7 +106,7 @@ def _make_google_provider( client_secret="test-client-secret", allowed_domains=allowed_domains, ), - http=StubSession(routes), # type: ignore[arg-type] + http=StubSession(routes), ) return provider @@ -138,7 +138,8 @@ def _make_id_token( if extra_claims: claims.update(extra_claims) header = {"alg": "RS256", "kid": key.kid} - return joserfc_jwt.encode(header, claims, key) + encoded: str = joserfc_jwt.encode(header, claims, key) + return encoded # ── Helpers ────────────────────────────────────────────────────────── @@ -429,7 +430,7 @@ def test_generic_oidc_uses_provided_discovery_url(rsa_key: RSAKey) -> None: discovery_url="https://acme.okta.com/.well-known/openid-configuration", label="Acme SSO", ), - http=StubSession(routes), # type: ignore[arg-type] + http=StubSession(routes), ) url = provider.authorization_url( state="s", nonce="n", code_challenge="c", redirect_uri="https://taskito.x/cb" @@ -454,7 +455,7 @@ def _gh_provider( client_secret="gh-secret", allowed_orgs=allowed_orgs, ), - http=StubSession(routes or {}), # type: ignore[arg-type] + http=StubSession(routes or {}), ) From 369213b191b3125edfa83e15035b53144a525074 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sun, 17 May 2026 12:21:34 +0530 Subject: [PATCH 08/10] fix(ci): install oauth extra so dashboard tests can import authlib MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uv sync --extra dev was leaving authlib/joserfc/requests uninstalled, so the oauth test modules failed collection with ModuleNotFoundError. The oauth deps belong in the oauth extra (taskito core users shouldn't pull authlib + requests for free), so CI sync needs both extras. Also pin requests explicitly in the oauth extra — authlib does not declare it as a hard dep (only its requests_client integration uses it), so leaving it implicit broke clean installs. --- .github/workflows/ci.yml | 4 ++-- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3886b647..999ebd52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,7 +84,7 @@ jobs: cache-dependency-glob: pyproject.toml - name: Install Python dependencies - run: uv sync --extra dev + run: uv sync --extra dev --extra oauth - name: Lint Python with Ruff run: uv run ruff check py_src/ tests/ @@ -262,7 +262,7 @@ jobs: cache-dependency-glob: pyproject.toml - name: Install Python dependencies - run: uv sync --extra dev + run: uv sync --extra dev --extra oauth - name: Build native extension with maturin uses: PyO3/maturin-action@v1 diff --git a/pyproject.toml b/pyproject.toml index 9e3e5ba9..91e36c38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ flask = ["flask>=3.0"] aws = ["boto3>=1.34"] gcs = ["google-cloud-storage>=2.10"] docs = ["playwright>=1.59"] -oauth = ["authlib>=1.7,<2"] +oauth = ["authlib>=1.7,<2", "requests>=2.31"] [tool.maturin] manifest-path = "crates/taskito-python/Cargo.toml" From c275147ae3a6116293c21afafac4f25af127bb79 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sun, 17 May 2026 12:32:54 +0530 Subject: [PATCH 09/10] docs: dedicated Dashboard section with Mermaid SSO diagram MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Dashboard pages outgrew Observability — five pages (overview, auth, SSO, task overrides, REST API) versus three actual observability topics. Move them into their own top-level Guides section and drop the redundant 'Dashboard ' prefix from page titles ('Dashboard Authentication' -> 'Authentication', etc.). Replace the broken ASCII sequence diagram on the SSO page with a Mermaid sequenceDiagram — the ASCII version had misaligned vertical lines once the cookie text on the last step pushed the row wider than the others, and the auto-overflow scroll truncated arrows. The Mermaid version renders correctly at every viewport. All cross-section links updated; old /guides/observability/dashboard* paths now redirect via internal references. --- .../authentication.mdx} | 4 +- .../dashboard.mdx => dashboard/index.mdx} | 12 ++-- docs/content/docs/guides/dashboard/meta.json | 4 ++ .../rest-api.mdx} | 6 +- .../dashboard-oauth.mdx => dashboard/sso.mdx} | 60 +++++++++---------- .../task-overrides.mdx | 4 +- .../guides/extensibility/events-webhooks.mdx | 4 +- docs/content/docs/guides/meta.json | 1 + .../docs/guides/observability/index.mdx | 9 +-- .../docs/guides/observability/meta.json | 11 +--- .../docs/guides/observability/monitoring.mdx | 2 +- .../docs/guides/resources/observability.mdx | 2 +- 12 files changed, 56 insertions(+), 63 deletions(-) rename docs/content/docs/guides/{observability/dashboard-auth.mdx => dashboard/authentication.mdx} (98%) rename docs/content/docs/guides/{observability/dashboard.mdx => dashboard/index.mdx} (96%) create mode 100644 docs/content/docs/guides/dashboard/meta.json rename docs/content/docs/guides/{observability/dashboard-api.mdx => dashboard/rest-api.mdx} (98%) rename docs/content/docs/guides/{observability/dashboard-oauth.mdx => dashboard/sso.mdx} (84%) rename docs/content/docs/guides/{observability => dashboard}/task-overrides.mdx (97%) diff --git a/docs/content/docs/guides/observability/dashboard-auth.mdx b/docs/content/docs/guides/dashboard/authentication.mdx similarity index 98% rename from docs/content/docs/guides/observability/dashboard-auth.mdx rename to docs/content/docs/guides/dashboard/authentication.mdx index 95964778..5fb972cc 100644 --- a/docs/content/docs/guides/observability/dashboard-auth.mdx +++ b/docs/content/docs/guides/dashboard/authentication.mdx @@ -1,5 +1,5 @@ --- -title: Dashboard Authentication +title: Authentication description: "Session-based login, CSRF, env-var bootstrap, and the setup-required flow." --- @@ -150,7 +150,7 @@ guard on. Native sign-in with Google, GitHub, and any OIDC-compliant provider (Okta, Auth0, Keycloak, Microsoft Entra) is available alongside -password auth — see [Dashboard SSO (OAuth & OIDC)](./dashboard-oauth). +password auth — see [SSO (OAuth & OIDC)](/guides/dashboard/sso). Operators can mix-and-match providers or run an OAuth-only deployment by setting `TASKITO_DASHBOARD_PASSWORD_AUTH_ENABLED=false`. diff --git a/docs/content/docs/guides/observability/dashboard.mdx b/docs/content/docs/guides/dashboard/index.mdx similarity index 96% rename from docs/content/docs/guides/observability/dashboard.mdx rename to docs/content/docs/guides/dashboard/index.mdx index e19fc825..4e5333c0 100644 --- a/docs/content/docs/guides/observability/dashboard.mdx +++ b/docs/content/docs/guides/dashboard/index.mdx @@ -1,5 +1,5 @@ --- -title: Web Dashboard +title: Overview description: "Zero-dependency built-in web UI for browsing jobs, configuring webhooks, tuning per-task runtime limits, and managing the queue." --- @@ -69,7 +69,7 @@ taskito dashboard --app myapp:queue --host 0.0.0.0 --port 9000 On a fresh database the dashboard refuses every API request with ``503 setup_required`` until you create the first admin. See - [Authentication](/guides/observability/dashboard-auth) for the full + [Authentication](/guides/dashboard/authentication) for the full flow, including the env-var bootstrap path useful for managed deployments. @@ -93,12 +93,12 @@ Configuration (how to change it): | Reliability | **Dead Letters** | Failed jobs that exhausted retries — retry or purge | | Reliability | **Circuit Breakers** | Automatic failure protection state, thresholds, cooldowns | | Reliability | **System** | Proxy reconstruction and interception strategy metrics | -| Configuration | **Tasks** | Decorator defaults + runtime overrides per task ([guide](/guides/observability/task-overrides)) | +| Configuration | **Tasks** | Decorator defaults + runtime overrides per task ([guide](/guides/dashboard/task-overrides)) | | Configuration | **Webhooks** | HTTP event subscriptions with delivery history + replay ([guide](/guides/extensibility/events-webhooks)) | | Configuration | **Settings** | Dashboard branding, external links, integrations | The full REST API surface is documented at -[Dashboard REST API](/guides/observability/dashboard-api). +[Dashboard REST API](/guides/dashboard/rest-api). ## Design @@ -138,7 +138,7 @@ first admin, every subsequent visit shows the sign-in form. ![First-run setup form for the initial admin](/screenshots/dashboard/auth-setup.png) -See [Authentication](/guides/observability/dashboard-auth) for the env +See [Authentication](/guides/dashboard/authentication) for the env var-based bootstrap (`TASKITO_DASHBOARD_ADMIN_USER` / `TASKITO_DASHBOARD_ADMIN_PASSWORD`) and the CSRF model. @@ -169,7 +169,7 @@ defaults and any active runtime override. Click **Edit** to open a side sheet with two tabs: **Overrides** (rate limit, concurrency, retries, timeout, priority, paused) and **Middleware** (toggle each middleware on or off for the task). Full guide: -[Task & Queue Overrides](/guides/observability/task-overrides). +[Task & Queue Overrides](/guides/dashboard/task-overrides). ![Tasks page with one task overridden in accent](/screenshots/dashboard/tasks-list.png) diff --git a/docs/content/docs/guides/dashboard/meta.json b/docs/content/docs/guides/dashboard/meta.json new file mode 100644 index 00000000..0d020d8f --- /dev/null +++ b/docs/content/docs/guides/dashboard/meta.json @@ -0,0 +1,4 @@ +{ + "title": "Dashboard", + "pages": ["index", "authentication", "sso", "task-overrides", "rest-api"] +} diff --git a/docs/content/docs/guides/observability/dashboard-api.mdx b/docs/content/docs/guides/dashboard/rest-api.mdx similarity index 98% rename from docs/content/docs/guides/observability/dashboard-api.mdx rename to docs/content/docs/guides/dashboard/rest-api.mdx index 1fd63359..68e743e3 100644 --- a/docs/content/docs/guides/observability/dashboard-api.mdx +++ b/docs/content/docs/guides/dashboard/rest-api.mdx @@ -1,5 +1,5 @@ --- -title: Dashboard REST API +title: REST API description: "JSON endpoints for stats, jobs, dead letters, metrics, logs, infrastructure, observability, webhooks, and runtime overrides." --- @@ -14,7 +14,7 @@ as the dashboard itself. `/api/auth/login`, `/api/auth/setup`) requires a valid session cookie obtained from `POST /api/auth/login`. State-changing requests (POST/PUT/DELETE) additionally require a CSRF header. See - [Dashboard Authentication](/guides/observability/dashboard-auth) for + [Dashboard Authentication](/guides/dashboard/authentication) for the login flow and headless usage examples. @@ -273,7 +273,7 @@ fresh delivery on top of the original (audit trail preserved). ## Tasks and overrides -Full guide: [Task & Queue Overrides](/guides/observability/task-overrides). +Full guide: [Task & Queue Overrides](/guides/dashboard/task-overrides). ### `GET /api/tasks` diff --git a/docs/content/docs/guides/observability/dashboard-oauth.mdx b/docs/content/docs/guides/dashboard/sso.mdx similarity index 84% rename from docs/content/docs/guides/observability/dashboard-oauth.mdx rename to docs/content/docs/guides/dashboard/sso.mdx index a43310cf..7771ac6e 100644 --- a/docs/content/docs/guides/observability/dashboard-oauth.mdx +++ b/docs/content/docs/guides/dashboard/sso.mdx @@ -1,5 +1,5 @@ --- -title: Dashboard SSO (OAuth & OIDC) +title: SSO (OAuth & OIDC) description: "Sign in with Google, GitHub, or any OIDC provider. Per-domain / per-org allowlists, OAuth-only mode." --- @@ -28,37 +28,33 @@ on; password login remains enabled unless you opt out explicitly. ## How it works -``` -Browser Dashboard Provider - │ │ │ - │ GET /login │ │ - ├─────────────────►│ │ - │ GET /api/auth/providers │ - ├─────────────────►│ │ - │ {providers, password_enabled} │ - │◄─────────────────┤ │ - │ click "Continue with Google" │ - │ GET /api/auth/oauth/start/google │ - ├─────────────────►│ mint state+nonce+PKCE - │ │ persist state row │ - │ 302 Location: │ - │◄─────────────────┤ │ - │ GET ─────────────────────► - │ user consents on Google │ - │◄──────────────────────────────────────┤ - │ GET /api/auth/oauth/callback/google?code=…&state=… - ├─────────────────►│ validate state │ - │ │ POST /token │ - │ ├───────────────────►│ - │ │ {id_token, access_token} - │ │◄───────────────────┤ - │ │ verify JWKS / nonce / aud / iss - │ │ check allowlist │ - │ │ get_or_create User │ - │ │ create Session │ - │ 302 Location: / + taskito_session cookie + taskito_csrf cookie - │◄─────────────────┤ │ -``` +>D: GET /login + B->>D: GET /api/auth/providers + D-->>B: { providers, password_enabled } + + Note over B: user clicks "Continue with Google" + B->>D: GET /api/auth/oauth/start/google + Note over D: mint state + nonce + PKCE
persist state row (5-min TTL) + D-->>B: 302 to provider authorize URL + + B->>P: GET /authorize?... + Note over P: user consents + P-->>B: 302 to /api/auth/oauth/callback/google?code=...&state=... + + B->>D: GET /api/auth/oauth/callback/google + Note over D: validate + consume state (single-use) + D->>P: POST /token (code + code_verifier) + P-->>D: { id_token, access_token } + Note over D: verify JWKS / nonce / aud / iss
enforce allowlist
get_or_create User
create Session + D-->>B: 302 to /
Set-Cookie: taskito_session, taskito_csrf`} +/> State is **single-use** and **time-bounded** (5-min default TTL). PKCE S256, OIDC nonce, ID-token signature (via the provider's JWKS), diff --git a/docs/content/docs/guides/observability/task-overrides.mdx b/docs/content/docs/guides/dashboard/task-overrides.mdx similarity index 97% rename from docs/content/docs/guides/observability/task-overrides.mdx rename to docs/content/docs/guides/dashboard/task-overrides.mdx index 562000bd..4fe33da1 100644 --- a/docs/content/docs/guides/observability/task-overrides.mdx +++ b/docs/content/docs/guides/dashboard/task-overrides.mdx @@ -217,6 +217,6 @@ queue.disable_middleware_for_task("myapp.tasks.process_image", "debug.payload") ## Reference -- [Dashboard REST API: Tasks & overrides](/guides/observability/dashboard-api#tasks-and-overrides) -- [Dashboard REST API: Middleware](/guides/observability/dashboard-api#middleware) +- [Dashboard REST API: Tasks & overrides](/guides/dashboard/rest-api#tasks-and-overrides) +- [Dashboard REST API: Middleware](/guides/dashboard/rest-api#middleware) - [Tasks decorator reference](/api-reference/task) diff --git a/docs/content/docs/guides/extensibility/events-webhooks.mdx b/docs/content/docs/guides/extensibility/events-webhooks.mdx index d2e4775b..83237535 100644 --- a/docs/content/docs/guides/extensibility/events-webhooks.mdx +++ b/docs/content/docs/guides/extensibility/events-webhooks.mdx @@ -339,5 +339,5 @@ Within a single job's lifecycle, events always fire in this order: ## Reference -- [Dashboard REST API for webhooks and deliveries](/guides/observability/dashboard-api#webhooks) -- [Dashboard auth — how to call these endpoints from a script](/guides/observability/dashboard-auth) +- [Dashboard REST API for webhooks and deliveries](/guides/dashboard/rest-api#webhooks) +- [Dashboard auth — how to call these endpoints from a script](/guides/dashboard/authentication) diff --git a/docs/content/docs/guides/meta.json b/docs/content/docs/guides/meta.json index 99dfadf2..aaec6935 100644 --- a/docs/content/docs/guides/meta.json +++ b/docs/content/docs/guides/meta.json @@ -8,6 +8,7 @@ "advanced-execution", "operations", "observability", + "dashboard", "resources", "workflows", "integrations", diff --git a/docs/content/docs/guides/observability/index.mdx b/docs/content/docs/guides/observability/index.mdx index c1153632..7b2b5074 100644 --- a/docs/content/docs/guides/observability/index.mdx +++ b/docs/content/docs/guides/observability/index.mdx @@ -9,7 +9,8 @@ Monitor, log, and inspect your task queue in real time. |---|---| | [Monitoring & Hooks](/guides/observability/monitoring) | Queue stats, progress tracking, worker heartbeat, and alerting hooks | | [Structured Logging](/guides/observability/logging) | Per-task structured logs with automatic context | -| [Web Dashboard](/guides/observability/dashboard) | Built-in web UI for browsing jobs, metrics, and worker status | -| [Dashboard Authentication](/guides/observability/dashboard-auth) | Setup flow, session cookies, CSRF, env-var bootstrap | -| [Task & Queue Overrides](/guides/observability/task-overrides) | Runtime knobs for retry policy, rate limits, concurrency, and middleware toggles | -| [Dashboard REST API](/guides/observability/dashboard-api) | Programmatic access to all dashboard data via REST endpoints | +| [Structured Notes](/guides/observability/notes) | Operator-visible metadata attached to individual jobs | + +For the built-in web UI, see the [Dashboard](/guides/dashboard) section — +it covers the browser app, password and SSO login, runtime task/queue +overrides, and the underlying REST API. diff --git a/docs/content/docs/guides/observability/meta.json b/docs/content/docs/guides/observability/meta.json index bdaa19d7..41daf73b 100644 --- a/docs/content/docs/guides/observability/meta.json +++ b/docs/content/docs/guides/observability/meta.json @@ -1,13 +1,4 @@ { "title": "Observability", - "pages": [ - "monitoring", - "logging", - "notes", - "dashboard", - "dashboard-auth", - "dashboard-oauth", - "task-overrides", - "dashboard-api" - ] + "pages": ["monitoring", "logging", "notes"] } diff --git a/docs/content/docs/guides/observability/monitoring.mdx b/docs/content/docs/guides/observability/monitoring.mdx index 29247a24..c5e1d2ea 100644 --- a/docs/content/docs/guides/observability/monitoring.mdx +++ b/docs/content/docs/guides/observability/monitoring.mdx @@ -120,7 +120,7 @@ workers = await queue.aworkers() The worker heartbeat is also available via the dashboard REST API at `GET /api/workers`. See the -[Dashboard](/guides/observability/dashboard) guide for details. +[Dashboard](/guides/dashboard) guide for details. ## Events system diff --git a/docs/content/docs/guides/resources/observability.mdx b/docs/content/docs/guides/resources/observability.mdx index 35c5a192..e9a25b76 100644 --- a/docs/content/docs/guides/resources/observability.mdx +++ b/docs/content/docs/guides/resources/observability.mdx @@ -122,7 +122,7 @@ Start the dashboard: taskito dashboard --app myapp.tasks:queue ``` -See the [Web Dashboard](/guides/observability/dashboard) guide for +See the [Web Dashboard](/guides/dashboard) guide for full dashboard documentation. ## CLI commands From 673f1071788496718a4b6495c68d3537b7d2bf05 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sun, 17 May 2026 12:51:56 +0530 Subject: [PATCH 10/10] fix(serializers): narrow decryption exception handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EncryptedSerializer.loads caught every Exception and rewrapped it as ValueError, which: 1. Masked programmer errors (e.g. corrupted internal state, MemoryError) that should propagate untouched. 2. Made the two negative tests fail under any environment where cryptography is installed — they asserted InvalidTag, but the wrapper meant ValueError reached them. They were silently green only because the dev extra never installed cryptography. The oauth extra (joserfc) does, and CI surfaced the latent bug. Narrow the catch to cryptography.exceptions.InvalidTag (the only expected failure mode) and pre-cache it on the instance so loads avoids a per-call import. Tests now assert the public contract — ValueError with 'Decryption failed' — plus verify the original InvalidTag survives in __cause__ for debugging. Adds a regression test for the short-input path. --- py_src/taskito/serializers.py | 10 ++++++++-- tests/core/test_serializers.py | 29 +++++++++++++++++++++++++---- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/py_src/taskito/serializers.py b/py_src/taskito/serializers.py index baa4c765..9233b809 100644 --- a/py_src/taskito/serializers.py +++ b/py_src/taskito/serializers.py @@ -88,12 +88,15 @@ def __init__(self, inner: Serializer, key: bytes): f"key must be 16, 24, or 32 bytes for AES-128/192/256, got {len(key)} bytes" ) + from cryptography.exceptions import InvalidTag from cryptography.hazmat.primitives.ciphers.aead import ( AESGCM, ) self._inner = inner self._aesgcm = AESGCM(key) + # Cache the exception class so ``loads`` doesn't re-import per call. + self._invalid_tag = InvalidTag def dumps(self, obj: Any) -> bytes: import os @@ -108,6 +111,9 @@ def loads(self, data: bytes) -> Any: nonce, ciphertext = data[:12], data[12:] try: plaintext = self._aesgcm.decrypt(nonce, ciphertext, None) - except Exception as exc: - raise ValueError(f"Decryption failed: {exc}") from exc + except self._invalid_tag as exc: + # Wrap so callers don't need to import cryptography.exceptions + # to handle decryption failures. The original ``InvalidTag`` is + # preserved in ``__cause__`` for debugging. + raise ValueError("Decryption failed: invalid authentication tag") from exc return self._inner.loads(plaintext) diff --git a/tests/core/test_serializers.py b/tests/core/test_serializers.py index 977a718b..c78f8391 100644 --- a/tests/core/test_serializers.py +++ b/tests/core/test_serializers.py @@ -103,27 +103,48 @@ def test_wrong_key_fails(self) -> None: pytest.importorskip("cryptography") import os + from cryptography.exceptions import InvalidTag + from taskito.serializers import EncryptedSerializer s1 = EncryptedSerializer(JsonSerializer(), os.urandom(32)) s2 = EncryptedSerializer(JsonSerializer(), os.urandom(32)) - from cryptography.exceptions import InvalidTag encrypted = s1.dumps({"data": 1}) - with pytest.raises(InvalidTag): + with pytest.raises(ValueError, match="Decryption failed") as excinfo: s2.loads(encrypted) + # The original cryptography exception is preserved on the cause + # chain so debugging surfaces still know it was a tag-validation + # failure rather than a malformed-input ValueError. + assert isinstance(excinfo.value.__cause__, InvalidTag) def test_tampered_ciphertext_fails(self) -> None: pytest.importorskip("cryptography") import os + from cryptography.exceptions import InvalidTag + from taskito.serializers import EncryptedSerializer key = os.urandom(32) s = EncryptedSerializer(JsonSerializer(), key) encrypted = s.dumps("hello") - from cryptography.exceptions import InvalidTag tampered = encrypted[:-1] + bytes([encrypted[-1] ^ 0xFF]) - with pytest.raises(InvalidTag): + with pytest.raises(ValueError, match="Decryption failed") as excinfo: s.loads(tampered) + assert isinstance(excinfo.value.__cause__, InvalidTag) + + def test_short_ciphertext_fails(self) -> None: + """Inputs shorter than the AES-GCM nonce (12B) + tag (≥1B) are + rejected before the cipher is ever consulted, with a distinct + message so operators can tell parsing errors from key/tag failures. + """ + pytest.importorskip("cryptography") + import os + + from taskito.serializers import EncryptedSerializer + + s = EncryptedSerializer(JsonSerializer(), os.urandom(32)) + with pytest.raises(ValueError, match="too short"): + s.loads(b"only-twelve-")

~l2 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̆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 i1eDa@h7I~Mj>x!RourEJ7HSqyGb*yLj#Z literal 0 HcmV?d00001 diff --git a/docs/public/screenshots/dashboard/task-edit-middleware.png b/docs/public/screenshots/dashboard/task-edit-middleware.png new file mode 100644 index 0000000000000000000000000000000000000000..c8498b63b292ee413ffb05e0aca7be2934c97843 GIT binary patch literal 49300 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 MLzBH(zYs(J57GE-xc~qF literal 0 HcmV?d00001 diff --git a/docs/public/screenshots/dashboard/task-edit-overrides.png b/docs/public/screenshots/dashboard/task-edit-overrides.png new file mode 100644 index 0000000000000000000000000000000000000000..282c768bd1bb2b60fcea8ea99f759e4c6690c68f GIT binary patch literal 56794 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 literal 0 HcmV?d00001 diff --git a/docs/public/screenshots/dashboard/webhook-create-dialog.png b/docs/public/screenshots/dashboard/webhook-create-dialog.png new file mode 100644 index 0000000000000000000000000000000000000000..4ddc6157b2412810b5e92e6eaed772e8fe7e773c GIT binary patch literal 62437 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�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 literal 0 HcmV?d00001 diff --git a/docs/public/screenshots/dashboard/webhook-deliveries.png b/docs/public/screenshots/dashboard/webhook-deliveries.png new file mode 100644 index 0000000000000000000000000000000000000000..3a1a318b18c9eed92c7f30b54c9c6a22e98f9432 GIT binary patch literal 94751 zcmb??WmH>j)9y}?qAd-MzRwMbj2{cP;KvBshU$1&X`7YjHp6^Ss~r zdDeQ@`F`!}o#d98xo57qW_F08yaXoND>MKAFr_3vDg(gZkI2F(2=L2Sr~N+wfB-1U zs)}XJu5JwEt(K*2&CKYJW#{B%<%ifBn&^I?&7R3I)?b;+aMje$8l%_GWRN$ti7qGq zT}Z+FCJj1as_^!x$KlLKKh20k%`KI3qVKk*pOjMuNre;0vG)Gv^Jo^^?ahxT2oWZu z6!z;CjOo9+xm(-ZQP(%cBc|v-+1fw5gan2E9-9QZzFl7K{5smaJeZAG>${ma(eDdaJ;%{P{YRcTwS2Lf$QZ){p8+dYe#qC+{M9E0W`=u(ZzKC z@FXp#!24TZ|Ik=mQrc%z)2`nBAS=_IU&)DmyXx-wc(m%9{RQW9Wnr;N+TWT!aZuDi zo480Qv`sA4trL<$!upHiQ^wCAq4|5$4R|Cx^Q&8Vg(Y}z%{{sfX-Ft~lU>ad`)lUU z64nRYsu$IrQ$3;UUy`7qUAuo4>Ri0P44&Li)u+rXtQC2i8b)-Ro0%u3f3Hgk)p|=| z>Fg2MvI*T^$Q#%xjt{@Dr~|s<`wwTNs3|ne+@P(UdmFQT{-Qgxy`|Ol0ZD~NYriH+ zAqCqL4Tl>6c>^}N(@mdw`et|V_}%eHL__Lkfxe=hp06RLlfU+QC6mUJ3LAJED-@>DoXqeplWb0&^I_fD-=~_7D#7~jXFr&^u@m-s4rmx{orDQ|zbg+xL zXGn74*a4+>uDSulB)Fl_m_`rNMMIKDPfM-&*W{F% z-lDF#y8Ps=Cg;RJx6iIt8w->A;hi%jX4=ld0^bH4x7Mec@?NQ=eM$eRmN{iIRqYv{ zUt653UNFby-QBg-=$78I)*3!F(6w{AH<W*}zmzMT5BGAOZjZ zfYiqisvdI(52zk`lRiZeIygTZFgQ6utS(JgUiDMEJZrDN?@A`Jd>%OfQAG!UD6qeE zKqADLv1S^(-^OBo;Z_{u_HL_3_;PL3M^eOKn+nc7U91O0zim}Snvy$@c-iN zKO+L5kNlT)9rfKoH@fnE4ZbEa7An5OYt68ChN-utEUBQvhAh zh60@)=!TAEdDQA8TSLD|I=51ssQ_UblvwudV4zbL7q%1|Yj-p^nA@C}m(&aZD*0a% zLJbkIdNcqNnm0$YgZ>gPfFZmwMhq;Fzat)us9TsLNgo3XLXw*tCk7ZHqrH9%0)c_Y z-d#!vdktb05h!`KYX<{^Jq}gjea?3}6fnv<6@-Rnp)v*29sq8T%){aH-if%6Ke-qD zWCGZ8Yr+DH-TR4NgHcQfg+u)y?f~TAtFt&@Wag7gO#krZC>Gfc49p^JhXV!c_3QGy zyMwjqE@O^RM}xuzXl@!Az#}8-9$1}X(u!%KC-m5K!UPy@Z4szcNr(|4q^pANSC)qc zzxv%lbolBJ+0{kdOGxs)8e+Vgd+XcS-sDqp1C2=L5kSEZg_+OsVA=H&v$t_5>T7o< zu2HHBf}nB5GKoJIequFRLW@bbAjH?ul`?Et{G-R0)weIm+=6W{jo(5fKcVgUd|EYM)$3-JxV zF#x@x9p!a+$WrXmN!5AJvs2>T+ZH4sTFmavJAYv469^+}~!E+#fM(8j;wQ|&$# z1Yq~JQ^E-eXLO|}v(T6ok3=(M9%J}uei43npV$fzr1PMm(TV^v3YY;{Few7q?Ot#% z78-X5m}m&x4n*4gT>`Bv@XV$Fn~&3603)Jv3@)9Y?GfU@6N@F6mpnNU(M~iPA@8Zu zjiq5_Tq}@xbR8$u*fdmpF9_!T1)dq9YwtW1xTvnrA-E|{wV+V>2t4yNzO|Q7AO@oV zRD&#`L7uosRa|#C5M#AljYUn;kaywdpC~h)5`@k3JkpV>M1w`|=I1WSaf|`bpyc(S zQcQvOF#!Wah@Sj18x?Hv0ie?XCZ`*kW-}DgxWnnY_U6TRgS)HOY_=|j@Q_Gz0rfD@ zg}EfZdN=?MXR1zS`@lG4Twdljmv(#hrk};LrK_!NiKF9TRzK`r`(<)`V;S_MUKdNc zu9cF}&-7K5Z44fpV^a4$;R*}>At!Dy=;#GHNZS{j1qz4at}L?M7%@S|}F1k}Uj z8quYabBVW`NE1t09-4pXF+yy4MViff_OezEuJTw-ADvhqAG_-F_8#QcUy^*e&vC5_5&PG9Xk#8!~zVP`1_9dd?`&)E9&lpRD z>tcoZ_ifD3%7=^$?((na7M+L=lD-iL9H~@os*K&0ZF`xKIX#kjrg+sbY?v%~W`1^| zhK9t>ig`EOlno;F7zBVxL_r6Vm5Nl@u*ANG$ognrsD+H=gK1}GzI)(2RSc*mY%eBr z;OofOyc&i?j$Sq3&cBM7FscfFT{{z))}=VE;ZzBMyZ{o>W}jf;bxX2HoK_sH32yq z2tx_Pya=ebMdb<(bTD`HkkOd}&rE$TN|JvK@K8Z~W>Hrh)L=2cu!IZCl`?GH=N}(z zOGwzJl5HDJB&f%7g)lzRvq_55*Rp)xs#mnoMXC}*xymKdDxQNc_w%2xWlyHh`cW|Antd+%IoEW%!Ck~SBz`%#C^`?4$G*K`&$!q7<0om*?5abfF z!-{T39qBS$7NR1*z)$A}N_HA5z4%y}1U?B~Gx2sF;D%y^*{l(aU8kA)8O+`Cl+Ot> zLUb5D_>Sb>*Pd_{D3atWcxzegM%VXij8MU=ZfIwXgdm5FxN zB)XhqT+fHcVlWvB;lkE#GoPo02kr-XOQ0l|+%CRiB~xUYdXc>01bb8qdc(Iv zN?%R)szwL%qG9#<`0*VPieW>;Pm~o!$9h}fj#o|y3lYok9Uh6f1eA?P9w0_e&Cm== zVlEzjyX!Q~_;Tl^l?u7qr zjv^(Sv5$k+e=&~htD9X6r-WKKib5i?)614hX`Gz7k~L1}=|8%eYw}Ov>@u9b z)YegO4VzbLDwhy^dYC-CbB)G^_Z{%U_GOci?WQaI;RRw#NTHe@kA z3~g=gJus9UR(!TkYE`@>RPfFV16*`}9L2wVw=N`Y3;BtXJrHwNH}PAkuJO!wCo=&@ zI3Tf;8dP@wUH6Y|op6ZBPMXnJNeC9KN+Qe47=4j8KM#)B%of*SgwhUc)*J_q;ruXuYwrk72T58;7 zJJD0eTF;)bJgs!S-3>16WR&cWc%_UhWnQ2#`RaTS2$6_}F28h08V9x}nJabRY15P+ zhh3u{f|=K}?2vIIxN%(fEqgSf+Khs@rluywZ&KOW(KId^wW_gj_=5 zL%+wD#hC2qApU#tn4>e6{3G4@XP-J+?tTh_Ffy>@DIf!6C;_aKR6luyX0gy7=v-mA zl#uS?w)4dpNuShS;7`Ah5R!#EvE(Km(eeoh}uMeESCNlPo_31Dk` z!S}Oj3q>jg*+BntDT3!RY09NfxD3!-gAcQ<4&M0yfW&+8PX`Y|jgS;2ekBRLepdTl zEolc)rjYBc(ysci?0Zw<$hwbvYRy~1V>5-m^Cbq2FOeMR$24(RD>THcx=RdMwsg40 zlok~2mG_M^{-uDbvLF=d6+-jbU^oP&6yWs>ldBe{V z!+{%gka`zCA0;ZK*P_$g(%k6?YH_hd1F6bvt9gh8TvCPC06oeCmttdAC+tGjrB{UW zKRVs+sFa^BZJ``G=b;4=l2N;6>Sd|5=bDoxmvwv-U`_kDxCc+udE{zMI)Asg%fzER z@`|I#I7Ekbxv|@cZX~3t#fEbErGL!Z@HEGb3c?cf!xmu%`{?L^`LEo&9pQ3sSVeMZ zmwH?3%D0P?MnW*Zp+ILK8aa>59{sN8U}%m5-LN@jW_f7~aUPiaI8+c9ap}OzGG2PY z_+cXGXh;xBAG!PJ{)R1F23jr2Cq(te;6z9bcYTX+Ra0~bC42lKnZenMxq0IudS{x> zz*gpE-oFuQDBMH(5jh&h_J@vELonM#&bHbI1o@41$!)c`ZZ=kbyo6|8B4S3c)X`P<4~ zfN_^37^5f_<`5`IE7cf)c3pz=$(*fBr!mu4`6#!VVD1fl;i`hh)98$FOC#CpyN4;h zh_(04nHES@U1T4_9|^*h0hL<=X@o&$NE3O`Uc1gM3xg|d?7-+1dYDV`N@X~Z8BIrX z3VZ(x+_2$o|1mntHoBRCt~wP3B>S>QfKwNYwO_=EDKtkVitq}$Zs%BLTeOH~*!rNh z%O+R`ZI9po2Hv5mKV&?0aaXU5tTrGkrkcEIp0A}r%`S-<#VF=LMah=^@|*JTyXBQn zxHPcuRq}K7vgh`|3pC)D_K_Tn!oCW&STt+D|9p^m)2&jmhI4axfok~L(xzI-!3%DO zY3Fp~3DjD^`h3?y#!e&e&3>4FDOEoTpEwP~NCDvcK`3nSTS*owb-=-bTSK(`X$;YU z`uwx0Ew-(NT)~$5B15^^;gJ*6`B!u#hqGkEOAC`%8rD;LNey?;q2~Pr(C(ZOq=*W@ z&9gVt2840FO#YErIZ@TL_qeB-^#VQE=kfT<`^Uo)&<#cFo-qd#<${u ziK19@{kSwF77P;qEOeUf#AL30a&?`S#)Bmv*Ww^nQMKi5k8fdaJI!uj=({z0E6$3xV9*hGGck3HW~gm0_eO z^NMc|7?}tOnomA6k;Iss1wB$Qoji5xyC7$QSe@iOT+no1fI#Oo5iIqiXAWTc_B_$U zdVsYY;Ph&>vu$;B4B=1Xyzz6rpmz#0_M?losfl09t7vb%V1&rYs6R;3mSP%)(3EmQ z45j$HWYbFOZL3NBCM&L7z31yzz!kDLSNpp+{*zwhDlC7FB#Rc~8Ahv|YQX#CI#yc^|TV4KgO z1hO`M1H({pV46;)5s~LU!nbR!gUG5sMvpWKA2{WLZ;(bDRs1!b9koH>PjeNQ8lV?l zex`}FbOEK7&@C%ochzU5kcTlVq^h5)Ai5xC$?9VU8ptZ`Xe2yk%tv9b1{9@reE1`n zFEJMjl*>H1epN*zmg69nKzyWf=U=lYQbQ(D!9}v!qa{YKK;kTy z=A00WHd+6wEJ!xD@q0V zl2^S{z)s45$ewA34^_SEf?JcPX&mWgi>EdvZyiZ!d^jWY1pEQ|$#0og*)4mYsO=&f z8#WQ13vh-a29jbnbX(_5f5&8r#S&nz0|M7`!!eNb%9kTBh{lnELz=Tt!8-?|&$*6$ zH$4q1oWIxDZX|IWq48w`doQ94Hqf%kNHn8B%Di{(v}S`3*UMd~hK|(NYDd3k5E}%> zzF*-VjVtLJV{^R{$(5!Hve70uMO}e}Y?lRLC@?{On+bInFjvfdjDD$HT#$CaX|+gv z(yasSwd34OpnWGInuR$qQZZ$1p%FW09lKp095Fg#&J{^>(CiLNS6wrJCoH$IX7t>8 z?@QihCX*5`mS4OZ6}vkh?so`#=$N&M<<0O;QDu#ON1)LRy2n9ckG7i?k;n~u%v${b zB6<5uCq%AczVxCIL+q`3yZnrzgJ4sQHFzdY9`Nds1Jt4u9&GLK9p)cJwL1gny9jW} z#sa5yUh3$`NQm-+;+dM=eZr>6HM1HC2_1yV`5dptHGzi%9-qqPrN}lRcJ`AM_754) z6v#bBZ1B`dalxJ#zXQbv^YC*rcO zU~^!E=uRlvDjx{Gruci zEuihuK+0PHkMR{7i1`RF3Q0PmGYXR!Q`^pjOujXwgV1@s*8fh-oEz>7n^qq;ZXZMQ z^xd5c4bTnOj?9Arksl~Qv8vw0M6X9$+S$8Q%yfx zmebYMF?jA%syyd*MyyI(a(Ft_Xxn66{rH9qPnd41P(;~%=Bvc}(cILJ`%LP+Wt`3E zFBq&@LS_Sb5v=?UZA0$F0T3*!EjhR_!tPn*-j*O+IR~ndYvoNxu^%V2x3dXgUa;Wr z%X-IKO(4Lm8w((hmunjE+p4X%*5wljr{(oLTUR?G7$TW`=V~{anC%#$6(o)crF)KE zawOtP@L8ETVfQ6lctjj<6a^i0=8u8r>4s6obaqFzACl*PyST^Qq=^H>yxkP8b2LFX z;1BMhz7H}VT#t$i*=xTiKyMI(@%E!ljF&DwtbBKa@-3rO7ys;4SzBFpUDi?fRLL+f0NY> z>hc_}4;TqQfZ+z7DzLBfst`!=HQFnYHvR1`TnTqIC^$&&w&6nmkbGpCYph{mx)SYk>IE2 zqzeyt*f5T#8SDlR$CLcbTjm>fzET47o|VU;r$eu;G>- zgmD~EtFX6ASSahQw@CaW#6pUCKN_XvP|;O~|K$*8`qZ>2`Xdt!daMkx1Ow!-=lA_5 zjv~9PJ?<8o`sF~g`Zs#Fwn<&@z&q!c`;)Pq#k;=r^PlARkT1{=d`nOa0YnE+v}{$_ z3`}wOF>=fJ*nQ>GOT#;quO|WXL0H0YFSCcfLfuOynWBUDLd!bj^Pefcc-Q*x2uB)M z7+uo5uheLiz1;F+V-&SZqRIi@ZW`1SCC+uq($2;BYUE}a=}?Yi->)A4Y}lyjgKf-# z$=ApL2bqgt?YaEh$JY>)*RTm3;Yk)dLuCaa6HonE*WdPrSrK3AMQ82YEIwF0=r~wl zd|qfkS$Su|5B|WW$-X8$!oCUjB`cp$4DCOuqAEbPdTxtv5oM%b1zt58w*v%G7I2E$ zuqAfB=M;l*88>BPB^Q?h;?(@W8Ej}Eak`P^dfU7&rg!NfdywT=dBBZ<#AS~s+R-sV zC|n3lBIaN1`hnXtV=*lOfDG3V6^|RC&DS&Ou7UCI8C)E=InOe0b`VdZo|Yk?p|iWJ zb{TdscLov`Exa@_b0R_*#>MvXf7p5@10nq3SG2+aE(ZIzCkps(!vmQ2ToB$jQR!tt z0id8vngY@*45$zrqCJTM*uq5PRLmimCNAA?{1kq~R1E6?0Lp7HHy9*q3Msy4(*nZO zqE|Gb?O?%I`xkOYR;6sk&eP>*mDrPMx6=vM8aX4st zKR>|LtV7Q%UtqtoLgi^r&Y&jk$Wu?j;9fgaPmImwE>mN_ThDE8ZTi)4de90Rm88wB9> ziqyI(t7?w0L$gP{Mlk)0bM!1g+NUS z3u0b0`dH@D+l{~HXJQ*OzrVI$zyzA`%U=~Ro~}U(L~V9LaYEah-5x8%K$Yn+KcQyd2B5kk=?^ek1i)V=J`mTe7bM0^} zX3qKM76cmnRG=Fby0(U#IS0@1JzU^*AbLm%;>g5yqW-b^3~zUk7iE3v(Olml4X?LB zW+mZ?;;?7A2b?EV4eNmNUywLBf1>`Ls)0!diN8UAn(z*3q%0gT zhCrS`unAShdwU$DD#LeR&FBxn)ecU}*uL6_j4{slb%o~5^P(T+3b2L8E2piw=uImX zCm)dAH|s&V#BVZ*juB(YU+_f$$n+n;W{hvh1OO71Vzj7FlMaqtk6dt0Je3l*o{j-O zSRq3{9`~HeO$keGjwaZ_4eA5BNqm-R4@mBtXxZtvVi^WNGv#TlC?dNO!VU^A?ymR) z2u8G0tqNlvy$yPjD*yr&UKKxO;K{Q=MyasXtO(h9#&I+}TSE>>eCSm0Pbx?~$w$jM z%?2w-8QeAlzRcF>54$5dunXFufRkIksKS|a>4qc(^3g809O-8U_)52X`8a`&bTbio|Y&tCw{nBBdhM`bzJ_G3sRJk}o{uUaP( zLXKxec)f&iq4ejgx3mvS;0{M075Q1@3F-COcJ|L(vUu6)(_soua%P zkRKd~CZTybd;~v3%+RW3zab#NWLb~~)liuW&U)dFW&0a;Jc#V7!@{6dlUbeXYh5z~ zDwzQY5ni`PWQXA|5B#`3Mmu7G7X<-eTm;BCan!w&qyMf5p#J)QM^S^p?}hQ% z|EppC-#`9mVgG;Y{C`jIpKt#W#NTN4R~i4MApVO9a`9n$)tY5|%t9YGgEYVKLCf2; zh7(94y7vxBTCLQ}8mSNPdigzndp0lGdyV+M^Kx}BqfL|9TjNs%h=be(&Kbl2+wV8xe*(T4HLM%!Mt={a1eq^m+FV%19bCh;3`c;ShqLtmgb@5}G z-npl{(Zz#NtLX)mSw*VLWm%NB`myKqzFGNVjUpUO!M9GU0@PjEx(aa|Wt0x$<#s&{{`dFrWUNtl_o4#W`t<_p9^@`SRj3gZ0a*E+ zp=X=$AMNp8sch`EVs-~@F3`{ zs34Qt?RoKa)}V7ns|>0F{>lM@15+$mdHu%lt~Pn`(hh2NCZ~=kShH2&04;mr>2UKX zlF`EgnHdfU`)CeH42l^j5r~&5q;zx6JR8Dt%+Q7894WXW1jaNGXOzY41VYej` zyFQ`K#ppEZ#w9PDJ}N~=+mxV!)E8C1YZn`Hj}8j_N2htHD;?-m8r$WS`aaYF0P6&} zLHpL|!g>~E*bNNUq;U7Uuz#R|9BzAc z?IZpeMbXdUe8&Qz)s=k(Qk8pDPkSSXR@Xl(|5{>=B2NIM`XC}}*s-GyZLZdi{P*m$ zhk%Q$jf99f!9}XWAD!Dq5HH5TMK`5VTfOjw|G)!vVZwnap9IQRcT$}i^E|lT<^)UaCXdN&wzuVsyOe$(@Ybj8gT{BHf#1#pJv)mxgip@ zACZwph;@dzc62XFUHRX^t#@UHwqmhb6BR?0c`Cz{cETb2xR8Gw8IipPaN8}>)&Mf} zvnvLtTZCUs0=v#qO789gEkmmz1DLX%UT}9A)DKRSacSIt(@&>(2 zJcX1yp7FPXS|*QV=aDn1xy?-yN)g11@BdK6z#%mNtfyD?NJ|@2SHNv*khizNRl@^W za8j!!>#q@ik~tn^Vtl0|sH!}cX4!~Wd9=Vieq=O+_fOB4VF34ZzX|+=V)X0fjJECUPaeh)UzB-s5v_khH8e306$kMfF3r`!Yx#8kDrm3a z!G|5>0`JtCXjCexk6Ub<$^PNhQ~T%dl-a_1L|R3U>J`fw=2qWwr!^|wXyH8JIw%XRi!1 zm>~IVSNu&}2ce5P!(4L=y)V5hM=J`|ecB2gv*+0$-$)UhjLsjfUR?<#7idU|%FrT& zqh~uGA=E#IT1$)$m7=eK8#sTkFVsC+&8LofpOibVHa}}tpBfCc^YLiT6#OGT>Wu#T z*T?10H5S{=-Ymdtu~2Ey%8rzh=UjA}sqG>SCd%urB$JX!Byq^w+QE`hUeBz zi5r>63l=Gtks_9=&R$vn?yvP~YR z3$39Q&vF;*{JvIojr(x@yuk3xNr!XN^3PTyJ9ot2gBC9~+IybT;ZBg;Mlp?lEqn&> z0n>{ebA+YuEC)UxJZ8;kWj$wV2c-G#8~+T@Y#aPJ^>=Nf#;+{e@qbYZur_kJ>^@+A za~9p!UFU9JKDoYcZ8lpbxxHeye*qwdC?W78$MW6xNISjFu}x;sYlZtD_Fz@+Vhb77 zy4|e{jp<@qh>YvD;o<>YcJ2WIqcp6D~U4t(~4XI|-=pmauacq^nVmaxhZR2~0L?J)YQMx(wO zs(qcnm;5GMjR5~r(yX%-`ZqfHD=9pl0>v@bRQ34!ZCb~dKe3xvFMhhA#30XbwE%6e zkrY$sUz`sy9%DY#7yz!LS&Vml9a5UxG8BjI+Q25sc;tz_o#6KOorMwe#6NR@u+JldbJ-AQfD|Mv@Gi1->L2T0 zXx!zPxSZ4&vH6B`cGSh}1rsYNsu3T1v5)t?#`J-QuVXIVrlLKaoX>Khy>=)MUNdx% zvD>znZk)>wLt^rsyas3w3yw%|(>wUimDRK{KeVV2z{=ox_1gY9@Rx@Hqj12P{j>Hl zkwx5(Jd5i11mbAfz2-la0j7InLPFRWDW^v*QEfqj z9=^kQ;z$$|Tn?YTf3iV_7WV~fBzeEzCsz$6$>wAcz}g#m+IF%?3oqUGe!e_|=dA$T zmOsK`UjzhH<-h z0v?Svkqz8?Gl;*yXFEA^9XrZHdgNcqW@pD+h|3}0SoGedq;n)~2Qu$qG|Te*AEy_h ztprhkzi=AvJn%qey6y8EEqmhQk@ltd&=QeF)ULCilk&oVz;;|@!=jeigS^$Uo;sck zI@MKBMn9d~K2q9rAs);I;e*CfX)bK={i4U_#!EWHzlvkcD4he*R;&}kN{)kkv-iE8 zwzfD}OZZ*Cgd*=S^9Y^WE|CGk@NkWYWflpPAp0G?euX`64|VeS_{`Q0w{N^q9j0n@H2A=-FpAm`MxRQ|tW?4I)FM58s5EXX8X5mwNGqqh{#~ z#`8kfNqcCn;$1cxqF!rdA&s)nh!mFaBRMX=6Gs!;jxOd|79CHScOfyIXmnCQMu*7z zo_WDsz5L_i2(%Zsi5dhYw<|3dp7!{;+G1 z(2iR!pcVC4Q2F`rmtYOz|42L(C|J~S=l=b%(-X5TB-e6^@6|r(@G8M_zw!Ce`Tj=} zuhtr7+fu*iHKG6f1q+U=@Q^`+JfJ}@syuN6g7QV9g6ZksREG5Ss1M z;Ze4L$M>SDH)HR%l;JWM`tht~Ysvh6+R{=t|BvdV8J$dw)*mrad}CvzD!t&*usB%*qu~>vquT~^AzhELfB3^5?x+u`^^UKfO*g2fI2|Kt10@! zndu2#uZi9ru}VAmadqkxQPFYp#M~eFo4rOigRjkVI5PjN^_x>b$o1T%7(pZj6ArYf zD9KOYtd3}VTDmPLxYSt0W%DV>-(4+5zXxmHdaviM-;V$L@_=DD_?n9= z=v;VxN<|qB4jXKf0&zQsaK?LAyQ*+AnmdNnGlcBk_>c*%4Wee8eQs4-dKxi_d)jh$ zL(D4;C&;v-C7%|HR4Z(1Zg6{RFIkNxa9S)*5lSZsY#zQ_Z>((U(BGz1^S3&h>{UB# zZmN6oELSnL-vW(Wb~Rg;R0iO|a1|n#cTWjmv=q-x6E<&U0F4>Ol@{B2l|H1~x4FY} zk(5F$Q@F4(eB;wSM<4Hh^L`UNn2yLZR^@GCs$oh`&C7;5EliQMCKs95-A88AHD#^X|)%1?ur3iYxGnn-&x zS3VAQX%k=G&Wu@XEkk1G<@!YHThHpUl8|$ix0#`d%?66+^7Tw$vwo@~;n^+f`x`<#~+0GD-HHD_6 zqWN?$DxynP4_C+c8J;$HAM#&TgIyfNeEpqOEtb(ZoGk&E`_8}U{{Em2RI9R7f5eFj zzP^jL)Tb{LEYwpD%c{BYo;?0Y_OM*1=>3}@Uhb8T$T^MgD=yHxR>*#opI&Hrn=XTk z@;$ZCXX&fQDqCPPL**>&=ctZ|`(6WMe&OEt8M;HcyB*%TCOC3JQ9LwjL2?Mg%DA8b z8Sf&UB#3v9mm8nz-8MG9?@${Mi{L-*PcW7XNYV9=$a|EC-t6M$y$og?NVJ-|;prhw!H|+{`ar z?@&j5+VxzndgQ9-hD7amR?>++lYN&Ahmc!p*GMB1Ei&CT`GKVyIK4L&46Lpt`u6-q zNrDx0xrGG>%5MokHaHA3S?Yj*{%A~gshNF^<8%8-@HO$KkLTo62A35`)$F>@Lx>L^ zun_^so4*)3M@Gn=dN{s2A4izy7v;fOTwZq%#vfUY&z2nm!M5@KRyYzB}GejT7zX_H(Lv z^`?C5(Zo?hb-q2*XznRO9#FGfT9M)MCu<+Zi&FC)Z9YJva8+3YTcADqwo|zDOfTZ+ z$<3!-+66E{%<53FVA$%Q<_zJ;+MaGZjrskWd>m;|S(AJ^;d2zj!gt-)|M8VFfZuUt z#l0FYD17L1f4FTau}33V^ZgZr`v3-*UV~$Qu$jMX>hu(W0locAs|jIxy~`hanOw%h zy_emK0~NTzdo!mp(1g4fJZ!9xrwWbh3cp6D7Uipg-!Jag0C|5PvIIAHy^K+1kw1NB zN*y|*h`JIg>|wzGG2Z6n(#HU!z|(EvNEE}#r_;3FsDW|huaRxJGPm~`!f0)kU5D-R zUgzH`P4R?3$KMCZbAEmKpuGcg#wJubO5$qXVJH>og#ljT&R0JUkXml;M`-B$BLMwd zI@TBV?y-m`zZ8r5bUOT%7A|hn=ydepz@9{nh-h(fU7OA`MY(<3jqRX9js^1mSvdpc zzmXsuke0Y}*Ens<`+9Bq3YGeH^>+Ko#E zXy(qPGkLk1e0B9`9uciWdW)PGvMx6s691qQ03d=#J6@MGC8LQI3UR=%y#r-sTI5NqLTn5%LTPlrjr7>; zApLt69JkuEYw#Gb{?;)^fBZvs5=vPZ1MGQar-J&4%@Gxb6g|`FA@E2P$Zh^DzO|%s zR?0G(QFG`cx?v*n4msM<+Z;g|(7RPzxG(OOaUBq@>0#XTb)$}+bF4%p3jsJiebS(P zOw7K6X*Se)e{qgk5ztok0mO8uSr-#i2zZ`u<93!u#xfI<`Ca|-Rwd_V z+snw1V9={SG9;(fp#yv~oNtxJPl~+dmuwVRm;;oQYW#khT3XSsA6xzeQcdV~x9X{L}p{6@Hx#x#Q6dMyb%WiL=h6F(2=*$UhtUj|AEz_BgFk`BT(f2+yFv>f|2sR_3)Jxxv# zMWhCcL97$?Rca_eb`xSlFS(vP$ovnMmU zoZjO!X?Hd+kZLY;mz5*I`OtMw4BokGe|kKq_qKyTW-+aRmQEB%0CAeLL53IY$qY7a z-q$%lb|}w-8dQg|gj74X(XyROu?&b5;$EcyQ)vnn0PT>1)b2iK%kP3i6Q0w#5R`8b zaIgp?LSpy&ehjUvo9sT5c+eVJAl9|5nkBI4zMnNK4t+;@^=rp|e$Jcj9X;VuhPJ!C z>XoyGj*m|PIDf~`=i!^{m&yHo+Ebom@>i2)_6hNbY>6h{kL?Gd$8g`Fcon7WYgPk*VIt`%;Mn!h@R$~P36XORKs z(*rDW&E$tuXWxepm}V|K^Qx$g>N+w53XqJ>9b`6EeVR~r42K0rVxgF)av=gilj!9ddv*yPwVYXUK$caHX|!ZHsGO|EI!zYLf{N_80sDxz_%_0cM} z+`zxQ`rPPa_NFhMIjYUr)My&?&AkmJ`pYATJ&roEEfV35b-KH+N#=W$$po+OF;Q~y|qhgRirMNk9s9D+@Xds$m{+_ zS;LzSN@|Ewj z5(k)k*86vT$xHjK5~a~w?!OImecp81eed1-H}yqykVY|-Pn`9~hMi);{Mp$@_hl`T zlqcsZOVzFx5=vXfc79ZLvOyjv`9a?EI$8nuaU2zMbt0YynGM+@b1Rf>CMJZisL5VS z)?K?5&LI!vivb7r^hM7XW(frnY|PqKuWMJrzjuCJb+bTjINnp@?>7{2IM`ub<+|9Z z$&Ec?a1v5UX8_M^af10LlJkKQm|vSX#CpKyu1%-A0L{`xdOB}qaeJ7}8@yxiyA=HV zIc=ep&zW|hS18(Ix0Sp zsw>QqA?~(*W@1!YFU>W>vTD2Mj|vUA*?{QK9?D81SC>J#y+51`dF8*0^wv{(@ju*qlrS*wzhgg! zG8&r?r&7=z8{Y?&%w}SWCxt-|vq?`QDXj-oqn@_|x9T-UhUy8ke>(f|3pg7~)mn;q z(ItfNGQNz76Pd$bfYR)ENZ!Y;Ox$&m0Z&Sk$z#?$#&*2{cQ zliHe#MzJsKA;XP*1@d=0q8+aAdp_uF2ZU4rE!d8_NWC@c z)k+8+Y=o^bXFMgU0$nZ@c>Mjl=V2?$^_{&R5_}RmxqSSUo#6bVQ?F0+4r7RSOnP0~OD@+YDAKeWAd zT$Ek3Hhj-WBO$4hN=YLKNFz#jcPj`;H^aavA_4*`-6$p9T_dGPcMsj&4et#;&pGEi zf4txK{?7aT=QzX8wbx#It!rJ2GL^{ViAyGNold?c^r(xrh2}yBsf+e56eg2fA*8O> zkdsnX#bJ{bSjKy#HpD*zJ+AUNZ0H&qgvSdB(xG$dgQ**^T;ou)B`jJ>_4{1NUR}*< z1)*QJ9T-oh?zTcV_Z0hB%mi^$=FU6rPdCg>Voif^JKPMN6OaKRBMB0vf-9_M#!&QO z-};L@IQ3NO&E;RvP*<;T#oDQUeITd)l?7Y?)xP+89Hv}xv{5h>AE8X+Q;@#Z`(&Xi zol~qAb+n8!-8-EUohpX;j#V~UbDn+ujwfwS<=5k?NJe)@0sZ+r~|) z%HPI9kJ>3q9@{_-8&sQdG+y$gwc&6oPOlRSC?eH499 ztD<}DL`LnLThDLD+CG^wwEbx{PTakUX`1P@seVWptY#ZgsOts`yUb&raY^XhL@iCm zT%yv1f}afQSea4#j#rJNH3;pfC%fTl@Q z*kNUTs>QO?S(;E8%&^^gzn6sWpm|~lCFM0(WLn`NY3En}MDNdi=?^;-? zUoUIe+k&C!uvZU$CdAKS9)9pK`syIZ6czSnEB`K>Yboov&ps-X6ggn|W%Irvq;}lS zrHgJzRn$RWJC0T;-5IW}vNYN6`pmeR?hzth89!Yxa=`NIrJyt;qFo8U7C2(Snx?CF zuAhJJKn6XIk5~UdMH39TC2rYN=?U<=s_f+&QvzamEexM|Tku9nh!&e;y9$r)g72#U za^#Q1g8~*^7?AWe{8@yxv@msC+LPgX4hYoL3Hw1ZGex^Eq{l0VlhZFG&lj9;G zm^)eabd53)U=+0SK3c5CC$as#pQesAt(+uRE5jrB`!3pcvpO6;%s&->dhq+O1tP=2 zxfbK@Lh@*Wo2t7?>EzV_9oN@br+TqGLc5v*|%XR!p)SW0eV`0j2XLx_R z_=gf1-w^Ce$CZc4j}nVYejBsGTf4N5mc_I&-D{@aFCJ7><8dmc3M9{U3Tf7Q$Jh|qhE@C~Y;lz`V3jC?m^xO(5&oJ2g?*o(mD*%w&qrwEIaSpWcU zA!76%n;zdxFa5>icvbeA2Syr;L&O1#HqNm%KRFS~b|3lGa*9bmPs#M%4~39qi7#8BW9QDjj*pz0(RpZwfA{;Pr~36vN%TOm}whaMab zmmpt3*jF_G5}9???!$YV=JhV-$h!T_$>&VB4=LacD3Kv=&)ic%oA=ROyEfH6383@T zQ&N&*Ua)*)fgzjW<^FS?ow=ZO@FaktG>gvwM@*))=~Drtwqv|>rc4ZI)Zl=Ht~8ve^lI`Sev96|JIZP;dT6yXKGdBxy$T(CoWQy zDzukWeEfCZs=VW{oG;_Of;}c(JoKfcGl6mxY_M}j&SWdrm#1f*tA&vJEf<&j6~|>v zUCCAVW87)sg|zgNi4q4`_x$9#8(!OP3j}=4m@;CUR9EXPwf zeOSYwz?Ib6Dh?Jt%f@Yx%8GC_K5?0LuoKw|BR^rvgGbfZrViS^&^~GLFijGQ%OAhh z;x?e=6mzM-ZDi>NeSPo*RoH!qrHVVbw#zXPwZXujq44pR$No04eYK(UhQR8>>LRS^ zx4J*W9STN8-I?cf>z`i3+&W%DT;x@tF|#CwE(b4Xv7DUepvRLYQEzO5iOuXP-H5h$ zribu2f|4Wo?*_(qUpd$Qw)|;z#?oh`*;-eFD@INdG=FHaa=rT1U`pf1;}zu0MCuhR zWr9tz*_;(2wvC;#BwpX^+Mg`jy4IltTZDp2Jm`=Y}0+WG8O z36Y_=v{Ma(<5ROW)+wwSB{Lk_CGxcT>`yRvF(2W|M~Og&VDRnX*XUe6Hbk3|-4Eg6 zftXukGlwSNy__lFriTi>ZTWRau!IOnB1g^%Q}DrjL;N<`5`=gZ)WFSJ*$_$oKKwIj z9f@jqdL>?t*Q84^KcOD8bZ=cn4^b5Lq4gkKm=Ay7Dp>PtGK@hRGs&hnAwkaZO<;mC zt}XsW7)tl#e8Zp{K~x=?{BK375Y(3ZL@4-t0ICQw|mj>TLUhx+^6?jJAyRy*cMX8fRPf!V6=EGE= z{bm(ocx4PHZ7M%h8Vo^weJ#m%q?Nqwlpu?rLjw$MxK}csBmG;jn8qr5DM^~WP zN$*D>`}(4Th-NpzTtjtM82sN+$^m&_PpC^u<}rr*)?H6dyDFWYCIC{LY;qnS%t^(! z7K+=ysxZ7Ct^F&5k09p;aV?r9{Au?SzOP=<$Gs)>OJ<5JMU#H3sG1h7gV+zL1UM8m zIN34ngu`#s3BH_wsg=~|bK{x>mHc)H(E`LkZWJ%{cRmo8rl>Sx@dDo2ua!WCSoo)5 z0V2r$QCRse^^2l#v>xk~=yo35>9O!|CHGvY$MpB{S$Dp~i8?p5<=krxhE+j&50|Fn50g7hj^vLN?NY&Oo*qVIxbz$HDko)>D|KeN?b z)sh`y4026)Fk8kEi90WS@V${Rae&Hw-~)O*bzX3E&P7Rd_o>{$rcrbc#x`YvX57q7 z$Xml-@?VAD(7fvw2pmjnlOo^n+HsFPtJZ{ z&>{DaAm!tkI9)BL@gdxe=GfcYGw@OsmHISIyU%QIY2E|*v$$o8soH-$=LrgTB$LU#0C(tZ z_5s0}9+Qt98jR-K-y^t8U@OcGDP`I?M0P2G18IATHJwSEx>Ct~wC7;vzIa@m=HAAM z*wmV0J4!aUfSl{802g^|;Xx1Vv9+)>&vnCRZd5Pme=`h5f7j;LS-TWaN3l$BHM}?c{HI4qBAL3!JrDe5o;a=GmCS5P98Hv2x=znM z%K=&Hh%Ywwl*tplKP(CDeDu)Wf~SGH_gdo{t{6*mAZYN&{+txB)m;zm)36o6VS5Mk zHS5J3Y5JyvlRUhsnO)Iz+X}tMjm)X8eaOTT#I4{ZHRG;*MenJRIy0;8qfUfL_0|w% zAL9P2;5{IvsdAO<7ZYmr#sc-Nhh7Q=%M*B_5Es8~+uM~ojWV=GTj&*ZiwT+DgCP~~ zp-rEvZ11EqjYw1`2l$wqqc zF3Q`Q{C4gR16dWH+l(;Tm_KK9hk~&l31w4Qbu0{WrE9Vt!Ql2Iasc|ednEGkn2_b4 z@u@(M)5HQeMFywdtftx-cioEj(wZP#wp|%&=;~McdL$l4SLy%3%Vp@9_JgYfy#_CT zjyicITToLL3$f(m&1kt=&%UN=+JZMjW4y`k}Jt_)bwg+~Neg+l)wF-{--_y?A$Fu(d?x)W72d7xpLv#aK8k*yI~$559eOpNg;H zh{%Y37ca23-EZ^;rTgpv$Gl>*%M4GbJMb!(kFS2-S`(#pM7W68257+r$dD%)qT-r$p zI;JQ(_i#cvJ0Br@=aSa$3B`j5-F~c;qv@qOq4|1h%>1^?zt&1CZc6v}^P+wQ{R;J4 zJWC=A>Jb8KA3)Z#N0&P@LlfzgDVvm&_dK10knWhGqF?L6qyXaXhZnq|M!GK#~VLMO!h^ zqjB^fL&F8KkGY{ay3l4dI34c^k&NBb*xhzyreuH2wk0r(*0tJ&EPRcb`+SihWXYio zn{Mn(W)8>@4lccxIt2}KX9JO!G9V+AJ7~J~TgGD-Z4=FM*w^!~*ach+A9SYpUUCb6 z0#40>*J$whVHKS8qE zhSCqfAXz|0mdhPGH{qDX@qkKxsWt3iLjk(1(TRV&FsF@Cur}3Hwo|Od|G^Xk;=*!4 z_p|eA+QumJ^FDOBV@ITocx|(b4H3xs+jHj5e=O1`f{D}Pf;v;CTBL^|tCA6+9+$mO zV&)^1PqqaN-2ro8_X&x|EgrU~wy>{WhV!x$b9)zX`3f>m#V)dhLp_i8H-}XycwqP` z8RV1D+B;s-O-~kL3MoK6xs7YMY7}0)r)5bZZguLPRMJ_)x=xQ{a-OR^v3#2op)|Og z>0Kx7vlZ!VK`3sSr+-^$i=)EI+6&_Hy}OlRuhcKq#axB{lB}@YyztN1C{v}MjrZ6`72j|@WDiGNn@XfeJ-w&L(-z> zF8$|lOlf)(={=nB0AIX0%ZMW*`82D`%OKT%R?q>Wkl4^#?Z=}~`*|lQ+fkm+FHqrW zl_Obr2>Zt~NA#&j*Uu3`B#;dsd&hHWvjn$eR;pzL^IgYAP{mieUHl*uE{L<{LQqJ~ z_ocsG>Hegy!;7{(LTLMuT0meDK@Fx00FAfFksdme<4sEYh2xkr@pA+D)DU4l?2Y>N!U z4(;Cg+bso4L*%?KQ>cE)W3=sZ*OUf>V^iVP)r1rPH+i z{idh+LSP72wcyf)@x<8oF&OCpyG8DkB31t*wdWd=2)6XkkdRMEkjrQOUskn`J{UrB zDZG`Hv_}ryM5g-ox9c&BByMs~oSp+h@0Y=NwbY)zV!;<)Fd2Zq?i&;NRC>tvM?<}m zxI*r~oxcMPv3j||JQgFy1v_K-FN#(m9avpiq9K|U&GI&6mF(P<76PN7)_l7r*S>$? zHv~dSWHv7D?CcyE6*T+RB5ZRV%|SQzsq!$IJv(u9KpC+5UA(H&B5Q6yZ%1FBF+UCK zZz>5T7P46L*`OfxO<)M9qDN2_J=TC|=aaaPWKZU6IU4KWOe=>F z-Y3mfvxT-e<2r(vN^sOpAmEGyLrOn6R&|ucP{n#`!9vh0c0}WWHmogzNz`3P@tQv$|uLHj z&&1Flt?&9N)rb=>#e1k$_*T4RIIqgsd&l=aBzeQicAt@AFeW{E%X8>2rhI*V#5!Et%NEa_jnlHSnENtBE3(3(l5UUJ z4o*z0<8Bxj^bFMwN3TkmU$)fXjE%)%qrnwG#IRuZ8VK)!4OJ#c+5hn_9}2yQep2yp zviNZ^Yr=|)RCV+hgKX2-`v8-;mEpF16sn|eB0=-jQY6zB4;`_Saf#3-+IeoxATArP zlgmbPoQ*mv`PHl7tGMP{*RR$r=-q-^8e+ea2EC+PP#v!eE7Djc%a^cwqv;uBAn@>8 z!{JpY8H3O2mdLEAB_pnh%gM4xo{PU%O*bwWP`tMPK~ky3pG1=5du#3QD;?KIUE-8P zV$mEnqE6=6Zx21|OX_+W)4_G76u~>pBehBq9mv`luIJtRi44h*NB+qxbGP0w>HElF ztve`cH$E^fqsQgBnz{FEcuG{Ur<(M*%J+4=YVge^Yug@LLCyyo?R#P;|4D!b!H4+*wl zJ*mBS8qo_dgxP1rHG(DP<)mZ-b$EI_Q&@+BEdxN*a=^3lqlb!P?ZHXhq9rmT}sY6dX zBAk3QXGIe=0;s)3FXtyLA|`q!V8 zpeB;lT(9=u%1^Q8vTBBTntFoIEBm0U$Q~wc)`Z!Je#&P?OE_x|Z*lR3rRy$WUl={z zp|AW6MRt=K#(xN@Zh2Kn=J)WoQ!5ze-Vyg)z?_6<2fI#HILzkEy%^+hBj|zST}v4w zh!nL8Sjh42-N!C^a6iXlT=XH+vbXCRAJ2geucn|jTd7Sqr?v z*d-Cxns~<7d)4_0Hgq(sA;Z<4t7_FP8+E}=nMSZ*gs7~H2<3;)*V_-%*ME#;JqP)$ z!MpqqZVCytNRh6^;ud~sU47@V-~HxS>Zaps+~f7~+AV<!CETr zf(C9$?CC|VA`hR7b{6_HRIIR+qUlAu&^o{+3dz-bpg_mz`5u==Ew`#f!6nM}G| zL(p5CE)e>U@ayxFDrOnfV?^`KQf8R{UHjJg2L zp-oP;1U9`uHGVF=f0&m$OX|5C@D8I=q3Snl*kYDBn<58@VpX!<(WQ~O>ZMzf(T1n1 zBwvv?UOzRFcdxKF`(*|9hq#N5SH0>yQr!cpweHv8QU8S9sx@5h|Dl(E^=FP#D@)Qg z*2B*DqWy4X^{m9%y{>k*#rI)_+VFSOU01!}t!&n)#!MmWn9R8CjAQ$bS$E6<)^BV= zD-r6{&)8Kg6CHk@3gnMcPZ0Pwrb9=2bt~cA<$>tLwt2x%r%0 z)Tx=#7taDlWnSOSe6e`vSIqlAZx%!qPWs2VZAf;wxQ;x!Qg=`1c_lkR*wJSi1(7mg zSK|V}J^#M7S6TP~2A8S59A&PS*Ddnw47alK+w!E|hQw3BIdSbBStA^z5>9gs;hLy-lP1#qw*HViEbynO(&sq9`NtWH$_-3;6h|fdJc3-J>DYKQ8MhnBhRmZlri-q86qT0flQwr3As4Wt4>y3U~)YBjpRvy zmBsL#*H#{3vFsO?uDhbd1n>&&;7bZa(ey6;pdhWZC(>NXy^k?`Q#B7*YxF|)-}wC2 zLXKs!ZiS%&CjKOxv%nwkmcE(UpcFBlDsSd*(Pmsn%_TPAGbKCyq5JW*Cukm)moz82 zIcj{*_e4%;bxjRxddRjMO&f))J{_+LY?XvDZLj_=>%^v^9tpm=iu%O?<0AGoo4T1; z{v0j0pX=imi5Y}a7TbO1UCyj_n)meH0hg^d1V4E6*X~%fX-9#1s&*__-kHLKaeO(^ zCxx5jHlfkY*%}}r8h-(a!dP0-cfGaD&2;0%Bpv_JrST&s*WQF;x|+Yn)Z)4fvROpT&V-u@{Zw*JrB-!Mc)HWS!>ORU==u zeCw7L#Xcr>$NE=G8cdk0;h5AWqO!UByOd+{Do9oYYmqXv)yUq%4VkhWlYBOM)%w7w{R}XR!GnYfRXs~XS9ku4ehP$SQZBn}zZwVARX8JwH0x zqXR!}@lX6{oiYICh-s74t&T?>XH*(27Q9t*{KFh((qzhnvZ<=d3*cP{SsiFm>USMx zhSy6MU73gjsYTJrKI$LY(0Xd^WjSZ_Rt-HiSD}3r|0s5=0}BNu{@kPE7bAJG2c@mr zuWB_sJIJ}QrUQ&#RKAh}Y>jo=HL*doztM_Pnx69);}{}b*IFZF78;kMy-|_USHLS> zWXx(}GuIAtY-tlGlTf^%e~-mCMwHXS7^L|lugh8}w_oo%xx~+Hc-$K{N$Y`qrtq{> z+d3m!zNk-xWwy>oo$A|=ulbuV*0$aw6WQmsn-X-0#Rv4*DXdG2K7S|cZX4P#&({KK z1c^bMx`1XFJI%A&2vyJYamk~|jnj&tl_e^21`8d>k~avNlVvZWr;LSVmmV8{+VR z9GL@!{xBb5p4=U*uby4P_}Na62)+`$UzoL53UrCBaZdrhZPGt9FU$?8@oE?l)uQpz+rRG_Diy3NhWI+4qyJ9R{zgI;UZtYLZF|kf(QI zA{!U$Xk)VMp&!7ZOoE&ael+aS;E;df_#*BW00yC5Ap??B{EQ1dPChy0d@r@1*J3xk zx(mrY_~SFdGHsH7k5D`^i?b|bT|I9FC#Ce0&HU&o=7-NYTeVZ9A0jl7Z|gu-9PMLK zg_xn?(>J=vu8#U>Pq@Xq&T(*(LSO9)=O}%CbH-}2D)h$Ty^F5we*YXsI(};E(>Ro= zm5&vQNhI88{}n?vxQk3^s?-(Pz)UPQv4$!HeNkSq%(r+ok=XV@1u7#+UA>zQH)ogv zUuehZ*A$<+&+Abhp`Tr!fb3ShhWwIWU~;-KRwf}bjl36?o|ji|rg-gq$jAj&JuEYr zlg6RB#jlLt7DOqz5`-SPge>Ot-v4k%oby*yY(ZfE%%6oZx4|X)M+m1S_mO(4tRWZP z4C|^&;Q}@j-ahOt2k8cx2Z%tJJSRdc;ElU_O>~p;-KvP)*)8OBBu90+%3FZ72{)xq zwR&7`We*&`b_Uf9CJPSjl0T$n3`)#PtZn zyR{L5r?c>(O_mOkzh+2wwAjqgz+rxP(ARmWmh3cpXMVwOj+lSz-JH9@5nfpRMDb+8 zX7HsQQ$EnzEn{@R1iH}I_3{_Ukn zhJrZMNo}I5yebvk?}PK1VkU^_$aB2arhD&ky=i+on1x87Ip@}{ShV=i*lL>Mr_;Rx z*qap`Wx$ru1}=zwJ$<(dH+TVT7d@Cru3+{|%i2Z{$!>FS6s<%i&SQH80?%9Pxrjf+6YwJS3Ld1AB@y3J z)MTfHLt~CtJ;cJaLf#lyTyMIQ1~7;quRc9^&a1Bp`yRP{^_do3Ue*~Mf41&w8Y!~V_p`L! z_GPws4-0MasFkWV#BK#UV*MV#ub&M$ol zPK|f8Mjb|E$!iUk&3}a6lltgNWv}VeI89fMwk=4pV&J+W!1)YFjrQ~lL__DA8PqlF zPO*7an2z?34H8=ZzFrRIq^cWd`~=S)vmXp6MrY}5h6o>CB#wQYiKiP zDpWp4P?LIWF*G-h7J$ zZsR6HcBxZl(nf*1+fiP%zgu>C@Rm~_Zpx+-OPKZ5>f`+m!^;;{=D!rQ_(vZ_Q;#-E zT?L;h&w_SP-XF0}&HSAyo>b{J=^nqNAKwr{XOmphyN-tU4&~k~C_{JLU@zlGPMF@B zjew|tf`vbYM;gQ^5~2A(cpZM$iS@#hOiJO#0HfoEX)r9gmU!{%aB?6K;hxeEQ;Jw2EG%tiWbf=LQPjs)u*kxln~H zR-KZUPA%X%jhWBO)*b$&oP+9DSmACfF%?$aP87FTIp7;DWW21a#2{=i+^pZY@NA($ zGUyHs)mx$Uoohl!_Vf?(u+Qv^-QPKBQe1Rn{FE>2ZhkJk*BM^J>d-xaxHp{k^xf$4 z$3T1@W5e+=ZsYdmf+&iUJG|Q3Y-fIAWXi<=O7jQ4LCIL4*x-Bq7Gm+EgRxSPCT7e~ z6<5RBOu6ZIgU%iOJoVN|3JVmBs~y-s3n%UF#^jxnwy{!B8_1A!8t{Tw&tBGEg#(4g@QtKMimYHsu2-)(6Dh%Fw4o@)iK}q>@$kb%c>AB`5MheHPF#7w|8=(#(6W#;9+@vghwx*qSO1r0 z#}Lgy-CwAxoUIH1k=2NB+4D8Tp79L22!_no>?YgqM5osDYER9*-p(ZDtoM=3<+EHlC$2>%uYVU^|ZyiaHcVj zMg*rdU)ijF0<;X7IC?Z|{Rjg0{><3|_Jb|3UNZ^|#?$XVMma2{7tYTb%e2nZNR4Fg z(B3$j1=tWXuQKqTdc{`KfoxZ&lbROjR<813h$Y!ghz?^XAYdo;;70$rK3B*kyea?<>D#K-&JY`Dl4=qe{~is644@qM zvBY)Cr2%!mL6+Q+0VH>Ha(P!z93VeI$Uw`W05BL+*Jff8KBj;O}3)DlY4 zN2h#J^8|YQO=yMW$m>r`>fqQ!sk5B<}@`>&3`#aA*ow}B-Zu^d{H}hJdtJvmghp!o5 z{&aHu`H&Uy0$i`_U;;OKVZC~6a(}&|BFWJaIRPi4@9e#*C>^42W?+Q6D?fUEN|5Sd z;o_HBhwnf((6hHN+XrS0voR^>=g8q!MCro**2H6!2BhDyf?L;kd((#1S6*9&ZlbD_ z>b8y~LW>nl`sWs_(!1iLq7f7vPfP*+hHcvJAo(5$f}AG3Yq?`pc~{@NqnMY*J!=A2 zK_0_|N;)tJ(21Ao_*MU5Va?Zh(D~1IGebs60Or3;^IA#r=n+0;2rvH@-Wj93TC1DD zeVa{N2PQYt(lTCRtot_$*hVC787w2s$xn3iape(WQt@x9GXQd%q$@Y8X+g~I-6%H& z?f;wg{r(p8yQUWbxB74HEvRq*FBTB||Gk5M$9gk^za#_yIRhk66k5JW%P=|dH!PTu z2b5D0F+Uw>Rs4H?yl_n1Gi79%{Qo%~%8&`p7wwlm4=|vvNVvrM62}eu9~V1#&OVxD zKL2PEXEaf-=f7GbKHgvAV&q@l&ECb?-8$NLH6M^4q zWAT4_E?{y9y=(UbrM>lZgYTh&@6K=Z?YyOAyRi$CME(<$m%u&hbx+Maa*^{FFWW(W z<6Q^mXF$`O#vibKU{0awt~^BgDanM+IDlVoI^YOq*{7ve#I>FH&%VR?#SCwYV)H2r zWrB5}t6Wr1>`iOkTIqKVO2~1O4LwlO}MB0l^!btwkRJ$oZTZy^PmD zDv}EKf>j&vuE&TRXj`LjG5`|wL4B_WG2oz5zM4BVshzUl-v+4|WO=tA6N{e{fXXe- z@-USMxvi4cCi(HX;)X2<2^`~gj5JKK@wI804W5u?M${y2jsIXe$|Vt#1i}V|og$&fEEM9L8ey_+tI)X~=9&xUwqjAs_%M-e{GGYVIn>S)N{Da@h`Z7EM zKqVhzR(0_^xDza@61Z{MSAHu3dUt8Jkb3v|x`rv54Yg`}YImGuyhS`0S2nSO>X-8+ zG#nr-@}F)+Ye1l1Id<<132raZ(>IMS*T50|ik0p?*7f^-LV7LE74F6s)qe9LY0*Oy zVIbi&IM~m0&=J-bzS1vnjGYts1nR`rL|&M8+IG4I@{sqvr1isPEy>txg#?4 z+{DaW!K+YE{{@R+jkIgMO%5*Wen21TZ04uScw*=XlGXuiRXuq7_ZcKi1E`5tJD&Ph z-kVr}gPZC?=aoaqzkBQ?0Wjap_VVN+_X`c(5M^2#tTZ#H`T~U-8Y)j^BV@<8vEF_& zLnxK2CzKu<=grOhHQ|WSdIY>?%oAs;;4mxqzpRb7|1#M8r$!PNzrN7^n~MFtbw}I? z>so^$&Z!~u{&(ldde5Wk{Z^kF8Ou+(9hll0Pc;_qg(f<&;GU6X7oxUGJ=Syj`{74x z4r1vd)^M3;+R5(Sr+j}X(l1>*MAT_gw_3<^V9ZN=TFVR4`_GvleVW>`fCD_YOz( z3E@?lg2m&)lgCK|8HbX;W&4brch4w2yjgx0QBXg&`YM}xIzZ=J$k8IAaLfULV)e@b ziMVuMy8m#x?;ps}JJp=hTCxpXq0*!6L$8TTOAH4}$MU_hl-P!)^DU;MBXUEgcsvWf z56A1s-O7lSr4X4R6+IZUj@a@I}`2qOAx)$f^kW6EI4o&b$ghRlf zyd6eS1aAJ&z3(-y2NyFdd!p6k>RxrQoeBz`Am6rwJo(EZ8k=%2;|Etwe8!>62lbiE z4bt*MznJVxwHtc!J&I05cq>u97lwB(%6OvTu7-9E)64||EobY#-hw}~jOOh!-R~%; zX4A;l2VhSzfB`%&>=XqcqG5%zT5ewsmu`@vgjhUW<{?5CCuJ{G%sBEyhXwLPfDrmL zYWGfTpeBFzyBd%NRUIqEC$bYxtpyAcPt-L?v!g^sjD8({bU!$iHcm6_?lm^Oik>*O z-^5Mvsr54AgL>>dYyehY6jY1Mo^9k56t|D4bbURHE4hRGZN2r!5Bft(w)Ve_h*5Ja zB^=+%N9!2m;|aOka2YEmgitd6x{!Rj-89MiM3d&F7v;8KB8!b=M}=-;a%7!gP|!2TKPweQMm+iYkJE2xquXI=kUf4{diT~AMRVM+1;|-2F}$-qb6u7 z>;@D&*eP>Fc22GnIz(Vq)>g(1(aooD>$vUfDf8{gF61 z;NU*i^vGs)R9PR@V2wI>`ZUgA9>{o%Jr6mHMpPfo8#%x z8s@jsPKK5e0J-O*7p0jTpu^D%&j4azBaaq|y@|5Z{?ZMz4JC26IQ9?Uj0+$|u4dBJ ztPKe6`WXWkLu=#4L0d$i8v~BkQ+^U|HM@7(p3hW5DqaSG#^eEvizQhg2?I9tY5954 z>7WTNM+1;ubNTTq-+~PJdsDZ6?l8xuh!doG=*%xS4A!U#8@ybrcr_rviKzZ~C_|qF z#bZKb|L!J5;?|i>rp_EImx4sGb5r#R-kym$u~#mE8b-e7M1 z`xVfIFP(JWbtS>*U9(#e^AAU_@fEp4q3mV6?!ELoM75k!Djk*-EBv3Qo28i;3(AmOt9OE zT8Rsvvb>s8hkDeWzU8#<+$g{V9jd-e+K|k@yDS6n=hP}O-g0l7|L&D~v3nLzz|^T@ zi|zocA0vTu&?7FXUF}3`h8DR%bbz3ZmiunSTIAUSggG~o>TP}AGsqJLRQ6p5S2F02 zRSCK;R=t=nNI$a6eP%B37M!v2OZLHp?@hPP9{NQ4ID1K9~z+yMv`Y zZ_Rr-S11f8oQs~(Bom3B@LU~k>v=AagZV^0X@`{}FNqGL&kaGE-;6In_G+@9An)hc z$<=(10I+6u2W&yZ}nCUI<&OD;_$ zu5$xkiuLzHJ+{N~gRE>$u!-|r>$UIdHIhlP48E4m)tEAiHcCgLu(s|NQhmC?61Nxm zn@WVpRX8bVh$x)jsCOAa^ZQsnE}(I0a`xdBGs4==6%Ep%1-Qz#->DGh0)cmEqVIsv zZy)FLEI4zEZ0Bjn#eGwFV8(Fw5!l+~p(DsJv9?gHUQ3=bTw zebWtx?EhkC>y`st#fjC&S8^mb?*G_3(X`9)X|?H+0BT&Ee1|c~F~-5#FCa@YbG z)^q_mY(Cc!X(*6jO8K_eKwLbb_3dKs()Jylh@_bGH$RCUoJ}Ld6__URcV-tae%meeDCO zG&Ol+;$}SEU_g%S7*6m9R;e`X!z{p(4F3#6Jp7@@AjC)o@dmBu$yaGmY>?u&ho3-S zW4w_7eyO~0+L2;X#7G52RP$+OAxC#jFPND!xcO*?Vh;La4viLJMZC7^Hs!D%>Px~D-dSE_UZ&mr z+`2##_|<43lJZAIT3%TH2(1TWw#dGcxgYB=bKu+aq|j!4i68ew`ezu1$<15K`saL> zf74ihxWC-qzIYHVZ1v$RuNwl%J*-z@nW<*Ymn3b2F8kD-l6???3{?Dl(g>Fpn@q~Q zcxwzA(*aeBFCqJ4qU;Dk?CNS^q5m>wk{LJt`?XXrh5L1og3OgY>>}a-4@ey0#}oOz zIx_IG2*G)XopKry3WT;1z2g_*K-kQU)@c7&YG{%s;>+8o?rl-`rG0;VW`XNmJ%m}% zb;Q75!MdWKEEJ}7sWM3!_C{w_sz`nkSI4&i40^e4V9}CeNQoxlK%aK-GU29N?c9TC zFdrH!xq*2bHE3Y)j}>CDi0=8@-B-hc6JZVB0w6N9h=GM0H-#fp0+3c7CSDYQv#+C} z_h|bN*&mZuHo5grZe~a)Kkm*fG`l}RPSIy^>d3(=Co z0zLgsJ;{+VGq2|0f@>TmNs}{FlB7 z#i#vmzWv`0_W#I*aozfj_u#*D^Z%IQKTeUDXMYi@cpy4l9JJkU=wGv-;Jco-fhGjl zwC2ciPI%2WL@^W))UrA-5hL$6N6DL9P0?82+2OURp)U;&G`b(savnc+;Sx}ncYj^4 z&P{7Sul%&m)IBSBB4tFRk)y@fxk?k*)>g(|IG=S(dI`JBgpu_^KPP zxw#kQ$zt>)#O|`=97wY{X?R@)2%P@O1;{k?U2aGil54avn0ud;)o1PAijjP$mj9ia z`tt;uEFboD%DNs_VzxR|BsZ%)$m;)K@^JziVoL@N%QKB;kthua|5eSpE|hHygu9V0 z{@vK$TfgYR>MERdK%8smME=2-Njvs@5^+PaN=x{`hAU`2*_-@11}W~VpVO$*%p9(x zaTT)OuJ_g&9tMN&9)jg%PUwXA5e9aRB*6|4W1o-P{%eouqo1E+pN>GoQ-R{K z&`MGQ3iw+5z}l-0&CunIAfg*T9pc>Q6Ueipem84VwjZPLxI%g4JAa=2pj`dzD<789 zl2y9e$GQNgXr(;6Cs<*;aIOGaUOxPp4PmGSUhk49F6Ulp zSI$fL#eN&RpNtD7NuNdh`%cBqv`~3hg-zY6!*|N=M=ys@Cky<;(N(szHF>q^w{XBv z4)~M>x9u6=>d{bIhuB-^wt6|3aj3~UmUNK8o-(1c!5j%tv+p-5z ze;Tv*pF22JoFM({Df;~Xyn{w0x_L29r{=%(ZbzkPs+S7ZK!&!6&4Zceo8^9s6vlJT*U4xN<&r?e&HypM99}A1j zA7Rn{ou0T)7_cirdo6)jwA=swzxu3x=-&VSM{@GGT_Xex{DF#;HgkU~Xx#2}K$o~V)2>>P3WAOXDoa9f z#6)kycvqZ*-SdBc$zQp$UgGOAl(wB((p?{Th6hdp9Q&`BZQH&-U2*hjHYllkw7^YZ zjZ)uE=qyNfPs|FTodBmeqNH8o>6+Ea^}tEuyN>XQyG^(pl3FUVXsh7f{v6-@zURcm zzMJgZ@|*i{L?;ige}a?|upwIWt<#$>soVktWa0^JxjEmEI|>F|-zKJwf%R7=hH)ng z#154|z1sX{9&daz37Xp7PCHBHMzX%f!1|m69qDB*vzIrQ*4LLdZqPtp-US;@NiQ8v zhDS!ZI9L1v>5f6Z`G`IH%Dp+_lz3l?uLh?ly<6W_<-hqR%(8@6LytN0k5wgJAmi=M z26TNNuYn~kiDN2f!kp@fJhl(`gD-RTCPOy&lh%Tpn+mGB1y8#sz9(vH&tLXWE+}V* zw%)fe`_BEG6Sfc4nR;|k(A|z_uIS`s64QxddAyJBTaa25_?rvlOQMIc89KfdTPYx2 zQcZZw0E#crWfc7k*g83NZ|pY!nk}iHazALc!t5OQ65vhD^Kn@| znDgN)szAW*&a6txZ)I%LHZD)I+ScP?*VwSa>^J>r=PnX}sS{eEOO-gl^f7yF?iEKk zST57Dn;tQR2e0yu!<|;lniEql1d?3!L&+hRZ<=aP&V($kI9%GOR!`g#Y2uE?rq^A+ zb!TNQvwuV1lSc^%?8iz9*owCMdmOK5sg`Icc96|h9ge;V-ik8~)ZEv73xp~F{>#e0 zabRf>Hv&2Y$SYk8vZIdA4;kk@2TZGROrp;pgaAq)%I<}uzyPFk z=#uUMhK`wctM~ou&*4bWlS9;av(-!ImP1dqBmPRPq)*Le;}GoRe+rL8E^P6Rk~}_?I%`USH}+(GwhK~ zmlVneX%eV&2^RMhWx&08dSQz9F@Qt3yuMYOxDUOhOa^7D1|pIlK*TpgEAxN&oR`@# z=$*C=^dr}~yzU*?o~|BKb0Gt*GDh7JD>q)EmwNpQbU(@CNZu4IL^7MJT2ah2C^3PE z;k7kKYbRp7p%5AAA74nN4vWnCNWgyd18KXbqOx?O9x;cIr2<0m_YI`iT$V zJMk2r-5!)ri<1YCCR?i~?>u;2BE0jpX1qiCW{VJXXXZMGG-ukE zcMui7QtxVot)1hX!3?trF4?q0tq-Q1u_XOISG{h~`kb!6NA_oZ9lI}R87hHx8Qzg& z&y!1bvP(KK{VESeir1UuhhGknCeF}5F?NHlP2V3~Q!G8WjkY_Tt%9}y?dX;!+7!+k zM;v!;oxl)>2OvB6J+WvvDW1cy&1KwaKo^C)G4C85Y>vT?(9q0}%rJK}rA0cp&5lTs zf3BoXr*wTOZoNOytf+x0xja9}TH)c0Z>jP4h-OtEg%nc8-NdjfAq|UX$(p<8qP*oBG%foi$=Mh|sUw;I`QckkT*q_BNaKu2E*Iz$zf(=9 z!lB)JGbLI1#v?>-Vc|N)VL`fj4(Dm9yjXq>FdKghXGIcooD>?<2aXeT*iX!{O&+!Y zcG37l_hs9j1*TR7{Fvh_R@gpo^gHQZTpy5eew&d%K`Yp* z#in!%K?+URhqI~u3_9)C5Y5KRi3Tyr^apU>+ObPf6zKi^K4}p& z5|CSQxh!WVY80A5-r91;EV5Uq+rf%+fUD|zVxQq;?V5{CJIHAf#`qQM`W+LiQ$&hc zj_zR@e0G`Y8BNq1L8(wn6agbxB|8%|6R3?y;Ql z>(N!2NWs0(&5!c+M%%Oo^rCGSl~*i^i-y+BUJU!Y@;=n}?sa*tLDK4&r579ohG2O% z4^T4uXXN)VaA>zIKX;v(AhW06FIUW?&$YRq8U8hJ z2;y??S6HZ_50ndwt(kTBq5Lq$w(jb4pPG}@n-+5i+q@uj_EztaboI%0w+a;Sasf8J zSoZV-)|_ap5|=Rg{)%%pu;R&0g66ukT~kM=EDSx}SM$5?t+3XjU64+n!F~c-I+rjK zfAXYu4jHO1^6pnidr%wtCR^P)zLaga+l9-ZL3&Ev<*2WbM2c6L?mTb})Y_Nx%j=mZ z;6H3po^g$7v9*JtbV{S7Pmek;ln70Du#Iw5-anCuJG86AwC)fN+Siu1U(X9X1{OOD zEQjtH?^Go8Z5)fK0XQk!R>kP}&T7{p4nMzcZs}568Tx#Qhu<3#-ayxGPZp&h@3O*0 zi{rG8!$0XWj9ng|^z;gi?>Elbe{5&Qb0~C?w0yfiyb5}g%=nJkwxB@AblVEr(->Dl z#CsLMpHO?oTZ)-Z9T&W@(|z2eailbw+(-B1fyl~B3CtoLW)&L_W;YLHjf!{Q$)4Ck zDGHS9KP_>0yhzI9?wn(UK{5m=Ks#lk)iozRMmV?4M{a+NE`wj7ciH?o=s28i;YSV% zd&^MTX50<4N?_Jvh+W(%S^%GQEL!WBYAa3|={j8Y3oM5-On-)x-6qKv$pIRgz8Ly2 z`@W<;>r_-+3FcKO-VHrb#Ltw(#w6?9_j}!$V1K|!8}k5;^WNoZ62yoccR)e*u$=0$ zRuh}9R&Xq#2J|Zw42HLh5-h766f4R-Dis3-kSXOK;$yxDQ1EF2?5Jvb!B1yC`j^%# za@ZdJuY`67PyHlP-78CHF4shteOEK~Jgnbt(6PeJYt#Erad+cYU(b$F5K4oNm}+3N z`F;77nmhU^ABLalB5~geyd}YxD9oZ?hR2X&vLD#193lMCCv@sKFuZcz$7Lc4cDxG5 zbTw$r!i>$B&SrT2JD|^FLs_Fd7c`5^9Tt>T!N08grh}avCHuCz;tdw4x@?fhdN!8K zHEuHd+OuM~YpABB^!LOoI@P~jjR)74;MeAZUz5Es(S#Pr17n*Rw?n<2DCL;fSl!g2 z=BBD=0&P~hv0IG^A3v{T0_`<*$&HNd#qWTI&e!8g%4RRnV-c6o3;yST--dhO8eiph zqYKPCQV&QVoWiw5>L5ro3_!mNg3Pjm!v$cL^sN6C~QQJw>qF?aU&dzAsNccFAj{1EZ@oV1yL z8?8=OguU;`Rc?8 z$B?!zA5Agjvq0`T#m<2cgf}FrDsOygjp?gl zzgXi?0kA_e8*)M(jPMR6upV7(cOz}yl|l#=dvHk?029NV>C!})Ud@x1aXNN?bx^wV z!>ARkaSF?!(`U`ktr_5aJagpPkB}Ef{IKtjAuga_IC`TB!0bL26W=w0;a%Wq1Ge}k z_prmnmW8%{$(7LYECl6c!>y`%W8dk~hZll_$6}sGW2oioWO^uzvBPkrp)UkM;ubS9 z1p2>#+VlZ`-gVCSx*tsZT8Wg3cJ=lLAWuP>ohC!MG&sgcRUGf9O;EX6GuQB zq1q=;>^~}l-5q5lq%;YDS*IHdo8?fc52LPf86UPXr+`2)>?)~^GCc=4pn*@>>{i2PhmD&7HXB4`^ zLJtCyCh;HmaGQvoX)SNbpl3EF^mz}V%H*v9My=g;2=txupUe+SredWu&h5bw5qPY$)+?U`HY4pZ$qBhAF%WN8JwZb z{1)irzY|!XlCIDFaBA5X$aEbEC+Db7p-F>3sa>Sfm*0b<-mFb7I%`YgWQ5#XNQdC&$?1RF*ej5#6p zxj);7avZ*W{_VUs=B%>;r;O$bRTo(jts_A8{$0#jUhj^v=JCv8i~EY4q6w9yzXU|n z7JKl%MYQi=|FZG-kZ6aWdE?T8K^|MH(n+CwX53)z5lVzN=88DhV=>Zk5An8TLkTc` zgXM1=b7DxBAE>Vh7TW*RqRAY=6kX?MSEh&aZV$S))P@QS=ia{t75-+!W2S4|vc}N| zQscS`PpX}Q>{_a{T70UdEy4`hk^Ll`@UIstC=>nO_Ed^irgevHe7QBd1ilpBPxPg1 z(hkmsd*>?sb!s%`Ak4RPw0(3qr5M0@df$8Rdlc_yPF;XqJ2)-yP^_lYY+o#Evd4|G z6MHDJlv0WQ6)%*pCYBln%HR8f2AcA+^QF-T!FIFH?%7#Owg93n9&9e{Q%d&fB|`BN z&IJXXHzuMu;DplX^cceCjb`v|oHJ4TlfD;gM#@U(wtfQBsr~ zi3`v*1IMF$QYfUTtffKwJ>?^jJ?^(s18$6DvrogR8OBc1*@Rpa&OaR#dSEkUemsl0 z$k_PvCUdJ5z*=kCw)Sa=2wtUdM^_g z2{SqAW?cpj4uV&@6V~g`(Y>O%Vm0eI__5wAU$%36V7ED?@iV-b-9@CO6-!)!(kH4LT{w{31N+FP+I=?zIbF5)REma-2 zBjFvb^+cc`*rnA^o}_iE4`IoMpGlK6=>^J--)3`*aCa2D%DYM`&|FbfHYjfZ2bqCR z4tU?FRt%R?3O^p?ER=o+XVqOn z=h0I|%u%75KI1<;e>0Fw`Z4?4l*61ih4aSKE>rn^T|bSd-84cyYIA4Pw1GEAnvL#_ z7u-5SjY&CA+~ZDWR}7npbPX+^Kj%K}xXv`n`lPz0UjIpO-{6nt-;#mLk#S@iD|TvH zw8l=3j17LcA5_5Phn%I#(GGmKUZOl7T&?N%$%?=SCKcRG|%7Usk;^tRlEh1@BCs1JI9wxR9i zsxLY@DLQ%BvGRG_UKqmRFq-`9wUZ8Dn_Eu4xQkJyzRA-J6mg{%7n0&^bdJc?>HLWoOpvCut?0jwK;5|5pEwzyv;>rzGS#Of)mu-c$+Gmq>Xy z5ohFbeyh6*q&ufAQ2J=wC^=k#Xj;{>thE-!D7*u@{K}oEio05HAujCiXef-7J18&_ zJMLPOYAX2i2c%N)m+iI|#t%cx;6jE`=$uvvoJ<3!zAf)(r*Z_)l`_Qr#E-)RQ-Hgx z^{eJ|!-_&XknHyx%@kjQrji`$=0e;UODl*Awnyz!#OM%uPfhRxxNvYfY$$j2&s8l@pL z;}$6XL+Qc=55CR?_tM(n73;J=zC)*ZDIyNij*8(p{-H_rdVuyFh@y!1&v_eqUwHu@ za65gVSERvP45U?)tks-eRQZ5i|fW&E?TAG9F3&pqga~F#8vjHSq4}76WATw!nsDNqt{TuBU zwlQ(++mrG@2+kkr$;UaBPk^Y5bnwwmCO}zBX#CZLSAJix(9|okilDYn>&|Rmxa^I% z-KUb|4IK7k3g!-xLlh0qc+N_QJ~S68j@S^34PNlVoC-vVuzIDgJ{3One=v@v<3-Sw zEQbI)t#OyCbwQSbw1Ih8&`ayYYp*XMJD*0m;9<*3`N-3^RwY88ol}HvIT$68TsrL$ zTUbLrtQ3DgYV?O_Z&V|fJpM8H;rR+(ixtn4eY@#^S0NJ)>PC3fzT~`LwK|^i@ak09 zpKGidDqLt)n##Sq(J&8IbIpmYUF0+iT9VH}Pn8Z}F!%i^ZLT^ZE1xn0{+GYX^9#z7 zrb03DXtV^kLTO^YCW1pSl(NHA`{KL9p(zx>Ak{$MlU$N%W-z8CJtfAKLnz=QrD zMEuRafBgCWKicB|e&Ks9fAb_kl=8oJ^Phc70dM~v#D7-KtNqh3nmGTc7eI9Krc@Dl zdx8k%zwFxoGupzvF}#YLW>&^+nrV+elz_Y(Krp0=-*NMA!}~&o@A2}qHvet(NqmzW zl0!>mMU>Ych_AJZtksQvh?6us?Vlg`DOz`Sw(xj7lXRLUeG#zW&px!0B>U|-F zfP%-zKNo-L1JE9@)~g4kfiX8-6&Ov|G?Iloe6>!w!sU0tlly&vt*)7rlYHL7Q!ScR z2?yM&sXoH_;39b1S{SI5!o)IL%{oYqg|^*}2>r*{N*|gSU6m$f zM`1iD2=eDs6SU2nxEl;g3V&y@f<5D>59F@Wt;A}x1=+(|r@%U=etE}?qSMk;>&PIe!jbDKaJcFgv;5oVdL*%%)268nTZ=1y%nVv*=2+^H>1+9fn& z5+r!Vy@=7%Ej_By-43X*uO^ltUx@QLMD-pv#Dg+Fu>bo??m*XEs)TYsHQ(Q68a*o0 zNUqr;W+VB)dCfqJsc+cKUB7#TS46vHV)F^LBDK`AABEL=5ih5&@_{am`hb3^Q{YWW z3XO_)t6)m`Z%|j|BM)~wj&v6V?0!+g}d5?+aW9ep8d! zbxxyZFhb;)(ShjR@Ho+vw7z>E6?b7RQX}CeYvEBrdn8`+XVi5e2yw==P9D zEct+!h@*$_?cYlf`52JnBTv1JyYqBUWAU|hw-rHiYyZw0hw7r2<;DQ)ZtI`)C=7yO zkkGwFi`Lk74DFK_2L>nY^!*tf@3mx*K(=;hlK}a+VoQO=uj^{_vsH|K)iu%D6i20n zpv}yhf@l4R95s!(ZPEtjS1A*-(jd70Kbp0lC}wrwrSt&^Lb7WUh&7alES4}pMH_7F z_Y8-ouV~*IRv?jLbfe;k4%1FXY9>Td`@mXvR=ms`gZ?@&o?Zt0xxGO}%z@~y!)e&E zuV8CfrQ+ejwsx~H7q@#fo@(hCz6i6*Ab-EDR2dzsEghc|gtx zZ)^H~(~XuQjrU_AFZ-z*u)9;m+aK-lp#@hi>twk$^s=<4Tn6we#>Hi=|`m z1)sH(1%A7)CbQH7wTr3wlh)$l#y>hbIvs}jqj>c!td)ZSukh5W0?u z{7DujQ$D!7x;fOtP3N|lV@n)VDWU8`<39CKa`ed05vOO>4yp(aplh*{wYi^jN=tLf zjoD26K*1+2PvG+(8((xRxEBR2I-=3sr*FDWg{DvUOob3$Gr3{yZ&GhRd)$R-!8_GG zB)C!LdUjBV8$mhwtQ^hW^P%exKfohwE=j)%y%9Ry5@Cp;UyN+OSRW|caY#P=@_Zyu zS%PFSfA$$(h1!O3Mgnzv^3tnIo|I8SV1Sc-KT3cTbgn>BGjwh z{Ff7Ow|3m3`_-At|L>uy3|S+GsH`gJ&>L66?Z(qp(R!7N))Ku4cxR*UKqe`xIH zwM>#rd|lH>{?-LkT!&R>{Tdq&o0RFZ;$@K43C1NK<}wa;|G039`@+>{K+0o7v1J0`zOQkK8MX=&&k?j~JrR1-28n>k`I&+GH{V>RGblp*m}&2B0%`1*ag z_5`ZyA38x2(6zMJ^@i!Q)1G?FfUm0!CJ4wwOn9hlO;Lkc*}s@+mBv!@4^9zRzdJ%= ziyQQdLXP#0+9<@6$M#>-Kwpa_GvW5I;PGH%E#HR+&WFd-SEc9T{d`+~sUNmQ z`ig=9)XvAG)8ZazIxEU`1MMVZmwNfuA|w9J#KM%5fs2B;&WWdsi3Q8hmYaG4+0M^Q zD)Tx@J0C4&P)j^`!od#FEFbqU1<^uq2ZpQXrcFgQ;ar=u(|U_>;&F&t9`BQopF3B- ztIIPGx9H`sK4KdaYih~fNIE2LDT+4DFYc&fMr|Z5Yd*q$93MGJKI?13HcSu2XAmQ3 zJ>D2s=v(ubtL_X5P`f7Tn%3+4Opo1H3M5uu*W@~5M3!}tRTkA#j~q1VY5}Vm4MN|s?ds-ln5pRw{LWjo~R=;H9c>)t(46D&? z)3?b#Yd4Gg^O^BYzPg0ql7V1t{V8c$GnkBa&|5)vIW^F-F}52>v}zFheJ=m_s>@Kt zr$crHWTYY*?t|^)@MRdNz z`KvPZc`hSQqf9Vj3wNMiE>XMSASs`&rsX@xL69|$*TD0IOdmQaysE`0WsFtw*o^55 zPhJfKzWpFHS?)?!A#%H(YG&*bQxe-}crms^(A_oKGdh=X97UJF?d>*S|Dt&Z_yZbFK zFWa>&vC}}1zr1Cwa9~lP8VI5c2%QxdAwM$<x5hHUO`fs!a@7p zbgh#z{e_Dd*Y<<#{2+uunNgwrn!WvOL*Xvv`j?h9rF*`3;BR?t3R=FuC=Q8vR=Jgw(0tEtG6_a@YvVkg#Oa-OQRhH`Z#E=!AR)y zN69g&Il9@y=nLp&wb22xQsSq6R?JGFVw2U;j%yZ~COVDU#he{)x3AbmZ7x?KMo{$l z3XE&i`1G8)ctc<{bJ~(}s-n*NfQ$U77m}?jl}D_e_7=}Hrd#%2b|Hr>Xi3rK%f%A1lY!SP?!)D` zV%w1MA;#`<#N{K5{nbVDIm!sp4NiWj4!av-^75JZZaNYD%Ctvt5_zFO`zLATy&A4`K-T`VA;;?L;uz?Oe}&Obc~Yf#{nywF zAEk1=Lmzz!v~;G*vsqD!fkk^e!L;Wq_uxT4B<`KcuJ60x&)?N2=D@P?iO=w#$N1SI z?Z2{yK!t5R!GL5hSv9{wlysK!$k^8NQ}a&a2XoivNY79debL-O63oUmIF<3@9~W$U zI8T$g$nIpG@K|KH6rl2nLnS#BVkSTE9od6X?G3{|`Ih)og*+ z&CZ0UE%#Df?X&gn2l7V7c%)ED)xS;GsQS6TFEy7rC= zd-;d_xCWwXP2RXy$+4ppQe$&vimdQidzP6qbC|Bu3+|@1b3CJLPWLTAWE8Yc@pxUs zSJdXm*Fpvm#~E67Z#YGEfC_{Xt(C^PXNnBje%4E>)xE|`c9{fM=p4LmP+@Bjh}{*O z$`LkM_U1T}*38zl{o_21U?<73Q;B%LE>=9c%D6VqJYMMxF69opRwm9wwC36mr~4rM zf6b@f0Lu~8A}zt}XKt|X$O>=i0`sNj?C+$3`UWLm&30;|I560Q$%;-nZZfEpjkhzI z0I{LF5P_Ha6Hd|#CT%RoZJVZ1Bpu%`qR!~r+R!Tx?vDk@^~GV>fsY=uvX}3|5w#%U zlMJl;e72;h(qam+^ySz*Bd>)r-h;b?%xqQ6tFx>#BIJon(#4(FF76TM&SufsE-y?t zFB-=nzpY5S%Qz)yX65`aE_Ybw|H1`OwD|e8YE?fkEDx<_xO6<|n#G4G6ZY`8t2aJf z(8v>1*)TbvobvX=Pm=;|38zm#0Yxubz1!}Np_f)QFtg0?=V?+Bg1xf-G29>6gtefz zeAcVN2Q{Q6%T!^l*fkF8NA%uyVBBqy!nj_|NirNvta$_^lcUwL=VySAyOmKs=`ZvdE zAe+tUve(QG>>gB_C+2v#yImikb@c+Y3YW6f-;6lYQH0)3Z<@F981Q(~l@%Zd$p>#b zFGi?Zxf`Gb>^{xH@Y(@zDm~qd1$-yZ?wgvZ6J}bt zoaSFxzFjANAw089n=RRxtiv;dH&CyCjDBP4f1BjT0XB|%x}`C9crG0}wwt9$E3Z8+ zL8G9ltB^@F&udKGx?e&j&~b3Ju-=-jN<{Zdv=n@Tl1`AFtblk zpS+E`-(%BgV0%K1JiYk-xyl5jknZEEwxH}@1rF6aZr=$5xQt$RXmAXU3Y|1uxPE$B zf5)mDEbJ1}Z6P|YWu_=wgU00S@K`WX=hlt0=Tq?mn%6X-iBgYW;G0MRDxI~dWgGrj zOsr%ao$??wF+&d^%dZV1CoFX#hRFno+)x6q{x>fAGHO@(OfFS&;H|R{Q zsjv5P-$DDTCC(bY$>Sh4lZ@H=-b<)0fX)Ny{-mwW+ncpd5)|a@)hND36yENe(J1$R z;U35gR}&h)upfzhDwOt7cSj&kuaA6k`evV~8sC=Z^ChRW?_yDbWx#BQ`y+)LA$^*a zl|(pTFtv72vmK4yufSfE3q>4dtC^WJ6|KIIHg^W~J5n$MltSvXX7ZIL9@kJc>)Cch zOC2b7F7=NpF60j4I;+Wm4V_Q7zsA&@+a%nq)%12Qer{E4L@pUFr>qBK!gB}_v(wnt zwBL+=`d`5Pc6Hyw;z^^{xZ@z@YvWmz&+8=_;dW{_n&F%wZ!-<6E7PPfBip!64O91Z zj-l~_Iv>A~{j9>r-9|_-?R>lt$tFyfY}o0u?x8z80!e3>{hxy<^|hWw0SLh49xl>S1aLN?pFu=Uarc;(O9eRlTL!VzRL7~>ya>Ac;rd8l`Jge17UFN1)2R@`QV7k6pa>F2xD z^MofYolRM_^G1N;e{f*8x*(#tswzhr=XvzoajxaIVmxSh8g}Z`?VAQIXCo79(JLF- zsh{Drx9W&pckSJ2j~_ijz#go-ZxZT1jl$@%wGQLjlG6AF6>Kui@T;vGcFRc-{yh7xnFD-^sT86VSK!zrWm)HMl%6G9VWpbx{ z$-|c;&{LO(LQ=L)hSf)i2zDP<@pWVM}}wcpL0au1H*Se zNOi5lJouf4Ojb6C!cSz)X%!|Ez4o`w{aE~S>3I{wFquXxGuiDVr=-ufM#_8Ah$UyN ze1$~&lMfXIq~k-9FV;_MB6J+gxaM#?%=TY&x!CypjFzDHR3|4q5}Clyw3>0x>C1GJ zn65RN%DmBDKo`&@R1IjAk%$pax!I6S+0cxYGN@X90HkjaWL~5htZb(0p9UohXHDNO z1VnSkx-U5pkIEInk`iMU;YCCbGUvmZxn^!&;^V{gCPAUQ*xQYMRRb$o|zu#|aIq8068 zWZ@oTkpq>-i7lp8;WknD%B-UDka9YsNcrAutAn3s11je>qmD{ibOsn%c3`Q2t@N>|LB)R+rCZX4-c^b=?&#kzUl~bT|H$&1yWk0Or zi5J|zYA3qp_H1(c4$3V8kd7e8BrwucC&hAYv8~hE64Z6qQVUeXu9TkajJ)k4Bc7pF zzKBB|orkyQeSNcM}9j;^7;&%Fl|`iF;J-wSKQ5XOEVqkb(y z6LoM;K1Wh4otN$$ygRw@_VfRGF-%BWo2vZSWJzBG75V+PcN^(k?_I`*;s=;V4zp}~ zY5RjZ<&u}ImJYJX1vKHO?&+1MQYD`v%1?uXT?y2-z34*kOS`noe$L6UtI6}@+uBg> z0TjHQ1#2U0!SHnw5P)^|hfOI`^cEFL9Larba$Pm}Q7l&I&GGq~!$GPR-^%)C2D)4IOojrnsuE4tK4eU-`2#&FW7ZdN;)WWECs6 z_{^7u+%pw^8PnfED*g3Cv^N_Zl1FHZOl!x87Ek>PJ?AvQMy4g&X<=YkB?|=i7BhiF zk}=N>jNB<*D?dZt6{j$zsFLvzY>`g?Ss_0Jzc z6HoQYxDEjXwnw*(1^(!nOhVOE?@{oynEW7Jv+JPwr^?8>23KE}m615Q`d7205toCP zpOG#JCF<$#mu#MAIsTn=@TUN4(tvN`=p_~nZXbl~6iHiU>F?Ad|CCC5?m@PwvNQBS zKpFg3&&^gN0e+(oUVp!SxYHh*z z=|GU!Q;nFlp1x&oXhB_5svKCD{A~?H26~nq9bX%aF9k+>tcJx~jPA^b$3p%<1U$>Q z6>7mc98*#}>cE(09a*soSmgl&~^QaMp#i&a|3hu8bG~cI%VFKx(!jsw_H`!-p^9C zcQng*qM3+LP)nz3>C{YSZnA~sSZLizS%t>k2n35&P4Q_hfupf7TH+tzbpKop-z2gW zMpHq?=xNtM|H#=-4|4EXNA9hz7E>9of;B9kL}l+cHl&RQugR~tI7r*qguY3?!33gX z%_=?x2A|Pmzy8~if)ZLz&+I3`w1>454TfL@byrUNmfhhQR3W5Mo?AZ|`_her&uGKp6n9us(Ft;?@XjJR^i9r8Sh+=}& zo<`y88ZlJg>D64IA?ig5VIJt=y|u)Y0hmuS|Ep}2q=mlz_SNM&*$t4)tRs><^)ScW zJ*V4xa_5iF7Qvc2eJM8=+)^G02d*dpd^<7!BGPokkRGmkRovHm=H>aw9aLu)Zn&S& z)0d4ji`X>VmY#Gyl?Pfrssobb0%2Q82SI$O|GuirSsT5;gXx#b@z8>AK+!E9^{r`X z#MgKquQbE{W7`o8FRjpwpxoVwpFT_hVM2!W4ENx9ftl7p6u19dZw>Un~=|}6~7?GjI8s+MZ4tQ%NQD3xjeT{Sy^=^W3YeAB(i(d_B`|4Ew6@U90 zfP8`A%SY1uU;wC9RYcoZqN(Uek0G~ZeoSb-v{>=yX-(F2&H7|y9@YChNTov*j4MGq z9u(982i_t%2T#@btP}rpLoCw6e<=@EJ#`7Hq>_S#=AXSP7@YmlXS>tdYz=!qlg(iI z9w>|}8k?A7{qSx5;jEh-D`|=wF6dU1VKCIvd+o)_f7=$w&3oeF78=Y4g>W?Y9pFYt z=;ytxnzmz*S$*_LreJ zz*IpQ}0 zj{A|#1pMv)k#_L^nePB@{jaUSe|GcVC=dTXFgE@g8`cvTCH?xK1WK^_TZ%fuaIzifPIYC(P~V9 zOYam5&OZ<5m)#(lY_Cuwl&hridBCaNEJP2dT1Rue3tfZOwjXGFpapJsNX#<|v`4t?9r)1ze%^YkReodCykJ6=c5mk00e8t~ z=coo1ZILMt>Y*P|?(h7LBjs|d4dSV(`ZY}jModaVOQ1z_>G&o|BQ1wtM=LT7?Bd-V zCkjJ?T=^RJj&*_n>AQp`Y9>1rd9Y`3Dsr60E;mz?DTQ0Y@)kV17C?&xHfW;WR0uGO zX^lbPQb`%Rxm7{nkr{QTeGY-=W6!Y?ru~&;5nFM<&go3G-*xIMw>IfpkFA=Mb8~u8 z9^K~6r|f80zofqXBVOhkTNb$|QRP0p0*M#)W_!d1x?T)$>ws}O%6$!K;m^ax`xXJ7 z9~!NQi*bt2vq%`=JW|kcRaQlTWFZ-Ht{xk5s`E1@xHXRC@@?eEeQN242@nz~5=SN- zI9w{tyqh&XefevWh*?*weVTgO&Bep>)a}x*Q#D}tbxNgyw(K>N@yCZ#%k9m;*_Tab z7h}LnNeq&znn6SL6yx#e2|tP!?slSfc#!zGV@Pb2Tl$CgqjJ zcNZDq$28s?&gb+Q9a#C5sM`EBAL1_bjoGLaKkvP$T4z_dY5i>##4C?zW^CN|7p@ax z8)9f=FiJx)uG^X2@m~C1j;-<|8A&tcr0)36+@9I#DyCB z$Wh`0ePEcXu-7?TZYdN~bk%I#ZDQCVZ&q05Qca$6TM6yyoGggze)|@2budt%Tb@OOvnly#%Pr2XtEo;=v z_zkt~R#Tu~7;r9CTE4z+Qn2Eb9uSYJob_G__PiTi-Mh4RCcb&Xw>(AHqx3=6nCmPu<;yagrOXU;{A{J9XZtKf)T5r&Yrko) z*%mZ6zjY0A(9e>tVOumfwOxdiEIP^hNZY4`oXcpWE7O$JTAh3el&-h2zDK4nq+jvHf;h9mXyZz)~Rm(;g`qQs)k>r|h=lKSujxW_mB4YXe zsGVyKO%)95fcR-MI`R8*L~*T^V3au{aF6b3%0&=j625{I1F9?(?~+ddm}h=Y|nPM`Sn+lQHHz;8*#EkI>X?K_p!m zdFx7@&W#^upLfoE%10B^U26#NSFw{^k2C{hlwM)+@sn%j(~GCYBYaE{HsB zusV(6nDEso4<5uHWFD+ofpZf>#A!xC^6cb*j99$RJEYgDwVp04f6voB5P`lXdWcu5 zvr}{);f6x>31OLnFFTY{ny=K5}O#8^2m` z8r_45zRpoT^w!zDEcN-WZ3Yy0Qa_ch`47 z{!qdx!=<;9ObHMoN^wE|Ona_=@d=Q}YEXZVgvHnUzV7J%GhbBlE7iS{$w zOErNl7S&5bSqSEgYFvW~$kuiBHDKC>;?{>*tfj1P7rM!E=|PZykT7u9RSt+7XAO(# zCpYG(ywNwx8vT@Q_um`G37BWmvZyRs0Qa;5JU(_qCL!m8QF=hXc%t#p29DpVB=OnxZb` z<1{z!=y}Y`bV<1~e9`!+v{ z*H3_X+Z)PY*1(oMqu1%IT;kf8LcjIKzVj9*nEB7rFD~0kZ1qBVmLF&^*+qEyMUF@V zddSGSH4vLHUnVu$`Y7S zyBkG-Ns@18V}!m}ftaY2DN1hmI3K8?d>!9gRUG*4o<`4LQkDiglj!#!DuG8+LM#;) z&%MhsrI!QE$?I0uY)T6fNk!G>&SISnntv(En_s_XGHXMTZp{r(2*6ck4tmC@LCd0W z;6V>@x!8^_fZp;B8j>L>#tDonY#Y@!AH?@jigIYDlZT{Ivhe$~7`urqnlxQA85p3Q zDLHxu;XgYJGnp&xDD}otOiok`M$BFM1oBI;!ZmWwSs@1ezsYyKwi)j2s7Bn~?-Vt1 z2yN?r)GyHJ*+0^5wz!y>_1zv9{3Z?%-fVdsGikmrAtC9uO(z2_knuZH>>%kii=-R& z`L0a$SzP!?`7p!Rhl2}mw~zvTP^*eRZ_WP7bRd#(pLJ>Uf~rY@I~sbD@$h1*pSa+? zYGaY(*zZP*qf|t8 zqd%@HOkG(%-dxonlL62>U-HL4ZX5@kwM^Jws_@}*Uu1htZ|~-$Y|qYATE(4no?%pM z2sN=W&^Oj~{qJWP5IQ1jt9ThB z8-%M$jN$WW{?2SDrkHWBU(xd-k@oQT1iunRhIHOKd3DY)|AwUXrV2{X*oF~4O1)b0 z1@r#+(BS3eFq_;tx+e0yJ&vI4ei-o=t7$FD>j5f^F;=@Fmb%mcc# z>1_#Tr0BNOfwPOTSIWxjs07j~`4eTl3YJW|$f-z36Was$>S}{qApSgK8koav_L|Gp zW1BSQVf0Q>C^1(}rPL=NXi0-0(=duz%8-h*)$1-~Bn|P^11LvhN}GY8L&8Qp)wWBn z4{Y%AYAUJCj;~e5<%41gfb3ltuE=vs69!y|{>mD_7qijp?rRqyCe9Zt-iZlw+4bmwsE!~|WAl+S~v?wUuDV@^IAfVFS-QC@1>GQnre$U?foPDly z_Vv4d|1sALYu0_Qd+zvtDqvI({NL7>#J=CGXt+Kltz;Eq!CPOw!<^3CZOIz;FiO5F zq>gEQC--uV0ph%bcf`q$y5e5obE87c8f|`?%|aP}r3ui=s&-$j&Gz(=u318ykyiLH zMJ#L*UfOZ+99(4M&Mj<R8dDKmcgh)rIV?~u zfjI-k!H*OZS0&zj*z0LT<%*Zqsg7?acO?PhOE3ugDgXZgs;X4ACEJ)p$Bw@;lm$sY@Dd>DtQ(lp7>7tdhZ z1rx}t`^~w0#@$FbZ zCWDcqe!c!3XG<#RP!;I#%53dS56x@`bs;YERsc4O3n#7yN}SywHHwYLk5NM!QpU5| zJ6^O}X-q}gbQO|B&eQQb&mGt-);Zitp;N!Rak787u$TG?@?0DD8A9GqD+x5C_9yU_ zDUWcDZFIihGg@aAD*FRg3Un<~`}ojwd4hvqd9tmqiiz$Dw)Tlj*8LpT!iCIegJ+&i z(JF4Fqw}sI*|$>ADoV559_n5zFKeWvpVZ$q3$7{%$$(0>cgbp4>sg^#@VRos5sB&9 zSqmdq6o*ij_%W>K7Q#nJ_n@ZP}nyzSnC9AQ<|O)&zzM!3mN*>ZK`W{gg`8~d3U^v)=(B{f{IaGi4TG&a=UlI|`o3}) zB@) z72^XQ^P`HRFRYhNq}?M+AijR|UHikdBR{qX@wTp&x8gjR>2&pK z#g3Li4U*DR6EoIulyBfHhmyip^QPKrx6KyK$VKFQ6h9$mD#e)3GkoJ_(`+E1IpW5X z*Hc9$fBuK3GMz^>;e}%fi6c3Z(j7%Tnjz7~uqItBVi`E_yxP_wdiHj|uJCz}0R#KA z+*twG7w8kV$3}PPbA~}bE(IOyF40I>z2*vMx4mx`kylwFpBw{{%t*AS@(HsCnx~ws zv^1YMaT-204TFWzoiyKX=JB0JJ~tCzzbJrbBm3+4#AgG)YXVVA*7UVO60NT@+~s?z z@_$fogxKFr);9*CDKDX<`vq-Y&tmOIM1uT!qQ1Ou%{0n!gG`WUhTKGnIq=h*c=S$h^vJ=H40wVgl5I}P)MM?em^>g4iT)`8`kTBO?;C{-I{JmHG{jZ+Ux_I8 zQ8O-m-!-jZ5Ifhn?N5u%QTxED&(pmh`F0RpU+8N;vk*I@diyOd26)hxEIJ6MSM-|V zmn_}D*F$Z8y1R}q(jTYj)pDHyEJHe?9HCK10}@dmk#LTKuYJC`-Z zTbzN4TsP(IpkEvcd9@i#Fr2fS!Z|lN79JI1f$)@V{Dk5~p(DIU+= z?D+y7FH-GgW^q(X*jB-FPhN-7P2CD8e@rC~9K76(K z_7|-Ws=gjjE-wqBwhuq6ds1fbYj@3fYOOrzzE69DNe(y{uzHM_Tqf0-qO(iM`#`A! zFy!)Ry>ji}TJ8snB)YCn0;uUt?59u6*@G;rlum|@mlhoggP|MRRajcIZ&IpOL#N{11T=X1rVzUnXyt0|Cz=Ka7 z-BvC+(m{W!U*6V=(2<~<%Xa$BE(SH6b}vj1T^vnss8dOewXn`+JwRa+cQruX#ztG& zjvx_{W=@FTFQ&mrJhoRn(`BjMrIOS?{yiT#O}{hO#{|(4yptu#{ujN8z9Vn-$4Zjx z&N$W1n0E8-qeQ+|Cy>mPASs>C1bK#nF$Wk){9vRgn8JxS`YETThVL`$q~`~4(Yn2= zEJ4DYsYHihAkvx$04S^>lN4VMZ7OElYviwtDjPqO82wD-HwhNZ*V#c!2fd-4^wAfv zbE76w`3NE6fFcVujZrF3R)k^F#CN{wR3ik)Gv2K>^Y>3t5|@pMd10+GF^P>{$NmfL_6n$oqnt+mr(fqu;4g3k}&! z4ZeU0jDOq27mpV>z|9MsL0ejh+g1Ey8LE6ZYW{eJ7rtiw00gf8D0|!@D$An~h8`um zuqWLa{#ES{K`Jk=sF=Rehg3|JcY^r*?=m{?T8jSsSaQrL3n87|^bE~Bc|(Hf zC*Tj-LzaVE=9e$^H=0jY{bXuJ!*Kr32gj1zXdnKC-~V=s{onbeR)UtRWH1m0`uzve zALHC~Q0htlmwoc@I|kqTcL{+1FE4imq0;X_hywuLg1S{S#NVhqc!z&|^S{G#{$E~B z{R4Lb|M(33qnaC-JAVmqprK(R{lVBCd;&C>if|(91J%8k{xtI;6jEe~{W3fg7E1fO zOEB$D8h{ebZ!JgVLqp|`c*uIfM$F5{i%x$&?Trl8D*0-cvx_G4fP zRp;&`zxHs)O!B}QhO)bD<*-g}d|K{-SUv1dqp!sP0<=Ljtf`{6*snF*5S0embEU$^ zm>`wR6T(t0gFzdf|LmY^8d$dINMMtMWD2Hw3fjVDwt8e@bekxy;T#UaPAt&(7`K1( zg6{9<=L4F-kXHxnPg01h=0bVL_cE)-9*8fh{beEztHp+gDhJ)j<7cMxLpKg_Q4fQk z{r#3ZHk_TDf#O^p^;@GCl#XWyGyQb*sE4wK*pV-wxYQt_D#v@#reSAytRFuvm&#}M z9iW43-Wdkkd*7xtv9x!aE|(hmYT^3n#Ns+kENiQ{E^PbvKYKyu7hIfXT9V+c8yWG} zGa!Qrrt5{c+I=3>rm&9(3NRPcx7JVdX&ApA2$@&}?>@)E>8be8qv2_(29c}oSLi== zKQh~nTc<`o=_m483?7wmQXTokQe(iG8!~!L_xC8B@BvA`Ed_yQsF>I5cM1<6LFfxN zFxN_Im1!ZLGKOqr+>%JIW3v$ay2~y)3-hk2uf2G48do2%>*%0);W^P`0+j7hs+rnU5IwugX;nwGpJ*hUZ>IC%Q!S+ zwAlzg@9S2MG?TjPLErSBLMR@23w6J>c1UerY=bz|l+HeZ|7zn8pi5q^U>|Y+PQTW; zMD>rs@0uj(FtVIL-2)}->(6@a$AiRLs`7Nc5>`86O_0y_ZVa@%`?TKsIFDrMjk0&$ zTr~T*0T9;SziS*Ixk-V@dQ^rhvii<)xG}UPC;G2gJs9Acmc=kZKn~;BZcnsDTfk<5 z@xQNV{k1Er|LWS*|XNWH(=T|EoX`RJ)i_}MK-;0 z)Tc`KuB!YLcC48`qd;ynZdz#{DRLGMKEo@J)w-WE^r@~@p4(H2YwN@TH-|dfE7VR7 ziI{OxXhscfFDDxf9?@IBa3l-<=ktG|%{Tx37uDyUzE~(KNM?yaqog81Rs|d3B&BOmWie`U|kMy0%NYIJ_l!ynecrj5> zt=guZMdCE`;>7gTEK0lmA%BG8&%hjsIv|W@pj+KPYyh-9ZdeZ%I{kpbjvtBm{x_BA z_jr^Hk-Wu!x+>M%|HCz`&4mtTNwMK{O4c%SpsIth;j{)i-$1^PuGu5&!xzIsE4QH- z!q4hNevOT!7EAuQ2t1uYWOT7X=~l1wSVSN-Fhw3=)|Z}_Yo+v;LeFPD?(J&*REzno zPDt;etWSoxfs0K8)n_b)4`j3IUr)pVx)6iI=AO-CXpxf00`dNx2pKC7fqxD8V_ru~t z8@~!Nh!sPdzhqDrGq;fTQVnkTTz+P6S{a(24G#^=jjxEIWxmwVYkV%&@u)-oDaSOi zE22_1i68m-HO~YNf(qGRwh3iPBPhyibFomZ;W{9Qt`f)ov@cfDi_K`&Y^hjVD$u>N z{qCkEjz4i_@yA=))_j+ho(|6{!<2JIF309BsxO*S&oIIvJ;VExU-2=_DBk{=wjn!% zL?fu*X!5+dtOD}7H&TJ-6W4)8HM_E-Z$u{~R{jZT)SFiP_id$axFi>{oRE_X)lc#y ztlTf|5i*dGOJas{hl1N42SrXQyx0Gh z%fJvDNNnpuYI7g)Jg-2DfG?SC#5%bk9(%Wp_Jmm-561`hPqrBV+1W(mT6OvE!L-F* zbCB^z?EZNH`gRO()#^9@qn#J|j&2mA3lv`T{2_KoyF*UYn4Z^Yhx~1+@#MdO5b>`I z9wqD__lEd;wpm!{?`E{n8lWVUIXb%WcKp5aLQyc|&c?+~(T7Ez{Y@TR`XrF=_sGNJ z&fv3o$tuU+s5yR*^~%&v>3H+9tn@4_jt1chq5u4>g7y&bfP0RVjxE%!1w3X)<87I*}AjKu9JFp zwrb}5DbYHNx}Tnjcxseou+;}UDrlJPj^z#PQS@ur1wNd~Mdf-ddQ?q)w8$^*wE z?{~5nCoOk`A05Cy^L_gaQrTw`)erWeyw6c znzpY?&pntjJ8nEWT;DVPGCTIIVhoa6iy#i1J>PHJzu5R?-OTgdrE*oAN84tu%lqFR zLIQZf{NI?PcXZ-)Yrdz^KqL#%m)`9^l7e%X*I26OB5(B7)k`K zT6wUD;hmJeXBBDG4d=?!>Uh|NGqCK7%#aG$08Rx3%ZmW+`f5ovRiN%(T^i9U4-_?{~AdeIYV!^pUOEC0H zdh+CnP4!C-QNbVjRl%e0Mm5P`m~l`os&-j)Vaz4$S(zTpKy(hGj*jMLFz^ws=po5< z`YUJyV;uC@tX}%=#BU4beug?bdW8ht;7$nv7PN)0Cqq^;d@$kdD4@Lf+42W~`}tA0 zhq;wy@%fk$8{z6F7fAGIl)KfKv9f!bg{waFZT5#)%6_#Eb#<=UXJbW=#TQ2cPFe_% zpc5Pr+)VmRdOhYxtXb#~YY&UwL@p_!QA#NcC<-b)3?vt<=Mi!!xtDlW;%>fZ^+{2C zsqX?Q1t|&y3S%*V(5fJ1u2+pH(DoMPF|d|`M{-xu0P`Y)g`bUn>jj(|MZOstR!VH=2X2Kt(!XX#VvRfgovhTA=WsZ``W)F&Wcumh4!$kMpzwO zaq!@LryHvOk%%A*WFKwnsNtU%zPWwT;9lSXYi_Xv70;6Ql~JS(Hk#5vv44c%#3HPF zz3Cp?vPof7pt`9|ARGl!+3dk%n#OVxTS1b{9*;QiAs$KtnitOsV#0%vG%`_*<^*$k zp4X0F2RM1p9eBOg1&+;Fs|(Ll_;dHScuf zNcHYKqEftL4DQ$}cN=kAD|B0pss3f{!*l%0CfIvXGMd89g8z$DlYFG&oJPxjL&7S3 z@RAN?vENz!G4GL7vC_0jq%}m&@GEzJDE3!6?KctQ>;x>l!`2_!W}Ej%3lJ2=2}G>g zhqp$gm>q1G(});eDOqj)udG5)v2OCkc2Bp^Z&Cwe+A(>r=L0fhF8^SK}#( z`$O~a3B?25IFO@uQqRk%+;ctELEPh&EF~vpIH)k58^()xD zLpS);tH0FR&GiO#0cmyBqAJ`=yX6boc=TE2`ImehplP|$f>gTd+9|rrT+HJ#VNPl+ zI*ICYh2_uNb6=OOZJkd<_;G#*&Qj2eMYR2M-J!cJuH9>u^er~yL5nR?+jnErmOX6hv z>La>Q6~~E|a309QnW?KXlZ~xdN8{IW$`Uv_&ab9>a1B$p$u5jOrcK=1!PeGiN+>q0 z{uq%aY|@s9&@6y>;H*TO2yTzcl>`&A*gr!v#tNhxy(c)sgup08@*FP4*oJ~#b3Bht zYH@)42U6MTdzqMX<4>Qf%;`urP}1oX=0_QVxUOfDs$&adiD=); zp;vm7CAZI~Zxu)5O}-Z~wvES6jMdVRe0oG)UE+SjuKlQ!{}=b&s+gf)6a!lSr&3{R zs5_q)T|g;6G_PGjTTfZU`&$nmuWqh1em?vs?j%V8YvsRZ3aCt8mxH=}7)p8p(W$@O zfrCV|J^I2YA;CLI>WKt4|8QXIJ5z)n9y6@(37i<`smEKk|0tO9G0r9PYuG+kANNnl7q}H>kt;U;0o+ z9;8rczv)lj!8 zc0TCF#Wb}t66?jo>|k6CWHd_Eu;S972aGlzCSXV5!Y%l66^~5gI52*M0c-bDBsQlT zW>r#X3wAbM*V$lku2Mlg219t7*Mt@t?-u7)d2Cx#z#HM9a2)He4IJ;}r&p~aAq@NFLo&jJsaSzFTZ< znX)Ct)!4kX0nO_#yI!jlTUFl*U$}7cKrEx>wSq{MON4w-KpI#qF7&CVXKzxDMPn~q zlR=J?m7C6?fEBB&iVosTt`!dzudO0X%u#~%I+jxDRHm3>f0Xn+MF-EoHpBx-MRO<} zX@Y@9zmjP`;>tLjrA3LQs(*_&=BDc!GHTN}gpxxzef!zFo`;*R#ZWk6zpn8;ly0_1 zu8wAOANAC)V*VYOydtf z9*^Q!N@2lAOPCjkdYNbKucclEtLT%Gc4%UtKqs6S0w#t)pAal~2gICkV_G7^wzo@V zlrD$Dt3)rw1^2nRMr#s%mh;ES$`8X=*ezgY4;)cR_-VsWzyJ>$e(a>*@E!3W5(L9D z*g50s8(l*?pS*JGHFLE8mV$IdS>r7Od5EzYdR@;<1v}@3=JEUZg9z06t*+*usk*i@ zPR&4C6I#1qVE6g{NL#pi)sa;mVfY=uaPCwt@(kjvBI2AV-5G*)Z&dm$@)vZYPQskJ zsrvhKl-IYCf|SYXg_Ip^EHIK58Qkj)k#eu8N3(3txF6UjT0JzPLv0^nIb&vmdCCZK zePHcl?*)MnWwQ3_x7Yg{&LmHA?=4@M+LNh%22IIR+ye!A6`(ItElD*AeDl$!46oSV zqkYxDS=CP z1E517_xmxjNL+gW4fHU6Ylzr-GOw*T%Z3In7V_PHb8Q| z09+Zauu&Q!l|=n+0wE5ZFF*S@hM*VOO+P#FQ%U2mCHHATfW(*#Q3?M|v5stVD4-rw z`)|;(TLUgqk|!Z_=L)K&s+tOmHdB3i_wy2GH66q zbu~9FjF`DqL+?k-5zF|}&2f$t3Cvid1X{k8v1G|&iEQ{mqFr;$hOu+(Wd*O_8k=P$ z>@-S2=_10MJxrrY-W4KbuHHS%c4e_iJAGG@zq;(ccvh|*li^iJ)u;G3)LJx zs@Vu+CLJ;-AWev2m}z;HjGoT*I?zqdV`_Mg%@gipPyPA+#F-p6Hfivi1DkuENc9=X zqP}A9`mw<0Gu3Om3#-x@xnYV+n)mUMAb8ooFM@o&o>>%vHJVY6fn!aK}G*_F#sp_RP=cSHoo5eW-3rH z=%cy$pkVdaRR`qyP%JNJC>o4){WtK}H+AAS_=_bH@)8o=-^qtFzUR~9WoWt;?iw=< z;+;QRUVh4HCVv4YGtU_Kw@X z0G`#&rP~F1n!T%XjGs%lNGqO)B=Wu*B`G}j;KLKi-Sc|E(S42 z)VAJPa4>T{giRZ+d)o7^@caept4A>zb)2u2LCl8XdwzZa-LLe@hwPB(N_@G8J_I_n z%-hAzRrR)66{onlqBRw0Gzt&>s3N__E-9>dX!pk%4m$2hix=l+$y3JL#wLDosm%H1gVFK!56LJ$@LPj|9{8Og?2U^}r+uc2g8v%Q*h&pn zjg8VjJRfJ$S}S=&Q(ut0jy4uZoH&_sol{O9#2^2-+49qS``=%!O#-3cnP#1$jHG3 z>?BEBxY_W_c&81L2_A$8g@`W*eTkE<5J7mTP!N4Q3W}u*3KFsnHBNSYL6rBVnfdi- zDYRB0(Lmk(v3(sTp1KvEKACpaTf@=ke)e$@*nvQ*nYyQPNDi0OvYog>@JfUVV|m@M zi?PT7i-(C_Z>Mp<~s^+3m z)ojJGUp18)P(gc`xX@X=r%W&mZPI0V(JCt8ZvY5_JsbGeV>>0_GHb16;nb6~t{nlP z*J1}T2jK}Bd0wKP`AW8}qk5X1M$=jvpG}@?$akVE-ggo$Xhs&-LiS9s?3A-lg$aDt zI%^l6^sq6c>vb|1n3pI_Xhd>{4A`QLh}t4K9Cc#^02(plqcNH;n!vgy+PAA+m*$WD z`_tRBSE^$(Bey%^PDeK)Y`;BaPe>o3EBxezHzGX*5MhXTa0Z-Sjt5#L8a>-`K(-^E zp=okA19nrYAP%?=K&}WjZK-`8-(%e)z{K(3j^v4n0lWo{BC*|RDd_3Z%?Q zxt@;y=if)&kt>`+HUq~gLiSd}T!y%F7muU4q zPeZegQsT^mne>%O+Vup1$N>tEBdz*ZOk;(EU14iEd3e4nNS(pGO*`3skty>hH|pdN zwG8Q|`iavseu6myR|2o9v;&uSAkN(`Wu?brk$z49U~7Xpyl}JGVgWn>@O+U1y?Ru~%t{yoY>)!x_|w0$e19)K7{sB*{4x}WQFt6kQ z=LP}Rjt?)R!c<&`(y23A?-Cc1WYTY9zU`D%irtqn8G;+fBx z|6t{0uv}$8Ousb+C0#AQn;%zX*|IYxBlLAWXisG{n3taAc=uGau~IS$-+O(mAbLb{ z5yeQ~zDbY^x2!YSTWud5ZSj=h%h%Ok?~+@!{)U5CKLCBtl~Bo~wYt+00HJPkJQ@NHrnW{84$VvbqjZ~Y~ zbZs6)*846awcd*awNeyFPa&>w)%XWSt+n#9OYmpjq)p6f0`yAUJ++-5xvRUfOgoe@ zf0(WqNNsmgSD{ETJ8WiEfYL(z$9=$G6^Qsy&osLS)kvr!Xo!0swT4FyQ<>^PPl?%P zbbPRc7pf2?(o_3#+m*>Cyk48K{j$1Eun7O2ZpcZ@%eiuYUD$qat!2m=N`SW(*V>)JEHr z5L`|>C8&tj+a@yWj4*oUkd0~h%jW(8R&4smKW#K3fQQ@?e|gr^R%P<;3pB4sv63?z z_1l%6iQ!Uq)+Y*|YFWU=(~gdi7PN>p7}}U&=k!!8-lNeW-0gtfY8GtjrCy4JIHo*b z>Upt|MxHXySgBlsxBhmIvLe86mFx2rCzEmiP94eLxmNm$WAO=KS&+K3#-o;9>9d`n^OC; zseR^8`2?wWH&1S0W3RS5U_!oa(FYBB_sv*igQ^b<|AuEp2(|;>PN|TKdq_M1Z$)eT zGNxFpkipeuEWxuN^R@QIg%gr;JEO@4pJ99W2baOU1Z5>WJ|jFext*S+mBYN2$@~um zA3~e|`Li47B^-oUU99|tI7)+C5t7;auDHX_zW~b5(7bw0#sbUlUNA;9Rxm699Qokt z^hYKBx2SsB9NQ59Z2Mk`zf}7R`};k^{3YxDKjP^8W)%Io;J;9d{*4Iqx1Gp;C1D36 zn8C084h!i|OgN^c+ z0BS~1C5FpW^-lEEdu_5oez40Q3_U~`qDOOEYWwNN(4&Us8;MX+igM-52r*LS*rC)P zmwd7yMEeZP>6N56Xv(Ad_g>0~FYILEa)obNKZ&FLdFCPH5Nm9!kqDRX^7-ES=9MSKc=7kM`rDi2A{u(Cx8PrOgA82kF}!U6{qX5&)30Q@}C|} z?nk3S+eVYqXBhY9rq==_+Y2TMR@E&{vU_DhzBFVSai7M2)-;#bdS8=sSeBhu9IEPo zGBEFJf5+>(<%HGg^TS-p-*;#vf;gaRdw%8WMgzZLlsHT-i#PZ(V{AYjmZo@mF<-Xt zevcdBlI`nqS2uLT9pBWUnID#mgr>@HdbU**+#A+_iu;#G3>h?@O*_}edixAX;^-u9 z-XZ&*Ilt2BB?xhKKHFvyZFJGyG|y5ClBR=#6NH{!il)wGl3{F^@O>r*uD`7CxpgD<^G1s^C` zW`q)5`IWlKLY_CzWF1TQ=&`FXvQQ&}+fQ)zhJ$>?7rb4mD17=}X}NsqrV288i**@A z5yf1=j_;uxZ!^?d>^jtSfc|c_#J5)wukc^~d2&G@DcHitO)ZOLAa@+v$r0LmsaZfe zwjU@xPvHDQcjxPYzNza)65B2{W_RV+QLLn@OFspLw`g0S&Ofx&^*K;oXs-@LJXNuXvlA3nFT*OW_LWm_`D zGh58aWnbm`g1@ka+Q(3O$F(*$AvPC`;cfcykFP8SY%B1J8%;K^d5_DJ7bFW3&n4XM zQsbg{DD&*+y<;ceiudC|ZYp~gcmKgpo4|yBEVa86uxc~+<$oK~nSghU}86>|($+<%*n1ZCG?C=Kh` z_zcU|dQNddJ$Nlez&w=oq*-ooV)1flr>;XF*ac%(paebH+*uC7~L;uFH_N zys9tMpZ})H`=^hYor}oCaoq(d1u!mA9BpcLbh6b5QcO{Duj)5>gu21}=J&&Rf_ZDT zO#qTO;M};Fzi)3=4fUJeUAz`8zN>fSsiZUkdTF&=LCoBp5d-{^0hO0Y{?4vaG2-b+ zVWYO#$a^RGzSr>I?HrirG&fJ%al0Kxb7$voNa!a5mNWo!61=6i9v&%qj2kxl+r;)YlmKCuCo(s+IjAB8_P2 z=Ci&`7B0H=e1~=KKe!lI&0=_Ai`cjzsjHW?_2b2lkL7>(5UP3+2L477Uh2Bu_MsyD z=)2nC7IK2c+wp?bP4=aO@jXAt$(tU_f@wp8f#vO^QYsjj8Pks0J9(;&^Wbxlr{_D! z>nIo3_igp7R&vpWjWor`v4So57*5 z%)R;A>rCGZ$-5tnbqBOQwQa&)_io_DdnakL=%#Y6qOgox*Q!%+$|Dudn6+x@m%c}_ z&7%sO%sw8vYuFoB?{l3C;&fO^S(v|FR<>Y@>o5e9M0ilFC<hblEr58j_hwblypao^a8&VKP#sc(v# z-c?CN|H<8Q9oub0t$qNy(Va8f1z2=b28kNrE;o@P@%!o!z)_D0kBu8E(sokoklO2i z36PT`mGLQg$M{BSpr30qO8g%?R>bFD%kCXEGZQf~syC#bHlB-Sd!ut(Vnu7XTgesn zJ3ClRv}~3=0WBQ=7oHW19T<=RaS+MPCR86b<+XGDrm)G3eSY3Mj|tyMb3HPt*+oN5 zcRBcm%un*tVJk+vX4gpwBpjTUs7CGoI@KaAonN=Mdo))Kx>U?3uhz4JEEkM|d1jtH zq=7hn;f!nOgw+)4LcrBdE!|Q7HhHGUx%<7C4byM-$j#w79zg+1^QnnVFq`b%!ZO@Biu*Zoc?6UH`zKeC8vqVYxtO)P0xnxB4C8 z)0E3CeUI$oXS(yf0*<=wK?O0%6n2}x!aS3x(i)3>=iNBERWUlMBxMp*{7aIh4e#>s zEWP9+nP8h?BWo`PDIF#JM*Yj38+I&sRqc{9?&zq)&T>4O8ix0eJkPuM*Y8R9U$`MX z22VKaU3xy*Pbr}KFFJ~}~qEXNTVEiTHwwe3|Ru`*grlo?Y-J%fN+{V4vZ zpii92aKWoUq1&%bvjTc)Mdy>|rupNqUhN{5F$tNy_vqqRjLrWSf{EJRK$F1ldV?Kj_Syy=Owt&_ZsSDX#=AlB$LY%7!?{F^%0iHiShJ`+( zovpu(Yu4PJO>*VjExlS2uy8jC&nZ5*qt~>WiN~#?T$NjO0He@tG$&LDm|@gzlgRzQ zl(C|YjyRekis)Hbga`K0>-Xm>WwKMi8O!F26Wsf*;njz4QJ{mihlX9=JHJ*oSXzta z4~W^Lw3DWkq@Xm`T-jZ(n~4eIqtzD0CwB#NZWX(qt}ebwL#)HYR>5NbcJutX<)=+; z^TN;eccr^SQF;w0nNRLkR}r?#OD&T*RrYLQDU_+Pc^7!EA%_1!ZvMVg^lp+l{6bZk3bETOjNCZ8lh>Rjgl|45fi3 z1Fuh_xYcUy8wSEUH^qaxi)-e`qRuC!@sQE$?U>q~F z7YacutAZ_tnA5H;F;yK^Xe;&hG6~{AWtkF+A*i4TO)2NSlsoKT-(Ii=DHdO$q^fVc z@`AdFAkVRx>{za^lFlU#M;1n@OPV`Eyi){d&!An|g02e%u` zbEXLw?suH<;Id}7lzp97eXz7Qe~cx%Ddu_NIl`n;_kGL^qhcg9&yG>?Dh7-ZH}xv9 zW9Aw@6N&?ZNTV_A3ybr?!F`Gq1%D`qQ=5#^Wku=(kH6W^_1mCpT6geb|x@h{MLyk%`r7IgQ+E5jqF_CW74_bz*%Z~wka zJ%PNYYp9ARe`W4%Hk+C6WlTY^(V{Z>kQ1nmO=;F39vBU_4cv9`9}9_1!W1D?AaFzZ zR-y{=E|g2Igo4foeLCxUD zst{-QZOb&<4{~$Pgt1MHls_QK1^h|7%ux4_5ayUMU}vuW@(-;vfIufG83OV`t6OsZ zL6L#C{#V8kU@|}s(tu~%ii7)%2~diLx^P)nqHhd!f0YZGnmtXFy=CCAh{NpI%QnII zZ}Enxhk=^b-+J~r@AY+>1hHjo5vE`q=bD3q0aTNH@0)lGQ1^+eNfDKkIyQ{}xm~OChUv$R|WL-`fdsVZ4lBkD<1<6X2wHG|yKh z%7dsR&wVPFl~NiCU<~~$|ilJ0%RfE&d3DG~)P6O9}oRTs{BOdjG z8-WHd?gwRWl~+%D691seMKxA(x3u}W}N01$eej@6w_)<^PXEDPMHNN z@T+Vu3JY4?YE$n!68Nv;Q=U0Gy-gFy@Mm6#?}r+c{p!RSlJ&b8^Bt*Gr=@EuX|YqR zi;8%y=uL8+k&jp4g$?0+{z^WXJ_`$;I!LvZS=&;oP_zjq{N~6%{#-x18B;Dh72)05 z5{M7CfMi?`zLE6^4Sh1V(Nh=(h!&xxKQD3e$1@4d=k@=?o@p4?_`hP$)Et5A8UERd zahF)6XJE^<;pp7^%>qyOKPHdu((liggm?~I3N8tu^Ey(z)!i)Lp~A#zxxN`qy59f; zT5K=`eZNzAPrM@20fhZV6^Nhuin^iGCIm_>*-&f*c{R46`Npj*hlJm!su!YnYtxWz zL|1CvGOH(&pO)LWtVM3=07Mu`9HO&7P!`CRG03Z{<{Ox{=IB7D{QM;~n{sl~&Htr& z11P3_AP$7hJpS?T4H>%r$*LjIjTfGMm^g6$#W12v=@X4l!k2I+C;vsyU>H+-s+R;3fe7z<{kpD-?nj23D?gfMd(Fx((*Zr(ftJfFb zUQwo?EaWVnv2ndf_xYY`PTMi5V*e=YeTV2_V&W^6PqG78i#HW69_V5z{lCT`Ik?s8XJPli!a5 zI#S5HZ6E#{;b!{@8Ejx5O6Sw_Qjn~>Hv->`yPj5I1GLatrqDN-{5o37N2asauWWff zbf!(Z!4_0b!8~KYUC8ci#}##wn@t}>AwS-G)Ig`9VD;okOq!dhv)YF)b3gnu3k?Hcnx>$Qu9xtFYxZi$E~y)ozHny z%{iD1oxEjPbuvjDNt)8{@>KqUJj874SGGdZg#D}5!GRg}#UF?{`9z$faq^ivS6tDy zk&qj!5_My8xFOF)Y~?Af+ROkx9M$VieV;!k4*G@0qu7%eWXhQcAg^2+bw$Jq$j0Pn z5jC9+*^MTFM*;_e%!wQJPKG5W?X(H8mjnhTu~MjhYW zzP9m~0n}BWVs2_q;JjS##blyaALsla*$`nBjIzxs36P(G3@1z)SZd?Bdd}Wk&AQzj z!1IP^rTBxLGzJ~Gx?%DyoR@W7VX|y*-reMXVPs#;O$=O$WIU~i^4!Na&yd}Q#)#bv zzH=w7CB_zsQ2(kq&4pteXFVf$WnHCmkmfUpI52F4kHJl=o3GeC?#06G^WzJYmrp&F zk6G}9XL#S_>Zpk4B=P(54?W_>{o&RsSleUnJnBHwr= z3Px{)9sXoe4aS{YIR-r{1+99qW3RDu$UzIODU3b4IFv@cklUxTps3WfApVkv^MS14 z7wNbXTAh<;Y&7WZ+YeRy3J}abz=OI5^axSN+P?zTxh_lPb26IS?E^8E#`L}U1G`1< zLvh-4pVXPBu#~$xvELNEN0gqnsvRFKH1ko_*-HIvZFOPB+piuIB&1kaaH2G#D2BF> zCU~zj+Uc|6wYN3)@^YG32BtVeP=_CaTl+OCBhN^~X}S+JrJTMCHEC+9Jadkj6IHI! z$MufEtu^F%5?hxO5(9F@PrTv(#8si-Lf%2pvXa?Kvro^9km34&wD;C=RYm*S?_5ie z5&=m?xCT6x{+p?C<-Vc-Q7qxs7QA=NO$KV)*Yapz0cWuzjvQ=_kKU0 z_ul*0f)!)N8gtCC#_xH)&u3?F7X_=z_PWPN5}q%+XL&q@#o7mo_)pkp9<^wd zC-6B}ZsbAHJNB%CRV-fo+UJQP9*kknu^K#$8+9!2qd$IE81@b06D4buQfiGg}&d$!-HEagz+PsHU&@XEt z-9f~1LBaA!L_t1M;=(Lg@tzSh;yXIX0=e>_phx}r|7RGW{ljAFUl;~QJ+z3c6RK*m zc2*>+A@WgE+~nEl=yRRvFP!W{)h6>~twH^_P@zCx%_@DExY8urYZtIX?i{@cukLi` zFTowljWb9&dJ}ze<(D56u~)4CFP-@!`(q~4PwSAgGctUnZEv`>6_q*D;w?94yfwf3 zp4*C}Md`>ZER$!g)U!=+=^E;y`~GojtM}F-RXb1Fo;y=mZUKT?hWF9_ zBe>x~>&p+JU3RDI3i@A!9TS~FDmUlzU+LkP2J7gZ!;|-CZ#BJR zce3+qG~2D>YfUt<4qvv)kG`!9=|P;4j}$jT#>6f6XE?s}h$Ud2weg3+v0equvGaGIj=M6g;uP|T~e!*so-DV0NnF9UKulu`U8 zw;4VX!J5RYxKgW@LC2eJ2)q6St+REM){nF10xU|%%Va}Ny==+@h@SU3@BWe=JY9F^ zM8tDUUNWt(ihNO(%X%+$RAzPTRQ7=(WTme{P-|MaDP_U^rRs3br1=7Owd)P;?TZe{ zOOks07X4YWN9tYW2*H!Tbb_;!5cXJ?%eB%mm%%^ePHv&Q6kfqgudYC`IgAI};U8lu ze&P^{MSr!h7}MJC_3lE;%hZ4I%Z&|K9eR+{n|h)|t>)oi<3<0WehXRAEN4!qLPMBp z_%$hkwrAON2(8_y9Gwa#{-KzD&FCCBf)&l>B>);Z@BYL#!G;~+)V+$|#ONY6K zhNxGS4LY+=>$xHH0TZZ~gL}5KE~DYr%a^B&HNsyeONIRk*bz&V&K~QirH7NdEGe0# zC-*!5b6;IB^Soghuebadq-7i_Ds)x_XF1(K#Q1#II4at{Ye{jb{X!bto$uft3*}N_ zUh?z{zLlxaI5BDj=pKdpo}gCjq+`5i?cJak)>fcK{&S|p&nz3)Z+`g^B&@w_UK?aH zVr;!NYG-JJC%;*Wrud9o-_w(MeUS3!fZne@ey&hi^^8SRtonLOzizKJ1{JvcEys-g zZK-L8&EWe#XObEr_D5NAEDX!=Dd_d`K;RhkofC@|4yOx*F8xPInd9! zM4Iy4ikTWZXW7(UY#SP4I?_cI!ed(7t>?CXH@i-zA6b}>bpb&5d7mJD-O@J3x$pxmN8bM7#fuO8$3^FG{qhG6&Yu0RVDyG>*(nC3ZMyr+nuR+FX-9l()oE0Y+kARWuSFNXQ=ASR)l47L*|dvXmFVK_i~WF! z!&|Z%^)bXT|Jm1Xas;F?9}eXwa= z3X)&*zdj<+{J-IH88hg0^0Hm z3dk!ukmt})*F{LgYidsLJMo%6)5*~*;SN2)eBff5e>QM>(~`^}%Lkgs+%^B~&s#Ik z9=SW0+o~aqvaH%|j3VQUCB2aEC14smq@~Tm=W9dec*`jJ#wm=)LIKUz5C7=KnyYV+c*LXeAnJa(mpnNy-*x z*=`-lwE^dkXRjAWQ00&Xc|DL@(ocqEw7roSzqxKHm%Tflfm#}HZAe~69>}hX#*p{S zaqGT4v#fthH_v3I7SUp+dc;X-qz3#UVEWC7xOZFP_Uo2pm7R)_OtVEsy zAp}@L4ZTiM3~Bv0YK_0?_{N6D$>swEG5dOH!8Ttd&w{#b{ARtyKk@6Iblbe$Hlk^d zWMA7$uPnw)vEq(HqLYS!3VFF|x&cGaO0U?0Djt`S zJunq;_`KYV3A-ei=2bCTdKP?e@@fr`{mqyVgi8g}35}w;K5ys7rcdySiYE2e3AY+1 zG<4Fc5BdX&DC$||BS+8Zof&<7A)k3X75jKV?NL*g4k2t%))Q1AniVt1dH*gTMiPHH zuRSI~Bxgl6=TK`WgPi`CZ<@auXnaH!Y*xPvFt!$***?&36584$s#?G6I{sDAemj6u zWUIK^@gF(W?SDDde_qW{xA(0~rsG_)3iQ}2MtV-P-B7qgG@w=y%)+zK{BCD9Kr6nu zfqn=cR{hW_hESMRV@E@nS*D~V<_-65{ne!a<@~2M5d#&1CmgxYaw{E9QJXOi8XG_p z?3mNqr{PB7wfJ6$8FfIuTOSU7LTj$NA^32qc%28Miy;#RTykDZqd+=Phw-*I?auqe zh+X=irZY9Ruq4uFIcQhpDC@?q084r_RQ#hsmP^5PzUkKP!d!B>Ym4{SBevLP3Sza8F+(A1-Rcgv?Qps!yNCU}K@H0n`ZrAhxgac(%&+!O z@sTOpAX<38B`a@?=U&!2z!O@Nrs(ca@fo+Mb-yVG2J1G>@R40Yr!9Mxy&2bO_mMX= z2rdX-y4P4e8CE^=Bf!yY7Z}TvHI|iifa^+nCA#_aqnv! zgC^a*uiW;kStXUnvg>pf%`Gi>g@S2o6@1ysW~P8Hu03;|D;8xP_+*8i0pT9gM{Yi; z4+ygJxe;~<&F@T}HuEVznOYZ>k0?cXox?m1*9qU$WbBVwx+jWw-B5-ir=Q%xUv4O0 z->=d#bn72trH?{cRP4|Xa8xnxp>Xr7+SC-%r&t5lu&R4^Z%6(uKA(We@{vPrs1Fo# zdFj4N2^~VA`GpZK)+?<{+Rd%UJvWg5wfHC{D+!r$^a|*3l?erE?u_BG!fpr73&~EL z*CC~yw8V%~*Udw&47#*e_mf`85F3Fr_i-#PzvOHJMhmr`kgO} z$ywj4ID2|-(XlErYtkg!Dp>p z^Oq@By?)t5wPQ>A!p)brBviNG%j&cXR7+!6^`^(*Sya^&*|^B8b~71SoVewXh&hem zSv=G4m4}Qia<>J*nmUEmUTHa44$(>c8|tmj6jURIdox*vHFn-MO1cy zzpv_~p|2%gAEqiDLLy;y)mYzqyP7%XT4i}%Unq$R{1MIl*T*+y3R7v3#1aIlPM_Tw z#thboq#uo9#ch|;>X)K#NXB|~#zbcF$oa<5Ah)wLdh50@m*NAEUf^Yu>VKF{ zCCyO7ga?*V^#B%3{e$WAFy#aC_1AOZ00c7|iwvz^u0*kE7{3{1l^GaM3Up5lseklM zRQkt$>#`BWr^9da)=4{GmJm52j%-n);v<1P3A2o7AK&!+ptzT{tO`xA2DM6uB;OdL zr&#R&au;w3uCfGMMOls%Ld`fkF_l+S{`l;x_0jWGF?k4{nCS|lESf(lKQXb{4OPH)s%Ox}K@-z`WeD9a_N zz3&T=%(>mG`UVPciK*PM-~d_9Ow+V{Q}~v}WT<{x=&-3%l(g6E31W#?&kRka8d#?} zt;h=-+?ifLb7RH|Uugcrrf-6&cObD}I`Z=Y-$+%3X&>vp_RdM{AW7OO6Z%HKZL9;< zq?6uy7zWXF<+j%q77+U|p?%XPV>Jeg7#7@aIx_MlQq99=g~|7E1Eb$1tT@VJ(=9h2 zg>vUGk-IVBI|ny~m)184O(vK!<_9;M_B3lZsS$KB<5ty!0E)-gu4#ONx4v9fk4#nb z-(!D!h#7}$&gvU0+asLa_0f$+lVDs6G&RRn;U>oFvY+{S-yA^FDV1pIKI>t{`9d6* z`Fq4BIIx{8)IVoP-XtC(;Bp&R=#3lj8n&)Sc`<6p?Og_gE_5AG5G27w+ve!NMi z@tPvi+B~nvB~V7`#A_w=jiNpH(-Z!%y=5M^dB6^N**X`9o57+}id$gHVyE7Zi40j@#W# zQ>rq6QU)YCF1qGrp>KALYl&@ZI zBr?jmBF3agqfihe=ASy;z}W{AckTb<`6m|ZkUsiSFUqR=eMbU%-!QVwBHx?*c)4P0 z@nOfTxyHahQM~;2F96j~KW4?^xggv5VpN6XKA{-TP%pQib2THJA9F;GhD36tChuj= zPg|Jk;ak>g0QozMUBNSwT#oQw!i=MUslIts>Z$!hvBxuIuD7TmmN5g0$p{ZAGucAY zZ&&4yM}=rwGk(Rwn*5r7tIvB)Rv>AXv7+ln*! zLP9>~qaJd)WG}HkTj1`Z(}|~hbF>GM`_%T}-e?+}Kh@!%Ce>5=nfs*lg)y4yjgb-6 zUZXJwL1OJY(tAOD>ZYwpZuO36wpLeLq=KOI%bp{- zAqv%n&DXkJN}_K~-Ty((DkuCI<-Y0D*(%isV?i*qKXF`}pl?Ud`+mc~eK)(Z_heCi zf5j=<7zS=9_enNhv{i88)nBGdb#iY$Dq%~Ybo2@+?ST77VGgU8-w?#At6LZG-klRI zM_06t6c32y!lH7%^7ib4C{lUwCbo0zo0Q4pPcD&|NPSUdmoV@DL8|#cIKXqqmSAS_FDD8?M|8BL3^L`)4Fn$X`0I!5L+Rv@_z^d ztnh`bXlieB*zv8>sLy7k^B8m0T}M*Tkv0^Hb2C!`;LET#`>m3B-oIv+@5c4sOvU@0 z;z6z2+dv|==)JG-PUx(=oR>rEgiFl%fZ#Ol$oxHA3n}}+_ES@5ORV)pxrL*~+=khB zq)ydu#K6X^j_}Mqkd7_kNL>B5h=Gs0?6z~T%1IH03Reig{hv_Qf`pZtOMct#_q~Vv z(J@+M_RaSNsJsvE9=@lq2MBwRbl|CC0{W6LZtFWKy0$tF!*xfTxNAqL`YTsW@;#_T zdCigtfdq0?B!v;9P+@R;C5c}fJLj=d-Xmr`6>g~vJ1ha(DDPqpoA`S~L5&fTcnyI? z-_$Y~yQQdIxmOxmFQaveet$dV@8TVNA95eFmGs-?c~}0y8Y@oqPT!4Y3Uk`qMaeR; zlz$=C_d1qPq8uMz$=e1O_ICTqR$s3?ZX zTC28j+12L02oI{9IbQ^7IEJCdr-S#jjb6kUO`#Hhkge1Mjz!(I>D z0;yewJHFOmL&T!GysTk69*2edM^qu+wkg$*css@m?cJOF_u%~Pf^!V6T!U5nAku3z z>^6m!h6rDZR8{&|auzAEEA{Hs9)`?X7u)j>}2(>xdbb$R?-l;E$hrQw+%uZs)p9+^iycd%MY^Z}}l?^y>5->JAJQufiG43|We? zg`!Ze%<&aZBfkp#A8Ch}o1e23T5ovgJAVvOs}t-oul7L1{&ZUCfF4c=HBx#P1t>jcX{_JEcn#)jmvAQmt-Vj3gbttD$gAK zQ&T}X%LcQN?N6n(JW41lU!<u?6Edj-gWbogwq&NztPOthm&#H?CXC7c0kp&fV9uM1G({w{^@Aa?&Zc z5JuY-N#X2p6VGK4vTp7BdL%-jgTsGp@KQ_p;M3-24BC{Lzs{vcoHNebTviU6-@WH= z?2s^mUwcJ9*z$vkGxFJc1RyKq2_iS5(!jIAba&H~4rqKU?!=&V>V>cRG^TN^#0bGyR%n_ zBczxm{=!2rNg7%5?1OPj#8s(3et~W9ak&VR#$SLNvOEJt=LKV$6e-)OUq z$z;X#j8Ruxj8BVwLsTp3ej1+R`{eNn5!S#Uj)5~bf&3&K56s;1A<+=sh#w^*Q(&G zP}>WJ)<1>X=$y}cV>kv>sn3UyUV|C7>Yu`U_US_QGSTxm__vh@Z;C;QO8-&B{et*0 zw1-BHIa#YjB|_LL)_EOoxfXUw{U5*-h)3Y1_i?kH-mxF)T#hCgL9$8&93Vf*!wE?7 zqG_LCiS2CMR}R;oQ@Mu$WRt@7@b|LaVFu$;9}zd$Nt~<9G5-OSlEy;jw~Z zt@$~l!tLyTNcZ@UsOYZVWJj>xoWr5$;Fh9p|5+4bu1>~tkxPRCTXgY?$Pti(bh+}Y zi6~d@%73dvZRsm&5}ye9;ljyY&#fS{p6}FTxd|X(Pa-p#oa#@P7&UeeP5LZO(|g{S z`!H9=2Xy6xkq;F-PEQ>9ITABt?f&DF2pN;Lelf%z?~MLNfs;_09PaOlzqlOa|o?Q0Ms&@NXOC-%kfP z!)HJceKizAHc~$?`P^0)ctN2}$pCcQ?j$KP>R+z|Hq3Q$;~G3yUnUPn*ahGf-dOwu z$=^GRx<0cG0}t!DA65*~y+pxywRs+3h=JwOfS^`^(M*I!_Ll=Q4qt2{Pm0&@u)Dh4L*&Q7mH>) zm?R}G9LXqWzJ{Cw>yjTK<7TZh!YZ!&(a}k2F2u5cI9Cc?Ru`q@VqmV zRKt;qznvia>pt?T2<~gJv{B2Uc>mKK4p?wW z<%YQ{sIVW6>ilx5f~d=vkXtKS=lAlPc!fLV18N;>+v7LOn!8ip7_ENSS|2Yd_T)8x z-GAFPA-GIIMLC%`ga0SH^nSeuGQ79iHG}Yug1}hVYOW4lkpg*grQ7I|pa}VH;j{6m zN#S`JRAh!}?5EI_q-F<{Mcxf{U`PaRYQzaEf)Pw=qQh=4F6UU=Bypk!q(^{)*5;D` zUSx5Xe@E4R;Vlscgkd{RN~(@L_GCAC-?~8u{dVv`WDf6k^5|Gv#V55i1;L5svSO%N z+h2*IaVzd~UD`|BKV5pI47%LIeRtV$@=KfUtHBIDPS0=Cnzp$S7r53rcn%bnhP z+{^GXB|MbTLm6ED)vKrxo+P!3r|Vo!tM491&%Su*9dv7N7@HWQ&?}49zND9!G@X%cQNtmLjWmfsu&dcj4;BAvv3#v_%Ast;l*&e9rbgg7?_RS4i zU@Pj-W4h0p=ubGj4PAqI$Z5rTiXU!a74%$3i8g~(PPc|^%(dfgda?kSin9$5{ zUAt2IK56%)i9pO#2Yd3W-~#~XUOXPgQ0Y-6`$&)fYaCm72^Gsl?7V9~eroC-Tj=l& zU%68<;L+pvgP>1;=UZ1(5u6_*zBCZt0M&?*WJ_*XeN?x57^(Nb;ilzZiUKzT%HBmhM_}=`ukulYmU?9jWgolpw6UNJ1|* z!o)f|CF1U%p^g*2uzh=(E6!#m3BS$UX;MMJPV_{I!9fcz7cQt-mtIPg`+UdG!50fjrCy2 zLd)X=qxKcCiNP<`279?uu+!o2s{#TzYrDfZyGy0912MG2-eWyx#?7@3Wv^^y0rBB9jOoYoAOx<6m)BQ5DBcMGU zs9^Olb~p1&TMnJoS2`udl+@3soyVT9H7~a(4s2|Ibg}}{3!mZq*01-EP#xIrcRxOa z?L530Y}(S(2B%?oc1L5m#|oxOc)z8xd7`p72l`D-W zw|KClvb58gg39df7N>X{P^n^OhE90*_k}uv)=7FD$#r`?J zyc^0%P-<|O1V22W69G3%z)=lIbv1H6i0Tk8c(e88yp4~we}CdoR}A?}fUohas~gy2 z8IXg027Yj4#qGkfRU=PlQAO|kY*%I;HmJ_`Y(CIWm%gcP z(eivGVPM_`U)J~3c2HOEf8`}MUR)+ubpaa_`f>xoeRzA?CmetR7CUQ-y_9OWa4p4Q zFzV0fAMp)zcyv>9#i$Wn56#-!MTXXqsl*#b&d%Jid{A8$)IKx)fz$--k*CL7^B{HJ z$B2(PL-lmLV7v$OA&-(m;{o6;H(1H)Fu`&{4DzM_7Ka?MBFGHj2Y{d6$sSR#T)=pV zT61>?exZ(W`BmrYBrvPLO2|1ktM{;hcY2(^lkXqvM0gv~@GpGuMu|hy1k0J?DguMC zJ$N=HMXL6V1F3>wJttsPL)3N}b?@S%4lc3(Vp2a(Kj11oYr!40tN`1|#i`zOKwm~C zvjC3X<$A{azc#3U^qvmUIS3LN{TU0Q0@_8l=2n7~h^$mjn%?FaoN013rI_4FZ;TSz z{cH+bN25fpVVI$G_ZL}B1#QG5__yRY)=xLn3O~2lPfvwsP#-)DQOdLAQfF0%DUko; z{DT{F=!(}2E+#V;(~(|{M(T@V$9dNuI`^S$lYKwt>Nz1%POb&sBE+y2(=)Vg@31`D z#vVq@4nrw~%r8j9g*=Yy+Mc~(8?BK??qj*fR5=^vf{54koD;)AuG&X) z)uGM1+@>GS?|3@ow*2Xioq;IZkANM~uh+WMSTm0tFtnqeoQX%;jQ@^!WRq0av0oKK zU7uhZ4weIUFzdOlMmM04O3-#HjyCijK9%_TJ-NisMZVg*tcXK$5nUbxJWXp4Xa`-_ zYi8(6AzsrGIWiYmR42(aO)Z|r2dSDw#@*a*sL$j(?Rwa=;*@?jn?CQ|<;v;qXPzD@ z`&1o%hc)ZN2m-NXTpB&Cuxt1Z7~wE2PHez3g)`q*xvx^HbIm}K6h64s9l>MapOO0E z$l;0@R0O+%{-G;RLNfGpzy@eP^YYO1l9WAu_H+z7C>%jWVD~7VLwlsVUEE>`K#NfU zq8=yPSCC<+k3RpebR>tI?Us*$Y94su0gC)>?Bxu}wpdpD+L*s5AOQwP%2-vgMFhTk zqv(f@XTv!6pXD@8;Nv?LLnK2}8n-GA3R|whnKJ`t8mvc%uZRIq44(Yr#*9jCYq?dd z3ghwrL{IWfJZ3=r{`T7Dsn4*MZSFqS;q*EihpN8(a>MXt!|>z91SJ3)9V4kZHS;tq z#?~BR&a?pj>Ko78?Du{82!Ev?WF$DpaDwG{mNlZJ( z(IHOH@HTW;3`(lEK8%`rL%`NPDl%STR8~wbQ}y*A$|XAsrHNN=PwRP@sGhcZQ>qwB z;Z%;CzmFOqK2gJK?q1DL{V7{z_XfK^_{MPL-0qruP0R8a+jGAhBezNu;WpqPfP4*gZ_kGy@ z0x5DQXwP}u)-!+HG&UqzkBOm(u(tRh3~P>MT}U&9a;#F7&*scZGpkZ2?%n-kqlkF> zi4MDO-HFMixR7!bYnAEOlGk6oEY!9uftOuvyV1>P%Y|Egqcls8vQ_RU=|dRi^QnaRDS(pJxaB#p`CmaJRS8CX^;50pn$ zjh8?2wAyN0-jQTL$e1kqecsGr4!*;IJ^yMvn@eppE#VFKy$4fD|F6)Kq5=IH>n#E5KT<15t1DT1cg3(Tc+`9srOq+Xc1+uDg{TsbeFw> z^qXVaG@o^|kf~E$eQzC>_Li>Pf@=Agc&ogJURd=CL#)P1KM4AGs@2Otkgivzp2*HF zUpnnhC&zjjGMeQo9u>)wj4B1b{sNyOocY9NSH#I|Gph6?@pV4JCVe-9_vx_Y*q}6Q zl%0*tBA8<4ecBvpcxjZAN66*jG!@BJ9=y} zQ}9*a3afK~ZEK)+au)VCbVc4Tq(}EcLNc;{x!Psa#gv0pPwfA`h@^RHhWiI;OV&%^ zBU7;&l~Xx`1Qa)68hm={>OM3uQOW6>dNG$H{lya~*`!OQAa7xaEH^7WZ4^FsX4<-wOh)UOH5*#uy%Q4WCx{f#*^RLojatb#_E(*rRur zXhpweBN@AuW4HY!ro1N1PJz0&*$#yr$KCNlWj?1Jw`npU>gEfLbn}N8b6I>Pvf(6o z7C$-rqJ3Xoz4AreFWTvfOyT6L+Hh3^hrHUsik4M|P?Hc>6JM#)(2SZtUA@(7Qli?p zc;Rx*)lcxJJIkbBtF;5P4^P<&3mzv@)EpgQn|UZhsnYu@@Y1K_3c*msh{*bfiDy`o zK5Ui&9=ZK76978+6N753QbU7E8Esk@qPqxPbMIMWDr%eY^zd347JY-J=yM8)%g52r@*_TV?GZ#)^x z(j~aG8IEaS5kg7MDKnOw(WcCvaKoc2K#&fxp^F)}Bl^tmj+_WIbcecs{l}f9__P}m zPM8&($nJMtA*#}CY_}9dmr^?ly4T{nG;6n7Xhep`tqBU}t210zE)d^ADdQDyqDu>xq8*2f zC92i_?%B?O7;vpJq*VN{-F_sM1n3JMHa{xUi`^Mj(G&$@|L6X9MwY+tfA=D6b-;?V z{O~=7c{PG5SD7g)9QH`!Ug7mc`VmT)tnJ^%LmW3NtI?^^>l_At{UaVJQcqct@XrR$ z_kQ#|W&?Y^7hyl{VuRctIe)J&xdM}v%uj`e}(e`T93S(PNde7iSUM~F>lJuT%%J9K3?4**&U)Lq0g@h3p<4s-T~#!`Hueo2 zt4KNpAN>_Kak**6!v%9RTU+J71W1??O$164s;PHeyX+7GjW6I_WLlyBIPL74RK3xH zICI2@Ctd*=5+MD>``LqFO)z7mT9{!vj6V3LM{PG1oYxfK8`T)?zx^6(Qu;^`Ko>eL z5aQ<)Ual!@4lTABaynU=Qe08=aw`ar0Y z0PeEd=te$3CPu*+97(r3S!;RpDoFD#ta7Mq$;g2V&VS=HQ0yQ0<5zfTT?@qJ@^W&w zMv{FbfXw9U>_3p1_+=;s;tsVCJ5QI$IKfc81Y#g{=h7G3qhjCqlp%fsdb9{AEON3y6@vZJCBM$lL70CeCdEV0lM{ zJ893o+C7c>JPn`Tir6s2npXX*j0uMKFASkwm+XhI4`dg@bObvN<95+0unZ2q&J3en zy+HN}J?j>K>;29Y92cYB9T$)B_DvtCtd~)w5@yLihO1 z)zLLHrj@`wI{giscywDWv4s>B)}IWFPh1S0;y8yuFyN&;sQDhbX7XHW@q&*fikamx z+v=;2$5+`yF^XhK4l<=#S08NB5>h_HGAJ7Rq@5io_U86@ETK@FVa;s73f9RipOjZL z2S|!RR~#Qam^&8xS+&?u!AuGg*bjt0ySGXENl`o{`@Wv?}8s3W;!o5ZV;Ah z^EchNam9x4F5l?%iCW&BzyLadQ^4flxM3QOh*Rl)LSBLMNF8rg9p+Dq!1a+E<7M|} zBS6bj_|Lf%#7skC;Yx&T+SQ5#wcD#Ea;Lq29ZV7Xh6CuUYvKH&iq|#YL^bIZzT#0` z+I+s(i{sp^`$_1DA6L-LEZnfi0~|0_71Zp)%t37C4Sjasuf|bAtYhLD(=wOePx+`0 z*!&ckz2`<*OB=6oq;pHqfBQfn9K+V;KnQ`ehO0RNH>Iq_S=%m*#Nbkf=;mOpKuq4! z$zSuJ@RAnkbuRbu%(3Oc$Nwhj2*_I|Vn`yDfKIq&C)WM>V`Q*lE85?$^7IeTFWmy- zoBUN3+>fL2+8Kv#frYg31!6`cJ1Vgt7u^3g5Q|7wwQQ5?aud-Y@R#9XqQbomSE4X& zrIHWQvR1+omtSfCF6yUzRYJnhRFFZj`<%I2DqtokSEcRt;$sjUuSmqvYS~M8R~x{V zQjJ-B#8q0-8V?`X)ErhnHB*I2@L1J&FkI^kedw3*P(SP{zTNQ(<^MBLi14t?;b<7+ zZi;hgzMksL^TPKdR5!{VuezW4f4cFzSbH2G;>_`e7a+L?wIJ6=<4%%gHfuKo6PiRs{-$D6GGSOG1XPrNWmRkw$EgoAMGPrQX4%n>gi3p>uYEnRvKp*gqj8KPV=Y}BhM zuKHrbyuf@NeQEarhDQuo6}{oA&wjdBED!&pvZd!NToEZTv!-t{_r7U@T7iF_!hNT` zVW~=+-XRbUwn7WE9Mux-J9yi5@WIbx*MX2LS5~ZVdQiLaiUo)B8o2S^Bv0%3s{(?KlM#7_o zpLbADn`mlq=ooP`!YM(?}EYCGGs&X@*7 zXKS_>yHWW|i_pclRvC-~SC29yb^7)n{)q5iQe8h!rx<(-M&SdXc~}Rlz<36iY)w5| zbKw|J47}*0_T8xC{9m!Q4H~h+w$o5#!Bq$ovSUS5YZiETjDpqbZ`9cQ9A!k8^i{ z3aU$mK4U4(w|=104|ekW;h@G-5|WeH4N`WW#KyN-?e3l=Jl`s|YP;j{4Z=@_KQbS+ zL3wQ#Wd2af^1+KF)V5xF2vZ2Z&@TW0y+2h?{eRVsKSI_jx1JSBS1U;N*%M=r_Lrfm z$RebUD$|zV9Ptq?CF_*5yWGO~jg~G(%QNxT^jkbAnwPl2&&p@fyf@(JBIP7TNAS^{kxwW5sT`>mM5PxL(=tJDUS1$0s7)03SmKd@ZxN;2vYW$0p-WTLt{Kl` zBe^rE_v*|~32H(&=@EpHy@8FC9rtL8`Hy<-o8$-u%fE?=AAiL(t?`|!~zQLVlE_&jT(em%5&-?c%5sHsQKt0DT- zBAaL`aYWi;ob*Xyt|wY2ftb_S%O!et4;fYoyYgyD8_sZc>=MyO+p>nXuV|rLHc+H?dI?*Z9Q}mz4QL% z_dB(BLu3-#i+th81X}ImIgC1nSHB8#VDA8LZo>l2wuXOc^ENgq1t*q=ZLlywVfeU2 z`KZpxy=3%E*T(`N^ls;%x^ieKi6LZ4GS;)`irCS$fZe&zy2oAT6~5sk7=CRzC1zYh z4{|N7e$rON&~bQN-JE#kAtYCyCq>6`<;UK-W)jfMQj55`V|!blmrjvYI4(897xQ%9 zmK%`TMh{Ndk=iQ4eLSd%H&|m$n@SR9lX-j-P0)Zb+$DsG9#JwvAnGKB1(>j7xk8i} z=U-b~Y>*fuPCS#19nzU^xR6-yv3{yY8NN=UP62MaoL7=b7Kmkj3&z{K=J@G4$NxkU9OV<=h zp#_1Cs4SZP-1(A?mso3bf?uu-thJ`we+THva3&q?PJwda7U4PO9vpfA{E>==I-OH4 z$KYaxrQhhNviiU#buERtM8FyFddBn>bEU%Cu)s(wMxweyg*16=DT`r>Ki+ug&-WaN z7_*Rs4X`{Y&e|qT-E^;YESV(4AqD5R6}?&?P4P<`@n+^HR_0oM4I%}BKZXw9bgGLv+LfcG+wfVPH*<4bOfiNLFF4NIPq1Ow6m?zbAIC#ZW7UxA8w zx@PjiW|y(%UT`PUh*+m}Hoz+aR1~StL^(YB!ZAe24S>r0jd{7Ap@Ud83+L#EmXEq> zDWjc;F)z5W>z*Pd6kjv8OA>M{sZVvyVUMF%Zi-H3(O)`IqxB#=+Y67P zJ>k&Ar12^>Gd@XFv`ALSy`)fewJL414ix-^3GwReyS`Fgv<@ThW$5x^49TAE7B zUYB#Z1P0bbo!2m+iK&xPnaGqR2T$A-1BKzQv)q2$4{=apUX`1cFe|Fc)mrCOlnf7=Fr3F>}t0Qyz<|Cb)Y|F#W)vijM{ z|F@s9bDaU`6K8URvxi^vhVv=gzxdzt4M4wc2;hPG-?qK;na{uTfd1_p_3{1PLqJ;u{|EQ3|4)4R?>G@xKXxkCf&1l|L>W97#4pmm%D9_UG=t6C=GyKtqkkF_DPN`5gU->Ev0M<6dFM@O#?N7ckax7?C=L+2s(Ou zan+HccNUjz^jKsP49Q?G**JH^>) zX-GQDc&^q8AMe_OfSe(|#Avd3c%F4Xn7^)z>?A z2bfEOSWAQ#+dNM#ZgGzAZ1+}ZP4}BvPlSHi{nGjnRw=^3&A>K2`J}AH5oQ4_Q+!^U z^?UqF8n%B(LbUPiEodqHg#8+(C{)mtF0$3N8A^ zV~UHxxu{%iXkh7KLW6hPjGBGJOlI`Jc^4A9gf$7vwj@tVk&0|lnhG_$BJ%lR8TVL~ ze`dIXZAT{l-hYpmDeAmB5>#)(=&3HfpNF;R zN(oHSaZ9d3)-c&G+t24sPlGyQ$nbylZR&nB+YA{9)ii&L*VxuMHpkQ}3DVpC?l6L5 zp!X26`K^b4s|LHRTgJL#iW4_hv8);BIz&{!Mh1%_T!D`pSm@TvK11saCs0Jc{96e9 zLx?Kkbzo_tVWu)euQPE^WU~6lObO5LNHk1*5qFkKZs+ESO$U423l<_P?1j%Q_ zQEBD7CtUVy%WBJ7baei=Aoi^iEb4f#BD{MzIP(ttsb`k@#$XP&h>$i_kk5ojj=KjQ zhk9;}W)}Q;$0EZ$ju8e0DZQw}?`rk?t>0)uad&^u@y@h~o9Ru*-@agDo4j!})ZEx` zK#w1Y`S8N2T`D_%%$`%GhURRhfj+XglJkKLGGB@-7P_by9I&-_uwwUeUU(L65N8jyK8WQJ0w7`;O=^tYGLvtTs?9w6d(~L+m0;)y9+;?IdZKZo*YNrz^$#jclY4<=kJZg{_TX&h^y0u*u>O^5bMpMg4C@1 z#k%0#sRBq?p1NE9(CB3Ez@UFZLx`2>Y^D@N8Z3?py;GF-7>-#KFS2+J=^z zpJ#PR;XKtlICPpGrlz?C#fNJHp~chlzt(bxp$hMqB|tkym~c=yd;8-Mzv-Xf3G3N$ z>p4hB%T~9J>rZmt+nmSYu-NKP)DCK*A*T40P^jUQqHS#XDR(G&`fTLnu3={PQ_Ds} zTerEH#qm;uEEPpbb^Ym5VPZjphM8NE8qM}XzooOgTlG(y?770J{jk~vkH!s|#HoBQ zv*}XEXD9Rc^coO9u9LgV~)x0{y~#o7M4iLFysWW-#5qdEgc z;pXj5UjVLjP~YChQcu2R>SRN1LfXWBUTWO&R0`1J-QQ54#X*6?@A~75Y{TA?VPtP@ zQEpRB!Bmj#S8vSC&z|UYUlR0 z)Xg>L78tYk`G^CJfxO;*ozZrM0agtMo8UaJ7EgX&8Vb+Q#2?w7G)6@!HT`uzHu^tF zEKUx>Q_$gZX%_Y@)Yx)0$GNUIIpg;RbGo&H1`5nQJ`MIXeiWei)t#Vj05K}tAXG_- z-`jFBFa@QUC5E_ZIfc48TfSiC^^D7(9qwVbt<7zjUC7rh%1#={3zn+cHV?^PYmbzF zuNjuz@Ro}zLXKkSVBRC4{_E%|@6ycBZr?(QDNtwj#nu!?^NnQBiBU*HLP;+Vwz?(& z0svVFQC0WF{WfH8)y?!LGP_VtU7V_BTfdGczb%K?*mSWbUlF&&5D*iC-eQSm(sGhh z5`@YiAc2f6lroWU#_@9Nlybv$kQUXN>nrD|^rSU7pF7ZpK;Kl2`i}%Lt4VyFMG#DWR9^WDFdJ$a6~s0KsBBH=tiZ zu+I(H=hkxr`iIE#=l{ztK>+xl6bi`mCjXK>(e1hpM0pD1w03u&X5StL99w7$~)Xr})AG zMMZGhTM|y+(Jz<)a4SM?Awva=k(&b4o0un3%&A0I@9*PnfC0lsoXTu+0x$x+Iv~D4 zv7u!Z@E#pFMxiPT0Eq!z0FoiXhs^0bICunLnLwW!4~(Fq1pM}67Xg5K^ubR+z%-T! z8!UMpV%95mpeTa-XAlyLC^^K?5SE>v=q7$~6&f^;kaM9AP&o)@6~9|ieUS(N3=hrp zB)+o2&_{?q!D0L+gIEJVD{G7y0=Y~Xe>2VmnNmXJ;RUF34{R9>NNxN~sxmH+G})hj z^(Us{`{5zII5LFdt)*66RkAos-h- z6_!0Na!XY_u3D4oCgH-~WAj5;@G7us>#r&f{93`-VN(nbPn`OV{aqgTq9cyYtx};8 z4oQsMC|S-vVJ6$zf=)4{0Zs)Ky`I)eV!zt+ z%!&cVjINGu#(8+c zJ{}7#%NDtBn;xkK2q2GYkkr73Wsiqb+RJ;wgmDh#5ZG=w&9R{A-Lt>AV;gNP>x26^timF8=P`Db%z@_8TMz{vcmCe$QL3?visMj?4Qv z_o5A>^W3tHR!|hIdDS_?v+k=<4XA6}(Bsm?bW(p!*CUJ`H@$N4fZ8zm$CAR`E_Td4|f>izx@}&e1K5yQO(u7^o|spW6ODu&P2f ze0z@xuth$?l>`VG*!L5h-ts;Ek_s8)?*z{m?uA^n*rRFKK9k5f7uUG(X>P1%3X}+ZJ54<=%n3E)K&8Sw{0A z?i@dgRZ@qh&Ib%5x$Dz5bv^o|Esw_fqsm7#%gw;y9_ zaFvEL=E6F6p?y>k7yBGD!PE9>tm1)=#L;m!0s+L|r}_3rfl}bG)7w=i z5A{!!+yWLST=e`1Qi`GqYb$sQmupM=*TYV2Jx|4Q3yf{H78AeS@{C2`96HLwiAyIg z^y?ho34(~HctFJ1Z-MWL15RGAY?qZi%C0ZRNm3{vwUZf7eW@NKopipG3D;iLkNfDo ziA>{+ypG^`Nju}=AgheixQ{n8_%?R?#iuunx8wM{uydR}>58gf& zh8kcXJtXoAZZQv7^Ec7>1m4dTJg)izVUNoGopkNMvAIDW%zaz!zPo;Ym312I7pj1c zi(cvefhkL;C~jx)qW@kDogK*e0A{P-GUK((U6r*W`zu&>hfvEalkV;c!WG|29VBw6 zoL5T8K|5k&=D1skoA#|JLXDE#6up2PX=Gp-u+ zbmcWT%;UQTK03RLJE*oy&U(JnUifN6svu&STN+45(@?klEjdK>gf&BTmg57A_3B*< z>f@eNUEHNSx!F~TE-SjvQ5fHoY}j{cJUkM0 zkX|Dywv9K91zM&xDK^uQUjzmJ>2#!o8ED<A3-EN2CdH&1tgsCPT{ zL(KuW!Kw)fsyD@adMbogHWzSADZ@Xx$*kzwVN3O?(Qyr z9EJcYPj^QXr%T&Ut~{LtVEQ+%EV0cDwVXT$=wqLYQ~?HgBoeUZvD526ANT^pa-Iyh z1WK?dASoytCDYg5Gnjq}+8ONH97iH4G@o3MMKY-LX}uOi)l=DVI7wMY+WXWm^A<^Q z&rf%7S#$x9{vh1EEh#;|0?XgXYBk*!H{@G>ZTHE2gbi_@Rrav-E%Zn@{SKs-FEbIy zMXx3)R~vOednC8JDp0reF|o;AV-5j4ebnwh%rSq{*?-lYID|)1bogh4K8d(VkdTPP z1t=S1Stb$o={wC#o71c>mQt?8Yv;PfZVeY6Ou1n@s~+%qE$yo_-u)9#!P@dr0i|*V z1@2sqIF=6bO}i~n@T7(Be9_%FxO7g00m8yRe-&kh*;rz=iR>Ik3Xkq2T(P?v9zQ?T zF77NokNI)IJEy%ls6z91(GENQdZfs}XwT0@-wlx!xR{#^zOG-uIz}gzy-6kaio~uR zwyj8C!LJ+!Fm&y1LRd|!xOw2I{_OOt9O@|(8B!TMhWwo8G; zAm(rlpdr}XlLHn5(q1*^oqQ0<04YuMK82}{)q2mKDp)yx7A&ATv~3)7Up*s++m5AO zv2bc(lh5T=5FQr*TqzqR!rz>2pb8!$%W9Zy{=9Xh1{Iluf~+eVzZMLw8dyc#FG|s z{Lb&UyANC=CRU%8wWk^tB?v-sZwX6?tX+QL$t`5r+uD{Mp6=YDC64gy>hPm!%s7+K zrcU^z)0kAv6a$CjZq76NbZ86g2+;7~fdEhN7T9CEDYEGo%^S6u{Zjvy1Maww7$21D zH1#o3=>s7lm`(x^FYgjE*uW#bH@9`@(C9%T>WP-QTxt-NFEvCy<)a*FO-@c!x3t77 z##P-Vc@1L5jE;S<`Jnt_Vwg0@C_Xssbw9dB(}xh?5I?$B-P1uGWV32FOnUUCL69E2 zE%>t&u5!(x)de7`tB#7YdA4mM`5qybpRf+XekSl*f;%=Ryys#4WM1Ab9wsCr!#S_0 zxo0u-0HW)}2UIRnYZ8%@80@h@#wz|xX5;WWB~g6lWZWK90iLdRx7Q#8g z-NvQdwluTbnnE9jsZ8M1Ai*;bmnYEU?l5qt(`dEY2^9lsdf%P5n~Rdcjn6#kqOvyw z)vm;~C4Y|bL#I!^>7uor>BEO3MXiB(tth|rgv|26IsC5c`%Lb)yn6NYo|IHQ6hmar z?x%PaDUj7iUU5CKy0>dG5Ztn?q@WRq(TR0ZqJm16@Y!I9MjgaST911En}J7N?5Zkb zzi5Kx;bS^s2r!BDQVL%oR1z4(P8u^}C3IX0!~ro|qFHH+tI6J%K?XbOJfWE$BnUav zA@yP7PgTTCvLdTJcas+`s8)cGsR#XM!Zuh_{63V2#t|vA>79&Skb-G+hk6FyxiXc_ zek@=W3~=|cLF5LNKhMb8Y;s|*6Rc2Mg6D~}BQ;Z_Uuv_kHRZkOoh}s}O^kfotSQV9 z;&KBn3tP3Ck$>1vwU1+YuRcr|!iUT$2ij-9Ot%3Mm))*xKXIo9D8Uf_LI&k7Uj^<7 zw&EVo7@%i(8}FfV#`J5uL`-Tjtul`VN*p~34O2ik*y8J8cFZIMMcK!}GA6@Xwv8N7 zu6m{I;r-@fv&Y=k(h!_Z!pB^TV@=aBNnm8zbpB{7Q}MBy+H&f8oN7eBpm`{7A55U~ z)fBr^%ITf;hAny&V3ifjXym(GY*@%ca~Nc zQ+U&w?=5UG8ls%#uMI;d*}4Wv+|Ji^pCqY4%v`PmRtK`6(%&?c%Y>Kih?4FiEr^DJ z@@fM(o$pv7Y9HTe#XS$-r3dpW2>~(zM96{lv@?I`z5i2dyl}kb_a)XHVR>L@Rrc;} zbsG&HNR>1aH+#~F8AQ!vsS$|KDT&i*l`~UD&vR)OJ3Srq6xb!y%F=2@72|Hepf5eO zl7O}7uOO*=AoEr21-lN021%!O4y>CnM=hFORxICw5I*P!cS}-HR&PCS_HVWaHKk!; zGBdEniZ@loDZVmrg_;69HW2?J?{651OcFx0mMhOi6Xx&L26UVe(>!W=ZLBmJgg;SsKAX= zWgui(*-L;p{cK;%P_Di^(=c-l%xOu1|4`oQC_SzXTPjzA(J7@5yT4!EX}a`X4ye;^ z1A!4l{KOzpjtWc>A*wQ*7pN(UrC2uu81V_8ur$LU@zOb^7z7^?49969)3C)sgi;tH z>pWb3qb|2r&6?ItvoUX7zVR;Ojnonmpzpz(`Te}vBBdT*+ry%L2_`sYfS<#ZRx}XW z(fwwI3DW&#gAqmzp)l_Gps(`N3c(Qdp{63csx$&I)8|ZF8z^W(Uaeh5%ruW8zH<{m zzJb$J8e@xmhm=Xbh6|SIM?R@h?|@4r6z}pOdrYJU<&yAeF-6r%Ag;UJ5^~PKnUkTS+%FR6st@)>N=LrWytoWkV2Z@I8Jd^ok#y^YXJ z`RHx!Y%4NH&~izRFX9HT1e7=vy^@A0XJ_0|V;4VL@I-gU7a1Wo%^lvg;x`$CP~eHw z+6&>rj@P8U(OndrzJLZC>?Cqe-zM+Gtyi!yKJJfZ3=kU!LvDB(_wJC^u_Uimzff$E ziZM^728lB8&(#vcrAciqHa3ckeblTlA-a~HnjZxShIB_`$*3<$m4T8Q*H!^M{0+eY z=ZS)R?|t=GzxGsbb@1=Xw@&1ixx_3>wiB}aSv3|#XzD%*f3x<^jt*9@`Z!R3knrvs z{ha}HH$jz%Al_A#t$e$N7+N)9T10oGR~!FOI?dRFK5F$?XdKP<7*``e*e1H*pP>h^ zjIm%kZ!{M^qBI~G;`_ClhQDwVkIBm$#;?2P`XJuL5Z9{#SNY~Ez>S^+OukdUoExBA zO?e1O4(ipG3_?Wb5HEQhQI?-w#rYNZ`pLAsf_o9BCXVO<>{{)!#3LL4r(}Bbq@T06 zl;y_9&yU`>J>B%j&y94032u9B8_bslmO&lT*sK#krgV#1%gganRjUh&lD3Skn(Z4! zx@0FOvKlR?7?TbMCP$wxr$8cN%CIql{6tr=e~cNhJA>hyv2p;TSyjMOvq$Jszr_n znF~V_{@@+xMPKS+*!+$z3yUcMu&k-0RZk;IRKD9Sv=?$|QMR&JS?uH&M4%OG_1W+& zM^!a@w=*Ux841K&zP^7vdTa?ix_x*FL-UO4WYAdU9)vFtacYHRq+u<;Fp#;N%03w9 zul)?xq-nXM8ZLp8W%=kbhDHmy94Yt=1W>R+T7UVenSU05Pmwe(r-z>}MY)pdsL=;` zbc}n))nP4v{l33fk>5>B%K8=X)ZUJ*_kV(?L7#!IakN9V*8brep#wLLZI!2|!V}g> z^)M-4B2hz?e}{59VyQQ+T_c{I{f_RJzIde15_GYH+=qhB9-<05p@J1;hvwzeJXT0ktPnNBI!YK);jC8pdwOCGgak=>E zOwV(cGai;J*TLUaW^&>OIJU-$+2!}Qv2%)m8&^t`|Gbi5RQH37w5BySi#vD!?CeOK zIqkrNt*jLnmXA&MnV-dU4rvd3P{~vfB}`8xqXl2Dmz3YWf^{%K&Ll3=7}s12f5gc| zU7FTQWRS$AxYEzmro2M84>L+`R<8KS5noSmSHq)qNoah3%C;*MU!lJ@CTf~ceXs!q z01ah?jYp*65-X}@6q90JOCwv3N^4t&Ux*rWGFAyRaGHuQTj=dxxE@o^L2=M!7%+(= z?`Xe7a^8l_wH8#!m-*NKtPQ2apWwYB7d*zf3tvXC$Sq1le$f%=~+TO?_!(jL`(&1kb? zMQ)>aw2+*NY68QQT|cV~cIad>L&y>1Ohb&2^#)4yCzm!IaRC6Dam2-;^s5HKBCOV~ z=$f+aa@`;COp!w{MA(&jl=H0_CJqix8=-}c@9KHvkU(?6SP7R!3cQ= z<9s1p00_})2Q>&AyX)k!HH!HPsf=#<#`K!+uOYLrWn@S`;g+C-Ta_Q*a9gU(1hlhR2Y0q9Z3%k)l>Riv0O=~1>b_YOwE3vmWIsh%cA$3itD52T zReImOzTofFmN-O1ey!v~H(ypeOZ?$ZA5K*UTSUqFH;I|9_K7@fiTkfNxPc-@$ai)G zB_t3rHOK_sqO11_ntQ{y{79%k8jW#nu#|Nus;$ReV@uz`5Xt}aZS$(SM%S21JGk$tO`j&gM2YFwSQkRhV!w1TW{_ zDC#+U;%w`zZW0jC$Y`Rh27+%?;^p_RZK_h585kfY(gy0VG21yx5s z2`K7)0yPY>0-quBgZ?Q`I>>J|-Jb4}t*yzYrn(z$)8t{PFrd1l=h!uDQ=)Uc9x5q5 z0U@)B{m=s0bYXk$O|PXWBD8qM4AG~43Emz`vV_76CZM?9wu_!Iu#4D`=a5g=C5&|h zq5nbYq=9iwG_v8HsCLQG{m+r^%MGb$$M>o{G;qh`Up~@?1p`B$C?M(>kFqWfa6rr= zJns*z2wL(%tWGB%>`o_TxES9xe}NozcNs9mEIeIXIkPmh5zK4sdy&jnG(- z7LQ*79>5O0UvkMx)*0ZLoM2c;280*N&U`tC1LrIWT~A-> zAU=qoW>`Q0drQF6j{}i~+JZzkIu*$x;o#N8MOVkD{!<3PomH_u+p*8m-%28K;xf=_dw4rW0H?ghf9BDN)SA#@SHdEeKEng`6^p zFtJ>C4i6ot$dv=0L!OopV+~JTI3?9-8cKsCEnS~CdpDaw73nOwr-lv@%@;nlCVWShk7zDDnhlx2PN$D&z4WJkjU@g3?}_ zerzrHv_iDEW;XuQ%>it5j!d+fIm`=9ffjL`04V(w~sI-FA+aBBZ`2hTD%aRzV z&=H37?^%j&vX)x(uyk^`ViWK$;DKB#9EU==O#Db;cqq`t!P^sSRwx4S%5R{nD)fT771n6vOQJk__kWdP z=;jA{MbQ5rD((N@1OW=jz%%IwN)`C0%Ku&k|9|BHU{OP!#R0C*Yshm0%0UQ&JhKuY zl!pI5O4EL?GLnyl_DM|3 zXabLqK{dV1qm?2MuU&VEpX)`hbh}#9hubV%so@lxuRdmOF(B9!NM`F-Z21^xGGQ$QRTOz=Ld8PqPhHu+~`A|GMd2hPJ~v|zAB zax!O~BKpsEB@NlafZ5Q2jDq%`__|gIP&=j{O0RC5tm zy;|iH@AJSiHu4Ch%7T3Uh~$pl17HIEKGA!i%;z(}=WR*&-`SuJ4fDnpV1|}Fei4x?37LhM#+5%yeUXQX;f5@ zY=vXO5o63w-Rhg0u_DaQW5$d^!v6?7G6{gtR_fAz>Rr#P90Xp)QEbUh4$f1>yHMUR zNc4@wMSD}K7ROq;D+tA;8q1UsQgVGstD*Wo(L<&f*a~-T?~jlof7`VBYZxUGk^G32 zHUOAp^y9cDAWN3tl%R?+Hp8C`1}0r8T8+Cz(rxVI|A_w=ePptMYL$653tgaS_hdOO z@%Y_C5}-m9K@SPusrqL8G1ATu&Gb}33pVD0cSJ5Z6lv5{?WsHYc|QsrJm4!MMD^mc zza!}UJnqi>U}vV{8^*e;1IQ%MM*>U4Dy9o&hU+M_T6X~zCGt{B8#I;}|ES3&o*mHj z!)jI?O62*=qY`-y7bAJR-Qmq_s4rt(`vU_k!*nExC8D^5=(2B6L)K|5Pgw@Gh+ru| z;lualL;lZb+@bNB-&K3)wd;P8(;#-Ir@AT=-1t;q5A*zppdtb}Q&l82>Za9bD+u-)x7=uIzv*FJ28ShlN69lVKBjUR>ld9(`3U9&J%h$4=wI}nnKg<*5{?0*)p3@#g-AK zYsSim%3wLjeqS|W`BUn>SzJ$S&R9_##dbn>D&G2jH>8zPF}b2e{TQ$6vKIh zOq;V4D&AkrUzmZ73R( z$RXfV>-{|n4P1i4 z?Ubvh(O3F9J);Ju$X%-rbBkO2_klB25Dta> zQ?Cc63RW46I8@4W=#dcx#pwwP@4rfceVVK}{v$Q5Sc~;R4UnuIKT=#`wB9-a1>hbu zdwcb{-`q^4r~zy%%ja2jsXLuCtRLycLX@`{z>WI*w?+vVS(T5qvUfy^CikkSs8DJp z!33E1w&Tf*^Y0`@;425nwFGPlKI-|@*r3WYfshz-$u=t~@TV4imRO-RQz-sBH(AoE zz9Z9z(HWvi(zalvylfgl>~WYS%NBP*oTz?tQ-9<-Vs#iq6QAM*AR3T{jQ&Kb9 z6>cO1?xsaY;MC!o^N8qYT~JU}wJrl>EPQe{v?0Zn9!@D?U#PLmMwRkU z-lmSpdGoJVTQ8x@Vv!5QHw?% zSo(v!DiKx{&aao53yP}^WyY{hP9_E&E+u6(+YDQVlqwq=7EWOfL$_X&M3E{6Rv{Bx zve4CZ+CK6GQLjyt-T)8;LEjQ~oD!mu_W<*3OsL6}l>IaN%^`xpbw09q@KZ@C^q>S1 zu$;ktEdxYqL*ZTKaG1i&W%^s(-1ry0eet4FKm290M`A5~>BV75j}nIo#250~d@v4Q zJexuRAV(1h<&ENrEYx1T&5pUaA)Z2dVo=;(T1l+~J?USrG2M+a1GS?Me4-(rd3zl4=IUCi{OEehH?Jn@rmL5|93n z5ydyg3Xzj$d(X_#XYMV4`_Dxc`0tps5NwcE4Wi%OO&3i1iWfwYruXr@=3_bNKMkbk zh~ib$%0OQAi+ns%43#VdPH&{vq@wdz}dU!Y6M+M4$n&WYE-uTwpNPPzO?DN%v^Kkdx#yB-%f9@8r=Lv$(2ENX?@) zWlwWru*tMhTVQ$xHU$avr>-lM!}M#P=sCWf^MpD)mg_cHQ;dNm+t#R_)8; zqn$>qf|PfGX2i_GKLv}a6O=TI0+IloLM0C#ZBXKXsK|Z2Gt?CcK7f;GPaG`p9d+3cnIC_Ld~i=DDviR(k;+w~vOhxR9= zv~TE|>z=CXsQiHdSj0?%>k)g-D?{vxy2MIWZ;^yfz8F0TqIxq|Rssc}S4EBpP0RV- zVcH`fh?S@!I4k5#pw zq#s3ieWQTnAD?V>`f1Edt}o&shuE)1tVZFLyT(Cbt>g| zls@tF;)**Ggk6_C@f^e-auow9c-+0j4^&~mkk~IGQ}JJ&I8M;0m23?168JFjf&8ud z^$fPXX#`I|+{eGbK!he}-{$$dE;V4?#Nwh_GC$iV{n~#raWXDHL z5so!!A$=}wTA3eD2J*mDGX3UL01!ZpkU81r#|!DsD&T_q9~z>i#ey}Vxs1a>;Rr@=kl|vyx2tWuPK=zNMT;&X$Xf1R- zH^2Laq;xsdo0b0V2cgHJLxzd9*F@T|Z%0nTAXycxmMh}2Q$5cjutchEI* zL>s)%J=Xqc0A*M&p)A23cQ8kas8Voh!=07Gu|0)|;)vM0a^-yTv;DnN-PTAhodS5a zAIxSfADKiC1-HP_^0xS!q4mKXH=o$8J3(n1FLT~|YU_@Px7ax9*tjP3c0ha8liEfP9sYVD zWR}Thf66d(Y!UTAd(MoJ%SR3GgAP0|{1>l0*+T-5cW6h7(RMu%5;qXAJ8jW5t?!Dq zwyv14VHCAIHQy~2HLuOQ`Opinjb8OoLB83}RxNGN=8V=LW=7mq;W0;Rt_RO4h(V!I zDCB|$${p$raI|q)_Tz>>+obZz4W+TUfcdqhHCI3(bn4RjGffXDIypf@?p^J$u{Cz| zz<7o1k4+Du>A7>yswn4i7eH+u?cmz;R5<5avPx;;pEErE^nn^8HdH!dYC2e4hk8)| z_Q$Pz&W3CPSO)e4bl0j0-ae7{(UrRGGpOoLczxx;4T?im*9^o_ymsVpNVjAiu9*i;P3LYj^({Wex8h1FvzRM#4Y^nVOu;zxXnd<9C)y_Vm5h5~lufdmvqT zYHejDrnk)c$;_F}?ac;krLVu1iU0#Ln3odn`7{xMCpbw3g$tzHEG%807A)NI!V}%f&&&_279^ zyaQK>>4=49--a*Q0g|34D7vkGQ&j-W3&nFw_?W%mB8UOq+?7n6pXF@R9U1t%S}xHt z8>d@#>aiUMVv@FZ{;#=N9ICynmK;}JEY$aUrU?J$LV%HG^j37O;qDb2`DL@`Kd7eo z=On&_d;JiKUp|K^F#wwya>8Dq)FD-KZK&+K%Fy=N4T0D_$OmcKc9U{&C=3~IJ3~jJ z{fE~?bCM10YsYA4)qc9-(Z#2sZ;qioW3-f6;xp#ooEVxN5vzjI%Jy{PDtaD1XO4L2 zb?9LWT|WpY8m5kG5y4R&*jXJ3w1&@O2hHqPKS(xz6^k82sn2ZctJ;b%W{SUHwR+1g`L%q+pB+Qz5wUe*y z+RO#~rH!9+?PEt{^4PDWJ;Wbhm0JQNRaZU(%Q0*=%3AUWz*tn z)|x9pScZQm5Snc{0B}k=w%m89y8qpBFKws2FA1S9l-sA5ySWBRHME_F_ zg2@bF`+o7${Y@6wg5uj3k^M*!(yUo282=vR(DDgB-&#(fF!c2G+L%kV$GX9TI^h28qN?GQPIPAKb91-La3)~UiO>HVMw*Q3!&zxAQx#w^J_ z5S|@e4Ezf;8=4>)@GXEd zV4wyevpUHF_O&E8<|VPUJ72iMa+2)uXY=JEzt+$FNdLh;P^#R(H*2C8sL06jqe9bd zPe~{dFe!6y4gn_ZO;=@CC-Z~CD~pf?rQGV6`fVjRB`tYpn3{H_9%N*^lgW)++6R_J zP2S_UczrlH) zuzuY)Rw(WL%m~>v(gn!msvHWOhT;;)euWVp=CV>*M3Y9edHWYV zDEQg?2`1twVYl&seOp9_H7K`)_Z0qUZF8M#sK-SPf8ydNCXXH7s5ugJ+@L2~+`&4H z+oV%S;A6Ff93a0)|LQj^*@Hlov8+uZ;ej;#X%)H9W7r-q@J*|SrVrl!nq@-po zAw@~S1=+~wZ$K-ODY~L^hPS=wC~89oE}{L^H?6tLrT_X;Oqv@}b3hde`0KC42A`FF zFq#X2fwd#oU$hF6o9WT^UKy#h@2S;?9{=D@vy-&q1Q#EArV$#CWxjzD^n#2GNgrDZ zOBEmIqO(^2c@+UM%z5xQK@#^9s>j{d-;zAEJ39*M_?`k82luCS2wOBs>}YkC{eck$ z5ae2&?FmiWE{{Mld?EB-n;C#+JpF=$K|6r!z0+@vSO1Q6qM~Vdl|I^Kz zg#@nr-1Hp6=q= z-rF6{Cm9YZp)H(U>-F?q2F=Ws4|YQA$z~UaLUc~=KAiYbkLs)uWze@4#~8fw`+WAX zC}-G@u6&r97CH6Ynv=%fp0bJW?A?06@}t}x#}H}^^Y0oHV&VRVF(dOdVi&vpJ#Q$G zyU0bXak-*iXfJzP5n9?#LaoEb&h)rcuYvDTQ60ImF3x8Cw{o|REh2YPPK|6h7Sti( z>-&P1>@k`bR*9LL8P*!W_>jem2z zCti7qE(WC9M&D~g581g$XP|wpn9~78Tv3KVEti~N3rv7uifu)^-J6j^Rz1f{>s|;% z8DX;#ujE|qkdR~mX64IyO?fx@t(x`Fb8asr!oWurkG#t~gWLwT@9g1X?7tD)Pi#E~ z1z^!bq)Hk4jC!6P9V&lJxxQW=Hs!w26OC&6c##WYuSNiCI>+dZahVUvp4L-q0Jbmb zMyq(&z0J<|1Q@^s8khKS75ZO(zR(jm+;GhayYOU!IO=a8Vsvhpvp|4=I6RRqD$go4 z&1pmlAg=+IcDo8m=k7itB58|)67oP-49KCjzweOUTX_es1^j6($7kVUOFZgv7_(i6<8n2nCgi$Jcb?jj0%YW(3Cw{AhQ?2(a<0Of)&>Sc9!bU?J*Dx?O~7kziR`7 zh)mx;5RXtk;J-OR$n?;o)dxc1CWalA;EwtKS~-;Tk_H>oI#FgnQe!@3y@Y;BXLEf% z<>9ZPc)f@|puOZD`)5!$Qkd#{0Y^=@b9YN0jK|g|?7ELTor8?p{7*7x9N)fiY%01| zWE~CMUZtF8GtQoG3?PzesoOI0oox^`KZX>$anV8q&7qqYVJd@CTYN_Ex~^IC>D~=b z&U8V#Z{B>vzJT-cVAvwvAI10`I)0zm@AuDS&RPg%bAP_OcIMGx_Rh+l6)-aXHb9e5 z90+A-_-l;K4IRxU!mM~p#y*;<&1@Ail-MWe&<%Qs*5ohZbpCml+Tto~4A&PdAl>6w z#B>pZ^XcYgE^@}VO#F_Kud_}m8@5}78p^+BbDw(CUfmqW@_Wdwc#Po-7;fZxMv;{) z=GfcQ-5pu_rjXf=Iez`j^(}PdeAQ$~z}t1z#LdpV`Y4c#RSPErz*@<5VNM4V98{L_ zr_#S?tkAxT&nC_I>gbK?5w-IYzb{MoPXe;sn?;~4dx@Wl>oY>f?YD}{LDz2@vmE`0 zA+eivWH$Pi_#zkiVKEO0#+Vv{3JHHms^_PgcGFLtSqGy=5>zLU-!~!H&Y2JmPSOc~ z2vJr1>b-Fv%lu$vCgj?#w4&)kKE?$T#Yr}RZT?$tr13-J>PW@TGy z3QJ?Y`#y58Y{|bK>%|3E8Ce$p$8ov;OZBX$5OnVt(1k6+rTIOTW7qQp?~qgy{{T}# zay}+@E3$9NY+*?Ea%wl= z_PW&5&%h(}NDKqF*VcM<`_r$KrlgnDOhdU0E#fsK5W3@boAx2JYTxL|q>q_KX^quXsti1bLtOZ5n8Q_zx;rz0)3jbwfKE?H)gx z)=B4mPszf-C2s7^ExYpv?_|nyIJ=PwQlqIk$qJc3A8xL>kYd*GuGVLmwVof~9abw@ z!BisjCzxwG2zVV`Z}`PSvL*M>N3Tt|)O_^&p6TS?p<_6pZC@<2P(d1QQ6ys5I2QWW z6zhqh>%E3NmRHNyQaQ)PaCiB6SA`+LDsdXwQR+};ed`f+K>K%SaI=5mT6oM$yurbs@ ztt1VTTC*^jmtvPJ?tkGA0;Ei2Suo`AnGpjTFh|^5YPPP`twHfFH6|HrgxAIt zg2wgn7REDc4GY0L$eQrdGWD&KdjA(~Zy8oq)U}PSy#XZ!q`RcMJESEF(I5 zlt@Y=(%s##mG178M!Fl$LVe!%T;FwmeBU|m;SW33+H1}+=Nxm6G462>`$(hRC#F-x zQ%0rfgRyI`umve>_9s$zNJ+3Iym=gdg*-HmORGnt6m?tidq0roiWHua>frj1qIR8h&FKf8Z>V7_ONlnKqx}{y8yiTm;qqN&;YJA=ld4_V)-VzdR>2Tf1rP{>76u%rnw*Ss` zVb}?cK*AlFh4e);OmuU~TT}-)5&8??nSoMMvWfDkL#~U^ zIHIROZYmPbRVnBP8$uOG)I!xBaAuSdXSwPpk9bRgFIcJ1c)ILRjF5wxVi)&k=!jJ5 zvfDF5b0Pq{hAM73YwgqEk9^%P5eJ=BWxBn4N(p0reidAIGNC>8pZK&mTO|9@jqTuS z$lEPK%J{%N(xhy4N<>T3d_ZaQc@Yuit}LBf{Y9CDaZ`d5(6)0><0(H?B-68MEUq3A zY3x%|J1e{`M}g5-#Jg*!Db9CYbH182-?6J{Axr_fgq|iNy`c4me0wdE8)p!u*hvuR zIu;o)hEFRZPwry$zzxTtFaDzDzU+FD@cC?yW*dcxeDE03ZNbk= zK01pS-u%9`yxBS*+2i0-4Kk(A#ti0SnEir7LX{0wnCTGA*JjLBzdW~9t4lcgd3{?9 zK^8D?ii>+2;m2x8gry9mXDh9-2o@2Yx?O8@^%zwjJnj#2Z8v&iK@yxXsh&`nNZ#1C zY*vJ2&0?n}pIF=ZcrFSeVJCJ@e0pe#93s3f&1>hy1r4 z{iQ#ZAh;?APHH!S46y2YGT6oGoB2%HS5ar5vp#u$B0wC^-Qt4qEZ7>pcW`NF<^2rE zDii6(M6Z^Qmsf$}H6?MntKwyJI@~t>vgTRT!J<%AWYqez%5DyOuU)S5jV;G;e&>)v zr|UxnD$Ol{4iI{&pgv_@1npe-82T0NoD5_4hE(Xk zXdjBioZ)Y?{x0Nz-=nY<&&@7v@l|8yKO*f`E<^sN^Fg)|`9M)pvi~&kY=v<~ot=G4 zlRMIKK#9?Qmq_BYcWM8Kj$j_%J@7tq2g<smX_7wl$r4H>M`hfk~c8E%~E zhDlc>hv;{qL+`o%*eTOoOMbZq?WS1MhC^@{I2AyR}Tl>|D#U~XKhy^ezMD9Hsjb-Z!qv$&-YGvyRlZ( zq(vBcm=H7C_W`$q8reSOO+n(4&K}>O4Bixl>?9r9-`|1jW%mNw%l^UWKcI&>`9st|<@3rYg3VTPw0x;9!w4nH=a3?#VL z$rYn-fadqu@DC_4Z=1hCYsPngUj3P$WVp^G(XEpn(oZa&r)rhOy_-Ye%hQ0xIa)J1;YiFcgkN5Tm^!9&QO)Vd<6_&oSIh932la+m#@88Z)x zVb0uxnl-2NI!{A}!q57`=7c`PGZNnC)g*d6yzqs2R4zt|k}4 zQXnAvoXBIZbeB!~ijW_|!i9R3uQy1Y#ChpXoo$j7^>j-i0z_jmRgZ1VwRcmXy+K0O zKU_ge7)E288YcU$0VQOHnjxp`XndiJn%b86gwUr3KUVIWaM?r_Is|8;EW!`BB_qM2 zC~uUM)ej)@%1PfPXl#9q^|yOR2IeF8yCE4M`o%HLsa`~MBGaqW4qDjKEvhzBxU+fZ z(!R*JNo_+_qjeatRzK%#j^0a)kR!rsX*jX(QXv<$c8QI6FE(|6^`F=@*+0G^=b?tx z%7S@vuXmRNp@H{6rw^(0u$TnPJdG}DgIbJq2+eiluEtZ7rNFJ$^%X#ffnqZJbG^N;J+-g&Hf<1UBptlz6W#)i)}Q z8t3}m%8)0WqR_60-U%-#m+d|3Th?JL175|{haJhrym?M&wV4~g8h4%1!32$o{=SR* zfO>nCEgujHlUW&C9%s|HKGUf4O-=pAijBwt3e83KD;qJL04OtD$P6398(=DHD?z7j zDs}uOfY*NFIpkVyxk-PWkHxSxU_eeN~A7jbjQ#GxhaI<3(6MA&;v z0`TQ`_BEZ9 zWu!tW(bY5g!DI2%qJpr>I19HHnMk5&p}9fcr+3yD;+tm;mcZAf7d7Aa7lZf=c|~Y zn^%)y!8f)vz&nf}WUf$9irO%Y4}z;K?$+oql;vD#kumHHY@v0~4}DwaHH zq^5D*T5<3R&7^`ZwD3)l`euA=N?61F92+)fs9WzFl)!as=q_d1DinrB>2`2OJMimW zXy5RJ=Gy~p>iTWwT4aN!j^4TWu=>z4h0w^e@b-tCSdEdE5qphp)u3iswl$-+9`5z# z=tKjdHEbP*g!3YH#uNMpiqG*R>U7ui)gWKIHQ0pf71_-nW1w!Ue+~xLI`Ad%gUWPY zDEL;#HO(7Kyf4q(+Lq*p@UXR>oaVo7%$+S4Cx>M8dzA|`e+Gt0`}xxiPJ6c4Hs!a> zQtKm(v`xBM0;3>XXx02f0xW`3WFom)fVZjxa>}w+g|6PI10JL1X+Yx9Jm=nXG2$UI zrGYkM0qwjLdy0C~!ks@hqm(3|$zhB{Ast-z4Mg)=3Rw;ximu619t*pDD5Dp56E647 zV@lL0%twaGlMf0vI_{(KHRzn%^^{=F=+WC$y^=%Rf8K-}l^-$2Qq?|XDSJS<7%_HUZ8NbBH55=^$65t8K?wK?m ze1mbBxr++B;7GdSi1GWxFM`5%;*)%!!f9W$s*2u*%CmjaWmYtXT=jM}bWGQI75A3t z;5lmeD{EHB>VDn8h~55}32I4lU0~`o%$#Rps_Tkouh!)kbdKqMM2Pi5K#K3P7iaAS0*(k^LV7AP326#Q;RB*&c44NurnD5annsM&ya7CV6F0@58xyYkVa zP)N&az8qt=6Pb!bO3@4TOGmzeSyj&(-sv@|9pq_u)mjrDPbgs)y#TXBPb3&bDfu<~ z+qEOHswX&a1hpw@dZ{xezBj?XF@Y6fv_7Et0-0iVsNw6myECLhH~6%t$GeD{uIKdr zOy+r@aEl}Ti6-c5=q2UmnYt96~2V%6G?z|oDwKa_t zj`5<-E5B$j96I@<`RFaeB#=oaT=hAEa}FuIx_S1e-280BTN1SB%D%){QyWA{xns}F zx00#1YvdPsFBd$sfayoDkoAncN%fp^Z>O!z>K5(QoD2}3+}G9gL@!+#XOmOo0YXef zmyA8aw%ve#%8WIs?Q6)F?_8XcQ3CNXZj!*1StmoE2GnF3$^J+s3!gT;Kf}qND%Yy65)~WjGrcAt_8pKf$KO!}_ z*T14(1PpF7&c|6;)ja!XHQwUh>OLmy(k{uhlu)J%+JQJk1j@jwlXZPcZ+a4IHg_hg zS(&ToSS&w@=gRNPg6-&j`!$W2U4xI^x%rwlOcUE-DuC!rE8*f-7G>B*C(ZQ-{^|mq zug83I&s~~dI;m-!93Dxlm6*U}&Q=MT(t5;(^l|h-yjYb5Xf93-yjd~Qq_FPy2oT$z zOx-(67uO%mdJh4%}lwcv}eaQnI zaw&xj2e07hpcjihJFQrygMps2vL91zWin?PpPw=Tl#s^Q zGfFrf@3)BN0_VQi!bw&GgQ=3hhl8f_B5*iV=G0EhSQlxg3Bn#LPJuxoy>?+*##@1j zgrEGTfvFv7D3o0zhlONccjK9U&m9%sP`QLE1G=Fy#4#IQt(|81z_9vne%^X;vTR{r zz1f?L*COh32h9}~6CEB%)I{ychgYFhq3fG?o^@}4+N2GcBZu_{wmPb+@3rwgu7}^t z3bW>47fn%Q1MbicI$UcXdk3$tnf}zm3`cpD`dTs z@oHh~Of}#9RcY2(f2mPbZRS-I6VlfwD3V@Hq`npuZFiuvZVm3FN}BC^)rp6e-02k7 zlcHC1HcZ;59Rs8u7;PMLPxV@R@>yrx=7hMpu?#y*x?nZl2mZ51t=_EDY{h}=-4QG& zg9Q&eT1)#_Z3eV3f4Wn2xCsA2?mpsP!<7wv63SuO7mD*k^-`IJ8|wuDE>+Bop3B}l z`e(gt-9$fYc^rm#r^CB$;bn`co1(j3M;WB$NeD~FJ7VNa)d(O|01R?-kdG74U5eJ& z{*doenleSP$|!Z*k9^s@$S$Q(g7CF^fKX^kC0KKnkdsXm;VE!sM}ZkJ1G$$p7L>4K zYdtKM>4GEb~oRB?tcva8r?TRnU>`Dv$)z$(f zGl)$M1!;l2g=}EE@BTc_zwtgu3K+nHEfT%;YEqC4v)l}i! z&?Y*v7ehpee8LO&SXuB7{TYO1=;ZNbcFQjxVGzji@17#y6wKoJ6Rrf&s{j28lo5Cn z92pl57v8)0pmJ1Nk_(uee4|@1?y#{kWin6R7DHzK%Q9*y2wxLj^RfBp zo?yd6Y)XpT>f^A6{RwR0*!c zP$vQHKz-v>Z(+&5;Zgt{0%3`myWL4&aLyrvRDp;jUnj1L4smP<%Ub#e9PS3~qgh(n z_D4D!Iv;WFi97E*izRWmr<~^1aVhD=3UY_6e-$)ZN>q8rVezl=Q2?L+7mWRdGYKp z2lyOFVRcfPh`M+tjlG|Ah!YK0N@@dJe;;iIT2#PD+jEhyYueu~s^5v$(?y$b2T~Sn z78f)vIczhQ96UdTXCLdYEcSvc0KA@q6e&EI!s1vF&Bt`>5A+NG}+ko4-JO)1a#b0=wpa>APZ692c5mbBhaA2P$DU=y!@7@aiUm zG;oE?Jaxg4e>Y|U?!vA#bD<9|3sYORAK1!0>C4flj9HAnTO0i0?P8g^a1=*wJ#q4d ztbG!W)0w^F$Orw;7@|Nj-u>WwG{R11E;kqwp_aSQvKICjyKxnqTQ>bEV)FIA@$%~g zJ}j3X_1ddXAM!vzPDQY1;Dk)ct{YR=*51lbO}5yOd+HG7)UT$l&a5l!kv;yp_@$5= z%BRsOD^&~gydi854}UK|DcO6)aQ_Ny`kxsAo>%^g>i!Ff`)~f}j{6t?XP5lJA_0HW z7VdAp*Bkf$|7fxM7vS5!m)w7G--G|Jm;CJq_$&Xj{rKOI#{Y3w?=drqM2Bd9ZiKyl z3=c1=rfm5uAbF}kO3R4wZ+{q>VY)xC>_MypD53jHTih+S-=N}efZ()c3xlN9e906U z)#iT2(dxz7zis8HKzuPlvV70ngi_ ztNEt?b*l(cNz3adxX_6<&ws`rT@%8>3}#w?JtZ`TBL#o9_y^Ab&}|@JhWIJ5;fZ=Y z-LMG)>)t3ZhbMdL%;@ZCmOl=6;&EEuu8xY=m?(4DH=o1r1^b)3eV(N>cumu%c(ar< z{jWzOol-vkon`20K>G$sIJoA+@D$dyv-P>Pkrxk9gwoCEFAkBf3Gqdcge^Mn{W z3F1Q<5^Ko_;FojLQ7GnEDQR_}pM$sz&2M-4FFRuLvi zYEzwsoRY%^QUg}q29r8yZ=s#=C-?s(0zp^zi)Rx`i0XXu90-~i;#xepY!G3CIZD@% zF71sGLSndHi~+iP`a9%?hMW=43eJZ^E=ZNHV<<;K2xU(90g@4~|*4hkVOq z_FSNbfkcJhGSR6ek()c!UH@BKhbuuW2T$}+7kh0Uudy#N7Oxy`lF zZ}ygdt1qX2EcH^)GeRT%bN+9ZHtB6#<#K{h6H=x3eeTM(P^ zXWgxUsElx^#zMx?4=W>MFx}4WOWFX<-t#2saK(U10LYxAzxf| zsX&eb07g{^-o;S@4II`7-x{nCgGT}=^4)#fnm=u(m-oMA?<3CGIN-anrGye+fb#zG0=-i&WRSw13WjhX^=C6_*|5Tdw9>vs0?)o+lj0h8JQmzY?y;SB z$si3s_)xWC5c*q${CBEA5OY0c%>F17gPO)&58b~hn+$D~w&1n!eaS5^VGwAFSdt9V zL-!g>OFRFRVr9A0kAwu%P+Bnj zF)oj~yq3Bv^y_9b`oRI`J7*b}m;mqCY88hqdg8{Q(G!#aw%6yLKTcij9=c_jFvWjm z|1Mal&|i^c;&y_bd>Gezb=muQT%|0EKBhDx$q}dU&2j12$YhNs3zk6ccEC5XEkP%@ zpCOb#`wl0C%5~VxR5BxZSDsMU$R;VC(il^99E*Him&W`oOvI zqqUb#0`4i zVf5QJr;MQqG;~L*$~p@b9ZAL8=QBTG$b`g4QQjb}*4RzrdPh$Vtn;}Ig%e#<%B|^w zS*{o<|kfGz!kf$N(c+{9uScynmTM!kL;Hg;KZJ>kcKjuSo5ZCzR@BCvoZ zvF0Nz3i2~Aqws?`K<;>bcRN(dzNpiJKqv!0iN<3~LQ05^$!i2;?7IPu5N$IxD<{Z#*!~vs^Idl$y)2^blX+uMQw2g@ z&4O|~Fu+y+1omTBw*9H*omuRwnZQ}x9Z%==*S856YMgR*1aOZ0CS1_yLvaKpP%=ug z>e(Ps)2OWl(X=12OJ?{`Emk^RF7+v9xCUv^{yCXS4K@R;z}3UQ=Z74YgqGnok_C2& zd(x4R?~gnLV}zJjdN}1baN<7DduzASqpx3zX#43;djz0nR&wBk16vb$%EHZMi0`YZ zMacot;f!Yk=b8!YmGTRTMBsr&Jo_6gY`=VV^#s4}#k}&droQD}aJ>iB~0ncN!F$Np#K268}dK(PH%plns-S;tE2l9;> z(bigh8RZ}t)a|d7fI@8gOQzY%!x~q`+EK_8?oL*m28KwGNai~uh3}87ThzsO4Gb=6 znVTkywIgK+IOB4?YwmKi@Fp;fv;}A-+tMfkALl|^{nxBRZJ!Dw?tg3_ksalIMJDkJ zR#9(UA9gkX32VsKNOW5sEWedxIM%5hLT}yi)Y`HQt@*d$x)9U3AApn17-D3TncL0` z)(+VmO&L%I5KQySSKJdh2tg#c`JT09cj}8uy3CcAO&Oiq7H3TeqsY>rmSllsbZD7Y zB!%Wj3La z(-^Or4USOw4-v!lT(9GnK{jXl12Xdz#ft887Kf&zKguoS}!on?g6z!I!DCCg_KSwmecs@w2En!k!hAmGD zb(KebhX^T%`fxre@ur-+VV)ICz}sY8Uw!|kpMHnNJyyWi;Ye;*H|7PTK(L_Rw&sCK z4lbB|_4w#1P2uB~WPe%l3&qx?XNVk=247k+;GOsq`X32h^o~`ui(j*u^81m-dgp>X z>olkYkXc!&kF5?&4{Wi3?vA1d?y|z%;U_DC&RAW4~e%P1N;4@0f(8UMOuJ~x^-YTYvG4cRAm^v6pAfdfO zb9y+F*-$mQ3wnyU6df%PUzrxJKgbw=laTrZ8zC+FaI|0Zi^khb-ZN^{6rp*WPipY( zR)O!f;KA*^^{@&XL4}3fU>eqBCLV`&l{Z{Yg^h!ll7vRIhr~2dPy`+0p}0A?zIV#Z zB1O3N%A`>jy>*Kxo24d%S&iX^bxjmu_wrOB@!9Wg8tq$WzQMj=0H;?KQ0B5cObC@X zt*L?*iT@z*hE#_#!4$#+yi7(y4|;7DEMLm-=`$jd3qYb&Y9bh5Wmp1wkPOc4(^-%S z)<+Q?2zY+D;}gDBieivtkD$!gVaO9))|U5UcQ(Q^mi84`MPqL3{iu^gL~*Ry+b9bQ zUvHe>A3{VR89RGg-TU59N^DZph#9mgtObPN6}(GD(zJ<#*Y@@m5b?Q8FKws1ooHU@ zeSZu-7I}!!*6A506>MQ(!AEf-h(-n9;c3i7kMW< z;(hK%g7!bF>uxrA1-|~XCm|4{0F9 zHhg@k3lIK^^7js`$8a)mZ7J^E zKgs{JGuy`e`&8@0Rr}Wf!G=r8lxWDqA;*8VLj)zvf8WhyK6jvpn+61T79fMkZg;NS zs6KzKKhWx$oS5n^UlR~V$Ae5%t4LFyWCa9~tVU|O-oYH3>m79{3NIPh*suj;rT3ZQGQ zjlZW@8kp&?U^CW_Wgl$D{LgqIgGAZZ*>r3WboSx(1BnGo-GxFocl?coE*$+J;IVjv zHV!oKd&cjC>%)Cp~+J zBJ0BWA4dQc?9|-{dkgXy!c!~zPRe^@<~{!h8R5-q_?72)J4yoy;WV-U&BtqQRLFni zPB+QnOQ@t+GhbboS{@FS=RNZ=R-F=>CpB6k=d0czKmSXd14u~& z+Kygw>0!wFmCrCPn#6Zm5hcfX$23oa%8%VNJ=s{9{crzGGb4~cS<59oy+Ea_5ZxP2 zD*f0mo1@@7 z13G-~a}O(rtAlE~)bSVMDE1ySm7V`Crsq#YxLcy1jVoz96xlE(&SuGa?>NvF6kOc@ zE7`l+m&IxBq{ayxqs`5oY|C?!wpTxW424Oiq4G2^9DI5u zK!*G{EEfNY-6!*Oxq%Q$Sj55^m?tLxu%kZ-&A*?)}`Q;_S754e)qZ z@f*-I8vHTLb=Z69F1sPn0Zq;8o3Oa0 zWzt^ISSu@gM!*!FJW#*@vw+T%tX>fXE` z!U9Q&&JyIIlMY#FH}C{`VG+Zk_v@C+LC%>^TU&2xL58eC+20W6u<{;=)VZb_;6>c#%Mvfs_|< zXp1@7-U`~dEXH+~+KHtRDNm zBuCTO^X4BvzswXF*VlqfEQZA5y#XMzHign<-ohQ$PD9&si7jDPLiDaKFO^#-gYqTbdUJeM~&=ggWk<4A-#VA`n+Tor z#}3(9kB$_7og^FoSu($lOF;Vv zzg`<_kHtFuVUy15nkuJqjPM}L;E#1SoEHLioY;~~Fvnycz3Y!#WFYZb(A@=*<8a}v zejOrNbEjVyw;fWS^c|)d<^DkMdnT*G2$ zSK{uRc_T_n^K>>i`U5T{v$$z^=KwJ(V~^Zj1SQd_i@C&@%Ia+Huj^xw_9Fv%vDoaB zWgt;pA-HBD!PP2or9ggEiro`Jr;J_RPDfYIHonMDSz#42vsFb9xiH@GDq&| z+Z$O`w16xSQmF}%+!LR}b@vc$Uw^tEtcY7J>c~o-uHEc&f+>$CzDdU57;wTW-SuPS zTx#;{&)TYiMd>*n3$6 z>Xe3uOaSj`Wq3J>o90r1;g93Fa|$dPQ)5O^jzMCqxUj*Jm1Xrj&s@SrBAx zFC+bBC(bWBFit17bn?sV#+V=e{3NT_n*7EehfFjdaA%)~;N3O+5EomemH|s^-IqfuIUQxqL zE-Goxs7;Rp>0{MkBu0lp8yMc*v#Jm{?7Ck_ZhN~MQHu0vcHh$%0z`|TW}oK1JAuSaE^-DR?tWkO;Znr!r_KP?{ZxFr^>2=Cf97?DJZ{33 z0p@661ahGtq{Dz|q!(QX>E0P0V3JOp@7KOhH??d~*M+5kN$mb>)jPyNV_2j-k?3$& zxwhKwghSGg(pj_V6&)&z+{v}!9L|Q!i_ad$ZA$TVk`>SF=w@r1w3z@xtbXN%tJY|g z^`OR)0q}GS(l#KzD9oWtscf4?4`PU&Hb4D=k@ZLmX`k7IOoIBUtdepLmIad1O!u1B zhhR3Ex1L+$0oGO72E&LxT4KqJA8f_Jf+N~w5^bs8)l>>948EpgLLfOJ?{DB%xhWXP zbKBoBa{|)IvXlXe*?ku`!vzq}?x5NLo-f{fmwUgJhMGcimX>Ysq=S|;rt<1Yzgltl z^FUbeiVC-wifuv3+Sy%8?-r2||59CIv}S8#g$84J0p%I4+YyV?vf;V@RL)&j-{(kh zReVw%IU{f6LLr?K7E$yCX%;yU_n(fvA05xE%F6SfChE}^`cU=x>ri2!D)6ofdMh;a zwK!L%&>Uf<&P&*&X8VJCKW5O2$5R(p-#fM=FRLETA`OnCo>yu6l#FxGcwn*Y8@A&q zhQKvJYH&VC#glzn>{-*ArfGOgsn}R&K0GNC2!TE~gL)U<2!Wbq* z=jdu6%pnL+fP7&K!%yxR=n9K2Yeh@p#skn1<+4?Lmbx=+%`cDBu_Vx}#-=n^4#-g} zOFHWLanpQntHLv)_*jjiqM6uXd`;i3)kZWT`B!A}_3L7Vss^x0r$ta~^a7~!m z7kw&NjP@{F?h)drHG9^$kooMfL@u5G=r!K}mV}}>ES@>;#A}EF=}o;a=VyCCA2p{z zCYaqZLZ^FfI=NPO?Q65y*j^s5uE~I$QfC&`bQ-IYzDOClgN&iJDzC9U5aZ_!yw7Gh z%<)_*)W+#8J{p@T9o#gS~!uV=Hr)ex#I>7$ixHvHAQ<7VMXYSCcXq zJ$+1h{Ej2f$m*(?@fe(I8Eq@0>iM=G3vaTh;-jRMa`DbX2H}&TTBBlpgQro(O(11) ztn#z9EWOp0Cn%I8{Y&*k6J}hN(un~kEXFZh-Yu)b^N(BJV4c-o;|VeNZOK#0dZ2o* zUO&S_$p_r4Zf5TW6O*HzP!Lr4&PU9-y_p(hkSSl1=Q*G?uc>J3D!!t8qkt!|By`(T zJ<)WMntaCAqCNXuQ)xtQ{LFN_=X_HNo7nfuS$1P&=?`@7riw{%G1iYn*mgWV(~lCU zvC&nL!to>ZVqeyWg&|5RbET`H6B`mpSdb5*ejX_H-h${o8;Yq6y%)9g6AE`@{dWKL z+C1g1l)mWq^{YMv_$o8z*}nc~28EcRJtuNHJlY3X1WwYxW0@z6L6wzMbQ{0mgDT4YEl2b6x{O|WYJ zBL$At(PPKp46llbCpST6JMPi76iLCO0ZNy8PDh(9rC0)t(9URO%%N>0 zC7^)BG6g3_7GJ{J+Zpo2gFl2_4-%CK);iV#aiQRF7h?jSlfkKorRmcbcR+kgVV2CP61+3#_vv(`$Y3iJfRbE+<+W zWP9HDO}k^Q{0P$9x@OZk{I)5KXrK-}>7y8(ooU%NkCO`u-Y6-g= zC6(mo@bz{J>?+p<3f&eDdfx^$x@HTY^IDJv5NQH!`z-|7M@Q!=>9johFb`4;*k!Vc ziv^-#Ut=62;SY#}Hs@B0LbTYan+{uS&D>#m+YX|UA5pWZ;(8C>+-I2jmo)B4YR3b+ zs`GEO=WFuunPR_CtEwH@U=|@{`8q0RB{9V%r)OLz=3M+=SFXk8=wQf@NA#de$ze^# z8mtGuDeQmfM?dJtPj|yNk)=|dHzPBm)E*kl0=pBGjrSPp5x=V30?XROpZdJEAPuF0 zuOO#{$&RB#(EB+KgYx6fwx2EOHApjtZ{qZP9r)_?gMeosl~z`Xqd`d_KAe9~m_AV0 z{5eSUzcGWBX;mEA7(5r@wKE*mm)JOkz|D_DW==}-kA^hj6;kXSlYmyhS`@;T)m7fmCLH%GJ?-Af$#NT2K+mW;fESP zu?Ro96bw|j#6Q5%@YEEmY?_^5Q?vuYh8l~9hl^P(E)XN;eZpiJpe27&Y>J!@G=gK4 z!BQfs!16Vjdv4|dTpeFOiTe|G42nToP{PB;POb1k_$O^q;UQ$BqjmA*0T#xji|L7} z^NIgt+f!=2j8=gULHMq17ujMY)U^y&Vjjug8@9Mb*mr(1QaduTB; zm;E)H7meHd0Z*M~#x7Csy~=8!)=UdAmIMppX?O`1Z>a~@^)~`lKs`{K|NO(XWU$@G zhuu*RrDmZTHm=DBBbZQupNJg$p|s!lcyF=B0`cHEZUBUo+Njp4f(7wK)PQxWk6P!z zCg`ysdTVA4Wf;$jK5e!1zzOjzKOW;Xeg)Ld9f(_q-EDftX3;GPe-(M_-A&CCZ|r$! z5&*1W8W3?5j?(1s34lTFYBcC1yU7+mBW7f|&X!4)2DwY{mkonOyBp_A7q7Q3?U~)5 zCpDdQ8=-<*WHx3!rxj@7?d0dYigyCUH_R<9w|iHa_{4>A-Pdax-nQg--k48ni+=4e zsD5c=gbiOrOMS61cGLi$q=R)BahA!|?$My7SgAJoqbh8nRzS&rLIoPYlX#ZN_#^(q zIbtnj$ZVgNl^>YnVr8xj(GmWAWI`*-5AlqP!Pg&92=Jp9dpDz3B#0GY(X9ko(auJ# z_DB0Rw@kjODAo}1!y9HaS&A81wx#TdM+i27mjdQ zn7+S7c|1>*%*(yE0(B(;QDt36l{*hX@~+#QM#M=r7(ocp*^mra;u_oU$0qoO+hn%b z56Bu}SBZnVMk$8}4-aH-LtU-UPY}Q2LS`LX*DVGrd7*Fhdu1sd_$~Z1hYq#9o5z#+ zN$f%HnX;hjqe^6hq?ae`Y>)~og)$ID2YQD;WS}i_e+G&`cFYRNaFXUGB&Ts>F6nA!_`acwImRqxqLaBn^x?7Asx|Uk3QN7el> ztxf`of zR{7PQFBe1jx)mY&ud;#uxmnruZ62_6c#aJG=cz&xRD&JSmTo~SLpk#a=-_*x0+`03 z;>nT0iA-8qWyvIrPrrzvrD#3e_pyIB4(Y9?b$L*X5VxoG5F~^v8Ri!cYw()no~ME} zJinytQ!P>0^mV4boBhrV*6Fe1lZt=zWF{f-n)kfwcbj&`;$}foz_TCF%9uqZ{1fK1 zz5V#$&+k>PeHmw5XrBUMLWqMacO}KkuDVSFA_vLT-{fw&AVyj8=(eN;5~-mS*vd+# zJx7I_Nc(0>+P*yDUqqD3tNcygV_c+I(NYkD9?ZiR`Wdp6zupY@{bmt8Jo&ch1nHxr z4h3P2Myo%-s%SP|g26Wvihc#`Ha<@sP#5WLwZWsalj=QE9r#EMoBiVIF^AV6Q(~rE zx^|Xj^$fD|v(h9=6~JiW5?kqhK!|zv5|DAU_cKHKX5G%*MRzCn3{c5kiZ|zSV_sxM z!Id}^@re@Ky%?E&iRgJ+V>6P#a>;aI5V9uv>iL2M(&`TcS-et~M>T(%fiWvS7F=$NGZZ-E< zpM+I71LN_+RWHtKAmo%Brz4b1?v9|y3=htX!wwrgJ+?ed{orjzOOdx3&?2P^Xr6gv zYeQ6Uj{+l*+%}n2D^^z<*eEpxYesYdOk?6XB@!S0Al4z)2Qf8{2vXIFTbx=q)BH&H zfuV4Y7};C|!deOm{(BC>m4elqT`N70o0H_b>ywFWA6LQWheYs&Tta1)268&PmB4?FBO-=67LKlyrs;M$l@A;F7xWAs+g>)Tg; zIJ!Cn*9l;j`2_Pv>k{9leHs2NmKMrzJ-|g47k15sy*A=fF9#rOd8dBC?N4F{<4hrt z{_!9Ta(M;kIvJoNlv@^xz2uU4aYi$ZZbXa&QfI_HxXuBF6?{QL@$8WLjn&d-XDg&{ zpO6g#aN%mffHm1~XeaTpEUrY}``2B+y_T}a4?S|fBGG~6A|6T&5LtcF1v71D8s*S& z&z?F5<~^D5q_q;&x%rKfeyo8lQHEDHr>-8pXQkNkYU9tR(~Q0mqI`Pv>d@oe zapc|0b{?XRbkAB$AHC4K)F)pt>FvFsB)+3|7WwHW1ew^KntE4ZeZ^-;{5@0BfIil( zY5XHnn{!Peve3YD{UI3-mIpbGP^om^1~E#_>qO!c33C>kB0^F~1wWJ%X+J%x8wQcBBnN+ZoN_$`MX9J=LbDI0pSjxTm5qqH6+*+=|YMvO3NztIcDqw=z}u>M56 zubHxE-nDG?q58lWtBgVd*EPFnq-U&AT`~U)C?UQm+;aJ&qKpNSC&O!pglOmxG~Ib< z{9{wFXBrNXlaOu0uZfqCjN9tbaa7mmzxew^>J?9k1rpB?$y`rFoMP&6zUQwS-uEoL zYMV^hr1x0$-7L6GjZPqw$>PF|z^(gxh=hnqY5|zGHK}jw$C7YD=yX0EdL&enMhq5r zd!=sF@3JDY$MZtZUzFaJ;zM`S7i--~5q%Da^0hL?`w$uIQ^eNroPGST33V5PF2{PE zGvdxLrn02!koa2Ma?^;5qBI0OnUSC70n)YKzGDoQ#6`l(EQGFiOETS3?q4;#iYh7K zQ+31bXV{5A+1VB1jsGWt;;uy#8u+A-5V1F}tqpF3&g*F6ed5y6am=u$AIX1@|A;Zynzs|Bz7L?x1f z-M4Gb%ue8^9ePbn^8ZEKTSmq4uI--PjYF{D5Zv7%I0-?LAPErMf_p=7Z;}w)-6goY zyF+l7;O+!>rub*?_uXfmSuSR;;vze&9;f?bQJk3%*!e&Tc6+jq_0Xl5SX0NB{r(1CeDZV4 zZ|vdGDPXpRoYFf-Zk?N2ZT`}OPO>&X9I_1{n68?J?4?1CS&(0wn`=xjR%YI7m>JbQ z5*32dvi2uGBHleK0_-B!mCyG0STF-FkXGExES3S?t#EkY(u<0oGKn5{Lv&y^Xx4q{ zIN51+P9oT?u_%1Mg)9LfVEG_@kIudtEKWy&za-mITE5h9=7Iu!R@g<1Iz$CyuSg?5 z03UM~Qg7Q92E|^0I_kY$T!=~WzxyrE=#KZf7aK6tepb~7WDb2;;#1XIBeQE#M322c z6HZYXgVe2Rc zwHhK;h(LK-k0nq)M9WxG(|64n2RdOwJ|M)HKZh2;1aL{>9p%gQiX;AfQLn!@Teu`a zu*7zAW3*>2EA?@N(T&Ik7mRDrbT4~$!xeLZ)Is8Q%;Y8QLj8I1ZcVIb#CuiLfEgmn ze~&J;ZE`(G?W^h4iL`W}R zTUK{Vkw|oyMC`m}fBA~G)mVMHW3yj!e#kxKFh#b2Y8)b+(BPsN_2$CfnAEk(rR8^W zxJ)_m{cC=_mtWeatvm1Ez?W{>PkJ_!Q^9Du`mwfGx<55z68GW54fFB^`UD{ZS8>7z z_K>&rmonV<*4YW}ouE%0@hq2Qu}D3Q<%nWN7s_YJR-$$^x^%Yk;7pP4R%5*_?(>6{}{PgTGL7_dw1J35r;iEj2FTRSNGwe$S*)aDQ& z(wu?p3S2e((ka3Y9$}N>pes8SYuV3@Ch+DoY zeN*4esrJ6ci*mxEXu}aUPFx_DWw%N*%`rVec`sy@anDl5bPWx5|0iKc<66#3L$*3zi1^F zK5~r(vUA;k*UtKYPG0L72@-m~d=2xb@gPJ5SR#seNbk*dhJ)Qyl&h{$Rn;Ew9O9iB zR7^L!qYGY4;9bKuXG+aF!POlvalA-scemP+)_GyzF>M=OmiLwlQMaTl+m-cBI`xKf zn4eY^G~0qGdVj*fjQ5XI#dd~}I!~I&ef!wgF9|_p8ES@3>os1@yy2A=$GjStdwQ~R zH0oA|Xj#7UwSZDg{wu!8)U#jvHRJdvgO6sSt*Vqv!dq9d4vVavBo1xqt`tH%I!c_KhAU{?}Lyjp^GlE#}y3S0D^ zZBe!(x{>lTth4b1n9A31DxhaDALbk(!Ef?d3bLu5v`@#RHo}Lo;t7(J{lw2Q3CF}b zldk@w!usXNe@^v)Lm+T$0t9uKp$WL}&T}jp~-X2Exy_>IhInk)tmMR1za_9xCZ!9Uh0W;{nM>N zHqq$UepVaKvO8<2o%&Fmc)5f@;p)EE#>8cb)L-p^0EIpLD5Ngy-fo{P6tBXqrSC;V z{!uul`mFxA1%C|Z5K84=AShJ}dQx^z{?ReXlaeHY4yLQp;SfEsNL;^k`cah|frDis zKjTwpH{>W05@&=hu5eY!8B<};o&E{nkQ0^j0;aM)!hd+1$pnje1Eldt8`&%1wI<_( zpCx5F49s7qhEl-_!{#E)htnfB%3mQ9RwKrV#i7b{S>*k@E4a)yFC_Bk9Hbye9)kxQ zWO`WerD?)fDCh`NWylmIxHVzOX?_lj@P?l`vX_{1pnQL2gpr8eT!rA)?|vBF`a}_=0&hvDhzD#>KJc^Zk`M4<{RVC*vs-jAI_1NFdu^2MBHbw) z+Bf#^Y)QythZ)h&muM>|2i)3FB|($Q)3;w2X4BW2uX9BJ=;sfEp8kxR`OE3gKA^9L zXcXk*RJHmp0tY0aA?f(;u2kbRqU~K;RaS6&B`qG@n9k8jVNEAUS7!v420!4%`c7)m z(#ngs7rh>o6>x!`+CQTLdan|`y(u}@sO~sNCvwf=PJ+5b4c}@YqTL~D4Cv&5XQSdw zvkPDkWS%dA^DFop0P$db%gY%$SP)7hmf>?coZxsw#{k3o6Nn@EYV`9YQhjkkt*o38 ztVMI~!rhei9Y`iUF%JornoBj%zI|AGms!RuO|@Y!pngHKUB$BPp#5tv!LsBR+XOu6 zNd~nzzz6{|rVxI_K3kRyS2LqB&5zF3V?j=A>HzKihy)t2{E1eRQl_1Araq3FJv4wv zUY^(}7Mj{(6RZQ>|WEdG#uue{jM74=*`x283H*|6*$YN!B4# zwEj0D_zwm6&ni7py#Fv|Prre8{Us5f5V0qO^uJ!BH-nN^Q<0HTfqM<8I)DY@`N(=( zpObBBafpEZ@br&&$av;4A29#r3X`_3|vH*^?_3^*Y(Wz_Z93hR+`7XaG=*PLJ}h@1lnB z77{w?RtX04!jGQsA{PKgh(oGNnFPbFZ~yf%Gst@W#Ykq+EqZ}TW3L;&?jY~FzWx3w zhJ6Iu%iA)vDfqH%7l#b@S?b{?x&&Zqew(r{+WX1Y#g1Q>-lp6fA$7toyRrH{jYaHG z;6Rgf-xIOz&EFmIoE=7M^eW%I@lp)RJl<9w(F>6YIN3F$N%p%yRII=@Muado{ zJC>pX2-=$d*VyzdYMmXvzA=AhyEM0JT5o;s_M|GA{;|RbQTzeq4<17GJXT)5+t?gH zLi3dRFL$^KK%Acp#9AUX|6A2d@B{##zeO^aKUyKt)XfEAExu%HO>d>Y{hL_q__YH^7Mx*mWS85Rj{II%$0~^?K4GJs==9BhPAnyHqrb5XMsJGmJ5QRcI>n7ni2KGfC_hfy{OS z=XWM$jnxF#%VaB(EHs49x?3w9=RhC|>L5;>mJGFT<>)a({REfAJ)r7+sw2nEs!y;;VpOUNI8PSJN`)X*v zXZT%1AR{R{lC{>W^{{9Jq(%1i_sBnmq#ACR>rFOTI1!Np$xz*B-d!W!stBV#{LQ=( zZwF+~E42W0hs)mKy? zopSu`-ouWsQ25HTRem7#AB0ZY6`ecKmAw>0=P3;rVX zh$+ zZO?eRuFyXvJVX{;_l3hms6z-}@~Lun-N*_Q$3;Z!Sn z^AljILXd4gT_?vzB_U5Zd3jKm1vrn98m$$`&8FHLwndV`_!F7-sNGzwfz zYF+^IjktgoD@+9=stFmMiRFBdCN4|NJw3V;PZp!k2g0dqGydl+BftBPbua1qBG@f1)?FaOHzmJJz1R(ep&cCvVR0-9;3hUc z8E~Zz%@ppN!L(L|+TVSz%`6Vphf_W1r`Bl?W0=u=%5h{Elw9b6^+2j|k!1)R@D z&C}#W9N0XL&HxqaO`VdGuZ!5He<||ug)(J(^PNUW7Op80#hOQxF zNvu}r!p(>JL=1ex&45c$JeovMbLCaNZuzk~peh^#l`WQG=tzeDjfC(2#ij2iSy`CqbxTI!!8VyKByugNi+;ffjke5bfl%BfJz2s7_`}H@)Q%s78{@6WsaKhVcrhY$@ z#lB4{zq-DiUb^|V5cAArw)$8j0`_k-ypR=`5R|k1mcBA&ztWd#)cRD)MMM4N(oNc{ zakkWL%;2epnEIN0qx0T??54aJ+Oq)j<%F->L^Ql1x=7XWA*VU0K#4&a70Wi(DXCF@ zPnczR$~xhr_#PbpP({OM;mfz*DFM!UIQZwz`==@fg`r)}&?qaW0m2i|fK1^wj8Eu^ zBL^7t9rvPN-`oY6CN>{fm=}2@HdjtYY4LbFJO}!zkdK@}etInk$Dhf5;AUq4?4MFI zb_TO7zdz`qD0>d${3Pe+D;tOnUk$h-ok_UnanceyBnZN1WTmUV?zV z3VHdv=BLL%2G|sCXD@{(n~%@DvEWcD6RdijR_alsv9AoA4axfn%c5iwB_7E|_I;ut%Hi_ z9#1^Q=I3TQ6|9rIJI5oID}{hV34yo8$b!uRo`z(vN|tEUsdbS zJtOHupYS)v*8;IW)?@M9go;?O3b{-j#riu`chSZhi+>AVnZ4EtJHteMUfj#Q>_fNOT(5-qSEHVDoXR}W$3ruTX7ysiHLqSwtZ)HyL zRj(1Tjn14(a7u!xh{)%g9$XIK=?~Ds2goBV@Om+&gxh)S-NYdR>U=RCQ;+HcAG`VH zk^QQ!uNeuvD!5Vf59Ese=SRlSR7vdCR)xEVU)(CwhqoNrAo*E6Yn2x_xU;))h$nYI z0CoC-avW&U6i3`*ib*IPg(IO=kXQwtjh8s*_~l`&;hkNS2YvO^?s|WMTw*lxv(7py z7jC1mGVOH!` z6C4uoU8PYK!#~XU6sj3u2tMR3e@J~{cdn5_re1xI;{*-@{E=RsU`s#I<~He){hz>< zbfM6j-tU=oIBN}rCVgZD|_zGUMUucBDE{3E(l($ZqWYjq$!Ym5{b810ep1MK&WJEopv-Qm)eX;CIh5cktjHl~D zI0{&GfO&he*1h4%Fsqdc`ER{#y|!T=AK_kVSnL-P>h9zBl5ZYpy&w5g*47Z3~q(Xr&p z!LQBkcE^+_s~Z8gfP!kmdqtf=pF;$WVWS{|&d!TM<3 z5&V7)Pu#YY#zjAT-uk{xWAgRI0Tx2|?yK^mhp-oDvpj)L{qki{w^nVCm3qN`=WG(y zUlzU8;4h0#|Do)q#YxoFYAE0lkplG?WU;PBP5To5#sa}|No?UE_x<-EpfDgDZ-vCP z4E@-K!^tF$5;R}LMMm&>{_&yVi}s(n&s4DQH*|?0^K@Ls`x;yOn=@cOh@pZj zM`u3ly_2ApWv)Uvrv12SmOBbPeFm;PgNv*Cs-8LvQ#nBFWPXg;2Ba0{n+M8T|{mTtlp1jay<- z^OZyPNn#H4)$8asVdi0T49?Q-484h@Omc6*$~LHaW(;^}-c?QhC66Y=%Q~!hi5cRzkPHPZelpkN2AMbg(SYD3_;jJ^>A z49{B+C9&GJRF%X(>0;k*kzImAhkLJ%uQAn8{eG8*i+6K_S!&lCmhKM~)Dst&PqZR( zkoEK+77Xt-2Xw-ZsIVFj`kc7cm}dm+Q%gohWynG(VQ5~5UVdy>jf^DhtaK#(VF#GF z;LJJK>x;P>QNxe@vG}L>HA?suql>+^M*k+vbJZ>8QAh>Zt)#8G6^t6+Fv;E4zNAzDdkgz&Ol3OaESK7Q#3y%5zAudE{>b8hz@HZ( zq+l@OmGFp#X`Ms%=^dj`87-88(GD?h&OBeTt0dY)!f3Al0|PT9A|dgPPb+1EW=W>N zj+Lz$>3oJ`qyd{(HOmi>tAtTV(G2`Zx$R}&?pAa+{#$cxgb}R-I)|)UL6!QWZbn$= z9TE@xHo8%l^Jc31v>jUU{67Yta>gDC|K3q;0%J%a-c#z?@|079-U>P^PR~+98AGRHvRaY$H zk`KgHjHyjAVx{!|;v+Nw6CC3Y#6kx`DPj5)i-F_OlEz{bZtu%b(?(<8%`GInfW-L^ zE`|NXtZ0T>C;hSfE=BQfYR>7obGXs@RVCgKGR5rini#L|2zu${o$9(PP`^abEF~&L zqYR#|+?s>83=Y`N86YwWJzX`2U?mM{LmYcD-;#@D$IqsPnDKWskR>vMSnWCw?hS6DJ%m)`#xDBZmr$+MSZgITff zP8;S$7U^Wmrd%OjtMPn0@>B=net$;Jc0Y#gV2K;-ocj>bOqLvu2J%_VmA&7-C{}BO zkEUb#g3ZDE=#2(0hnp2;dM9Ew+@5`;#sYO*IoR!&yrgLZSM7`!?K+O~M3${lp)Grb zeB9JII~n#osGpEF$i63^B=$8x<^T*pvi4}PCreR_qXi)|XllB&zUJ?y2$fvly~C(~ zoO3t(6Z}pBg>$B$Ja6kA1``hCh=JtXd1xxikXBTvPk!yUTy1wm<8~4&qdLyYXNgn! zKyHfzVb$kGbON}i!eNaLVr)I?ehI<}B1pFv6G3rb)rfF`uiqz?ZIx*1ntk{Ano7In z^C<{r`9xAOQHdAc6b)3Il=ZmMMCW0!nUay!IXd-ax>-|G;IpFBRy@Cb#`n%vz~Fay zKONGX)Y~^7TyU0=93w0=rcoxHIx(jqS?+L6t6&^6l2wt*zNoH6BBc=wsg{F?(kT$z^G3wz)-mgD9qxwe)JrJ?kEnT3AH$ z)-r$z21f`fWSLm9q!_wOX1MGoM`Ja>DO$=$11F7Vw!Btdj0gfP*D)VJUQSVXzz~l6 z?K(K%<7A#}24yPjo4~~g;2=ZBKdgeMiti8ZO5T|Bqq!zmc_5jjUq-xkDLNh$S*=>~ z0<6*ks9=lX3oj^y=5=_X^x>_n%;bzIuejn7)#u~L++5n&`pot9=HKc}vokEdB|Q*Q zw=ehWuEzzJ81g=w#~(dak}e9CeJ5xCbgLJ1xe4CK35^YzmQ6`gYptn zM*uMJ^LApPOv`KXOM3;vK^S^{MC*f;U+kir8XFA{o{+L9DlLMKGqfC4q=Is*4AEh9 zQUBaSgZ6VZ-4X-h-S&P0v+5(plr}CK1M4#>icajl%jw!NlyOOa5k6}-UHA=F2;EVxbs1Y5Lp9fo>Wia0G z@0_l)u%o2R>R&rFo2ICX3)_Yk|3Y5AX%DWuOZIfjC_%8gS!9l6gsC1Ti|+TsaM)m! z+E9NnNNsbN*3F6U*Wy5OLIkv4^ndJO4G;XnaHUaf?!T^9oy*CJYQfuV*!6fQh&KJe zJ8NpqImWay84{Zv)Pi2`IH`&{wNHD74GHPyD(vd~bWuntv3gnB-CPnA{+q0_3tvlH zAkcYIFe0|%oVD`!(viwlc4IE&XfKU-yEvV)7J5=$GmQmi(de%qt01YQg8fo$qTPdI z(@oPI_278V;O@&drAVh401NJ;p(N2n!IP5|>hnC(qmanu0 zu3yJkVl~>RWwl`tJ96XKc%vr^KEqI+^_urFnXxk@Wro9-QZ3C@J7BVqUm;Ck{C1Pb zF0-PUbDHhoH4#rqCa~hh#r>*U>;U3C8fQzc&11Yz_>nNlb2N_Ap!;3}Sx@l( zNDWVP7{A%duHwx-eS%I7KGTo>F#a zTc#1C4X>d{4@CzT{%n`}W+Z}UiXR~h#P7K6k!XSP9L=;q(B5f7MnGtIu}^6+Cg`oX z9s6k=*K#SU3%+zFCvYZ1U{)iFnf_F%Q+`~&%#9AV7k53Y2`+eV2fh=gzq?-#bB=d6%Zfd+XrlsEn=>)QPtwbnpjD{@`Z6s2 z6Z}4ka4~d!y4}Kcl;q^pvgyhb6P^PgOJEZ?t<^$iSOKbtKnBO9q{r=6{~R5`Zo{_W zCZ!!JRP7xJyL)4|n5=@ z*kFt5U6;eg2xWU{>UE4ocry*i%l$bsH*fl@kADlFAfh1_Cw*%4Ny=+5q6_SEL|KcR zBm{1fv8iDFPM<(-&P--MH`mehuGYysp6_vc*DiGjUZ~x(bNo{2Qb1jnC1TK;ISYJo zbLd%LcwmUb{+#u=-i2PaL1VGha0>!o6q{>EF~TkZth)|5@A#hMTEJ^^zV zaKgu?Y*L~r$3@mnXK+p0sqXhTAN?G=%y3}ESX_RK&QZ8^SBnwzFjiO`^jRRMg zquHN}QJ}hr5x{e$qUg;h7h1mTa)nfG70H3jphAURNHxV9fHUxz%-o5{M1l*Hr9#qe zLHaDiof@_0n;SEI=?A@yJ(dhp;ui!R)2KTFAL%NTsJ{C4uWtmtk>=L<1Og>4}BnVqqbEH!N?Oj++2G>5jKL2P*>gCo{g%gJi*>>sQ42ENcsdhgX zC&Qvqx$KP28S}EL+z;cEaX;c#_yFdabCZ1`+c9kW zHVB*Bx{D)sopEz)4?8=P50LBXKIxKyH_FRChC$2!6ro9#x)W4iRNfs_KQne5s6Pxe zznOt9dTSWA_=%EJY#u5fn1-q+0aU5N6K+CXa3?Knpu>jUk>SYa5h}`VAy26qEdYd{ zJYPL1+>xP@)XSAt1)e1rj9X!U9+%_oNffLF?LtKdg;sIyLHH-n^&~-kb)&jwIrpF` zJbaP+-TWfbN*bpN=F|6VNn50UIKi3evn&qfRIr%75qe6anHiTY34L2sbX2arkOhTe9r!%Qzt$G@u35A32RhgHRP@!L%@Ngx zlu|y5V(iwkwV-hE?^oJp5BGk2aPi&nA1Wc)U@<0GrQ$MS_l(&Zn=4?(tE~y9e+3fC z1XXLm+w6V7ZEb#rARXwIm$o3u8=AYIh;Nnc1zDeB-DNX#|3*TgZqrcgw7oa|6cDNl-Gk?^Lwe7c*uN zY&HzsoPRAv^R$op10PY%#i(3IGj$ocx z6@(9a>1*G#Y)2s{f2v;FG9H_jIrXV5EXJ_(R@RD}D_&x1fyD!Ba5L9Zc#UpoVDGM^g&bV0-?1;s>4%+3kyR(uokdF=f0BH?*` z<@N^cQ_-3qvB`^J4O~&A9)!jYAe|$-Zo;7$;Sdp*tUao5$HV4Iz~BY@=P$(i^UDFD zyoryGn&uV=!Z{<0`5mV4UvdZ`h8RaEf;Q`&n(rLi!5c|UE;-6`kZKKEHa;i7h_PzPz4FG1AXNz!=p2fH8u9MF^a@k+=PLTGKh@WSPA zV!(;_0dqEuv#Qxaav%MY=k_Z%;_67(;cc_^i#~LIudfyy8%JgPT$=3U<%5i6LQ~{v zMcBxxP41(PvtgeqO*aAy-ED7kg&RPJ6T6qkWjxYJ4%w}*4$E0=n`O3*6nrS}WvympG=loKGYst?NPD`|H7(@{<lMVbaXX??f@Keu_BQ)a2t&u-+-TKk?uUWF{rwJ^Vgt2@@=|h?%wfRHV z@uP;N?*ul=r1IwQ<0(a{94rnNS_jNBNbk*>{XrNO&w2 zN#(#VSB0|NU;i4QY4BpV8#S@#!lI`Q3UtMDTH1a<08-V8zuW`Sg+t!FC@u|}8ZP(= z^fMNm>QEph{j4#2pwK*4gvZ`$i1zlG=n%0YZ7bREEfAvpt%O4#sc{dDbnPy|;Bjno z0v*TxLXaMM&R>u}Bu%D|O?|MImg=uN2`+7i)G5amBbc3Thx~vzS&SFTp|lj9B8!f1>gt z%1Sa)bzsT1i$q%@}42eQJNvza>R1b*OR(N!A+rL7USvhzUJ!WJi#!d zHz2o=+f7HSYU0Usw82kEQ@uDdzdb(e?cjcFY4y1Tq_FmuCn@8fZZQK6jL&(HRejbrO-%#oud)ly=JQ;pDlm2yZ-u_52V_2&;FX}n@h z>HEEyB^4GKEuV*(_Q+6;O5RS~s@XZh;{+?YT{5*_eRQp7^>jX)sG}bkb{n@UAWv>~ zy7KS(K!O^A3AwC&@JNY{_K}R2>KQJNzqXt!RmBr0_SezP^%7ICVt%eR_V7BTPHjG3 z<-yJ98d7H)CJcVIB*f2O`kj9YnI-X;x%)&}N$53l8`v~ zGv58nM`sRcsfn4(Mv&$(!SA=(au|G(q2=1W2Aj{yuvoe1%$!5zyp$`l+gw=%RpT05 z{5k!hbC<7T-jj;cj=s5(FD13ahs5NPew+Ro#7AToLw=}=TD*`+*3~xjM>4f4T z8W1E$nay_@uXyy9eBs1!)r2;=y=b=&Rb&3Gv86-vwFOT(yS|aPQS2v8W6|*QB^VW* z5J^dq{C#E#k?A@_JmET0ZzcFyg@&{Gm;^=)?pHQ#|VB;jm^Y#Gsl>03Sx^MX)Ti1C^e$6Ze(Xx0fJjUdJ4|wjr zgopO1;(ridj4;Aj>&XBz2JvtrIHrN;kY3RF&p4aNFJQWtcKptM^lW2vGzRti233upEuTwkNASj|?xXTdS|IOtM%<7M?!P z^Ig~eazjRYpTWR&P|;>@u*O*0YWY_VxvF|STDeld#K#|h4}LKgA`{U9Ie4X*SpJGr zJVVMT?-=XX5JvRt+n}~oiM+J?!=!}fOH;19qwU2NHIOh(AWS55Tk4zcTyBSHXIWB} z&-hXj26E(m>o1IY}3VDeZ_lLuApyvtvJGB_Mxmu zJeqC5s4S%*<+6?&uQDT50yO!?!ki*plN`-ZB|imKF?fW6kOJ>#RPXOG%Sw38VONGv zYhF=y0f&`CB) zkThVtPaz7%wG}GQ*9MVChw~=_A$`)hKd(h|5Mr)~Ylo{mSD9c4d|-RcS9q;?1#08r zn~P|A&Xd2j;?Y2eaWwtqN;$OR$f%~fx(q&0ZmXlS&S^MdUJo@D(Al#tYrWQ9oK}Hm zKrMu7flN7R!o?0T2G3bx2g34L{Mj3vXY(wuIiLz{_$ij;M292cm)oYc@1u6|K^kLv z{vbis4?xmc5Fd6r(iRo$U4{stOgn67bjR@w%;YiZbHFu+uri)eQ2V|=nNF<7S9^!i z>|^xWe95G9XbeZ=nLCfi%`wa4`LnK&v^GDS*4F3hbA1|626k(5>y6=q3qjuxICbyvg-|L}fy18S!J9#vB>H>(i$lJJ#KmDA1HKPG~L|0oM~U(BAIDRtDa(5FMT%`9^r zveK~$j8IXQ4U9jOq&POJ?w~pH^C6Ms=K36+SG64P<^#fAgpKA4w~3H6e;!1!JeOhq z*x<8OG-Ezh_ygd+R@<6gKz+#35Ri-CV`BV}=UNclL=#Px!(Z(-MNLaUL>t1Jpjwt) zLYh5Fy5JpQ`u2CoH-5kT=_fWaX^g~0XnSx#XwM%oW?WS=8)kJ5Ie)_l&QzDwB62z9 zT%k_^f<0~sh3+jON!S|Mpa#-N1Y!etPUQoM_xIVDBeS2hy&>V^QEwVh^sW~5FxdE7 zGnCUmyzWpoJTAh9Yl5d>omq&$;wS3h4|em2 zmIKA-(FzFyV!4#JGn`3>I^USlfyMtj0*fcR;QxZCvBdw)zy1dy3k>=%6Z`*1w(-9( zw*NEAj(@fpuxryno|X_gLezfl-6lFT(dmF$-j#e#Vx&i`Mc@?w7n+~^ zG$saIs5HkL(AGvjyIlfc=>1aq?}(rAgMX((&z-BgXR(7ML4dj;<3$qnzup-Sa&>5G zTQeIuue~tDYjpr(-X3<6jh7-9kR)AfmU~f_b}w?KdpFt%n9S!fqyD(i7o@Bxg(@#d z#OQk7L>8vtD0oJ6O%)tZ60^R~fa3g%h;F@YE`iMC?j@5crJViuNuODgWZP6Ijjy5K|u>;L$?BM6~k znHU+;eira_xVL43edN3bbE2{46T8N{fkQ^^zYm`>+~1tH2l|bPXn!%r9s;-lQ#;MW=TXBBo+Jh*l;!%O&f}ubvFxzm-){3aOxL%Co}VB1g}95*zj!!YO6z+&g6U` z+W2^t#YGmgn1DHmFFsktTYV~dA$F;rM;Vj7- zMqeOkiK}v--r4x?0ZZe;0>^xI)T`MZW>qT}UP5ekC0u4jKBS_(pgL@R85MhP>joef zO?&SqOM05>sNwZIZ^u@h+sE4ebDhm}p-Vqj+x4teZ5*d(8enVKu2y2|DTB?t= zj+d72F0v)g@O$2pH+aSmZ!XM{I^EHk^oVp*!!KKPsU$cahBV1}QNmMzt{TM#K$e`% zeH72rLQA$zgqX3S;X*cEb4iQtslRsUySl3{+xj}@Z8R(c7yH$vEh!9)Q^0+ zxMx3*xg|clMivJhyF9AE>zOH3v!^__hMFaT31h)Q7H2-C1?VRSI+b>IX1~U#b;H?; zT3Bw41>kkdzUVf8e}8{cxWNimb1m{z3IFheyZd)`3l;r9xK^Ln)_D|xN%_bP*qOx@!3HA{%I zO3lj_{_5Wr3lb0xHjoIf*61lP`vQ?>|7%ouU^TJ8Ih{DSOsS_(gZHDz?=(PlU=8NSb%QcJ5&|B3!S7JnjPQ+jQ_Xfj<*UO7Q z$1si_qlDKRzQ2}T-i0=d6SLNAGE1c1>uHo{U50--?YhK8tKEN)Y~0&9c<&3Zr=dzBTOY)x|w5(lLdMMb~qtbA5!xu{udvkls$D?>4uIuH|wqx(o(Mxn`s=D-}ZtcIe zFu5(ru?zHjnU??tYGuTMWr?U)_q=BVfniDvpx76d*o43=wE2RdH@_>X{|YGkIf@`E z;H(Yy0}gO_3Sci`!GQfP{(tw+V;6Riz#O>NZ79h9oJ;wZoQ?eEXZAUi?9#(&INq_) zE1O^oJ~4FXa(^dusp9^nD3I??(ZdKTAm6#F_<5B?pd)Y|kR2GvToV&&A8AtJx`g2S zkjw6LHyyv`F$BB_v*$`xXfKDfO>9V*uf^xL*wTyUjVf|szF`2Z@AW5z={_bxc_ilw z?db1!u!Xlytse@|NMcon-CAKVElV?JH`!5tqb^@C?_G=WJ8!2%P)9gpS^h)jq! zgZ5p_PcFKBwo=sY`wk2&?~+L$i}J2Sr)OLmZg(W-$>xMl_;;@Ra0*CjqVvV-9;Od| zdWD*W>OStxUFN3TcMcK#MACD3%t$^z=)Sdv3fC7<$5kY zJyx%gpJ45{m^4p14KSeJ(`Bi5S+OrnpUExqSII<^XWeRlIEoETA<5yo<9ICAY{7*l z3$B86kXtL(FfRv?0tdTO>(=5Gq7%qm4kqH|z-l5#n(h;RdiON<~1XPq>B2|RYTWE;|QK^a)rENt(r1zdg z5s(@M>5$M70)!SqLdson@AErl#M{_-ECSbkaHB9JR3#!{mN4>~bPny_<JhG)9_ZB3k8Qr;r_CKPVZ7$l}5nN#p;;#KFXE-*kGiJ zE+su?f$6Oo+*IY!Cw;zzVSO~YL0xwvF8HwBD<0UlT8f=TXXR>D0Wq)1gVREx$BENbKmb1>;`;SPMD!Zkriv!fdL;#2pTW- za2CF?zrWG@E0f4u`&1xbv~jK;8G5%}r{sC2Hr0r{q7liLZ67$u8Wkncs7Ip3P)gAG zFQa#|bo3Lm9EctV&>dE|Cj?8T_tP#O?vsJGyP-RCk?%v?2@G1V$H`1G@8)FVNqCiHE@k9NBy#VC_-lauZu!m>(tky({8lie9f-}*bwg@!UEqzOJJB!f zo`US82J8FJU)9zk;$qG($_omGP{L#mSrDr1b0{-!`AU zc3Zcbcf z<&96sR07dIIOyv)KEZu(GCn)+N9par4ZX{yj0Hi_-M?nk1qdr!Th^`Xt!vLljRs#k zAQ5h%6?_CM(`r|lrvB})X@vpWc>n6)=JbI@3*ySej?#F!zpH%}VmlrRMAOq2a-~nX zJ_vVtIxcqfEIM!M@~KF^o<@`hF1O(xM-yRQa$wto_WJDt3!c#szrE}E9*qGl?s0&% z-r_!4Jjmh11*7ehFh9<0N}DXM6Rg{PpYDO3dMvF0UtHC}kUxjkMHC03+@D-K=3tz> zo*Fz~8y@%?xPd#CZL3Snr(%CDn8!(=7H_pa8I~^XG??`-oOW&5ED?}lS{+37_pW^8 z@(uYTN9u@0nDejFt@lyy>Cthd?&vQ|8*=oy165Op_gZb|?AU#gHPa_Ba^qRrXE7V` z-DdEe*|fC=&E}$F9HYPRi}!p}dP@$n=~G>QBH>a2Ra6rXNNhNX9I^0p`xcjNpS&=a z@BkV2mUGQNy7~!jooabqS`rS2`*#`kjF(Xb)@MvP{65VSZiR!d-=m}gvuS%^pB^Tf zI_QJ|k<6C3gms^CFp7#Ut7VH_)i|veC>q)aec4nsks_}6Of3d#UTsc$v0Jqk3cPP@cN-~8N z6<-N)q6(3 z(`=gb$*+47cgm~Y6^Gxbf+wOf8mPfl1UDfv;{>3660+lVIe?CP%mdR;*-E8a+CR&T zPYq)DRas#Nq^o0}OfYiWAaJPtzz^Ti!qi?dHNYY(9hdp6U+jy<##C)y*jXM}q)VjF z6r#2KA^BQUWyy#*-S5h#>EZLIk6Z4L1U)wOE=4l@mF}T4u1JdJz2PIviqEuGo{V74 zz5HX%aB5*E{Z~;ug~5ukUEVQYS;!zygjZZK46nbvox6|zfNpxr)R;3Mjvh)%J&(_< zp=ruW_J(YKnwKON>Q)5fgM+)`Cy=2r-_>PWcxQ#pWDn&z5eZGTt(R74dFDo9rd|!J#S`J^EK) zr@z@`lhbU%rjEW3y#MN5au>B^e%vFe5An@JdP)on+TfX@{_WO!PBnm4GEA!je;2o; z6Z2r7&4U|9q>iGfriWH{1?On0P7)_GUWyXjNlRMADN1@~%5^L!6x z{Hv+8mD97&?9#bU7C+dJ?JEPq+rI-hg#O&ef?EbhHn=GEBp?WY;$VTng%Z0vg zaatT}gxgQ^ZozaAg6L+nlhnno$UEJd zbu*Pb0@zV>I}LpAxf$`1X|IF~I9(U;?P)@rhSdkL!QFMcMm_3j`Fc{3-3QHIF@Jog z6}KI}$t*ArX!V-H#Y>`E3<0RlV8bwY#wf-`ga8^0k?Gmj(>C$4Kz^&J_e5!zEF*2| zE?sc0G3MHYftQ31@Ypb0H~4P*-e!%hauSs58FQ0)2$k_2G91nilbP?kYJif_d@I_! zyVTQivI@WcaCCofp7$AI{^e8i)H}rV@S3(Xh^5YrwdNcN&S7CxwJb-B69ApiieHUZ z_ux7r$nc2Fj68e0ioThcv}|rrgJy7Eb{mLa-5m#ORaz1tbeur~MB-M+?9kH$o_Sn^ zC7{6)*)TX^<}}2ymalYIe<>jK9n>|%S06f4*h&J+DZy2`t-ELPV>We;w_9gFBUS(1 zGS|GYpC_nlB(_{hL3RclzZc_u9X$5HHWerHJWnZCEAE*X<&r&T-0%6bO%p-l*;qIz zuc(jy^`Xvh@-v6e6lT6_hY>RHnRBhZFsr`gN3v`;hslg{Pos7Er&?O)2a4@2k57ZI zsN<9@DhxhT9a!4pFRcwkcXQ~MG9MjIx@GN%AZ7K7%gG;c5?M?T!kZGU;%yHxznLByC=q-t(cbtNLY=)``Hr!>0jU19c({ zf2w!SA>7j15*Du#7#ngjarAYpqi31R$HnIC6)kxGg<%uDed2`U&bICDoJq{@YNt)x zzTc5_%Nzia*KPky&C@0tag`>lp}-an)hAX>M0F&%7HxN997ea=QqONBMU1ca$Gh3H z5~_2Kc<>zbYF1aOqvym2la4;q9E^OF2i%Y=(ShTzA4shqW5QaX84gD%@$-ZkJIVYA z?^!^%(5Vj}9QZT%Z2OT4w@i)G#xl&>yOJcSabd6GYA=7lyd|pNUNU4xaPhDgV%31N zY_9e|f@wxoa33WLVIez$5q9XNgoo^?-yhg;nx6?LzmQTQ7ol%Wi*^Q3?V2r0PZwr1!03;kbIYIk|*lhoPO5tNt{IVVOCUw0jmsJDJAXB1K|lc8S8-OHEJ+a-NJ`0~euwAI1N>BB zixO&T_{!^4{ku{!fYE?OU!l*SyG85s-qwAnR?D%zfD}9>L$C9AVn1Odw1GgE!#UI> zyT1)+Z_MwNkV575c`TmROKOAIWbGz`u*Bw92T7Y{NDk_9mTX~(4A##{?i^0~L?&cl z_9aNHAk_y%qX{CRi`~BZJyERlgTgOvZzg%4ueUMX#GU(E#njFrSjsnR(TbVd%7?+=!-ro|RF&hnETfoz7p0Ut^6y0%FBSvd5Ms+^UAnUUh2@&p@rthhBkFlZH zcyHH67)4B_ctLZcfkHd+`9T)ztR}rvQ;Nl{v{| zqcJ1B>_ty~bLVcP#FlM8A`0`~34N)nvM%3oWOO7)+S#VLyq5YT6SLC$0}?{p8OgL9!O3;0L+mCXEx5Qtnox_m_D(Zx%OtM z%CY9krsBLAXuRnnV$qBJ(UR8!ZUvFOD~(1IE&?g0%n0m&fQZrEKBm9>S+Ot<@LEBiV7h)1b&=MaCsl?5Piw!q zv^Eyg&3XPc6m%enVckv&G_6O+3rf;i^h##aOqG_e;~)HhR1z@zo#50$0ZN-Zw*wW1 z=67?R@=UggN|%PSs6A8Q)!LX0ha_bA+@c!>qYiz$Sm59tzW%(kCVB>r3vx3H_EN6Z zqb_5YqBR5=_TnbAWSF^mja|PsvI?-30TQ}X7VNLGV$fqHQ9F9D&%pso+SRH5-BN8MW3;Ztr;8J z1n8vQ>|b=ob{^mv=Jn%3c)}d%^BS42F`8@g347`HOcv1t{PQQ&;}jMN4voo#|NfawHAY}-e79kJiE~7?NphF5*Xe#SU13J0e z%uY_987GfTilyflC2C9Fs@PGK&=MO_yyI948PQ|9bLttYc0jIZ?eXbij5xgRGODav z2)Yl{G{0J*nQqo(^k+_pm!eCC^#U*Jeqbb|k4ZHuHa6(l9@LM~kWL%Vw6oLqF`+E8 zerl4Ems6tWn?qbupwq(Vc~xieT$LiiM>Nj@(h;lRt~S~~bC3%0ns^ocA~?d7)-cYC zT{0c?^sdhu6s30XR2ER*#9xA-8J`E4h4yD>4`8eTmsw z*tmtvJ_pKCOhvkD8!$e+rQdohG2Y* z%)w}$$sgg!iGlj#EbNLybAt*Cury zickJ?@myb#hkQqF`JLWg3|=Eg1eKtl<#oRy%hmT{GO{%Dt{+0;um|F~t6h*8;wz95 zII;{?8bE)<6-EsNeIltx>MVFVWlX+u1eaP1RxF z)XvCq!xniA8rJV)aMqk_uY^%OY<Zn%KnX^%QM0tRt}*B;@Ko@lO-X`CaZgFFAX5P| z7|{{Cws){m$&LlvIb}0A5q2+P$#EaHE;_{G?fElb$CGpcLgVB#-^>Mj7v?jFr37c4 zmVGu$Tb;fzXc${J(1n6_JtQlZp*gwSp%Amr8ousoJUC<2OLgc z0iw~SHCxc;UvY|Duyw(rn2JI(06nOu(>ztex;)s(Xmgy{o~lG8EbV%fiJ*pR_e|~w zquo#T!iGx<4wHq1zsklv<(}MVE4IM#OIv96@JrigzsQCKrgncUw)(+~^|R48oF%K9 zmPY{=vja7Mq)dnYb^+I8@REn#)m^ZwZ-?zrMhgTkZydjt;PXJ_^`9m)r|)wN1zh z@r0E>yRk{;uU=z{1(vf)#Ln=ASW&)PNrLGqiYS=$NGaW8H^KTUZX7Kn)d<)kteDE_ z1iJq7g&9QPn4KUd#L6E)-;M>8dqQ!Fcpu*v0|V`{fHB8KaIhKyh!@Eo9zuaF`F;6U zhPlE6iLZW0+r6ClnI--8tmnXJ3UDJPSqXQPIFhxm3#(Z8v(^1r4mS+f&-cRW*vdP{ ztY;2^`ULwuFu3Gm9}72p(oQt#%CW%-r!=jMyL;O!uhZU&h&Crv!F>IEwl>MUdm5A0^*__j|z( zpm=uOs7WzjFuB1ZyJnyK+v|u`9T@#_q^}B^c{O`75MU{1~>IMj#C+9y-z(^(=Mr^oURn)rdtu4cn1-z7tS z>^!AI;qpLKt$Btew9))AML6lghC4+(bwAUqZEzY+wyur(>mbU><_NQVqP}5A6S3n) zMjqL8SR+Bl0oxziEM6qNDym$gKYVQZR+c-tvzL!y?0BcY5hN7Z;>D)9)h(+Tdlw=Gg_ zVKE#&lojv3iIEbL%6UyA={vrEmZhP@^q+kpW3J}Q z-`|VX@^&7g=UQoyX_w8uSmgJ~pa=y#cAiada=0gxsmG_ZEIu@EW%jae>rU4=N@5SY z@03wPmmT)?=4W_8N0zA!P`q9Lhi&SdsGgkDk5rh=uuD_sZtmf zxacWWZH29-bWE=jU#^V4WEvmFUoMm*Jqzv;eH1Up3JD}vF23EEuAUm~jK9Zs z(X{BFGeX7adFz<|y>CUNi;`$KB&q`b2k?J6IF=jzvc zG!eg!Cq5^Gp(OBfiK5ioI5Y1W3kvksNJ`uAe%)HHg^cNJqD_`P<$LUssr{)1S(IL_ zX(4l|AX9KbuyEBu6g?gh*8gref$2OU7h^LsWF++fM^qfI4r1gOyM-Q&o-kEH)HS>B zDRVR4$!iVv)b00)E}MvUJb`Osn%iJ zQJWv(dP=Mz(p!Dn*ECL^H<}TDEtV$)8AC%Wi`uFB9^kqxFOfV#4dF+a%*D~~JG55x z_Fi;%!|wy2B;WZ`c}<45cAB*4n5p`5ABKreLT5Bh3eC~QurA4&d`k?&y6&La;qXkLW6p60hiamP7fh~ z?GMi%Exwq3;}vEO_?P%nB zofwMszuxg;hYOiVfrts-;D>@ZRYk}ECq*sJml|Tr+gxG;0lZMS6 zNt?Ncl?6}VZ9Y?NcdGabkoVZzRq1Ni*203*CsR@k#guiGWip3f`-}du>F~Xr)@j!1 zZu2{kiNqrcGLAyh^5Q-yE6m$H=_}-# zlx&U6TC00=xgocj%VL%X)gDrK&T(JM2)nfW5QEs&V0nwxfU zKsZtfAWj9F=mDZbgQ3N?x;Y{Vh~>j_4{3ka4@Sl9J=HiAJGtFctu*h?BRa%U*6@ea zu9;1J1M@};T=b{MS+CHkbIAi#vx0H+u)HZ#$hYB!cmv2&T)uLt?mvBtpaw?mHZ}w1 zA$3~uyd`*Qdg9%#Hh!!^%$kjwE+D!R3$cVQgU5C+^Pg-u)elZn``~Kkb7Ify<2N9ePa)m!CL}0p=i=6LKC;OUB}kV#vh6eaPc zZ*i$R-vfwchOEBHl_MKBW=sz_t_}LZMSPAvu(c|H_SZ#Q184THo8@>I*w~DX=TLr} z)%1)s7z$`XhGh5rHqAsJ*5h2T*Y2$M#@t({WAH}#uYH?#3`{|hiQ9a5Q^iUWQ9ctq zmn^q;5JdttiaEs`6_sYBWs4U&*8*8WzO4&j7%y&+>N?D9wTLC=TUr&^($0@2H27uw*fO(i8IIn*D*8c^KrwvcSe%zw*kUk2J-D2*CFhdc_r z(!>pXS`syTE6PpA)SLsGYw~ym$G)doB+UPK2fb>;m@=sOsZmRQ10VigH~piYc?fb0 zIEdo8n;GsNSrT_p6mCEMrX;g^a{5Rl6aYCd+8nB{3B@bEZ`+?e7wz;8%doaVd@bbx z`8jf6R0Zvqlw}t0E6ngOA{&HfBp1F zp!asKV%7D$4TIN($+7PkvwuOj2^12q$T06>pi7I&=FgH^z zDY{c*rOjT9b_GBM?Gp~MY_9NI-|~1M_v9mdvOHXRBZO0Rg|4T5XW=e4zy-Dz%UQM& zZecNGn)UI64KJ-?Vf7^XXS{u!LwmDT}ZMSjNy7r@37n|DV7yuRr@d6U5fTHcu z-D0)9v(rD=7S!A};$s>5VJ~>16Xe5|t#^J~NWKiP8I(>3P-7c*UadPRSPxcsdZAQ_4#lkj+@|@a-b)NHHNg+arn5v<$TKn zssLedsh{v;vpkMgqDaB`#v zqo|n8mtW}be2-q$BiA&KH}J>)9>CrssEk8MnfaO?as>qY=WYEhL2t+@AYn%a!1^-k zptA$7xc*{4>GL9DobLis7Tjmv3nq)0gDzaWfDo~t`5l+40B}v(tcd}zKO#oKXHGm# zD+D0mAH&i#fc<0dp2q;{`Q|+pfRQQIc?$)oKGY`)IsrF$x!|*p!hcE+M%RGP z?mGaO6f=cT96qz{$;}~v&wHKamYO|(0pSX)ix5Df1xIaR)QO-tZbCS)1p`Qqn}GZU zAPv=NxN%Df9LF!NQxM4H6Gklb70w-;Njohyssog!Y3*s;eeiEXO-&~GLAnA5Wb*5C z%=8TPnZ_UB&n~i}AV7v?e1Ojta8T=UoZyx{srUXI-N>Fi7L1+jyYQb={r*$_-+%lN z6r~IQ-)*3hRDiLDh7VC7z0l+hugq6g z*O_Tfkh&^60XGU`E zhP?)mU*`ZIUJ+Pu9)TI%t`cyyVx>VsS~8fh9&f4c3NX@t9TKkqhz8@bptNGPxw*>e zW12F75A9j8s&VTEnm!C)wz2z>Nwz=67G%|T96mJOXnG*{!mALDvOCsqT?iSce}D6m z!{=HGS)jQD+BF!b0+NXwdCO>pfypFNcwLP_9j!256n@mQ+27w6(PhTR9m)FAb_>*Y zrGw@PQHdjVC&oJFEtg9jW8?!%oE{#*aoYuI@ zHc5tJC{8O4#Z#kgZ+XWLp?rDQYIYWAr>RAW_^^%ZVP$D2UJ1_MO`T88GXdcMPI*-? z@Jhj1V#U^+acN*a6l(6(tgZj+RfliO&|PFY;Y6cLBe;iqCU;h@weONkdrcv?>Gj^R zj{zG_AK%~yRmn1Ak<^=D7{a;P;)#cw5-unz#xz@eNzvTqV?938KPz5F^AgZzgM_;E zR9bDvZ6CY>*m%VYjwZW2zSn5eSUJ>kMbxQ}7QHd2uXLsbJ|ft^<&b zMZuBvqaq1%o;Z@`ZVV`M2T(ILvST*#N}U@*sGrR7^c8V8px0VF#@i}5$RQHpu6wd9 zv^U;+HnX-)XH_OrDLuljLCnz$OiuPhnoC?slbZmufT8}p*x`rkj!fgw!EQD1RTI3} z^)YdtT`)v2{@Jr%i_^ztAC_mS@bi-{$~oiC;iH^op-w?9QRJuMM^WULP1eL}?#Z%r zVor<$uA)FC`_aNawvRf<4ZDwC^d0pcCEL)oBZ~YV^6$lFyAc$lzjSSbBy(5aRG+2S z4tmuvL$H9?SU_}DOA(lPbJ@N}TK9BU3D|avS4sOkE#&G{2?=^;Q+Sum)+}C5)w6`9 zcS#rIoig`*I0w|WT|4$}?QM9a`?S|SumD(8$D>~u{|;h#W4CysQTI3yJwd;qk2^kW zm+^X4Z_~8E_X67s-2+l6{DsZhBVy6O{=L}J_FMG_P|ag0Kbvfoi=XjTH8B|rn)#18 z!8{e6_6)KU$|yIgNqx!#Blh$V4WvWP3!K@Gyv5#KANrv4Nc?MIIA_=q0HTXh?d#Zu(t@_JU3Yh2%>fi@w$%{4MeWB-jBCYNZ7@9`N@-!XW;ZV(r zksYwo7p;3ezwA0RU>yB+So2(A>!Sq9|AjiML|npFl4bkK#lLMoR(yVohd8|++)RC?2>sS~YCOfF z&t3b{+Qb352Oq4CzQa%VG$}C%G8CRfz^{)q?`HS2FSt7#Ug7_BY3;ei(R_NONi_MBwz+zkU{Z#0{#x3|A{G z1^!8n@YdK$V^{+Xgp;YEh}P!MmA z!^3Y~-0HlR&*f)mt%y4EeB1FvFlsi0(`US1g0f``0p!hP6N%{MZ*AbkF8l)}Xmk(R zuAGNPo39>k%B2oY-Rd78)V}leAFHB&d5GSA>7{%QZGfls`^7!d~Emw zdTH=KFs}oG2e2770{;ga{~vtFZiDytuKoAh_{HNY zn+yUOnu&4uVOc|L^^i^A^v zkHjc}PwW*zF|2A_zuhgYEV={A6~jrlXkopYsx_rV`OsCF;%1Sc!%Ymv_u&IpDpr>p zTB!E~7j5`1fXJY?qXMKqPx20FS|O|=OxH`Ob1nSXrMcaCzTXxlA6S+alaTl&?F}f~ zr@PeZV&09{utcpibj)pWZWY5DG+Y`>@RLvnndshn_ym0Ds51-I}KQ zPeum&dbOI|e!Ey7I*1dxwwF69ntk_wYgk(thKRi z#Jd2XGJoyQi+4oRy$u<4=teN2kTo|V5O)!i?BNjc!S<7W{p_bT0>h|ebjR6cj8E-b z5VRWBPa)js*X^}`#j;vLJXqMi9l7u?*X-vH)G%g89)j^ji(=c2Z*2@*B z&&_d;oJeYk8kvH)LDRfF9^f-e2eK9gEY5-!^>Q2OuC+t>0zS;T<~9Q!tmcjQ?bTsj z%9AC2?#qDf`7b7fP+)Z*4JnqSeO>tDAn8t!pE3cjp4Sc#Z5a zVMQV>6z$avmyM??*`$-b!alMzy`4ui-3Bq_xYo!;t&7yusX<9KgY=q;YMro%g^YaB zCumQwHU$NEzS(p8s19K8pjx7F=qPIBM7q&HeQu(SMSFQOIkk176_lK-?Le=I`g4nb z{B-GW6?p301Do+SVtkw^`kMZV^HgxZYyaRUOix8Z8;?p8v{6Q2eI-&gPI<8A zkW_LsfpcV86@0qZ}m80h@Vzl+*i#Y|rn|xIn(GcOK*OJ%#npfpnrbpc%sp5n9?qyOZTNOHw+%)J%`&YZ#)~5bGFcTv|iB|T2NwX(|BhOEM?_B zzo-V))kz_@Z*`8EOnL+T(GCIe?Hb6_dG-1Io9*Q5VHJ{2+#tuM^>gSoaxz61S_xLD zJq(hW(x-{U0```BCW%H%HTf6OieZ6c2Ae5^93YuzN;|jJ>5KZW33JS5m$6XdV@^|* zXMEqb{ssnXCA7arz#_dV2KiQX6Yf%|z+SACM?sJVf9 z$LC!W$bo-WLytMDP}@`$bPV6;*?sL8)%Wp0Y5sk1-^fkNPv1{T#mKQN-wr-i&i#ST zX1K#W^e78D4^s!-J}%n}oATct>&(F^y>;jsGIG7K>`)3?S^OhFqFa8kFXZIIC$pht zHNUQBJWqeh36^$|AGU?6zGI|_Bxz1hg~kCAej5ZIHI+)M)LgX&+VI4n^^vFTVB~cy z!1loT)w3mVCBoh&Ke(^o%aF5^fl4~BHi=Gv$yy)`bTVsTby-Srugc&Mr-TAv6cg+` zLehvnG$Q@NE65h*ausN6O%I!Kju~pG#ALnM9({U!nd&ttfl@$b+3A%Q&)qo2(pNVg zyc%U8h)why8CU@;e&9PLJZ!CEmsiKD1;dWBusNtY2N(Rp%mb3?G92Dt*{H1>yy=lIG1 z!B3)>Kf)_~x9oKrZE8FWea}p0?aufq*i+vCb#jhneI|8`Np-erHJDbW%|Vn6&_35WcSdk5kD4NCr-;1 zVpdFQ38VOiWHRR4WAfH>Jd!=Z3Sd0ZCCrP|Rq+rlQcLQn5O9L}byYFZJx(h(^u7;i zmiq4I*vUSz7^@PPld;%>i@B%6?HF<5pX?&~+dtL$>^{RSgGG9Tt)$7n(aC#25&rK! z_YANg5Ic_K+-!VK7*?W8f@Rsg=ueZ5>?qb4r>4>(Q!H5SNc{~?IZC+vtcL#$-q0tJ}aPHw=h+ThH40^2e{xQuUJt{24E~2b1J<#eT0Gc(QKe#4ggGX3tV~0>y zE?YczR60XTPfdFv2E!iy1|MJdfw04r?i8A`a^$0p-W8`UMI72np<$?E*~|ruz<@aE zFsjzg%57+zsW%eu8+7?pgLiLHMDt&ppZo$@uNGnyLbI$Zn(usCMZT|aAKOfCIXpnp z9m|}knC%JJUCkHe_YBj5KJe)RlbGI!)scsA!KkhTeXAqGH4`c(;AFwF9wW|Hd%}9j zp1L)r=Qce>tvPq<1-)>nBHQlf5+Zka8dID4iIQLHRi}CfpG>W;^o|j|2RUw`!|g)`-8BWraoZxN#~B;tP&WEK+AtQZ$lj3=}g3jAmI zU$%jD0vkSL1BR(y+R${xYT_d^=}_8dA9ygSw-%Z%N%y(Lwn`~r&FfFhhs~tuGINJ> z(o>+t=W9*0`-K*F6+RK-fEe7+<%#hCN&5yO^D|HN2*wuCKt_b!=^ojRp;vpY5ZN-n zky$#R>%5Co5_!aQT0-{?t_u6hOoys?cGHHN=cnF~fg+cCS25XcwYfl(yKB~SD$^_yZ@cul|DToX}%Yyfo`$q-Gbht{Lxlc{pmaQ3>;2E*Dmp5n3AR z6no|Op3R7&#Pvt&ox>OQjQI76%Rs!lD5SO*HiMbmnjfjDK4f*Lz~qHxH7)Hu3I%4rH1u)TXhll{x0Q4{5ls0F#WFe zY^sA{N)W$;#c<-?+grGmB?+gy*pQQTZJ|olCqVW~vxVmhb3`VREhDw z^dRX%q`l3r`i1A2DzATc8}9%pcUpfS%8$g~B1vB_j_iwX_1J8+y*{v6VI$R@AE>yh z?6n^f61WeG`R2w;NOaw8%Yl@FQB2UL#Y42*%1!`gcKps^SDKOMdKY#k=KU72rqhc$ zs~x2iqnU?^^`H)kMq5EotdIpnRj8M2wDIpKpZp3!O@c?G{tgR|bCg*jQ;PYJH>A7G z6C(4M6yj``n?Kc^$y!`VM_`rWlTdQT`&in|8SzJMxFHI`3m&v;^MlU7es8bPAHc$D zPen5)=i?FBkY(^#M34AsmOZB0_sS&ChFRlf5Foct$x`=TymT~g@3Gb;RWmcv?=wQi z0f^;jnabtS$rYZ)nH;+aaqG~PhmHracLMDg3}a2`Xfo1{?FpJENbG?0!8}u&KIr6xWMl+{(y+d(d^H(@TvT5 z1C0`z%ZUjmgW2X^c;AKp!u)RC1?-d_A2!+a&imfNjjE-3UArRBE|t=cck~a=6fV5&%$>rK`Q)RF+}eoBSufA5 zc1)|Ow6|~BTG=+&-9{j_8Mnf^W2eKm!}D&}pTpOdH?QqEDlV~m?YDE3xCWHi-V`i* zkGEEi-d@UZkkSFdq)IKuW#`H{RB?6ml+ISs_@gKu#G&7MZFm4hEG@X>#ke%K`rT*D zRk)Z;M8J8z%*B*`Q^`LC79+`ieqmo|FV)T*9kUN_sw1ue{KzWFkTagNM_bsXS!>0V z>&BE)p2>?@PkvjZgL@`}H?A5T4!XRrDg3*Vx!w3R@i7IwQ!HRx|5wcno0g%M!v!04 zKH6@SFg!T?y$sMDPCD>g&FMgqy_I;fN4m|~F*(kV(9JUl;6tda^kA{ z$UI2&3<$LtDt)2%wKZ!zx%F(~K`<_CdI@01G}kym&IkQF=8}^N9PX)FHS{LQAQ67# z%--Ynr#MtGPGp^!M?~=2T6WHCWp0`l*0ugwG+qqF6YF(mkYM}ubFx-b9faN*9Ch=kUS*b#jG4Io!DN!-wud0 zix>jg#;u~Cl%&UOGQ1UoU}G_+cjRm56Ji@*TBul8eZ1}Y;Da63N&jzQY7xZJzQ!)i zWqP+AX|9$pj7T0;2(VQ9rP+5pv6Y-d-H^dJJ}Er`^z-BYAex_5VQAh4q?Z2?mq;M* z$k7$VgVqNz$;m<8UiBvlg6X3Q`17Ga+nb|qdG2V12avSer8Nq*!$RRfsV&2e$rQieTmg!d|gR3sWC#8$wB#l zEQjl>6d#V8p|YD;4bYhE%}n2n)1^7vdka>lzU5F|aYa9faKiigJ|V0Dy5IiUruRVX zMYU4h*^P07gjWJe2_t>yoYSMxpIbr>fo|!4f49Yd4Do?>_&r z_&qpK4*<4x`lm}+%lJUdw6t9&r-71um<$`$9^!^Mq>!VaA}%fDIQp|(gVi9i*gH!w zWAXf@gByR!c`OYIhGyMk@zg3Jl zC?C?F zm)M-YUDoz{JeR;rPudi9^x_ugk}f>ZEVUH3h< za{64~D#7+G2@hhh35(v;hXu@IUHV5y%g7p-lWfoe<}{1ILJ$S82#cp;WYgniupM0H z*Ujh6qOYHh{~o^)V1la{i0&7v24dYPX%5QCp_f=7MK?Bj&vsiSGb_T1o$E6D95pQ- z^v_BUYowV@O4>@Q6iaz3a@OU;bs=J=n=hLiLa(PZGhr{9a~I2XDt<x-@zwYLfDG4>PTb93t$4Tgad)Li>RQ-Rq2W*ww z!v^O6$7zHA**4gqvW*vufzFKC|Av&;At6laf&%*}mEi~pY2zJAZ9Xe>%?i}OXkpaC z?=uSsI)0msVdv4YNAC7|>}Ta6J2jh0wMR~}!|bOIqUPSSu4L&w0-HB!0QecfZSDES zyGa-*i(ZRh2LXLHcwpLF4h?~$M`ZO6v0HG4?5tP$ZCRit^B=`FKM5d@$9qSV?a;a; zB}t9PSbkH4pzvQ`WP$~Tz^gGd?U>`A;#mk@Xsd+|o|6AJt~m`5f|%6)>nsX&0Xa7* z4&KWX5AD*dT9XB@pmFFbjUe%yXA*C)M~`}b808=~#GlxR`MKT_ZqTJK&asx=SzWhV zS@GLeJ=u#*$VM`=Utf32Kr-6}8`h)1>xBx#-AD*c%vQI!q zhKIWJP5yL91#RTI3b&WQ&>d-=;6B!9CH!w*hRq>Ve;*Awg;|i^x^axexX=!LZH9{= zc7G3HX{mcR3P;&se!gs)`JxLzw@L!mM+-N30&XxaMD8vwHg07!eyR)8M>=1QB*mxB zYjVMcXQNB@Q6k(7eF;Z6zB~8=lS&}F3k__IwUzG=_|<@G*K2fG)q-Css^U9ZzU^Ij zKI={Bkl6x0MgZ1esJl9m9ZbRv;Vs<>bcRhK3b zdE9kuP)A4K96IVflKNu3UK*();=qsXSMD}asj=n7dO$_84a#42Tj-q6Ym4DC7>`g~ zxe{*>0lwc6n!}4N>*$}}KkMW4;_E>{4N!PdfNqf!(9iK*8kALav|pl3O>waOZuRZO zRb1OSz>R^>-BFnxL0-L_YAX1A)4(MCpi{A>`gw=rC+V6iD)EMbPv7zhGQ!b1n&IRG zX=TP(j~ZtV60IHiSPa#rpD5=%PRdB2XGSm#)Iq7h9^`>JF-c?7PAn$esfTMXL&R~p zDSbu@MkY-^8(%QD3v)P{sRO^FG?k@Yx#)MQiFIC`dlLE0Et>9Od)S37EJSVmujZ~Zs;O*kADW6FT|lMCFo@K^fC7f1I1Zx- zC`uC~C<22KLK%qEL`7tzia_WLDj;3yNJ655jM71xl+ZyT)PxoScPDyh)}6b)_1!z; zkDFgfR-f!9ad7t-*0u9M@$#!0n#(sIW(_G^I3m9oyi%i#+r}JT=W-5B9nS4y6 z?9z=&fl$dDhKZM@?x`Plk*>a-*5yKKoHB=6l!N>&B&Lj8YSusSht|wE2o!V#3| zT`7LK+C2l&#Gdx{#ktGTsfK%)j`bLrp1xG^rtk7V^fqG*1($Vq`0%uUdn(1?=M#3E zhxDm)t-h(Xizvso`oS_2Q`o&(r50hKSoKI@(HUrxO+3F?5R2fk5`B2fn1{U2R*lo4 z!A2`b#b`U$OidrzVe#1jcd;)-Tnv_3t8MdVWJG`-n{bc^K0`{-3bv2S*O^?)P6mEkZEJL zp{9FV`uc_xf}6GQ(U9YC20kTRfbP}ciXtv2X&Ae*q))>u;}U>?M& zrng(c!4kO!qYl57PFPlu^Sr*m6=`CL2(W5cc*w`9W=6rGWnpaUgZX*JN`K2?yow3W zJ{q3XMVz_W07pI!$gro?Zr@x+)W4PT+;1d^G+uQxF2#nsN}@3bO}E8`urkYAxxrx$ zRgM%9@AA{H*NxYM8posd_4xbS?jB)Z9&1m3U;Lokqk_v~zOT(4v$0e*% zX~pa7sMa$Gj zQ5#dwh6RTx*Qt!{Ro9{htIz_vIhZbuH@ga9T4}MXv+$Wtv~g*R39=kd%^fXso7a|? z8o4x{Ion)AV_oe40jjy~>QDPZM$?m|Mo;HhzfuLIE3X+*eB3q*GW-Vom9EH)` zv6&r2Zz4(~ArEESjj5)-0cvC(F?rd8K8-dIm>;U@UC!6>mXeYSe3dZS^s!W+3}j8f zCu^jg+}>}r8Fb}0Cni8C#O07nUDTwnMr1$S23m`BRKPy4+8(hx!29vr6thb`f$1xE zUG2YTX4ASqsqqch@sTuGI}X!AX!CP5TtNhj!Mv&D&pHhB=Sbi4lTIq{X8uyij$ymv z<~nsIdDBP=8w$zCp2yfQT_X#3JyDuukve&_%5LiEVCho)+LZ$7UJ7+uW(H^Qd^?Hvx8XHhh+LZR$8O7}N$7Y-bCW_{-zhguYyVwkXXfA1KpZbF65 za{VN-PRFo@8DJ6^h^n(#682)qx9{HsF3GOrgm= zzj3?h5_OoboT2Z|gG>5y&hqC5clFn(6@R8h`~fLF%f|n7zAs!JL_8piNG~mD{rvnJ z2y8YdwB#En>-YvLTa1|@5%j6rMlU%5%LH>{xh&m9|?z~|$` z(KpL5%`G|yB)(&Qg?m5NdugS=61MH#a$?RtX!hl1RLz{E3LF2p*2N35pjQ&M0OUL& z61NBzU7y(BFa6?CA?C8gZai{!i|%HLzCizRb-KD__0`(c+ypuCGr8d^*Kl%>arZbp zxMoS|#Y$*6GGW$M9m=q8EHR5`fy0NGfM>}KH7SNxA|=JP=DJ$r577}eI45#}(_jHtn$-kb z)kO6vj((|RF`^xD?&XaY7!L2TGG1;oADgCerlyC`CpEYDK7|6hug*L5pDZ8S5s)5u zR5zP2m7C_uznG9CwkHrX^*WYGSuxUr<`tb8MLaUv+HDy{HF}Ld7;u6rao_Cv$xud~ zlJEFKL(`0;D8UH*l{?gi+4cHwW1I#Fs8$#L))skwn0<;W&D8$D+yzCw1VoenlW~sKbLP@Svf^7d0AW3E_ESD zE^9Qu-~Q>IE;mZl2u?KYw42trILRNH1Um1Xx9)hVp2nkmT+#k*9p7w+&{66^FesnI>|1`7CVN7c3~PX7aI6lit>SHpy1Fk> z$5EN%0>gBoW5YbCOQEk-HOy7Z=X7(+zOg-|Zb6c{o$*4ScJ@y~%)?RCb;uVut@(^$ z+e8peg9CI%iyg_>QjsK9dQoDQZ$-#LuY*Rduaq58VplsgJg!_*pLB;AmZ|J&X}jIe z&*TnUM*AsNW!b0)RmL3(qS_0Ft7edG^XzLW6G@=Sz+<@Ipt~scO0(NQ56TQ9U6CA*d>whGd zWaL2j!!;wXzJUrknZ?h?;#lMkTmg5rmNoe!9Ce}UCO!6LJ3o8i!Sr@V@9Lk2f#kBy zyxvIOAZ}vjWnF3YV8VS^O{JUrerbG8p{^H{kWkp{k8nr9UEG2X#eh0xZ1dt1uks0Y zX5cx`O`0ys79<{#;VP|pgAedL6th`Ju*%A}4`0Tv@`}oHwB&FhB%_zZXX=11XjadX z#m;~XAj6p^WR)-^T%-Z9EL0Nf*^RQ}@*qdO_vlN#Gl*F|;#%RpvQb!WLAO*jvl=MT zGppO1IaaG)`rg|~lwVvz4l>rm5g!nOKZ@O#%j`@W8-Ap~+PHlJ%D~9y*ChD;%JNYs z8=J`B#J-U%>Zu=tcnTUIr;}BUk)8J|&(6%xe@U{ysGS2gx-C|~62E(~k$WPlgvL{` z_w`+|E?fxHzZ-z!?d-HtmgGy%DHM4))^YLMG47DgYti+C_wV3Hm8Vykt=v^VAp zFA&rlx3qJJ+l%G(8!&KAXSS8N4!&?)R}RP)xhEm20f2YVMZ!;VEZ9i%MqevNY}{rL znSOuj-zp{lQ^lFzv*51duv@?>{s%w{)>ej9K=>PgEE9nFyJIjdk2S{M2X)(!&{yYi z{;^^Cy|VS+?1Vrh!!qHJ&;45eW~D5W#{W0)*BFC!8(+gGzQ!1=yZ#!aThQn8`zG~2 zRnY!FP{lt1`Jb)w53G=x%KkI21wr{QA(Jd%vA9&vhA0q1fL9nKxAv6dt{U{mT0D%-mDXD;6R= za#@IAt{_&g$4-KMp*=&_Zsip?(0?hitEn@^9w@fyw&?Jy^jDip-dWUAq0SNBuBug6zZ!q9^Vqz56 zU#V-O&gxr~w}i6wZTBs^c}|QfnGa{>8_qYwinq`1-)Xm8M z&b;+VnpKF>vEn=c#DMDo?Q7r4^~cL}8a6jaj99&u4=mvI#Ldq&J3iBa#(Ea@yDGfE zyt^uu7ZxF6K0Nm~o#F~+ZIy4aKhr2&NWHCEOgMLA{ZXs>N2$hp)HBh5?&<$2{!BC5 zTGCbhh0%BP#`;&FDGu14{3Xb~O$0j+)7opRTy-_W-F2}I!cUI!01>HzAD|&ch&kbp zb~w~3Y4Op4p#SWkN@0QBhjfI6G+Qcy%oXVLz{kxy#Rx(wvA?->k_G(=Ye2}(qlPc` zmanFLa3>4+E2SI?d2e~g9?X7XEnmDAXd$D?a&&GA{`2FG1PfSnyA`KHYVCoo0YL6b z*S@#KZuk-epx^zHMLBUX8x%RU$NxDQ~L8$H#DmR&_f)sX{bx8qBlKR6-9f> z;cl*Wl0`nBw3vQ1%r;FE!h-Pdd)7!50sjQhtO>(0O(E-#_y3z-2r%-OmW98c>JCB# jPF6bq*T8w`$u_@~?2^qcd5a*rbcZ0_49y@&cX#K|HRLd7 z;QPDhuDjM<_pI|f>;47r`|f%6-p_ve^XzXbN;1TE?%e?ZfLKoU%?ALu`4?9b9}Dy6 zv(wfg0AK+s3TkgBbBwcdlCs9e?sIYtEX?MG*=c2rJ^Gk#Y-XHl{DP$`@`1WxlBf1c ze-e;mZ>-2OS{`c?p0?ZKrRGM?G>Sag7@e5%5B{~ayFYb!j$H3( zTSHnHe(LHQ8bzFFd+R>Gmo~>c%7WXZ*CfGVEHZdeJ_Tq3dreh<} z>Qk)iC!a6Dxsyi~uv!g|-_UPaSvmQuJ=rshtDmB~rmKSdV_;(y@j51!TjMYd>*&s$ zC>J09$=RjdrOwLu5RXrvPNuTAMzRY^s}jeKW0Eqe8~$!|#Ne_}2DhxY$GT>9uXpdG z@_vPm6hJ?jSy(xHc)*r|bB8OY4-SiEbpombv$|y2Sg-dx^L))H*)=p>;x|Kd46M=B6S>3NGp)Zh zeJhSf;~hPHs>cv*6B}ZKX@M^0lnjDnzr8dJp?uyg>W^6|`7B+g0#QQs0O+S_5-M_bE>9ba>6+7#6^<>SVx+Q)0lvmY4eWhX@awEdJ37P?#; zr2T?5UY*0DYCX4gvO3y5FTe_0OGs@jzbnn_Xr?MU$c>Ug$}(}dGB<;gO?Eh0-7_ky zBRg<1Q)gvn43-_Q;~eZ+KAoY%F-jy#{7d}L-u;!n3b*twl(R&!dF^Sac0Plr|Y zu5^5tUaqhN#hZD^utnFeB@gd(mc>mCcTy|-a!zO;Kis;nljZ4bnHXRjyVCP2uv_!@ z^zXGHlVF%Z} zeVfRdz8%+I0O;aqatkbA=vtn=-xI&V{8`@QxF~YK({QE zGxT9esLjXof4K7D%?$jw^xbPbBvsb3uV;P$fw)2J58xCm{*uryyjd@wy_6h&eUv44 z&*|lORJP^rDWrG1hiM35AlI~)6INMQo;hkYac&uo?s4Gx`u2W39ssb+63v0ZWB@=0 zWdT3{0K)%2eUJsfH`K6MFvNjZHxFR4|IUL6J@ldNfyeXgZyxo9U(hPRmobK~Ve@35 z;8J`zzT!*NzULkIG&Wg0_E~u}&}xBa^uR!mA)>n8c{s@0Z@vC5FoO4a+ux9fz;j}(AJ>@xXaG{L2PPYS3b0|rkEz#9jf{kyIdoMV+3`Wag9Pv;lc^>K@I z>N}kQ^M77BUmTb#i8}fzaa`~6O04-NyP#bw9C0O^pIFi_O!XY}MgCMu)zLe93dA*9 zhsb$If0YM?TLX;;<>#Jfll1kKQBQ&8e@RN}X;Zj$k@nLa*`z*kPWJ`jQ=c^z0W(9X z9$`m61Er+5$_iz`%Ip(`i+2ro1%iSbBklJk27R*oPnbDC_$5^f>J(7z%RTP(^&K(F z1=EX&fMqnHT4IloyX1?_sXz%ac@O~p0WkC=QLK|Te{=TmCXftOkaIj>5#tEq+VAdm z_KN+DktkVUZ7YoRB$X2m^MqiA$eKImi2Ugrd3zNojG+M)9iyI|#a?hBkvw%j3fgej zuHyA+e6)>)we#2jV)=gXK>fP6siFAK1{>jsW@SXoQ5`)R2(36^o)!!tZG5F{I}}3d z?gb`;FIb0oycC{c`yzkemf|GuXn;RCNSyXH(3c=#THf{u(42GscYbv$+y~Oqrcp;& z2g4oJ^zSS>39pSWwaxRtYjg;(EC}KdB7D0<;SO@?)LoFZ4RrOP#x3=jxvaU$e^r z$cna;re}q6nC+2`Q~%uUI|jCUAcT@{Y*F$f4>JbS4NM-9+oHE#?hJN3!ZQjX6LEhK zCE|8xPzoUFNS5Ffs@g;46Tu-|jK_)DIB9QzaCaaN9ZC^BM;r|e`0~1*U8qMow3Y3A zW?V?0+~o@>j!X=ZYUjxE8USZLVIuWmd3zZ5q&VKq4DI!KjJohT$X7AU@UzGvO560D zuk};UDvAglUmj8x$H+X2Ih7EsT9p~B3}4xDOS1g7LxJb}+wR|8mzkjwOa{QU$38HF zmffpwnma$+%%+O)ySzMF`_@?A9RSjD8)Qa19z89Z1Xmwk9?m7M^UbHHI2`xMtf5@z zivoOT!#)9BE7rtUIGSvP4p8On4w8mmiNP01nltwl*PIRS3oE)w<>2Ud_o+LMhZRpv zzW4>$-1WWd2Exj`+`Omgdxy{Te5NczTReYBNLh3vlpnkCmGOe+4~EOiw~iOP(@R&U z>(Zv^(ddmFqLViv$c%z189u-r|Kogf7MYZAyXOVYLVq9)RDzxpuvSL;!P@hOMPla7 z?8V#~w(!z~H;OmLj(VR%%dfkX4#du{sG48`bG_QlCN$95Ly6@9#^^zKj#^C91$p7K zS=T`e6i*wN1Hiij??ZNtsTWP_Y0fb9O8~qV5dbzj51{MBF`@!45f{$W)a_`jlFjzL z=q5#q)(zVROMCZll1uqMD-SU zePoJhjRf|+?X_!JDHqyq%POF!jL;(i*T^Ya_x`&XWN_V+cz1w(+TG2G9AAY8RL#`{ zFxb#IxQK!WJorekJG*>bCv|7{)4?oN=0RUx>&a3>6YNWFh=zn0wa_HE`p=*epj z!|&;~>WKpYgQ6#%QPrz7bU&Kr5K=~rD<%u%wO$?#PhG6fr77>D{t~|xU>?^VlF*Eq zg~TKRHi=I;8kfNBB7}mKgdT-cdS{2rypVm}^S$=`vp4rJ13E=l`W08Mly&@`c%>R# zYqAl+IkPPRX_A+)@3mvdS_{Jm^MFFtX`2usEp3%{s?b4ee@_Awqp)Y#X)tn?Kqsw; zDF?EH6ulpyiThukZ7Z<`y0i`*6T+J>xxGOoDWKX7G|jWW zomNz-T|h?0wk-;uGee@qe#*MTu7xLOr}Q^*j2z{ymG|R*E-@XY<|h65JwXbWRkRL4 z(YjjKV$HLvfkB?OA55`8=%t9?)|ZYwD(Tv-%))>EujQ-Q5e*c-qY4jCo7e_Rr0iep zD2-gc*Z!bNQ@GW6_-^^xR>rrBu_~ML2v}7|O@~P2#p(3GfR))m;hxHTL3y2csmYtv zNj7}RQ3!f^e(y3Io!kK65lHr5PrHots(3pqd+j{)F1{oAR_3EL5aTN=erS0Dz0;ln zaXc@$7Xr965TC#5w5WKop89)HnHDxm#8PPZorzk~H~fvuHWkP5;Fp6h=*6LXw%R%N zYT5Qwp>da6B8otQKf#v#2j1Qnf{Houe?IJmBE^Gyo9-UPXDi#~mU^d1cZAt^dU>`u#hz$7#-q0Rs*oTJq(-H-3hF#bv66UB=UDb zX?Wdr$E+;jczn)0h}9;-Jl;1%OWhZxvjr>CCzkk0@_Jx~}Lx0+!2VrkB z`Gm6fVida`WJI)5z0joZtcl^jPdkO2u#Fj@-9u(Glgbt$6t1gNb+{ z+eK8dZE{{ib#C#ONuqHI#x`n62)(5BwTSa9d{qJwSaswv1Ln!#!YV^RFXrplB7ez^ z>ra-wL#Y?S<}33Zv8_$#A1VO5l=*HCbXOZrjP=V_p|gJ<lAKGnM^Ie6X(Q zcbUkOH-<5ju;4jlvTNsVvJ?OPDOENOT`D5D_~3Z*3@d%v$Fn_n2^LQ#+KF<(2sGh% zXy0%os5bIjX-jbPo&XgT#??y>MGP9oTqfRubbh-zl!F5L%Z!@-eICddyW5-Xs=v6G zGFjKtu;w}KVvd*X!4(O^J@*kmVl)zUzQG-XgaPaA_D&M9QZd};84~zAs`n7nFRmR< z%#!XxMYd(8gil(&VwE&k6kdDui|r8*z&rYIDWE4JM`E}o|7@-8x!D2#kDkg_pO7id zSPntw9SR%ESs^>^A6xydHY1Ad+C^}Ajt6OByw5|aJYXqeR8_;+ zvA67-voXvq{!w)sI!pFQs^o(}{N#^6{3{O?7Z`t_bvM5nCR>pDrrPb%wlbQLx=d}4 z@{A&atdEM+K5)O0V*z#q_9%8R;fnUff2=&CRn+{pXWg~h+vL1@)E?1s8UcKA?s~QY z5MBM?ZE4hgUb)={;Q-p70M>`ooEs7oCP-D6M2YLjIbH^cikfqpd_L;SzBHABM(wq4 zbJVNjabkX{-WM7f296e7aPhYSP+Cu8QlC2_t|H$e6&MWv#$5j@Uy~Ne7Tt z$ao0m&ic?>sP5~6eB(V5Bk2xxR(LT+Z3WjnSxgoF)-o!R+Hi?S2p^HjncA9Vk(fK7 z9`xbY3R4BTPQ^-{&F}XwK8bui;-O1on?7l8P%N;>H^D~0#*dw>F>Cg|bodhV*E?a$ zZ7R``F;v^Esl_cvuKryyg)KlmZGS%yO-;lLlp_ttzzl-Gi6?{ApYA9}p(*>8lT^dOwZN z4eeO~Ddw;fB?4eyio`Px5;!Fl&>=rn*@KcH%b85-@c zdlA>_y4KMuT4evi4kSpqb!BV&Uf-hEtiRAJ$0dQ!Bf^^_)@94l%paWC;csc|LqIjNd!s3l;{6`C{P~og^XAVnS+iO%)!%qVqWfh- z;F5UwuiFNpg5RauM3 zMBjF0*xrD-KQ7@VC|xQB|Hv7x#a%tnp2lUuaNAm2Q&=*GPAtC`1pWwFHs!9Z8{q%9YDJh#X<& zK44%Tm;{$Jd#fL)0mwL@;E2hR-e``30@~6@?Z7+^#tmF-ui>`2cc#Vzne&1?$)~Wd zYq%Z+{ozWf$jlb}Kye2SmoaG&+wd#_exZN52}}?>2w{~BNo3lF=E7tHgM8k&cVu(`K}FNwCdIuaVEP;}cWUmg2b&d?XtK9!+PjJ3S+(a3ED z)%a;%c41B6L5~Nvk?6fg)ksn4i|<~&s})nvp!qt!2YMjd(_%pzBxUgt7EkWAkjnhf zST+`qFA|3ldQ^_LaFvdeRz6mT+_P!uKXiJABcbX^bP%t5uumNGa2%eqN~=5fS(A`N8HGIUcb?H<32#o+%TY~ zG1#3v_<|@*pkk`ff9r*TM+gcweV|P6Z8}P_hv*4KVXheaR*xJD(tOKqgU)C!iVeU^ zP64oSkE>u@^33=;V|op#_9yJHW5c9}d6UAK1mo!X#|PqSJ?-hYej*I@#j=wen%iF} z>wBNfO$cjXo`2HVP}AC14Wi>F6B1NewU76?s2}ZX-RT9^?_Q80O#piAD6vYEvgnSj z;3wCwyiRHdGAuLk%t$2#T%&*PI^>^tvSGEI;I0KcSHOs2y-y%@IsvGD zEbv_s&SlenF&G(`FllohizVu(gGQT)-z|T}^-=oArbcSQT)SOzUqcGqV+-l6nXnUW zX7MG`)lDl9mrq|V;QNTu(Lqw^)bo2|lN{~CYqOIk25J{g*epD*tJRcAb9{LkqK@9S zF80MabowsI*X2WnFo=UjLWD5t;FI|3Yv!<@Uj(3rUO=EYY+G6;Dx_JWr3Ex$D?ayq z2hUyVbyLjl2DHwwefxt0$o8BOJdyAe(3N=xjIulxiJaqFSP4%S{)_AVeO94bl}0Do z(5CYuCQ=XdhyyuyqJDi2zlgqE1|v82lEp6{GeBSEqsP;Y8W?>Wa&p#FUg5g#q7B7P z<2e3}tPp|3vO$0DJkg;c@TFd1VtANQwEJEY)<+_mr$nbaKBQVKL9`O86B2cJ^qdlE zwco^u0J9u`z$y^8s_h@C5f6m{`g!5P!Yp{g*imQC?c~n=e6##Aw4=KCCFG zjZ4-gA)@fYV+{B;`bgr#s=1!NL%FV_3!F{cso+^;;YF7jA|t`J*6oOqHecjkVRzZ% z=cxgfYgAOI`KG>oNXYlKFbzO@>Yx9B2HPn-E%R0PdYqwB7+nDOZrJb=@|&B0IWXqg zg6iB15Q!9S$Pw`s28PWg6v1RT%+R%ZbZtuY6|F@jhcmGXw9vo1A$QtKO_GsDRCr+dP}0M1+>vRp39o6yB#RwnyJo6 zUI;Nh5u-43vxDW0Eme!Di+u)+vv>sX(QLkWM0u}Ob-(j$;RjU++fsh7oM45W~lpM z_(c)dUi$mlz~k=EdG4$~+l3#e6do(kw8=aM+_S=aoUeY|vk`@Bl&4mg&650`F|vd4 zB|@CBjpXVkat+)L`YQa>rr)Ms%fCv#GZZQ0jbR5lbZ*qEFeYIGo(e{^RxQBn2)FZs z&6#oXcu&vvb5(2z`(BctMdW4;qy08>IAyM^uM+5+_votW2+%^b9*=nT4rBl@4vH@E z;t$WkXv`J`JCjFiSY14`<-_Df(tGHv$bt13`ndO|X{;xKUI!TknCk2WK0e}mi>IGL z#aAT5+@2C>wUeeAQDMb?kQmmk~a1 z_pY4ZhD2JphTc;8&ULceFXx3tFXhOgNAq_X&Le=NNYKWw?foNVeJa}$FoSyGWYOE$ zODT?{#srH~Qxq{d6mEK6&A2Kb=q^p)cCso{=6B{*Gj1#KCM467^jlguyFW+E%urJhry`yel*CW|S+Cts%OI5v8oatEzX>Ax`fUYyjb(OBK=Y#mC=j(7Tt1 z^G#^zG0=G+O+tYOKoAU}EPcdG;qqYSqV-33MK^Mu6jN^pV-SE*OGmNgi$lCCQSLtP zetU})*L0JhL*J>s$;l&oWZ&I_W$A}NPTU|Zz87+vl@vQnjv1N-dyBF@QxqZ82ul-s4k@MU0l-5Toygs(85mhnIjPiUZ{=fIBP=8>=j8M z%J!fW&v&RN-&a%4$Ti>VIr`v$+3kjl+aYd5jfdYW8s^<&Y!+xKp`_S)UsVUDP!1B; z<_#oqTOQtX7q16=+R-#m;8CAZG@=jVfTS)^u;YLR1GDJHQf`a<|HKRU_@WNfO^Cn_ z*XVQfko(C6>J+j@fx(=l<(V#Bq`X~%ZjlAK#1GgD*w{u$aL*6~n*|hn7j-9OIe=3C z0h4Hy&7a|NN(@9r0B6NEssi_nLE=HQ?*p*gr$37l%Mdv`h$mY_Dmk21Sz=~YFT%>`vFfQ=8AYkdfCumg&q z3v-`(HgEJNLU?(dZmsTC=3rq_tkt&-8GVU`UHcHxb2T*_LoNpO zl&}LktYJQYV^JDP4tDqqR-rMHjk)fohK8INS6yVZ8W-Zzzf}sn?woVaEOe})vNmIF9M*rDx3}sE>C^OCdpSIK*2uBCgO<*wEY>z*3D78( z1o(JE0lh-l!e~kk|46QlDog`rciczzUcebUO{jQW!cL`aPN8y7K=kPzEqwL;@VDG3 z$?L~_+b==19~7_^G#&j;0QQ%8GwBKl>8XvT148(s4cI4wR}v66kv7+Mam(#1a~?&_ z6}|#H)P&cN>}88>bBjLZ2389rG;pv=*u2cnkI#!e|Iop|r47HQ-z0@oF1%qkN^!X! zge=oRcfU#PG|ku4VDVH*a}ON_7gm_G*)%=sF1Y$= zq`^IO>J6Gmt+m4k#hqivLzC|o-4O_@zCgfzV!-?4B|kJlc5-qLhQ`{gtbDKrEa{Cw zDQAIEe87&s7eOUVz?H?Yu&6rxt=CGdcV-<(eWU8S>-}!*y4bq^GD9sN; z)DgpPhTr<6g$VF>G_yB+p z|G~UgLx0xFd^krA1puRW*lEj+9L2p5n`kyna{*k9KytG^^?VkX1}Tdk++f=eZ(jep zBT0rC4h67psr>IfJODgNF&#!XUhjXq)8M)E&4fR~F`o3c^#KDF0)QJR0Kf(SY!-Y; z0FVSe|2H0PHjg3j-`LInk^?v08PNZ0z5l1J8voVnanqdvcffM+7G<(`UZr_{{RzHGwvzN2AIyxrwir}uWOSX(%b)o6zo~596z&nu8078l%`)e5NSX>6>0N!F>bL*< z?b_qwjk1vBb+NdKb%Z&)-OJOw#6HB^!Bd2`J)}#aa_brSkDEilXl3oChL>S$;|^?= zuZTc$)*1`P2(dP)o(_bv^9O?Wd!G3NdcN-+p4^C&(F|C{HWs6^gz(rE8r|uc9rv#3 z5Pb@0?fhrZMqIc^UDxFkFJyXT$KCTi4b7aCgqMdH7q}raYXnG3=pgXYl2nhkjn4~0 zdS-80W8i1lMz7anL(6a(q1WL$=1r3+;~xdKiOHZ(cVZ-ROy0jN{YNV4J1sg)3U8Gj zCzKRI7yh0L?K=N`_dl!I_bgY|yv_%!jKvX)#(N=17@=hZ&KPIpDZJmaoq=8>p@bqj z*#VR5@+ABq;8ueeu=aZ-!r1w{ApMQQTMZGlehgf0ql~UE?$E4|z{`|9+;am!u!O2Q z38n}L%B_L#ITh4gyf~cWB}gmLi~2@{d5S=STNH!VRfd$a7oj8=W9t3AXs*0Y8E=8} zd~{5<4^Y@4tU9L^BrvsB-+%CSA^36&e(E^OOfVh&`O%OJL&$njPt-*;2q@E z%{1?u+S>zjoiLTxL22p^A#SsY?OY3}_ud}!@pvbetpeG2MmE`VnFZke`@Bu6TU$4= zG5~~H16(ER=ewuM;7Mcf9!2ed3y`pA?1Y^rykcKk)7Gw#?1X&q1C`5~g#y&pzsC9F zm^^J54jWzmj|#Ru2TlwoPHWi#bwvDNQa@k7#U`M}7sm^rhKAYNe_gh~8Imo{sP0IL zEmh2QbE@tu_-+{PC}4V9ZlvxW^vQ{-e>~uFLGw1V&P?48W#BzaLIJ%nX08FNP?;|6 zcC8$_85Ri;g-%V;2os*!?4?Qg#-%GXxAwgAE#kSgOwa=&$p#!{RB;V;$)wy?YWznm z6~_A5r$F+~DQ+<;@gAN-Q+S61a4{Z3sOQ4vC_~ls@^XTN{eTNRkhl%<4mtwoKl5fw0*&3 zpNS;B$m^b&A)zZ1-Kbx_jrawl6*FA1~hGDq0dCx|!!Ep3$~p0N&Lk zYpQY`_fg}B>~rqq?c~QkL*A&`a`OluxbIowTKd05;Hq*KcJ=d_+XdcSybuNN!UC1N zNz+B)?tr{0xJ}77r%BNXStoEPkBebSD?Zjh{9Vz%dqvj0ZH>jaB;bk`N$((_Nbw}@ z_;Az`H1W~xG0p7NrV8NILO54DqR_Z>+8nP(MgpgAU>rpIrI(+6$@sA>f6j-6ag4^M zAPbQl$VRu1&z!;G@{xZ4(Bvp(h2^g9ocuDK|&@JOi*1W##Ki6`R@f7`Gu9!2`7^kuZ?O8QA55 zD?ozNb*F|ilEB#1R=57gAnKmDZYmmV@IY!O)q1;9$C;Z`-&Mvfok-v~(C@lpIhEWe zsr*#xT0_D+CRhlY$K*0~YO+p9kbbYPi7*L!No>t_qdbC=DtTmUN11<|u5oi{K%r67 z>-)JMxOqL&sc!r3H^F2`DD5SW$KvO!)y%u-v5cfJLaam+7%(O zm2fMY4R_y)aR5?Y+RqV}(V&21e)>6|21TVh?LCi^`XFU|z3&gGpxk>Yr{13Pp$pJ2 zr8Q>a=w9|)!3=!_*#5~)Pc!$(F%UGYZWh7cA0NM<$7<*(7qLk`sT#j-A;wKUUSN}G3Ag3iwiS8OYTG!o=V*~rB4_*v6jg-VI z%^!mYM3yI*qRtNakjO!PzxEsDrS$}+TrLaTN9KAF7Lwk`o%VWE<;54Ha_y20?`v63 z|Myu9fCBNbG1h+8d`@!R-OCRidap#H&=^=+z@DV(B_FWBq;!pQBa#+4Y3@q9?c0M; zZf?cjv zP2VnR>g%->f~8f3)9d<*CdV0!QE*K808@I?u{TUSBK3FXt7kh>z(+^{JI$XpeFRTN z8csd`#Z3K7Vgs0um};JOtGsZFEg^=KzT{fez zLUzs9gV+blgx6nO%sqo;79_VcEnrq>y6DDmW>M1VAOPeaOz~XT!jF^yW;$Cus-yX{> zfGL@nGLzc>sbl3MPi6e|(BonFh`25{5Nn3Jz>PkxTJUT_3UTlSp( zQpB_qy%?*5F9~+gQZA?QBTQ|h7e(Lqse~SfgDcvJnNnA^ z8n;1znofUayPMU&xe1bu<3JVyJMq2NG#>7Lj~#qcI)&@<1zp1feKYO}@t6a7>r|M<5v18l&@|=<~Nhh9Pe2yjQybCor_EjWaG;U|CSgx7aBET!g z*~Uw8w-ofI`{;e|us3y0;6Dn7YlQGkI#D2C`n-Pa zsaJtlzv|IvlRDFFFtTx8E@FJ7;tHNZc-4(_l??(gour|ZnpVkcW;r$L42!l&R-icH zxjk$D_c18KgX=oT7^aKQjAX%_zaLZCOz*yGTQeiUY8b&lJAEy%Pkv49v^W^URZ0jK zUetbAci#!#q}_fgI0_zU5|(kmrcN|T7hzW7mb5?JHUe$DO%)Fi;6Dv4cpvK-I{%oC z23&J4=+}>ly|jV0x9_ZZ@V*8gNh_5CK4*;?JuO>Z^%I11lY#;+u5+kNd$c4eFhfKT8Ug>8$M4{HYFwp)_@ow1_2FSO4KrC!h z&NZjVwKZ4Io^15o+S)L}i{yYF7f55lDlw6>Qa)hL!-wb6L2r^P1#MrgV1&S4?suQ{ zj619`(WIAOcMV;2gqpZ+Buv2+7$Y1kI3ac*GTI4v?ej03uB=urIRBkz_f3ewqm5M} zQfUnnGdC+BTe3n27nQM>Iy|=*x``9mt$|usk%spmXM4bgd>z|url^=qGrDcJVA?#< z!F4Mo05QK)JSMPLTcM8Ypy`Cl8T;e5tncwxv5Tao>oGB}-BQ)9 z-A8=NMIs%0zivWRO!MZsmM&|bla!cN0}JxUpE=_XyVNLkn@`0AoqKt~Pj&Ar*+&Y* zh>@lAh!Cv&ZZTU*?LOYBRs3}xcSpJCy9K|V1x&aPfj`>{;s|J^0--7 z+9`qHX4m-e9)O4hJGs3Qs<{6(z@dEFqsAk3?&H(C9dLu8R>UQ}_iJTNEEQrJ3c1uC z>;=TbCGrLi56IW*sj%7meeYubeDxX%=Jc_uO1c z@#1c{hH3ZT%^R_7I;bzg;e5J-lIO)z;h#G(Gv8Z3CaxP*ib+v;<#|cYtD#`=f&$8Z zSud)Wm+-zn<3hP^zpL$4H-5f$@bjiKLs7jI&dpOqYJKue0^Q7zVeHnu<)(c z-F1z}cwVEOOq;JPICw!?(+|GHdyI;*at%bObr$IH=g9dCMtgctc|&hyC;>zp*&i)A zqztZktDZPT_NY!}EkU?VIT^mF>(K~n?w3pK!8X!fsCBxik2s+kmH&hZG}~|;<#K(F z!G4+lb%YA^xa@8E@?ltC7a`>q2;@{VVx&GLL(!(u~H`QvTBvfO0ek8q4W1UH%~ z249F`@$hQ~m)n5&`%bKR+yXtO3@X@MGdd3MxJt|L09a9zAC1N|Cw9JmQZ%!7l8HTQ z6xJ3*{;(jmwY@E^NHah!HNl-v0rfxMaz?9aca&YfjkpSH;G?C{HwPZT0xAP6Sv6DBTvNGRdIL)}Zz0WG;D9 zOUbraw#n1tIb~%}_Wpe1cq%eEhC$>oHlGw;VGSxKoZ(yxyD4nJDT!`gv`?cFd+Q@^ zWdn1(ZqJN3mgZM%|1*O4f3N8F|5m2Dv5o)h%2aUYJt!)~>)$TT?jy!Am>z2$jcHBY zFk@ejF>h1N;Is(PhnPxm|a<`$A9kO9ToU*vN9eVt+R zt@=NCV(SPgTv=nn?vAGJcAwa4<$QRfr|j$>-^xbM@mcjblj(?OR&%Zqb+spULcWlE zA@3~opoO#X8fzs@&GI?ddwp1r=yI+MV($Aa-RwzB;-?gME-SyI1^Q;7Kup731u{5j zwYHk1 za`>}fo_hCc-=F7e$DfLlHb~S?&lZ@a5*baAE3*w*p2=v^Is3%1ll$U%znA2kigJf| zzl10)KD>?n;1p?TT<)uA_{%-jylGT>(Q^ve-YBaplbUBeS$-*Qv`a1V_>(6H6UoQL zfc}txDjrt6u7_Ci0G}jJx(>hFk}Ykof`aB=sBY;QQ6XPPsQZ zoqmJBByJwQa9Kw-gWcnmvV86`tj!lSKBcl$VirB+(NMR9^S&vn`d?6Axpe%@fU|lGkTcfTH37-Mix&_(%Ivh-RuPy3x}n z7C~jyYo@AVuI_w(2_=�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 ms>MSB77*2 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 GKK(zC?X1%P literal 0 HcmV?d00001 diff --git a/py_src/taskito/dashboard/server.py b/py_src/taskito/dashboard/server.py index 2aa17539..bdb56eda 100644 --- a/py_src/taskito/dashboard/server.py +++ b/py_src/taskito/dashboard/server.py @@ -14,7 +14,7 @@ import logging from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import TYPE_CHECKING, Any -from urllib.parse import parse_qs, urlparse +from urllib.parse import parse_qs, unquote, urlparse from taskito.dashboard.auth import ( DEFAULT_SESSION_TTL_SECONDS, @@ -181,17 +181,17 @@ def _handle_get(self) -> None: for pattern, param_handler in GET_PARAM_ROUTES: m = pattern.match(path) if m: - self._dispatch_with_handler( - param_handler, lambda h, m=m: h(queue, qs, m.group(1)) - ) + g1 = unquote(m.group(1)) + self._dispatch_with_handler(param_handler, lambda h, g1=g1: h(queue, qs, g1)) return for pattern, param_handler in GET_PARAM2_ROUTES: m = pattern.match(path) if m: + g1, g2 = unquote(m.group(1)), unquote(m.group(2)) self._dispatch_with_handler( param_handler, - lambda h, m=m: h(queue, qs, (m.group(1), m.group(2))), + lambda h, g1=g1, g2=g2: h(queue, qs, (g1, g2)), ) return @@ -263,15 +263,17 @@ def _handle_post(self) -> None: for pattern, param_handler in POST_PARAM_ROUTES: m = pattern.match(path) if m: - self._dispatch_with_handler(param_handler, lambda h, m=m: h(queue, m.group(1))) + g1 = unquote(m.group(1)) + self._dispatch_with_handler(param_handler, lambda h, g1=g1: h(queue, g1)) return for pattern, param_handler in POST_PARAM2_ROUTES: m = pattern.match(path) if m: + g1, g2 = unquote(m.group(1)), unquote(m.group(2)) self._dispatch_with_handler( param_handler, - lambda h, m=m: h(queue, (m.group(1), m.group(2))), + lambda h, g1=g1, g2=g2: h(queue, (g1, g2)), ) return @@ -289,8 +291,9 @@ def _handle_put(self) -> None: body = self._read_json_body() if body is None: return + g1 = unquote(m.group(1)) self._dispatch_with_handler( - param_handler, lambda h, m=m, body=body: h(queue, body, m.group(1)) + param_handler, lambda h, g1=g1, body=body: h(queue, body, g1) ) return for pattern, param_handler in PUT_PARAM2_ROUTES: @@ -299,9 +302,10 @@ def _handle_put(self) -> None: body = self._read_json_body() if body is None: return + g1, g2 = unquote(m.group(1)), unquote(m.group(2)) self._dispatch_with_handler( param_handler, - lambda h, m=m, body=body: h(queue, body, (m.group(1), m.group(2))), + lambda h, g1=g1, g2=g2, body=body: h(queue, body, (g1, g2)), ) return self._json_response({"error": "Not found"}, status=404) @@ -315,7 +319,8 @@ def _handle_delete(self) -> None: for pattern, param_handler in DELETE_PARAM_ROUTES: m = pattern.match(path) if m: - self._dispatch_with_handler(param_handler, lambda h, m=m: h(queue, m.group(1))) + g1 = unquote(m.group(1)) + self._dispatch_with_handler(param_handler, lambda h, g1=g1: h(queue, g1)) return self._json_response({"error": "Not found"}, status=404) diff --git a/pyproject.toml b/pyproject.toml index 9fdfdddf..29509ccf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ encryption = ["cryptography"] flask = ["flask>=3.0"] aws = ["boto3>=1.34"] gcs = ["google-cloud-storage>=2.10"] +docs = ["playwright>=1.59"] [tool.maturin] manifest-path = "crates/taskito-python/Cargo.toml" @@ -148,3 +149,4 @@ ignore_missing_imports = true [[tool.mypy.overrides]] module = "click" ignore_missing_imports = true + diff --git a/scripts/capture_docs_screenshots.py b/scripts/capture_docs_screenshots.py new file mode 100644 index 00000000..8f4c3cc3 --- /dev/null +++ b/scripts/capture_docs_screenshots.py @@ -0,0 +1,482 @@ +#!/usr/bin/env python3 +"""Reproducible screenshot capture for the documentation site. + +Spins up a fresh Taskito Queue, seeds it with deterministic demo data +(admin user, sample tasks/queues, webhooks with mixed delivery outcomes, +runtime overrides, a middleware disable), starts the dashboard on a +random port, drives a headless Chromium through every screen, and saves +PNGs under ``docs/public/screenshots/dashboard/``. + +Run from the repo root: + + uv run --with playwright python scripts/capture_docs_screenshots.py + # First time only: + uv run --with playwright python -m playwright install chromium + +The script is **idempotent**: every run overwrites the previous PNGs and +starts from an empty SQLite DB in a temp directory, so there is no +"works on my machine" drift. +""" + +from __future__ import annotations + +import argparse +import contextlib +import json +import socket +import sys +import tempfile +import threading +import time +from http.server import BaseHTTPRequestHandler, HTTPServer, ThreadingHTTPServer +from pathlib import Path +from typing import Any + +from taskito import Queue +from taskito.dashboard import _make_handler +from taskito.dashboard.auth import AuthStore +from taskito.dashboard.delivery_store import DeliveryStore +from taskito.events import EventType +from taskito.middleware import TaskMiddleware + +ADMIN_USER = "demo-admin" +ADMIN_PASSWORD = "demo-pass-1234" + +REPO_ROOT = Path(__file__).resolve().parent.parent +SCREENSHOT_DIR = REPO_ROOT / "docs" / "public" / "screenshots" / "dashboard" + + +# ── Demo middleware ────────────────────────────────────────────────── + + +class LoggingMiddleware(TaskMiddleware): + """Demo middleware that the screenshots reference.""" + + name = "demo.logging" + + +class MetricsMiddleware(TaskMiddleware): + name = "demo.metrics" + + +# ── Seed data ──────────────────────────────────────────────────────── + + +# Task body functions are defined at module level so their qualnames stay +# clean (``demo_tasks.send_email`` rather than +# ``capture_docs_screenshots.seed_queue..send_email``) — much nicer +# in screenshots. ``seed_queue`` registers them with the live Queue. + + +def send_email(to: str) -> str: + return f"sent:{to}" + + +def deliver_message(message: str) -> str: + return message + + +def sync_metrics() -> None: + pass + + +send_email.__module__ = "myapp.tasks" +send_email.__qualname__ = "send_email" +deliver_message.__module__ = "myapp.tasks" +deliver_message.__qualname__ = "deliver_message" +sync_metrics.__module__ = "myapp.tasks" +sync_metrics.__qualname__ = "sync_metrics" + + +def seed_queue(queue: Queue) -> None: + """Populate the demo queue with realistic data for the screenshots. + + Returns nothing — the dashboard reads everything from storage. + """ + # Admin user — header drop-down shows "demo-admin" in the screenshots. + AuthStore(queue).create_user(ADMIN_USER, ADMIN_PASSWORD, role="admin") + + # Tasks — defaults vary so the Tasks table has visual variety. + queue.task()(send_email) + queue.task( + queue="email", + max_retries=5, + timeout=120, + rate_limit="100/m", + max_concurrent=10, + )(deliver_message) + queue.task(queue="metrics", priority=2)(sync_metrics) + + # Queue-level configuration. set_queue_concurrency goes through the + # same code path as the dashboard override apply. + queue.set_queue_concurrency("email", 10) + + # Override the send_email task — Tasks page should show this in accent. + queue.set_task_override( + next(c.name for c in queue._task_configs if c.name.endswith("send_email")), + rate_limit="200/m", + max_retries=10, + ) + + # Webhook subscriptions — one fully configured, one disabled, one + # filtered to a specific task. + import os + + os.environ["TASKITO_WEBHOOKS_ALLOW_PRIVATE"] = "1" # echo server is loopback + + sub1 = queue.add_webhook( + url="https://hooks.example.com/ops-failures", + events=[EventType.JOB_FAILED, EventType.JOB_DEAD], + secret="whsec_demo_signing_secret", + description="Page ops on permanent job failures", + max_retries=5, + timeout=8.0, + ) + # Second subscription: filters by task name. Captured for visual + # contrast in the webhooks-list screenshot; we don't reuse its id. + queue.add_webhook( + url="https://audit.internal.example.com/taskito-events", + events=None, + task_filter=["myapp.tasks.send_email"], + description="Audit log for send_email only", + ) + sub3 = queue.add_webhook( + url="https://staging-hooks.example.com/all-events", + description="Staging echo — disabled", + ) + queue.update_webhook(sub3.id, enabled=False) + + # Synthesize delivery history for sub1 so the Deliveries page has rows. + store = DeliveryStore(queue) + base_time = int(time.time() * 1000) + deliveries = [ + ("delivered", 200, 42, "job.completed", "myapp.tasks.process_image"), + ("delivered", 200, 38, "job.completed", "myapp.tasks.send_email"), + ("delivered", 200, 51, "job.completed", "myapp.tasks.send_email"), + ("failed", 504, 9500, "job.failed", "myapp.tasks.process_image"), + ("delivered", 200, 44, "job.completed", "myapp.tasks.send_email"), + ("dead", 500, 30000, "job.dead", "myapp.tasks.process_image"), + ("delivered", 200, 39, "job.completed", "myapp.tasks.send_email"), + ] + for i, (status, code, lat, event, task_name) in enumerate(deliveries): + record = store.record_attempt( + sub1.id, + event=event, + payload={ + "task_name": task_name, + "job_id": f"01H{i:02d}DEMOXYZ{i}", + "queue": "default", + }, + status=status, + attempts=3 if status == "dead" else 1, + response_code=code if status != "delivered" or code == 200 else None, + latency_ms=lat, + response_body=( + None + if status == "delivered" + else "Internal Server Error\nstack trace here..." + ), + task_name=task_name, + job_id=f"01H{i:02d}DEMOXYZ{i}", + ) + # Backdate so the "When" column shows a range of relative times. + _backdate_delivery(queue, sub1.id, record.id, base_time - i * 600_000) + + # Disable one middleware on one task so the Middleware tab has a + # mix of green / grey toggles. + send_email_full = next( + c.name for c in queue._task_configs if c.name.endswith("send_email") + ) + queue.disable_middleware_for_task(send_email_full, "demo.metrics") + + +def _backdate_delivery(queue: Queue, sub_id: str, record_id: str, ts: int) -> None: + """Rewrite a delivery's ``created_at`` so the deliveries table shows + a believable range of relative times in the screenshot rather than + a clump of "just now" rows.""" + key = f"webhooks:deliveries:{sub_id}" + raw = queue.get_setting(key) + if not raw: + return + rows = json.loads(raw) + for row in rows: + if row.get("id") == record_id: + row["created_at"] = ts + if row.get("completed_at") is not None: + row["completed_at"] = ts + 50 + queue.set_setting(key, json.dumps(rows, separators=(",", ":"))) + return + + +# ── Webhook echo server (for the live send-test screenshot) ────────── + + +def start_echo_server() -> tuple[str, HTTPServer]: + """Local server the test webhook delivers to during the captures.""" + + class Handler(BaseHTTPRequestHandler): + def do_POST(self) -> None: + self.send_response(200) + self.end_headers() + self.wfile.write(b"ok") + + def log_message(self, *args: Any) -> None: + pass + + server = HTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + return f"http://127.0.0.1:{server.server_address[1]}", server + + +# ── Dashboard process ──────────────────────────────────────────────── + + +def start_dashboard(queue: Queue) -> tuple[str, ThreadingHTTPServer]: + """Start the dashboard on a random localhost port.""" + handler = _make_handler(queue) + # Bind to port 0 → kernel picks a free port. + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + base = f"http://127.0.0.1:{server.server_address[1]}" + _wait_for_port(server.server_address[1]) + return base, server + + +def _wait_for_port(port: int, timeout: float = 5.0) -> None: + deadline = time.time() + timeout + while time.time() < deadline: + with socket.socket() as sock: + try: + sock.settimeout(0.2) + sock.connect(("127.0.0.1", port)) + return + except OSError: + time.sleep(0.05) + raise RuntimeError(f"dashboard did not bind on port {port}") + + +# ── Capture flow ───────────────────────────────────────────────────── + + +def capture_all(base_url: str) -> None: + """Walk every dashboard page and save a PNG per screen. + + Uses Playwright's sync API. Each screenshot is named for its target + location in the docs so the MDX side can reference them by stable path. + """ + from playwright.sync_api import sync_playwright + + SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True) + print(f"Capturing screenshots into {SCREENSHOT_DIR}") + + with sync_playwright() as p: + # Use the system Chrome so no extra browser download is needed. + browser = p.chromium.launch(headless=True, channel="chrome") + # Width 1280 matches the dashboard's ``max-w-[1400px]`` content + # area; height 800 keeps each capture above-the-fold without huge + # screenshots. ``deviceScaleFactor=2`` gives crisp HiDPI output. + context = browser.new_context( + viewport={"width": 1280, "height": 800}, + device_scale_factor=2, + ) + page = context.new_page() + + # ── Phase 1: auth ───────────────────────────────────────── + login_and_screenshot_setup_then_login(page, base_url) + + # Pre-fetch the cookie for the rest of the flow — the dashboard + # uses session cookies, and after the login flow above we already + # have them. So the page context is now authenticated. + + # ── Main pages ─────────────────────────────────────────── + capture_each( + page, + base_url, + [ + ("/", "overview"), + ("/jobs", "jobs"), + ("/queues", "queues"), + ("/workers", "workers"), + ], + wait_for_text={"/": "Overview", "/jobs": "Jobs", "/queues": "Queues"}, + ) + + # ── Phase 2/3: webhooks ────────────────────────────────── + capture_page(page, f"{base_url}/webhooks", "webhooks-list") + # Drive the deliveries view via the visible UI — same path an + # operator would take. + page.locator('button[aria-label="Webhook actions"]').first.click() + page.get_by_role("menuitem", name="View deliveries").click() + page.wait_for_url("**/deliveries", timeout=5000) + page.wait_for_load_state("networkidle") + time.sleep(1.2) + screenshot(page, "webhook-deliveries") + + # Open the create-webhook dialog for the form screenshot. + page.goto(f"{base_url}/webhooks", wait_until="networkidle") + page.get_by_role("button", name="New webhook").click() + page.wait_for_selector("text=Subscribe an HTTP endpoint", timeout=3000) + time.sleep(0.3) # let the dialog finish animating in + screenshot(page, "webhook-create-dialog") + + # ── Phase 4/5: tasks + middleware ──────────────────────── + capture_page(page, f"{base_url}/tasks", "tasks-list") + + page.goto(f"{base_url}/tasks", wait_until="networkidle") + # First Edit button opens the side sheet. + page.get_by_role("button", name="Edit").first.click() + page.wait_for_selector("text=Overrides", timeout=3000) + time.sleep(0.3) + screenshot(page, "task-edit-overrides") + + # Switch to the Middleware tab inside the same sheet. + page.get_by_role("tab", name="Middleware").click() + page.wait_for_selector("text=demo.logging", timeout=3000) + time.sleep(0.3) + screenshot(page, "task-edit-middleware") + + context.close() + browser.close() + print(f"OK — captured {len(list(SCREENSHOT_DIR.glob('*.png')))} screenshots") + + +def login_and_screenshot_setup_then_login(page: Any, base_url: str) -> None: + """Capture the setup page on a fresh dashboard, then the login page, + then sign in so subsequent captures are authenticated.""" + # Use a *separate* throwaway DB just for the setup screenshot — the + # main demo queue already has a user, so /login would show the sign-in + # form. Easier than tearing down and re-seeding mid-run. + setup_url = _start_throwaway_dashboard() + page.goto(setup_url + "/login", wait_until="networkidle") + page.wait_for_selector("text=Create the first admin", timeout=3000) + time.sleep(0.3) + screenshot(page, "auth-setup") + + # Now the real login page on the seeded dashboard. + page.goto(base_url + "/login", wait_until="networkidle") + page.wait_for_selector("text=Sign in", timeout=3000) + time.sleep(0.3) + screenshot(page, "auth-login") + + # Authenticate so the rest of the captures run inside the AppShell. + page.fill('input[id="login-username"]', ADMIN_USER) + page.fill('input[id="login-password"]', ADMIN_PASSWORD) + page.get_by_role("button", name="Sign in").click() + page.wait_for_url(f"{base_url}/", timeout=5000) + + +def _start_throwaway_dashboard() -> str: + """Spin up a second dashboard against a fresh empty DB just for the + setup screenshot.""" + tmpdir = tempfile.mkdtemp(prefix="taskito-docs-") + q = Queue(db_path=f"{tmpdir}/setup.db") + url, _server = start_dashboard(q) + return url + + +def capture_each( + page: Any, + base_url: str, + routes: list[tuple[str, str]], + *, + wait_for_text: dict[str, str] | None = None, +) -> None: + for route, name in routes: + url = base_url + route + page.goto(url, wait_until="networkidle") + expected = (wait_for_text or {}).get(route) + if expected is not None: + with contextlib.suppress(Exception): + page.wait_for_selector(f"text={expected}", timeout=3000) + time.sleep(0.3) + screenshot(page, name) + + +def capture_page(page: Any, url: str, name: str) -> None: + page.goto(url, wait_until="networkidle") + time.sleep(0.4) + screenshot(page, name) + + +def screenshot(page: Any, name: str) -> None: + out = SCREENSHOT_DIR / f"{name}.png" + page.screenshot(path=str(out), full_page=False) + print(f" • {out.name}") + + +def _first_webhook_id(base_url: str) -> str: + """Pull the demo webhook id straight from the API so the deep link + in the screenshot script stays in sync with whatever seed_queue + produced.""" + # NB: the dashboard is auth-gated, so we need the cookie. Simplest + # approach is a synchronous login via stdlib urllib. + import urllib.parse + + login = json.dumps({"username": ADMIN_USER, "password": ADMIN_PASSWORD}).encode() + req = urllib.request.Request( + f"{base_url}/api/auth/login", + method="POST", + data=login, + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req) as resp: + cookies = "; ".join( + urllib.parse.unquote(c.split(";", 1)[0]) + for c in resp.headers.get_all("Set-Cookie") or [] + ) + list_req = urllib.request.Request( + f"{base_url}/api/webhooks", headers={"Cookie": cookies} + ) + with urllib.request.urlopen(list_req) as resp: + items = json.loads(resp.read()) + return str(items[0]["id"]) + + +# ── Entry point ────────────────────────────────────────────────────── + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--skip-capture", + action="store_true", + help="Seed the demo queue and start the dashboard but skip the " + "Playwright run — useful for poking the seeded data in a browser.", + ) + args = parser.parse_args(argv) + + tmpdir = tempfile.mkdtemp(prefix="taskito-docs-") + print(f"Demo DB: {tmpdir}/demo.db") + queue = Queue( + db_path=f"{tmpdir}/demo.db", + middleware=[LoggingMiddleware(), MetricsMiddleware()], + ) + seed_queue(queue) + + echo_url, _echo = start_echo_server() + print(f"Echo server: {echo_url}") + + base_url, _dash = start_dashboard(queue) + print(f"Dashboard: {base_url}") + + if args.skip_capture: + print("\nSkipping Playwright. Open the dashboard in a browser. Ctrl+C to exit.") + try: + threading.Event().wait() + except KeyboardInterrupt: + return 0 + return 0 + + try: + capture_all(base_url) + except Exception: + import traceback + + traceback.print_exc() + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/optimize_screenshots.py b/scripts/optimize_screenshots.py new file mode 100644 index 00000000..23104771 --- /dev/null +++ b/scripts/optimize_screenshots.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Crunch the dashboard screenshots so the docs site stays light. + +The capture script writes 2x HiDPI PNGs straight from Playwright, which +range from 50 KB to 350 KB each. This pass: + +1. Converts to ``P`` mode (256-colour palette) where appropriate — + screenshots are dominated by flat UI panels and large solid regions, + so palette quantisation typically halves the file size with no + visible loss. +2. Falls back to the original RGBA encoding if quantisation actually + *grows* the file (rare on dense screenshots). +3. Reports before/after sizes so we can spot regressions. + +Run after every screenshot regen: + + uv run --with pillow python scripts/optimize_screenshots.py +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from PIL import Image + +REPO_ROOT = Path(__file__).resolve().parent.parent +SCREENSHOT_DIR = REPO_ROOT / "docs" / "public" / "screenshots" / "dashboard" + + +def optimize(path: Path) -> tuple[int, int]: + """Return ``(before_bytes, after_bytes)``.""" + before = path.stat().st_size + with Image.open(path) as img: + img.load() + # Quantise to a 256-colour palette. ``method=2`` (median cut) is + # better for synthetic UI screenshots than the default libimagequant + # path Pillow uses on lossier paths. + quantised = img.convert("RGB").quantize(colors=256, method=2, dither=Image.Dither.NONE) + tmp = path.with_suffix(".opt.png") + quantised.save(tmp, format="PNG", optimize=True) + after = tmp.stat().st_size + if after < before: + tmp.replace(path) + return before, after + tmp.unlink() + return before, before + + +def main() -> int: + if not SCREENSHOT_DIR.exists(): + print(f"No screenshots at {SCREENSHOT_DIR}", file=sys.stderr) + return 1 + total_before = 0 + total_after = 0 + for png in sorted(SCREENSHOT_DIR.glob("*.png")): + before, after = optimize(png) + total_before += before + total_after += after + delta = (after - before) / before * 100 if before else 0.0 + print( + f"{png.name:35s} {before / 1024:7.1f} KB → {after / 1024:7.1f} KB" + f" ({delta:+5.1f}%)" + ) + print( + f"{'TOTAL':35s} {total_before / 1024:7.1f} KB → {total_after / 1024:7.1f} KB" + f" ({(total_after - total_before) / total_before * 100:+5.1f}%)" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/dashboard/test_middleware_toggles.py b/tests/dashboard/test_middleware_toggles.py index 6713f098..cc93159e 100644 --- a/tests/dashboard/test_middleware_toggles.py +++ b/tests/dashboard/test_middleware_toggles.py @@ -196,6 +196,28 @@ def test_put_task_middleware_rejects_bad_body( assert exc_info.value.code == 400 +def test_put_task_middleware_handles_url_encoded_name( + dashboard: tuple[AuthedClient, Queue], +) -> None: + """Browser clients ``encodeURIComponent`` task names containing + ``<``, ``>``, ``/`` etc. The server has to decode the captured + group before looking up the disable list — otherwise the toggle + silently no-ops because the disable is keyed by one name but the + chain lookup uses another. Regression test for that path.""" + import urllib.parse + + client, queue = dashboard + name = next(c.name for c in queue._task_configs if c.name.endswith("alpha")) + encoded = urllib.parse.quote(name, safe="") + assert "%" in encoded, "test setup: pick a task whose qualname needs encoding" + result = client.put(f"/api/tasks/{encoded}/middleware/test.other", {"enabled": False}) + assert "test.other" in result["disabled"] + # The chain lookup uses the decoded name, so it must reflect the + # disable that was written by the encoded URL. + chain_names = {mw.name for mw in queue._get_middleware_chain(name)} + assert "test.other" not in chain_names + + def test_delete_task_middleware_clears_all( dashboard: tuple[AuthedClient, Queue], ) -> None: From cddb341f83948d76e19e4f2e0e4ea2c5e70873e0 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Sun, 17 May 2026 11:36:57 +0530 Subject: [PATCH 06/10] feat(dashboard): oauth foundation (config, state store, identity, auth.py) (#174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(deps): add authlib as optional 'oauth' extra * feat(dashboard): add is_safe_redirect for same-origin OAuth next-URLs * feat(dashboard): OAuth config, state store, and provider contract Adds the framework-free foundation for OAuth/OIDC login: - OAuthConfig with env-var parsing (Google, GitHub, multiple named OIDC slots) - OAuthStateStore for short-lived state/nonce/PKCE rows (single-use, 5-min TTL) - ProviderIdentity dataclass + OAuthProvider Protocol for upcoming providers * feat(dashboard): map OAuth identities onto AuthStore users User gains email and display_name fields. verify_password rejects any hash prefixed 'oauth:' so OAuth-only users can never log in via password. get_or_create_oauth_user applies the bootstrap rule: an explicit TASKITO_DASHBOARD_OAUTH_ADMIN_EMAILS list takes precedence; otherwise the first OAuth user on an empty table becomes admin. Verified email is required for any admin path. * feat(dashboard): oauth providers, flow, and HTTP endpoints (#175) * feat(dashboard): oauth provider implementations, flow, and HTTP routes Adds the complete OAuth login pipeline on top of the foundation from PR #174: - GoogleProvider (OIDC + email-domain allowlist) - GitHubProvider (OAuth2 + verified primary email + org membership) - GenericOIDCProvider (discovery-driven, multi-slot for Okta / Microsoft / Auth0) - OAuthFlow orchestrator: state mint, code exchange, allowlist check, AuthStore user/session creation - HTTP endpoints: GET /api/auth/providers, GET /api/auth/oauth/start/{slot}, GET /api/auth/oauth/callback/{slot} — all public, single-use state, S256 PKCE, nonce-verified ID tokens, same cookie shape as password login * test(dashboard): integration tests for OAuth HTTP endpoints Spins up a real ThreadingHTTPServer with a stubbed OAuthFlow to drive the full request -> 302 -> cookies path: provider listing, start redirect, callback success, replayed-state rejection, allowlist denial, and unsafe-next-URL scrubbing. 12 tests. * feat(dashboard): oauth login UI and operator docs (#176) * feat(dashboard): oauth provider buttons on the login screen LoginForm fetches /api/auth/providers and renders one button per enabled provider above the password fields. Each button is a plain anchor to /api/auth/oauth/start/{slot} so the browser handles the 302 natively, no client-side OAuth state to manage. Password form hides entirely when password_enabled is false (OAuth-only mode); next-URL from the route's search params is forwarded so post-login lands on the intended page. * test(dashboard): cover oauthStartUrl encoding edge cases * docs(dashboard): SSO operator setup guide New page walks through registering OAuth clients with Google, GitHub, and generic OIDC providers (Okta, Microsoft, Auth0, Keycloak), env-var reference, allowlist semantics, role-bootstrap rule, OAuth-only mode, and a troubleshooting cookbook. Wires into the observability nav after dashboard-auth, which gains a short forward-reference. --- dashboard/src/features/auth/api.test.ts | 26 + dashboard/src/features/auth/api.ts | 24 +- .../features/auth/components/login-form.tsx | 121 ++-- .../features/auth/components/oauth-button.tsx | 85 +++ dashboard/src/features/auth/hooks.ts | 24 +- dashboard/src/features/auth/index.ts | 5 + dashboard/src/features/auth/types.ts | 15 + .../guides/observability/dashboard-auth.mdx | 11 +- .../guides/observability/dashboard-oauth.mdx | 288 +++++++++ .../docs/guides/observability/meta.json | 1 + py_src/taskito/dashboard/auth.py | 101 ++- py_src/taskito/dashboard/handlers/auth.py | 6 +- py_src/taskito/dashboard/handlers/oauth.py | 115 ++++ py_src/taskito/dashboard/oauth/__init__.py | 46 ++ py_src/taskito/dashboard/oauth/config.py | 257 ++++++++ py_src/taskito/dashboard/oauth/flow.py | 167 +++++ py_src/taskito/dashboard/oauth/identity.py | 89 +++ py_src/taskito/dashboard/oauth/pkce.py | 16 + py_src/taskito/dashboard/oauth/providers.py | 411 +++++++++++++ py_src/taskito/dashboard/oauth/state_store.py | 137 +++++ py_src/taskito/dashboard/routes.py | 14 + py_src/taskito/dashboard/server.py | 105 +++- py_src/taskito/dashboard/url_safety.py | 18 + .../proxies/handlers/requests_session.py | 2 +- pyproject.toml | 13 + tests/dashboard/test_auth.py | 135 ++++ tests/dashboard/test_oauth_config.py | 209 +++++++ tests/dashboard/test_oauth_endpoints.py | 400 ++++++++++++ tests/dashboard/test_oauth_flow.py | 208 +++++++ tests/dashboard/test_oauth_providers.py | 574 ++++++++++++++++++ tests/dashboard/test_oauth_state_store.py | 98 +++ tests/dashboard/test_url_safety.py | 40 ++ 32 files changed, 3709 insertions(+), 52 deletions(-) create mode 100644 dashboard/src/features/auth/api.test.ts create mode 100644 dashboard/src/features/auth/components/oauth-button.tsx create mode 100644 docs/content/docs/guides/observability/dashboard-oauth.mdx create mode 100644 py_src/taskito/dashboard/handlers/oauth.py create mode 100644 py_src/taskito/dashboard/oauth/__init__.py create mode 100644 py_src/taskito/dashboard/oauth/config.py create mode 100644 py_src/taskito/dashboard/oauth/flow.py create mode 100644 py_src/taskito/dashboard/oauth/identity.py create mode 100644 py_src/taskito/dashboard/oauth/pkce.py create mode 100644 py_src/taskito/dashboard/oauth/providers.py create mode 100644 py_src/taskito/dashboard/oauth/state_store.py create mode 100644 tests/dashboard/test_oauth_config.py create mode 100644 tests/dashboard/test_oauth_endpoints.py create mode 100644 tests/dashboard/test_oauth_flow.py create mode 100644 tests/dashboard/test_oauth_providers.py create mode 100644 tests/dashboard/test_oauth_state_store.py create mode 100644 tests/dashboard/test_url_safety.py diff --git a/dashboard/src/features/auth/api.test.ts b/dashboard/src/features/auth/api.test.ts new file mode 100644 index 00000000..9a4e8e92 --- /dev/null +++ b/dashboard/src/features/auth/api.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { oauthStartUrl } from "./api"; + +describe("oauthStartUrl", () => { + it("returns the slot-rooted path when no next is supplied", () => { + expect(oauthStartUrl("google")).toBe("/api/auth/oauth/start/google"); + }); + + it("URL-encodes the next path so it survives querystring parsing", () => { + expect(oauthStartUrl("google", "/jobs?status=failed")).toBe( + "/api/auth/oauth/start/google?next=%2Fjobs%3Fstatus%3Dfailed", + ); + }); + + it("URL-encodes provider slots that contain reserved characters", () => { + // OIDC slot names must match ^[a-z][a-z0-9_-]{0,31}$ at the server, + // so this is defence-in-depth — but the encoding must not break the + // slot regex on the way out. + expect(oauthStartUrl("acme-okta", "/")).toBe("/api/auth/oauth/start/acme-okta?next=%2F"); + }); + + it("ignores empty / undefined next gracefully", () => { + expect(oauthStartUrl("github", undefined)).toBe("/api/auth/oauth/start/github"); + expect(oauthStartUrl("github", "")).toBe("/api/auth/oauth/start/github"); + }); +}); diff --git a/dashboard/src/features/auth/api.ts b/dashboard/src/features/auth/api.ts index b7d19c50..c4ae22ad 100644 --- a/dashboard/src/features/auth/api.ts +++ b/dashboard/src/features/auth/api.ts @@ -1,10 +1,32 @@ import { api } from "@/lib/api-client"; -import type { AuthStatus, LoginResponse, SetupResponse, WhoamiResponse } from "./types"; +import type { + AuthStatus, + LoginResponse, + ProvidersResponse, + SetupResponse, + WhoamiResponse, +} from "./types"; export function fetchAuthStatus(signal?: AbortSignal): Promise { return api.get("/api/auth/status", { signal }); } +export function fetchProviders(signal?: AbortSignal): Promise { + return api.get("/api/auth/providers", { signal }); +} + +/** Browser URL the user is sent to when they click an OAuth provider button. + * + * The server's ``/api/auth/oauth/start/{slot}`` endpoint will mint state and + * 302 to the provider. We append ``next`` so the post-login callback can + * land the user back where they were trying to go. + */ +export function oauthStartUrl(slot: string, next?: string): string { + const base = `/api/auth/oauth/start/${encodeURIComponent(slot)}`; + if (!next) return base; + return `${base}?next=${encodeURIComponent(next)}`; +} + export function fetchWhoami(signal?: AbortSignal): Promise { return api.get("/api/auth/whoami", { signal }); } diff --git a/dashboard/src/features/auth/components/login-form.tsx b/dashboard/src/features/auth/components/login-form.tsx index 3038d809..14857751 100644 --- a/dashboard/src/features/auth/components/login-form.tsx +++ b/dashboard/src/features/auth/components/login-form.tsx @@ -1,10 +1,11 @@ -import { useNavigate } from "@tanstack/react-router"; +import { useNavigate, useSearch } from "@tanstack/react-router"; import { AlertCircle, LogIn } from "lucide-react"; import { type FormEvent, useState } from "react"; import { Button } from "@/components/ui"; import { Input } from "@/components/ui/input"; import { ApiError } from "@/lib/api-client"; -import { useLogin } from "../hooks"; +import { useAuthProviders, useLogin } from "../hooks"; +import { OAuthButton } from "./oauth-button"; const ERROR_MESSAGES: Record = { invalid_credentials: "Invalid username or password.", @@ -13,9 +14,19 @@ const ERROR_MESSAGES: Record = { export function LoginForm() { const navigate = useNavigate(); + const search = useSearch({ strict: false }) as { next?: string } | undefined; + const nextPath = typeof search?.next === "string" ? search.next : undefined; + const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const login = useLogin(); + const providers = useAuthProviders(); + + // Default to password-on while the providers query is in flight so the + // form doesn't flash empty on the first render. + const passwordEnabled = providers.data?.password_enabled ?? true; + const oauthProviders = providers.data?.providers ?? []; + const hasOAuth = oauthProviders.length > 0; function onSubmit(event: FormEvent): void { event.preventDefault(); @@ -23,7 +34,7 @@ export function LoginForm() { { username, password }, { onSuccess: () => { - void navigate({ to: "/" }); + void navigate({ to: nextPath ?? "/" }); }, }, ); @@ -33,52 +44,86 @@ export function LoginForm() { const disabled = login.isPending || !username || !password; return ( -