diff --git a/.env.example b/.env.example index 1d1fa7a..2728e4b 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,8 @@ UNIFI_API_KEY=replace-me # Required only when alert.transport = "smtp": # SMTP_USERNAME=replace-me # SMTP_PASSWORD=replace-me + +# Required only when [webhook] enabled = true. Shared HMAC secret; must match +# the ORIGIN_HMAC_SECRET configured on the door-webhook Cloudflare Worker. +# Use a long random value (>= 16 chars), e.g. `openssl rand -hex 32`. +# WEBHOOK_HMAC_SECRET=replace-me diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 587cb49..44419d8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,4 +11,5 @@ repos: - id: pyrefly-check name: Pyrefly (type checking) pass_filenames: false # Recommended to do full repo checks. However, you can change this to `true` to only check changed files - additional_dependencies: [httpx>=0.28.1, pytest>=9.0.3, pytest-httpx>=0.30] \ No newline at end of file + additional_dependencies: + [httpx>=0.28.1, pytest>=9.0.3, pytest-httpx>=0.30, flask>=3.1.3, waitress>=3.0.2] \ No newline at end of file diff --git a/config.example.toml b/config.example.toml index 6cf857d..18733d2 100644 --- a/config.example.toml +++ b/config.example.toml @@ -82,6 +82,19 @@ rank = 10 # to = ["admin@example.com"] # subject_prefix = "[door-sync]" +# Embedded webhook receiver (optional; disabled by default). When enabled, the +# daemon runs a loopback-only Flask+waitress server in a second thread that a +# Cloudflare Tunnel connects to locally — the Pi never exposes a public port. +# Incoming requests must carry a valid HMAC signature (secret WEBHOOK_HMAC_SECRET +# in the env file). See docs/configuration.rst and deploy/cloudflared-config.yml. +# [webhook] +# enabled = true +# host = "127.0.0.1" # loopback only; cloudflared connects locally +# port = 8787 +# max_body_bytes = 65536 # reject larger request bodies (fail-closed) +# max_skew_seconds = 300 # reject signed requests older/newer than this (replay guard) +# debounce_seconds = 2.0 # settle window: a burst of webhooks collapses into one reconcile + [ops] # Operational file paths. All three are optional; defaults shown. # - audit_jsonl: append-only JSONL of every cycle's outcome (logrotate-friendly). diff --git a/deploy/cloudflared-config.yml b/deploy/cloudflared-config.yml new file mode 100644 index 0000000..a3e986a --- /dev/null +++ b/deploy/cloudflared-config.yml @@ -0,0 +1,28 @@ +# Cloudflare Tunnel config for the Pi. Routes ONE public hostname to the +# loopback-only door-sync webhook receiver (config.toml [webhook]). +# +# cloudflared runs as its OWN systemd service (see cloudflared.service) — +# separate from door-sync, so restarting the tunnel never disturbs the +# reconcile loop, and the door-sync unit keeps exposing no public port. +# +# One-time setup (run interactively as the cloudflared user): +# cloudflared tunnel login +# cloudflared tunnel create door-sync +# cloudflared tunnel route dns door-sync door-sync.example.org +# The `create` step writes a credentials JSON and prints the tunnel UUID. +# Put the UUID in `tunnel:` below and the JSON at `credentials-file:`. +# +# Lock the hostname down in the Cloudflare dashboard with a Zero Trust +# Access application using a SERVICE TOKEN policy, so only the door-webhook +# Worker (presenting CF-Access-Client-Id / CF-Access-Client-Secret) can reach +# it. The webhook's HMAC check (WEBHOOK_HMAC_SECRET) is the second, independent +# lock. + +tunnel: +credentials-file: /etc/cloudflared/door-sync.json + +ingress: + - hostname: door-sync.example.org + service: http://127.0.0.1:8787 + # Everything else is refused. + - service: http_status:404 diff --git a/deploy/cloudflared.service b/deploy/cloudflared.service new file mode 100644 index 0000000..914f4e7 --- /dev/null +++ b/deploy/cloudflared.service @@ -0,0 +1,23 @@ +[Unit] +Description=cloudflared tunnel for the door-sync webhook receiver +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=cloudflared +Group=cloudflared +ExecStart=/usr/local/bin/cloudflared tunnel --config /etc/cloudflared/config.yml run +Restart=on-failure +RestartSec=5s +StandardOutput=journal +StandardError=journal + +# Hardening — the tunnel needs only outbound network + its config/credentials. +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/docs/configuration.rst b/docs/configuration.rst index f46c57c..0340c36 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -72,6 +72,8 @@ These are required only when the corresponding alert transport is enabled: - ``alert.transport = "smtp"`` * - ``MAILGUN_API_KEY`` - ``alert.transport = "mailgun"`` + * - ``WEBHOOK_HMAC_SECRET`` + - ``webhook.enabled = true`` Example env file: @@ -359,6 +361,40 @@ The Mailgun API key is read from the ``MAILGUN_API_KEY`` env variable. email is a convenience layer. +``[webhook]`` — Embedded Webhook Receiver +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Optional and **disabled by default**. When enabled, daemon mode runs a small +sync Flask + waitress HTTP server in a second thread. It exists so a CiviCRM +change can trigger a reconcile within seconds instead of waiting up to a full +polling cadence. The server binds **loopback only** — a Cloudflare Tunnel +(``cloudflared``) connects to it locally, so the Pi never exposes a public +port. Inbound requests must carry a valid HMAC signature; the HTTP thread does +nothing but verify the signature and enqueue a trigger, and the scheduler +thread (the sole writer to UniFi/state/audit) drains it and runs a full +reconcile, coalescing bursts. + +.. code-block:: toml + + [webhook] + enabled = true + host = "127.0.0.1" # loopback only; cloudflared connects locally + port = 8787 + max_body_bytes = 65536 # reject larger request bodies (fail-closed) + max_skew_seconds = 300 # reject signed requests outside this clock skew (replay guard) + debounce_seconds = 2.0 # settle window: a burst of webhooks collapses into one reconcile + +The shared secret is read from the ``WEBHOOK_HMAC_SECRET`` env variable and +must match the ``ORIGIN_HMAC_SECRET`` configured on the ``door-webhook`` +Cloudflare Worker. Requests are signed as +``HMAC_SHA256(secret, f"{timestamp}." + raw_body)`` with headers +``X-Door-Sync-Timestamp`` and ``X-Door-Sync-Signature: sha256=``. + +See ``deploy/cloudflared.service`` and ``deploy/cloudflared-config.yml`` for the +tunnel setup, and front the hostname with a Cloudflare Access service-token +policy so only the Worker can reach it. + + Complete Example ---------------- diff --git a/pyproject.toml b/pyproject.toml index be7ce91..2384d01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,9 @@ authors = [ ] requires-python = ">=3.11" dependencies = [ + "flask>=3.1.3", "httpx>=0.28.1", + "waitress>=3.0.2", ] [project.scripts] diff --git a/src/door_sync/__main__.py b/src/door_sync/__main__.py index 82b3026..9b4dc56 100644 --- a/src/door_sync/__main__.py +++ b/src/door_sync/__main__.py @@ -16,16 +16,17 @@ import argparse import logging +import queue import sys from pathlib import Path -from door_sync import cli, orchestrator, reconciler, scheduler, tier_mapping +from door_sync import cli, orchestrator, reconciler, scheduler, tier_mapping, webhook from door_sync import config as config_mod from door_sync.civicrm.client import CivicrmClient from door_sync.unifi.client import UnifiClient # Expose config_mod so tests can monkeypatch it via main_mod.config_mod. -__all__ = ["config_mod", "CivicrmClient", "UnifiClient", "scheduler", "main"] +__all__ = ["config_mod", "CivicrmClient", "UnifiClient", "scheduler", "webhook", "main"] _logger = logging.getLogger("door_sync") @@ -123,7 +124,15 @@ def cmd_run(args: argparse.Namespace) -> int: return 1 if not args.once: - return scheduler.run_forever(config, dry_run=args.dry_run) + work_queue: queue.Queue[object] = queue.Queue() + server = ( + webhook.start(config.webhook, work_queue=work_queue) if config.webhook.enabled else None + ) + try: + return scheduler.run_forever(config, dry_run=args.dry_run, work_queue=work_queue) + finally: + if server is not None: + server.stop() try: result = orchestrator.reconcile(config, dry_run=args.dry_run) diff --git a/src/door_sync/config.py b/src/door_sync/config.py index daf3829..31cc705 100644 --- a/src/door_sync/config.py +++ b/src/door_sync/config.py @@ -137,6 +137,47 @@ class OpsPaths: alert_flag: Path +@dataclass(frozen=True) +class WebhookConfig: + """Embedded webhook-receiver settings. Secret: hmac_secret (from env). + + The receiver is an optional Flask+waitress server run in a second thread of + the daemon (see webhook.py). It binds loopback only; a Cloudflare Tunnel + connects locally, so the Pi never exposes a public port. + + Parameters: + enabled: Whether daemon mode starts the local webhook HTTP server. + host: Bind address. Loopback only in production. + port: TCP port for the local webhook server. + hmac_secret: Shared secret for HMAC-SHA256 request signing (env + WEBHOOK_HMAC_SECRET). Required only when enabled. + max_body_bytes: Reject request bodies larger than this (fail-closed). + max_skew_seconds: Reject signed requests whose timestamp is more than + this many seconds from now (replay protection). + debounce_seconds: Settle window before a triggered reconcile runs, so a + burst of webhooks collapses into one cycle. + """ + + enabled: bool + host: str + port: int + hmac_secret: str + max_body_bytes: int + max_skew_seconds: int + debounce_seconds: float + + +_DEFAULT_WEBHOOK_CONFIG = WebhookConfig( + enabled=False, + host="127.0.0.1", + port=8787, + hmac_secret="", + max_body_bytes=65536, + max_skew_seconds=300, + debounce_seconds=2.0, +) + + @dataclass(frozen=True) class Config: """Top-level configuration assembled from TOML + env by `load()`. @@ -149,6 +190,7 @@ class Config: tier_mapping: Rules mapping membership types to access policies. ops_paths: File paths for audit log, state, and alert flag. alert: Alert transport configuration. + webhook: Embedded webhook-receiver settings (disabled by default). """ cadence_seconds: int @@ -158,6 +200,7 @@ class Config: tier_mapping: TierMapping ops_paths: OpsPaths alert: AlertConfig + webhook: WebhookConfig = _DEFAULT_WEBHOOK_CONFIG @dataclass(frozen=True) @@ -285,6 +328,7 @@ def env_get(name: str) -> str | None: tier_mapping = _validate_tier_mapping(data, issues) ops_paths = _validate_ops(data, issues) alert_config = _validate_alert(data, issues, env_get) + webhook = _validate_webhook(data, issues, env_get) if issues: raise ConfigError(issues) @@ -297,6 +341,100 @@ def env_get(name: str) -> str | None: tier_mapping=tier_mapping, ops_paths=ops_paths, alert=alert_config, + webhook=webhook, + ) + + +def _validate_webhook( + data: dict[str, Any], + issues: list[ConfigIssue], + env_get: EnvGetter, +) -> WebhookConfig: + """Validate the optional [webhook] table. + + When absent or disabled, returns the disabled default and requires no + secret (mirrors _validate_alert returning the flag-file default). When + enabled, the HMAC secret comes from env WEBHOOK_HMAC_SECRET and is required. + """ + section = data.get("webhook", {}) + if not isinstance(section, dict): + issues.append(ConfigIssue(path="webhook", message="must be a table")) + return _DEFAULT_WEBHOOK_CONFIG + + enabled = section.get("enabled", False) + if not isinstance(enabled, bool): + issues.append( + ConfigIssue( + path="webhook.enabled", + message=f"must be bool, got {type(enabled).__name__}", + ) + ) + return _DEFAULT_WEBHOOK_CONFIG + if not enabled: + return _DEFAULT_WEBHOOK_CONFIG + + host = section.get("host", "127.0.0.1") + if not isinstance(host, str) or not host: + issues.append(ConfigIssue(path="webhook.host", message="must be non-empty string")) + host = "127.0.0.1" + + port = section.get("port", 8787) + if isinstance(port, bool) or not isinstance(port, int): + issues.append( + ConfigIssue(path="webhook.port", message=f"must be int, got {type(port).__name__}") + ) + port = 8787 + elif not (1 <= port <= 65535): + issues.append( + ConfigIssue(path="webhook.port", message=f"must be between 1 and 65535, got {port}") + ) + port = 8787 + + max_body = section.get("max_body_bytes", 65536) + if isinstance(max_body, bool) or not isinstance(max_body, int) or max_body <= 0: + issues.append(ConfigIssue(path="webhook.max_body_bytes", message="must be a positive int")) + max_body = 65536 + + max_skew = section.get("max_skew_seconds", 300) + if isinstance(max_skew, bool) or not isinstance(max_skew, int) or max_skew < 0: + issues.append(ConfigIssue(path="webhook.max_skew_seconds", message="must be an int >= 0")) + max_skew = 300 + + debounce_raw = section.get("debounce_seconds", 2.0) + if ( + isinstance(debounce_raw, bool) + or not isinstance(debounce_raw, (int, float)) + or debounce_raw < 0 + ): + issues.append(ConfigIssue(path="webhook.debounce_seconds", message="must be a number >= 0")) + debounce = 2.0 + else: + debounce = float(debounce_raw) + + hmac_secret = (env_get("WEBHOOK_HMAC_SECRET") or "").strip() + if not hmac_secret: + issues.append( + ConfigIssue( + path="WEBHOOK_HMAC_SECRET", + message="required env var is missing or empty when webhook.enabled is true", + ) + ) + elif len(hmac_secret) < 16: + issues.append( + ConfigIssue( + path="WEBHOOK_HMAC_SECRET", + message="must be at least 16 characters", + ) + ) + + return WebhookConfig( + enabled=True, + host=host, + port=port, + hmac_secret=hmac_secret, + max_body_bytes=max_body, + max_skew_seconds=max_skew, + debounce_seconds=debounce, ) diff --git a/src/door_sync/models.py b/src/door_sync/models.py index a1942ef..08c6351 100644 --- a/src/door_sync/models.py +++ b/src/door_sync/models.py @@ -116,6 +116,25 @@ class ReconcileResult: diff: Diff | None +@dataclass(frozen=True) +class ReconcileRequest: + """A queued webhook trigger asking the scheduler to run a reconcile soon. + + Enqueued by the webhook receiver thread and drained by the scheduler thread. + The reconcile is always whole-population (the CiviCRM client has no + per-contact fetch); contact_id is carried for logging only. + + Parameters: + reason: Short label for why the reconcile was requested (e.g. the + webhook event type). Logged, never a member name. + contact_id: CiviCRM contact ID from the triggering event, for logging + only, or None when the payload did not carry one. + """ + + reason: str + contact_id: int | None = None + + @dataclass(frozen=True) class TierRule: """A single tier-mapping rule from configuration. diff --git a/src/door_sync/scheduler.py b/src/door_sync/scheduler.py index be19352..71af2ea 100644 --- a/src/door_sync/scheduler.py +++ b/src/door_sync/scheduler.py @@ -11,8 +11,10 @@ """ import logging +import queue import signal import threading +import time import types from typing import Protocol @@ -22,6 +24,9 @@ _logger = logging.getLogger("door_sync.scheduler") +# Enqueued by the signal handler so a blocked queue.get() wakes immediately. +_SHUTDOWN = object() + class ReconcileFn(Protocol): """Callable protocol for a single reconcile cycle.""" @@ -30,41 +35,96 @@ def __call__(self, config: Config, *, dry_run: bool) -> ReconcileResult: """Run one reconcile cycle. Production impl: orchestrator.reconcile.""" -def _install_signal_handlers(event: threading.Event) -> None: +def _install_signal_handlers( + event: threading.Event, + work_queue: "queue.Queue[object] | None" = None, +) -> None: def _handler(signum: int, _frame: types.FrameType | None) -> None: _logger.info( "shutdown signal received (%s); exiting after current cycle", signal.Signals(signum).name, ) event.set() + if work_queue is not None: + try: + work_queue.put_nowait(_SHUTDOWN) + except queue.Full: # pragma: no cover - queue is unbounded + pass signal.signal(signal.SIGTERM, _handler) signal.signal(signal.SIGINT, _handler) +def _wait_for_trigger( + work_queue: "queue.Queue[object]", + shutdown_event: threading.Event, + *, + cadence_seconds: float, + debounce_seconds: float, +) -> bool: + """Block until the next cycle should run. + + Returns True iff shutdown was requested (caller must exit); False on a + normal wake (cadence tick or a coalesced work item). A cadence timeout, a + work item, or the shutdown sentinel each end the wait. When woken by a work + item, a short settle window drains any burst so many pending webhook + triggers collapse into a single reconcile (debounce/coalesce). + """ + if shutdown_event.is_set(): + return True + try: + item = work_queue.get(timeout=cadence_seconds) + except queue.Empty: + return shutdown_event.is_set() # periodic cadence tick + if item is _SHUTDOWN or shutdown_event.is_set(): + return True + # Woken early by a reconcile trigger. Absorb a burst within the settle + # window, discarding the extra triggers (all fold into the next cycle). + deadline = time.monotonic() + debounce_seconds + while (remaining := deadline - time.monotonic()) > 0: + try: + nxt = work_queue.get(timeout=remaining) + except queue.Empty: + break + if nxt is _SHUTDOWN or shutdown_event.is_set(): + return True + return False + + def run_forever( config: Config, *, dry_run: bool = False, shutdown_event: threading.Event | None = None, + work_queue: "queue.Queue[object] | None" = None, reconcile_fn: ReconcileFn = orchestrator.reconcile, ) -> int: """Run reconcile cycles in a loop until a shutdown signal is received. + The cadence is the idle upper bound between cycles; an enqueued + ReconcileRequest wakes the loop early and is coalesced with any burst. The + trigger always runs a FULL reconcile — orchestrator.reconcile's signature is + unchanged. + Args: - config: Full application configuration (includes cadence_seconds). + config: Full application configuration (includes cadence_seconds and the + webhook debounce window). dry_run: If True, all cycles run in dry-run mode. shutdown_event: Threading event to signal shutdown. When None, SIGTERM/SIGINT handlers are installed automatically. + work_queue: Shared queue drained between cycles; the webhook receiver + thread enqueues ReconcileRequests onto it. Created if None. reconcile_fn: Callable to execute each cycle. Defaults to `orchestrator.reconcile`. Returns: Always returns 0 (clean shutdown). """ + if work_queue is None: + work_queue = queue.Queue() if shutdown_event is None: shutdown_event = threading.Event() - _install_signal_handlers(shutdown_event) + _install_signal_handlers(shutdown_event, work_queue) while True: _logger.info("cycle start") @@ -72,8 +132,13 @@ def run_forever( reconcile_fn(config, dry_run=dry_run) except Exception as exc: orchestrator.handle_crash(exc, paths=config.ops_paths, alert_config=config.alert) - _logger.info("cycle complete; sleeping %ds", config.cadence_seconds) - if shutdown_event.wait(timeout=config.cadence_seconds): + _logger.info("cycle complete; waiting up to %ds", config.cadence_seconds) + if _wait_for_trigger( + work_queue, + shutdown_event, + cadence_seconds=config.cadence_seconds, + debounce_seconds=config.webhook.debounce_seconds, + ): break _logger.info("scheduler exited") return 0 diff --git a/src/door_sync/webhook.py b/src/door_sync/webhook.py new file mode 100644 index 0000000..7e1b1c6 --- /dev/null +++ b/src/door_sync/webhook.py @@ -0,0 +1,163 @@ +"""Embedded webhook receiver (architecture §13 / design guide Appendix C). + +A sync Flask app served by waitress in a second thread of the daemon. The HTTP +thread does the minimum: verify the HMAC signature over the raw request body, +validate the payload, and enqueue a work item. It returns 202 at once. The +scheduler's single drainer thread is the only writer to UniFi/state/audit, so +the HTTP path never touches those resources — no locks, no concurrent writes. + +Layering: this module is top-of-graph. Phase 1 imports config + models only (it +merely enqueues; it does not call orchestrator). Nothing imports it except the +run wiring in __main__. No asyncio — sync Flask + waitress (architecture §3, §13). + +Logging: contact_id only. Never log member names, emails, or card IDs (§11). +""" + +import hashlib +import hmac +import json +import logging +import queue +import threading +import time +from typing import Any + +from flask import Flask, Response, request +from waitress.server import create_server # type: ignore[import-untyped] + +from door_sync.config import WebhookConfig +from door_sync.models import ReconcileRequest + +_logger = logging.getLogger("door_sync.webhook") + +_TS_HEADER = "X-Door-Sync-Timestamp" +_SIG_HEADER = "X-Door-Sync-Signature" +_SIG_PREFIX = "sha256=" + + +def _verify_signature( + secret: str, + raw_body: bytes, + *, + timestamp: str | None, + signature: str | None, + max_skew_seconds: int, + now: float, +) -> bool: + """Return True iff `signature` is a valid HMAC-SHA256 over ``f"{ts}." + body`` + under `secret`, and `timestamp` is within `max_skew_seconds` of `now`. + + Constant-time compare; fail-closed on any missing or malformed input. The + timestamp binding gives replay protection. `signature` may be bare hex or + ``sha256=``. + """ + if not secret or not signature or not timestamp: + return False + try: + ts = int(timestamp) + except ValueError: + return False + if abs(now - ts) > max_skew_seconds: + return False + provided = signature[len(_SIG_PREFIX) :] if signature.startswith(_SIG_PREFIX) else signature + signed = f"{ts}.".encode("ascii") + raw_body + expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest() + return hmac.compare_digest(expected, provided) + + +def _extract_contact_id(raw_body: bytes) -> int | None: + """Best-effort parse of the CiviCRM contact_id, for logging only. + + Returns None on any parse issue — the reconcile is whole-population + regardless, so a missing id never blocks the trigger. + """ + try: + payload = json.loads(raw_body) + except (ValueError, TypeError): + return None + if isinstance(payload, dict): + cid = payload.get("contact_id") + if isinstance(cid, int) and not isinstance(cid, bool): + return cid + if isinstance(cid, str) and cid.isdigit(): + return int(cid) + return None + + +def create_app(webhook_config: WebhookConfig, work_queue: "queue.Queue[object]") -> Flask: + """Build the Flask app. Routes close over the webhook config slice and the + shared work queue that the scheduler thread drains. + """ + app = Flask("door_sync.webhook") + wcfg = webhook_config + app.config["MAX_CONTENT_LENGTH"] = wcfg.max_body_bytes # defense in depth + + @app.get("/healthz") + def healthz() -> Response: + """Liveness probe for cloudflared / local monitoring. No auth.""" + return Response("ok", status=200, mimetype="text/plain") + + @app.post("/civicrm/membership-changed") + def membership_changed() -> Response: + """Verify HMAC, enqueue a full-reconcile trigger, return 202. + + A bad or missing signature aborts with 401 BEFORE anything is enqueued. + """ + raw = request.get_data(cache=False, as_text=False) + if not _verify_signature( + wcfg.hmac_secret, + raw, + timestamp=request.headers.get(_TS_HEADER), + signature=request.headers.get(_SIG_HEADER), + max_skew_seconds=wcfg.max_skew_seconds, + now=time.time(), + ): + _logger.warning("webhook rejected: invalid or missing signature") + return Response(status=401) + contact_id = _extract_contact_id(raw) # logging only + work_queue.put(ReconcileRequest(reason="membership-changed", contact_id=contact_id)) + _logger.info("membership webhook accepted; contact_id=%s; reconcile queued", contact_id) + return Response(status=202) + + return app + + +class WebhookServer: + """Handle for the waitress-served Flask app running in a daemon thread.""" + + def __init__(self, server: Any, thread: threading.Thread, stopping: threading.Event) -> None: + self._server = server + self._thread = thread + self._stopping = stopping + + def stop(self, *, timeout: float = 10.0) -> None: + """Gracefully stop the server and join its thread (clean daemon exit).""" + self._stopping.set() + self._server.close() + self._thread.join(timeout=timeout) + + +def start(webhook_config: WebhookConfig, *, work_queue: "queue.Queue[object]") -> WebhookServer: + """Create a waitress server bound to the configured loopback host/port and + run it in a daemon thread. Returns a handle whose ``.stop()`` shuts it down. + """ + wcfg = webhook_config + app = create_app(wcfg, work_queue) + server = create_server(app, host=wcfg.host, port=wcfg.port) + stopping = threading.Event() + + def _serve() -> None: + try: + server.run() + except OSError: + # stop() closes the listening socket out from under the asyncore + # select() loop; the resulting EBADF is an expected teardown + # artifact. Re-raise anything that is NOT a shutdown so genuine + # serving failures still surface. + if not stopping.is_set(): + raise + + thread = threading.Thread(target=_serve, name="door-sync-webhook", daemon=True) + thread.start() + _logger.info("webhook listening on %s:%d", wcfg.host, wcfg.port) + return WebhookServer(server, thread, stopping) diff --git a/tests/test_config.py b/tests/test_config.py index e9a5429..81b1cb3 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -6,11 +6,13 @@ from door_sync.config import ( _DEFAULT_ALERT_CONFIG, _DEFAULT_OPS_PATHS, + _DEFAULT_WEBHOOK_CONFIG, CivicrmConfig, Config, ConfigError, ConfigIssue, UnifiConfig, + WebhookConfig, _load_env_file, load, ) @@ -708,6 +710,9 @@ def test_example_files_parse(monkeypatch: pytest.MonkeyPatch) -> None: assert result.alert.transport == "flag-file" assert result.alert.smtp is None assert result.alert.mailgun is None + # webhook — commented out in example, defaults to disabled (no secret needed) + assert result.webhook == _DEFAULT_WEBHOOK_CONFIG + assert result.webhook.enabled is False # --- facility_code tests --- @@ -1166,3 +1171,82 @@ def test_active_statuses_with_empty_entry_rejected( assert any( i.path == "civicrm.active_statuses" and "non-empty" in i.message for i in exc.value.issues ) + + +# --- webhook config tests --- + + +def test_webhook_absent_defaults_disabled(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("DOOR_SYNC_CONFIG_DIR", raising=False) + monkeypatch.delenv("WEBHOOK_HMAC_SECRET", raising=False) + cfg, env = _write_minimal_valid(tmp_path) + result = load(config_path=cfg, env_path=env) + assert result.webhook == _DEFAULT_WEBHOOK_CONFIG + assert result.webhook.enabled is False + + +def test_webhook_enabled_requires_secret(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("DOOR_SYNC_CONFIG_DIR", raising=False) + monkeypatch.delenv("WEBHOOK_HMAC_SECRET", raising=False) + cfg, env = _write_minimal_valid(tmp_path, extra_toml="[webhook]\nenabled = true\n") + with pytest.raises(ConfigError) as exc: + load(config_path=cfg, env_path=env) + assert any(i.path == "WEBHOOK_HMAC_SECRET" for i in exc.value.issues) + + +def test_webhook_enabled_happy_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("DOOR_SYNC_CONFIG_DIR", raising=False) + cfg, env = _write_minimal_valid(tmp_path, extra_toml="[webhook]\nenabled = true\nport = 9000\n") + env.write_text(env.read_text() + "WEBHOOK_HMAC_SECRET=" + "s" * 32 + "\n") + result = load(config_path=cfg, env_path=env) + assert result.webhook.enabled is True + assert result.webhook.host == "127.0.0.1" + assert result.webhook.port == 9000 + assert result.webhook.hmac_secret == "s" * 32 + assert result.webhook.max_skew_seconds == 300 + + +def test_webhook_port_out_of_range(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("DOOR_SYNC_CONFIG_DIR", raising=False) + cfg, env = _write_minimal_valid( + tmp_path, extra_toml="[webhook]\nenabled = true\nport = 70000\n" + ) + env.write_text(env.read_text() + "WEBHOOK_HMAC_SECRET=" + "s" * 32 + "\n") + with pytest.raises(ConfigError) as exc: + load(config_path=cfg, env_path=env) + assert any(i.path == "webhook.port" for i in exc.value.issues) + + +def test_webhook_bad_skew(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("DOOR_SYNC_CONFIG_DIR", raising=False) + cfg, env = _write_minimal_valid( + tmp_path, extra_toml="[webhook]\nenabled = true\nmax_skew_seconds = -5\n" + ) + env.write_text(env.read_text() + "WEBHOOK_HMAC_SECRET=" + "s" * 32 + "\n") + with pytest.raises(ConfigError) as exc: + load(config_path=cfg, env_path=env) + assert any(i.path == "webhook.max_skew_seconds" for i in exc.value.issues) + + +def test_webhook_secret_too_short(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("DOOR_SYNC_CONFIG_DIR", raising=False) + monkeypatch.delenv("WEBHOOK_HMAC_SECRET", raising=False) + cfg, env = _write_minimal_valid(tmp_path, extra_toml="[webhook]\nenabled = true\n") + env.write_text(env.read_text() + "WEBHOOK_HMAC_SECRET=short\n") + with pytest.raises(ConfigError) as exc: + load(config_path=cfg, env_path=env) + assert any(i.path == "WEBHOOK_HMAC_SECRET" for i in exc.value.issues) + + +def test_webhook_config_is_frozen() -> None: + w = WebhookConfig( + enabled=False, + host="127.0.0.1", + port=8787, + hmac_secret="", + max_body_bytes=65536, + max_skew_seconds=300, + debounce_seconds=2.0, + ) + with pytest.raises(FrozenInstanceError): + w.enabled = True # type: ignore[misc] diff --git a/tests/test_main.py b/tests/test_main.py index e748fa5..5ac3c18 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -21,6 +21,7 @@ ConfigIssue, OpsPaths, UnifiConfig, + WebhookConfig, ) from door_sync.models import ( Diff, @@ -31,7 +32,7 @@ ) -def _build_config(tmp_path: Path) -> Config: +def _build_config(tmp_path: Path, *, webhook_enabled: bool = False) -> Config: return Config( cadence_seconds=600, civicrm=CivicrmConfig( @@ -56,6 +57,15 @@ def _build_config(tmp_path: Path) -> Config: alert_flag=tmp_path / "alert.flag", ), alert=AlertConfig(transport="flag-file", smtp=None, mailgun=None), + webhook=WebhookConfig( + enabled=webhook_enabled, + host="127.0.0.1", + port=8787, + hmac_secret="s" * 32 if webhook_enabled else "", + max_body_bytes=65536, + max_skew_seconds=300, + debounce_seconds=0.0, + ), ) @@ -121,7 +131,7 @@ def test_run_daemon_calls_scheduler(tmp_path: Path, monkeypatch: pytest.MonkeyPa _patch_config_load(monkeypatch, cfg) recorded: dict[str, object] = {} - def fake_run_forever(c: Config, *, dry_run: bool) -> int: + def fake_run_forever(c: Config, *, dry_run: bool, work_queue: object = None) -> int: recorded["config"] = c recorded["dry_run"] = dry_run return 0 @@ -142,7 +152,7 @@ def test_run_daemon_with_dry_run_flag(tmp_path: Path, monkeypatch: pytest.Monkey _patch_config_load(monkeypatch, cfg) recorded: dict[str, object] = {} - def fake_run_forever(_c: Config, *, dry_run: bool) -> int: + def fake_run_forever(_c: Config, *, dry_run: bool, work_queue: object = None) -> int: recorded["dry_run"] = dry_run return 0 @@ -242,3 +252,93 @@ def __exit__(self, *_: Any) -> None: assert not (tmp_path / "audit.jsonl").exists() assert not (tmp_path / "state.json").exists() assert not (tmp_path / "alert.flag").exists() + + +# --- webhook daemon wiring --- + + +def _ok_reconcile(c: Config, *, dry_run: bool) -> ReconcileResult: # noqa: ARG001 + return ReconcileResult(halted=False, reason=None, diff=Diff((), (), (), (), ())) + + +def test_daemon_starts_and_stops_webhook_when_enabled( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + cfg = _build_config(tmp_path, webhook_enabled=True) + _patch_config_load(monkeypatch, cfg) + recorded: dict[str, object] = {} + + class FakeServer: + def __init__(self) -> None: + self.stopped = False + + def stop(self, **_: object) -> None: + self.stopped = True + + fake_server = FakeServer() + + def fake_start(webhook_config: object, *, work_queue: object) -> FakeServer: + recorded["start_wcfg"] = webhook_config + recorded["start_queue"] = work_queue + return fake_server + + def fake_run_forever(c: Config, *, dry_run: bool, work_queue: object = None) -> int: # noqa: ARG001 + recorded["run_queue"] = work_queue + return 0 + + from door_sync import scheduler + + monkeypatch.setattr(main_mod.webhook, "start", fake_start) + monkeypatch.setattr(scheduler, "run_forever", fake_run_forever) + + rc = main_mod.main(argv=["run"]) + + assert rc == 0 + assert recorded["start_wcfg"] is cfg.webhook + # The webhook server and the scheduler share the SAME queue object. + assert recorded["start_queue"] is recorded["run_queue"] + assert fake_server.stopped is True + + +def test_daemon_does_not_start_webhook_when_disabled( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + cfg = _build_config(tmp_path, webhook_enabled=False) + _patch_config_load(monkeypatch, cfg) + started: list[object] = [] + + def fake_start(*_a: object, **_k: object) -> object: + started.append(object()) + return object() + + def fake_run_forever(c: Config, *, dry_run: bool, work_queue: object = None) -> int: # noqa: ARG001 + return 0 + + from door_sync import scheduler + + monkeypatch.setattr(main_mod.webhook, "start", fake_start) + monkeypatch.setattr(scheduler, "run_forever", fake_run_forever) + + rc = main_mod.main(argv=["run"]) + + assert rc == 0 + assert started == [] + + +def test_run_once_never_starts_webhook(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # Even with webhook enabled, --once must not stand up the server. + cfg = _build_config(tmp_path, webhook_enabled=True) + _patch_config_load(monkeypatch, cfg) + started: list[object] = [] + + def fake_start(*_a: object, **_k: object) -> object: + started.append(object()) + return object() + + monkeypatch.setattr(main_mod.webhook, "start", fake_start) + monkeypatch.setattr(orchestrator, "reconcile", _ok_reconcile) + + rc = main_mod.main(argv=["run", "--once"]) + + assert rc == 0 + assert started == [] diff --git a/tests/test_models.py b/tests/test_models.py index 924ee89..0429371 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -6,6 +6,7 @@ CheckResult, CiviMember, Diff, + ReconcileRequest, ReconcileResult, ResolvedMember, SafetyThresholds, @@ -63,6 +64,19 @@ def test_reconcile_result_is_frozen() -> None: rr.halted = True # type: ignore[misc] +def test_reconcile_request_is_frozen() -> None: + r = ReconcileRequest(reason="membership-changed") + with pytest.raises(FrozenInstanceError): + r.reason = "other" # type: ignore[misc] + + +def test_reconcile_request_defaults_contact_id_none_and_accepts_value() -> None: + r = ReconcileRequest(reason="membership-changed") + assert r.contact_id is None + r2 = ReconcileRequest(reason="membership-changed", contact_id=42) + assert r2.contact_id == 42 + + def test_tier_rule_is_frozen() -> None: t = TierRule(resolution="tier", target_policy="P1", rank=1) with pytest.raises(FrozenInstanceError): diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 1702855..690ff9e 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -8,6 +8,7 @@ import json import os +import queue import signal import threading from pathlib import Path @@ -19,9 +20,11 @@ Config, OpsPaths, UnifiConfig, + WebhookConfig, ) from door_sync.models import ( Diff, + ReconcileRequest, ReconcileResult, SafetyThresholds, TierMapping, @@ -29,7 +32,7 @@ ) -def _config(tmp_path: Path, *, cadence_seconds: int = 600) -> Config: +def _config(tmp_path: Path, *, cadence_seconds: int = 600, debounce_seconds: float = 0.0) -> Config: return Config( cadence_seconds=cadence_seconds, civicrm=CivicrmConfig( @@ -54,6 +57,15 @@ def _config(tmp_path: Path, *, cadence_seconds: int = 600) -> Config: alert_flag=tmp_path / "alert.flag", ), alert=AlertConfig(transport="flag-file", smtp=None, mailgun=None), + webhook=WebhookConfig( + enabled=False, + host="127.0.0.1", + port=8787, + hmac_secret="", + max_body_bytes=65536, + max_skew_seconds=300, + debounce_seconds=debounce_seconds, + ), ) @@ -183,3 +195,102 @@ def fake_reconcile(config: Config, *, dry_run: bool) -> ReconcileResult: # noqa ) assert recorded == [True] + + +# --- webhook wake / coalesce additions --- + + +def test_install_signal_handlers_enqueues_shutdown_sentinel() -> None: + event = threading.Event() + q: queue.Queue[object] = queue.Queue() + original_term = signal.getsignal(signal.SIGTERM) + original_int = signal.getsignal(signal.SIGINT) + try: + scheduler._install_signal_handlers(event, q) + os.kill(os.getpid(), signal.SIGTERM) + assert event.is_set() + assert q.get_nowait() is scheduler._SHUTDOWN + finally: + signal.signal(signal.SIGTERM, original_term) + signal.signal(signal.SIGINT, original_int) + + +def test_wait_for_trigger_returns_true_when_event_preset() -> None: + q: queue.Queue[object] = queue.Queue() + event = threading.Event() + event.set() + result = scheduler._wait_for_trigger(q, event, cadence_seconds=600, debounce_seconds=0.0) + assert result is True + + +def test_wait_for_trigger_consumes_work_item_without_full_cadence() -> None: + q: queue.Queue[object] = queue.Queue() + q.put(ReconcileRequest(reason="membership-changed")) + event = threading.Event() + result = scheduler._wait_for_trigger(q, event, cadence_seconds=600, debounce_seconds=0.0) + assert result is False + assert q.empty() + + +def test_wait_for_trigger_shutdown_sentinel_returns_true() -> None: + q: queue.Queue[object] = queue.Queue() + q.put(scheduler._SHUTDOWN) + event = threading.Event() + result = scheduler._wait_for_trigger(q, event, cadence_seconds=600, debounce_seconds=0.0) + assert result is True + + +def test_wait_for_trigger_coalesces_burst_into_one_wait() -> None: + q: queue.Queue[object] = queue.Queue() + for _ in range(3): + q.put(ReconcileRequest(reason="membership-changed")) + event = threading.Event() + result = scheduler._wait_for_trigger(q, event, cadence_seconds=600, debounce_seconds=0.05) + assert result is False + assert q.empty() # all three drained within the settle window + + +def test_wait_for_trigger_returns_false_on_cadence_tick() -> None: + q: queue.Queue[object] = queue.Queue() + event = threading.Event() + result = scheduler._wait_for_trigger(q, event, cadence_seconds=0.01, debounce_seconds=0.0) + assert result is False + + +def test_run_forever_wakes_on_enqueued_item(tmp_path: Path) -> None: + # Large cadence: only the queued item can drive the second cycle. + cfg = _config(tmp_path, cadence_seconds=600) + q: queue.Queue[object] = queue.Queue() + q.put(ReconcileRequest(reason="membership-changed")) + event = threading.Event() + call_count = 0 + + def fake_reconcile(config: Config, *, dry_run: bool) -> ReconcileResult: # noqa: ARG001 + nonlocal call_count + call_count += 1 + if call_count >= 2: + event.set() + return _ok_result() + + rc = scheduler.run_forever(cfg, shutdown_event=event, work_queue=q, reconcile_fn=fake_reconcile) + assert rc == 0 + assert call_count == 2 + + +def test_run_forever_breaks_on_shutdown_sentinel(tmp_path: Path) -> None: + cfg = _config(tmp_path, cadence_seconds=600) + q: queue.Queue[object] = queue.Queue() + event = threading.Event() + call_count = 0 + + def fake_reconcile(config: Config, *, dry_run: bool) -> ReconcileResult: # noqa: ARG001 + nonlocal call_count + call_count += 1 + # Simulate the signal handler firing during the cycle. + event.set() + q.put(scheduler._SHUTDOWN) + return _ok_result() + + rc = scheduler.run_forever(cfg, shutdown_event=event, work_queue=q, reconcile_fn=fake_reconcile) + assert rc == 0 + assert call_count == 1 diff --git a/tests/test_webhook.py b/tests/test_webhook.py new file mode 100644 index 0000000..00dfa3c --- /dev/null +++ b/tests/test_webhook.py @@ -0,0 +1,226 @@ +import hashlib +import hmac +import logging +import queue +import time + +from door_sync import webhook +from door_sync.config import WebhookConfig +from door_sync.models import ReconcileRequest + +SECRET = "test-secret-abcdefghij" # >= 16 chars + + +def _wcfg(**overrides: object) -> WebhookConfig: + base: dict[str, object] = { + "enabled": True, + "host": "127.0.0.1", + "port": 8787, + "hmac_secret": SECRET, + "max_body_bytes": 1024, + "max_skew_seconds": 300, + "debounce_seconds": 0.0, + } + base.update(overrides) + return WebhookConfig(**base) # type: ignore[arg-type] + + +def _digest(body: bytes, *, secret: str = SECRET, ts: int) -> str: + return hmac.new(secret.encode(), f"{ts}.".encode() + body, hashlib.sha256).hexdigest() + + +def _sign(body: bytes, *, secret: str = SECRET, ts: int | None = None) -> dict[str, str]: + if ts is None: + ts = int(time.time()) + return { + "X-Door-Sync-Timestamp": str(ts), + "X-Door-Sync-Signature": f"sha256={_digest(body, secret=secret, ts=ts)}", + "Content-Type": "application/json", + } + + +def _client(wcfg: WebhookConfig | None = None) -> tuple[object, "queue.Queue[object]"]: + q: queue.Queue[object] = queue.Queue() + app = webhook.create_app(wcfg or _wcfg(), q) + app.testing = True + return app.test_client(), q + + +# --- _verify_signature unit tests --- + + +def test_verify_signature_valid() -> None: + body = b'{"contact_id": 5}' + ts = int(time.time()) + assert webhook._verify_signature( + SECRET, + body, + timestamp=str(ts), + signature=f"sha256={_digest(body, ts=ts)}", + max_skew_seconds=300, + now=ts, + ) + + +def test_verify_signature_bad_digest() -> None: + body = b'{"contact_id": 5}' + ts = int(time.time()) + assert not webhook._verify_signature( + SECRET, body, timestamp=str(ts), signature="sha256=deadbeef", max_skew_seconds=300, now=ts + ) + + +def test_verify_signature_wrong_secret() -> None: + body = b'{"contact_id": 5}' + ts = int(time.time()) + assert not webhook._verify_signature( + SECRET, + body, + timestamp=str(ts), + signature=f"sha256={_digest(body, secret='other-secret', ts=ts)}", + max_skew_seconds=300, + now=ts, + ) + + +def test_verify_signature_tampered_body() -> None: + ts = int(time.time()) + sig = f"sha256={_digest(b'original', ts=ts)}" + assert not webhook._verify_signature( + SECRET, b"tampered", timestamp=str(ts), signature=sig, max_skew_seconds=300, now=ts + ) + + +def test_verify_signature_missing_headers() -> None: + body = b"{}" + ts = int(time.time()) + assert not webhook._verify_signature( + SECRET, body, timestamp=None, signature=None, max_skew_seconds=300, now=ts + ) + assert not webhook._verify_signature( + SECRET, + body, + timestamp=str(ts), + signature=None, + max_skew_seconds=300, + now=ts, + ) + + +def test_verify_signature_replay_rejected() -> None: + body = b"{}" + ts = int(time.time()) - 10_000 + assert not webhook._verify_signature( + SECRET, + body, + timestamp=str(ts), + signature=f"sha256={_digest(body, ts=ts)}", + max_skew_seconds=300, + now=time.time(), + ) + + +def test_verify_signature_accepts_bare_and_prefixed() -> None: + body = b"{}" + ts = int(time.time()) + digest = _digest(body, ts=ts) + assert webhook._verify_signature( + SECRET, body, timestamp=str(ts), signature=digest, max_skew_seconds=300, now=ts + ) + assert webhook._verify_signature( + SECRET, body, timestamp=str(ts), signature=f"sha256={digest}", max_skew_seconds=300, now=ts + ) + + +def test_verify_signature_non_integer_timestamp() -> None: + body = b"{}" + assert not webhook._verify_signature( + SECRET, + body, + timestamp="not-a-number", + signature="sha256=abc", + max_skew_seconds=300, + now=time.time(), + ) + + +# --- endpoint tests --- + + +def test_membership_changed_valid_enqueues_and_202() -> None: + client, q = _client() + body = b'{"contact_id": 7}' + resp = client.post("/civicrm/membership-changed", data=body, headers=_sign(body)) # type: ignore[attr-defined] + assert resp.status_code == 202 + item = q.get_nowait() + assert isinstance(item, ReconcileRequest) + assert item.reason == "membership-changed" + assert item.contact_id == 7 + assert q.empty() + + +def test_membership_changed_bad_signature_401_and_no_enqueue() -> None: + client, q = _client() + body = b'{"contact_id": 7}' + headers = _sign(body) + headers["X-Door-Sync-Signature"] = "sha256=bad" + resp = client.post("/civicrm/membership-changed", data=body, headers=headers) # type: ignore[attr-defined] + assert resp.status_code == 401 + assert q.empty() + + +def test_membership_changed_missing_signature_401() -> None: + client, q = _client() + resp = client.post( # type: ignore[attr-defined] + "/civicrm/membership-changed", data=b"{}", headers={"Content-Type": "application/json"} + ) + assert resp.status_code == 401 + assert q.empty() + + +def test_membership_changed_replay_rejected_401() -> None: + client, q = _client() + body = b"{}" + old = int(time.time()) - 10_000 + resp = client.post( # type: ignore[attr-defined] + "/civicrm/membership-changed", data=body, headers=_sign(body, ts=old) + ) + assert resp.status_code == 401 + assert q.empty() + + +def test_membership_changed_oversize_body_413() -> None: + client, q = _client(_wcfg(max_body_bytes=10)) + body = b'{"contact_id": 1234567890}' # > 10 bytes + resp = client.post("/civicrm/membership-changed", data=body, headers=_sign(body)) # type: ignore[attr-defined] + assert resp.status_code == 413 + assert q.empty() + + +def test_contact_id_extraction_tolerant() -> None: + client, q = _client() + body = b'{"no_contact_here": true}' + resp = client.post("/civicrm/membership-changed", data=body, headers=_sign(body)) # type: ignore[attr-defined] + assert resp.status_code == 202 + item = q.get_nowait() + assert isinstance(item, ReconcileRequest) + assert item.contact_id is None + + +def test_healthz_ok() -> None: + client, _ = _client() + resp = client.get("/healthz") # type: ignore[attr-defined] + assert resp.status_code == 200 + assert resp.get_data(as_text=True) == "ok" + + +def test_no_pii_in_logs(caplog) -> None: # type: ignore[no-untyped-def] + client, _ = _client() + body = b'{"contact_id": 7, "display_name": "Jane Secret", "email": "jane@example.com"}' + with caplog.at_level(logging.INFO, logger="door_sync.webhook"): + resp = client.post("/civicrm/membership-changed", data=body, headers=_sign(body)) # type: ignore[attr-defined] + assert resp.status_code == 202 + text = "\n".join(r.getMessage() for r in caplog.records) + assert "Jane Secret" not in text + assert "jane@example.com" not in text + assert "7" in text # contact_id IS logged diff --git a/uv.lock b/uv.lock index ba4fc8e..5577919 100644 --- a/uv.lock +++ b/uv.lock @@ -38,6 +38,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + [[package]] name = "certifi" version = "2026.5.20" @@ -136,6 +145,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, ] +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -248,7 +269,9 @@ name = "door-sync" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "flask" }, { name = "httpx" }, + { name = "waitress" }, ] [package.dev-dependencies] @@ -264,7 +287,11 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "httpx", specifier = ">=0.28.1" }] +requires-dist = [ + { name = "flask", specifier = ">=3.1.3" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "waitress", specifier = ">=3.0.2" }, +] [package.metadata.requires-dev] dev = [ @@ -277,6 +304,23 @@ dev = [ { name = "sphinx-rtd-theme", specifier = ">=3.1.0" }, ] +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -341,6 +385,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -790,3 +843,24 @@ sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] + +[[package]] +name = "waitress" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/cb/04ddb054f45faa306a230769e868c28b8065ea196891f09004ebace5b184/waitress-3.0.2.tar.gz", hash = "sha256:682aaaf2af0c44ada4abfb70ded36393f0e307f4ab9456a215ce0020baefc31f", size = 179901, upload-time = "2024-11-16T20:02:35.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/57/a27182528c90ef38d82b636a11f606b0cbb0e17588ed205435f8affe3368/waitress-3.0.2-py3-none-any.whl", hash = "sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e", size = 56232, upload-time = "2024-11-16T20:02:33.858Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +]