Skip to content

feat(webhook): embedded receiver for CiviCRM-triggered reconciles#36

Open
RyanMorash wants to merge 1 commit into
mainfrom
feat/webhook-receiver
Open

feat(webhook): embedded receiver for CiviCRM-triggered reconciles#36
RyanMorash wants to merge 1 commit into
mainfrom
feat/webhook-receiver

Conversation

@RyanMorash

@RyanMorash RyanMorash commented Jul 10, 2026

Copy link
Copy Markdown
Member

Phase 1 of the CiviCRM→UniFi webhook feature (door-sync side).

Adds an optional Flask+waitress HTTP receiver in a second daemon thread. It verifies an HMAC-signed request (timestamp-bound for replay protection) and enqueues a ReconcileRequest onto a shared work queue; the scheduler thread drains it between cadence ticks, coalescing bursts into a single full reconcile. The scheduler stays the sole writer to UniFi/state/audit, so no locks are needed and orchestrator.reconcile is unchanged.

  • webhook.py: create_app, HMAC verify, POST /civicrm/membership-changed, /healthz
  • scheduler: queue-driven wake + shutdown sentinel + debounce/coalesce
  • config: WebhookConfig (disabled by default; secret WEBHOOK_HMAC_SECRET)
  • __main__: start/stop the receiver in daemon mode when enabled
  • deploy: cloudflared.service + cloudflared-config.yml (tunnel; loopback bind)
  • docs + example config.toml/.env

Loopback-only bind; a Cloudflare Tunnel connects locally so the Pi exposes no public port. 390 tests pass, pyrefly + ruff clean; verified end-to-end against the real waitress server (202 / 401 / replay / healthz / clean shutdown).

Part of a 3-repo change: door-webhook (edge Worker) and door-civirules (CiviRules producer).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added an optional webhook receiver for membership-change events.
    • Added HMAC signature verification, replay protection, request size limits, health checks, and immediate acknowledgment.
    • Added webhook-triggered reconciliation with burst coalescing.
    • Added Cloudflare Tunnel deployment and service configuration.
  • Documentation
    • Documented webhook configuration, security requirements, and deployment steps.
  • Bug Fixes
    • Improved daemon shutdown responsiveness when webhook-triggered work is queued.

Phase 1 of the webhook feature (door-sync side). Adds an optional
Flask+waitress HTTP server that runs in a second daemon thread, verifies
an HMAC-signed request (timestamp-bound for replay protection), and
enqueues a ReconcileRequest onto a shared work queue. The scheduler
thread drains the queue between cadence ticks, coalescing bursts into a
single full reconcile — it remains the sole writer to UniFi/state/audit,
so no locks are needed. orchestrator.reconcile is unchanged.

- models: ReconcileRequest
- config: WebhookConfig (disabled by default; secret WEBHOOK_HMAC_SECRET)
- webhook.py: create_app, HMAC verify, POST /civicrm/membership-changed, /healthz
- scheduler: queue-driven wake + shutdown sentinel + debounce/coalesce
- __main__: start/stop the receiver in daemon mode when enabled
- deploy: cloudflared.service + cloudflared-config.yml (tunnel; loopback bind)
- docs + example config/env; pre-commit deps for flask/waitress

The receiver binds loopback only; a Cloudflare Tunnel connects locally so
the Pi exposes no public port. Verified end-to-end against the real
waitress server.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 10, 2026 00:35
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds an optional HMAC-authenticated webhook receiver backed by Flask and waitress. Valid requests enqueue reconcile triggers, the scheduler coalesces queued events, daemon mode manages the shared queue and server lifecycle, and deployment documentation and tunnel service files are added.

Changes

Webhook receiver and reconciliation flow

Layer / File(s) Summary
Webhook configuration and request model
src/door_sync/config.py, src/door_sync/models.py, config.example.toml, .env.example, tests/test_config.py, tests/test_models.py
Adds validated webhook settings, the required HMAC secret when enabled, disabled defaults, and immutable ReconcileRequest queue items.
Authenticated webhook server
src/door_sync/webhook.py, pyproject.toml, .pre-commit-config.yaml, tests/test_webhook.py
Adds HMAC verification, replay protection, body limits, webhook and health routes, waitress lifecycle handling, and endpoint/security tests.
Queue-driven scheduler and shutdown
src/door_sync/scheduler.py, tests/test_scheduler.py
Adds queue wakeups, shutdown sentinels, cadence waiting, and debounce-based trigger coalescing.
Daemon wiring and tunnel deployment
src/door_sync/__main__.py, deploy/cloudflared-config.yml, deploy/cloudflared.service, docs/configuration.rst, tests/test_main.py
Starts the webhook only in daemon mode, shares its queue with the scheduler, stops it during cleanup, and documents and configures Cloudflare Tunnel deployment.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CloudflareTunnel
  participant WebhookServer
  participant WorkQueue
  participant Scheduler
  CloudflareTunnel->>WebhookServer: Forward signed webhook
  WebhookServer->>WebhookServer: Verify HMAC signature and timestamp
  WebhookServer->>WorkQueue: Enqueue ReconcileRequest
  WebhookServer-->>CloudflareTunnel: Return 202 Accepted
  Scheduler->>WorkQueue: Consume and coalesce triggers
  Scheduler->>Scheduler: Run reconciliation
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 17.95% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding an embedded webhook receiver for CiviCRM-triggered reconciles.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/webhook-receiver

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-code-quality

Copy link
Copy Markdown

Code Coverage Overview

Languages: Python

Python / code-coverage/pytest

The overall coverage in the branch is 92%. The coverage in the branch is 93%.

Show a code coverage summary of the most impacted files.
File acac68a d375a79 +/-
src/door_sync/config.py 90% 88% -2%
src/door_sync/models.py 100% 100% 0%
src/door_sync/__main__.py 84% 85% +1%
src/door_sync/scheduler.py 94% 95% +1%
src/door_sync/webhook.py 0% 72% +72%

Code Coverage is in Public Preview. Learn more and provide us with your feedback.

Comment thread src/door_sync/webhook.py
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)

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an optional embedded HTTP webhook receiver to trigger faster reconciles from CiviCRM events while keeping the scheduler as the sole writer to UniFi/state/audit (queue-driven wake + debounce/coalesce between cadence ticks).

Changes:

  • Introduces door_sync.webhook (Flask + waitress) with HMAC+timestamp verification and /healthz + /civicrm/membership-changed endpoints.
  • Extends the scheduler loop to wake early from a shared work queue, coalesce bursts, and support a shutdown sentinel.
  • Adds WebhookConfig, daemon wiring, deploy artifacts (cloudflared tunnel), documentation, and tests.

Reviewed changes

Copilot reviewed 17 out of 18 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
uv.lock Locks new runtime dependencies (Flask/waitress and transitive deps).
pyproject.toml Adds Flask and waitress as project dependencies.
.pre-commit-config.yaml Ensures type-check hook has Flask/waitress available.
src/door_sync/webhook.py Implements embedded receiver, HMAC verification, and server thread lifecycle.
src/door_sync/scheduler.py Adds queue-driven wake/coalescing and shutdown sentinel integration.
src/door_sync/config.py Adds WebhookConfig, defaults, and [webhook] validation (env secret requirement).
src/door_sync/models.py Adds ReconcileRequest model for queued triggers.
src/door_sync/main.py Wires webhook server start/stop and shared queue in daemon mode.
tests/test_webhook.py Unit + endpoint tests for signature checking, enqueueing, size limits, and PII-safe logging.
tests/test_scheduler.py Tests for signal handler sentinel, wake/coalesce behavior, and run loop integration.
tests/test_models.py Tests for ReconcileRequest immutability/defaults.
tests/test_main.py Tests daemon wiring to start/stop webhook and share the queue.
tests/test_config.py Tests webhook config defaults/validation and env secret requirements.
docs/configuration.rst Documents [webhook] configuration and required env var.
config.example.toml Adds commented webhook config example.
.env.example Documents WEBHOOK_HMAC_SECRET.
deploy/cloudflared.service Adds systemd unit for a Cloudflare Tunnel to loopback webhook receiver.
deploy/cloudflared-config.yml Adds example cloudflared tunnel config routing hostname to loopback receiver.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +73 to +78
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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
deploy/cloudflared.service (1)

1-24: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider adding restart rate limiting.

Restart=on-failure with RestartSec=5s but no StartLimitIntervalSec/StartLimitBurst allows systemd to restart indefinitely if cloudflared keeps failing, which can mask underlying issues and waste resources.

⚙️ Suggested addition
 [Service]
 Type=simple
 User=cloudflared
 Group=cloudflared
 ExecStart=/usr/local/bin/cloudflared tunnel --config /etc/cloudflared/config.yml run
 Restart=on-failure
 RestartSec=5s
+StartLimitIntervalSec=60
+StartLimitBurst=5
 StandardOutput=journal
 StandardError=journal
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deploy/cloudflared.service` around lines 1 - 24, Add systemd restart rate
limiting to the cloudflared service unit: define StartLimitIntervalSec and
StartLimitBurst in the [Unit] section alongside the existing restart settings,
using values that cap repeated failures while preserving normal recovery
behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/door_sync/webhook.py`:
- Around line 62-65: Update _verify_signature to fail closed for malformed,
non-ASCII signature headers: compare the expected and provided signatures as
encoded bytes, or catch TypeError around hmac.compare_digest and return False.
Ensure invalid input cannot propagate an exception or produce a 500 response.

---

Nitpick comments:
In `@deploy/cloudflared.service`:
- Around line 1-24: Add systemd restart rate limiting to the cloudflared service
unit: define StartLimitIntervalSec and StartLimitBurst in the [Unit] section
alongside the existing restart settings, using values that cap repeated failures
while preserving normal recovery behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 523415b3-c5a9-4943-bc96-bffb2b3be379

📥 Commits

Reviewing files that changed from the base of the PR and between acac68a and d375a79.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (17)
  • .env.example
  • .pre-commit-config.yaml
  • config.example.toml
  • deploy/cloudflared-config.yml
  • deploy/cloudflared.service
  • docs/configuration.rst
  • pyproject.toml
  • src/door_sync/__main__.py
  • src/door_sync/config.py
  • src/door_sync/models.py
  • src/door_sync/scheduler.py
  • src/door_sync/webhook.py
  • tests/test_config.py
  • tests/test_main.py
  • tests/test_models.py
  • tests/test_scheduler.py
  • tests/test_webhook.py

Comment thread src/door_sync/webhook.py
Comment on lines +62 to +65
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

python hmac.compare_digest TypeError non-ascii string arguments

💡 Result:

The hmac.compare_digest function in Python is designed to perform a constant-time comparison of two values to prevent timing attacks, which is essential for cryptographic verification [1][2]. If you receive a TypeError when using this function, it is typically because the arguments provided violate the strict requirements for input types [1][2]. Requirements for hmac.compare_digest: 1. Both arguments must be of the same type: either both must be strings (str) or both must be bytes-like objects [1][2]. 2. If you pass string (str) arguments, they must be ASCII-only [1][2]. This is because the function is primarily intended to handle digests like those produced by hexdigest, which are ASCII hexadecimal strings [3][2]. If you encounter an error when passing strings, it is almost certainly because at least one of the strings contains non-ASCII characters [4]. To resolve this, you should ensure that both values being compared are byte strings (bytes) [3]. You can convert a string to bytes using the.encode method (typically with 'utf-8' or 'ascii' encoding, depending on your data) before passing them to hmac.compare_digest [3]. Example of correct usage: import hmac # Ensure both inputs are bytes digest1 = b'...' digest2 = b'...' # Perform the secure comparison is_match = hmac.compare_digest(digest1, digest2)

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== file outline ==\n'
ast-grep outline src/door_sync/webhook.py --view expanded || true

printf '\n== relevant lines ==\n'
sed -n '1,180p' src/door_sync/webhook.py | cat -n

printf '\n== search for signature parsing and error handling ==\n'
rg -n "_verify_signature|compare_digest|X-Door-Sync-Signature|signature" src/door_sync/webhook.py src/door_sync -n

Repository: TechValleyCenterOfGravity/door-sync

Length of output: 10584


🏁 Script executed:

python3 - <<'PY'
import hmac

cases = [
    ("ascii", "abc", "abc"),
    ("non-ascii left", "é", "e"),
    ("non-ascii right", "e", "é"),
    ("bytes", b"abc", b"abc"),
    ("mixed", b"abc", "abc"),
]

for name, a, b in cases:
    try:
        print(name, "=>", hmac.compare_digest(a, b))
    except Exception as e:
        print(name, "=>", type(e).__name__, e)
PY

Repository: TechValleyCenterOfGravity/door-sync

Length of output: 442


Malformed signature header can raise TypeError and return 500.

In _verify_signature, hmac.compare_digest(expected, provided) compares two str values. A non-ASCII X-Door-Sync-Signature header makes compare_digest raise TypeError, so a malformed request can surface as a 500 instead of a 401. Compare bytes, or catch TypeError and fail closed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/door_sync/webhook.py` around lines 62 - 65, Update _verify_signature to
fail closed for malformed, non-ASCII signature headers: compare the expected and
provided signatures as encoded bytes, or catch TypeError around
hmac.compare_digest and return False. Ensure invalid input cannot propagate an
exception or produce a 500 response.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants