feat(server): add outbound webhook dispatcher for note sync events - #217
feat(server): add outbound webhook dispatcher for note sync events#217proscar87 wants to merge 3 commits into
Conversation
Adds a WebhookService that sends HMAC-SHA256 signed HTTP POST notifications to configured endpoints, enabling integrations with Home Assistant, n8n, Zapier, etc. Dispatch is fire-and-forget: it subscribes on the existing LocalEventBus and never blocks or fails the request that triggered the event. - New `webhooks` config section (WebhookConfig/WebhookEndpointConfig) with per-endpoint url/secret/events, matching the config.yaml shape from allenporter#167. - Fires `note.sync_completed` off the existing NoteUpdatedEvent (the point where a synced .note file is durably written), signed via `X-Supernote-Signature: sha256=<hex>` using hmac.compare_digest-safe verification. - Each endpoint POST has an explicit 10s timeout and up to 3 attempts with linear backoff for network errors/5xx; 4xx responses are not retried. All failures are logged (never the secret or signature) and swallowed. Fixes allenporter#167 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
asyncio.gather without return_exceptions propagates the first exception, so an unexpected error while dispatching to one endpoint would surface in the sync flow that triggered it, contradicting the documented never-raises contract. Collect the results instead and log anything unexpected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Pushed a follow-up: |
allenporter
left a comment
There was a problem hiding this comment.
Really appreciate this stellar contribution. Thank you for proposing a high quality implementation.
| """Translate a NoteUpdatedEvent into a note.sync_completed webhook.""" | ||
| if not isinstance(event, NoteUpdatedEvent): | ||
| return | ||
| payload = { |
There was a problem hiding this comment.
Can you define a dataclass for this? It could live in model? It can serve as documentation for each field
| payload = { | ||
| "event": EVENT_NOTE_SYNC_COMPLETED, | ||
| "timestamp": int(time.time()), | ||
| "user_id": event.user_id, |
There was a problem hiding this comment.
I don't remember, but I believe this is a private field so lets not include it.
I realize a webhook typically is "per user" so maybe we need to consider how this is supposed to work here. I realize in reality, this will probably mostly be used with a single user...
| ) | ||
| # Retries use a real (but zeroed-out) asyncio.sleep so the suite stays | ||
| # fast without mocking asyncio.sleep globally. | ||
| monkeypatch.setattr(webhook_module, "RETRY_BACKOFF_SECONDS", 0.0) |
There was a problem hiding this comment.
lets use patch and not monkeypatch
| return False | ||
|
|
||
|
|
||
| class _FakeTransport: |
There was a problem hiding this comment.
Can we use an aiohttp app server rather than mocking out the client library? I dont really trust aiohttp mocking and given the fake http server is pretty good I think we should use it.
| return f"sha256={digest}" | ||
|
|
||
|
|
||
| def verify_signature(secret: str, body: bytes, signature: str) -> bool: |
| """ | ||
| body = json.dumps(payload).encode("utf-8") | ||
| headers = {"Content-Type": "application/json", EVENT_HEADER: event_name} | ||
| if endpoint.secret: |
There was a problem hiding this comment.
Do you expect this to provide security in practice?
| Never raises. Endpoints are notified concurrently and independently; | ||
| one endpoint failing has no effect on delivery to the others. | ||
| """ | ||
| endpoints = [ep for ep in self.config.endpoints if event_name in ep.events] |
There was a problem hiding this comment.
how about it's all by default when not specified?
|
I agree on your decisions of what to implement for v0 vs what can be added later (processing, notes, tasks, etc). This design leaves the door open for adding those things. |
Addresses allenporter's review comments on PR allenporter#217: - Extract the note.sync_completed webhook body into a documented NoteSyncCompletedPayload/WebhookFileVO dataclass pair in supernote/models/webhook.py, instead of an inline dict. - Drop the internal, per-user user_id from the outbound payload; it's a private DB identifier that shouldn't leave the server. - Remove verify_signature(), which was dead code (this feature only implements the sending side; nothing in-repo calls it). - An endpoint with no `events` configured now defaults to receiving every event, instead of receiving nothing. - Rewrite the test suite to POST against a real aiohttp.web.Application test receiver (matching the fake-server pattern already used elsewhere in the suite, e.g. tests/server/conftest.py) instead of mocking aiohttp.ClientSession, and switch monkeypatch to unittest.mock.patch throughout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Thanks for the thorough review, and for the kind words! Pushed a follow-up commit (820c3ab) addressing all 7 comments:
Full suite ( |
What this does
Implements the "Event Webhook Dispatcher" requested in #167: a
WebhookServicethat sends HMAC-signed HTTP POST notifications to configured external endpoints (Home Assistant / n8n / Zapier / etc.) when server-side events happen, without blocking or risking the request that triggered them.Fixes #167
How it follows your spec
Your comment on the issue included a detailed spec, followed closely:
supernote/server/services/webhook.py, as specified.config.yaml):WebhookConfig/WebhookEndpointConfigdataclasses onServerConfig, following the sameDataClassYAMLMixinpattern asAuthConfig. Documented (commented-out) inconfig-example.yaml.enabledalso has aSUPERNOTE_WEBHOOKS_ENABLEDenv override for consistency with the rest ofServerConfig.X-Supernote-Signature: sha256=<hex>, computed withhmac.new(secret, body, hashlib.sha256). Verification helper (verify_signature) useshmac.compare_digestfor constant-time comparison, exposed as public API for receivers/tests.supernote/server/events.py'sLocalEventBus), as specified.aiohttp.ClientSession— already aserverextra dependency, so no new dependency was added. Each POST has an explicit 10s timeout and retries transient failures (network errors / 5xx) up to 3 attempts with linear backoff; 4xx responses are not retried. Every failure path is caught, logged, and swallowed — a slow or dead endpoint can never delay or fail the request that published the event, matching howLocalEventBus.publish()already fires handlers viaasyncio.create_task.Where the spec didn't fully specify — decisions made (please weigh in)
note.sync_completedtrigger point. Your example payload includesfile.page_countand aprocessingblock (tasks_extracted,summary) that are only known after the async OCR/embedding/summary pipeline finishes (ProcessorService.process_file). Wiring the webhook there would mean adding a new "processing completed" event type and touching the processing pipeline — more surface area than a dispatcher module. Instead, I firenote.sync_completedoff the existingNoteUpdatedEvent, published the moment a.notefile finishes uploading to the server (FileService.upload_finish/upload_finish_web). That's the literal "device synced, note landed on the server" moment and needed zero changes outside the dispatcher + wiring. The payload is therefore minimal:{"event": "note.sync_completed", "timestamp": 1785734400, "user_id": 7, "file": {"id": 42, "path": "Note/Daily/2026-08-04.note"}}task.created. There's no existing domain concept in the codebase for a user-facing "task extracted from a note" (the closest thing,SystemTaskDO, is internal processing-pipeline bookkeeping — hashing/OCR/embedding/summary steps — not something a user would want a webhook for). Rather than invent that feature (out of scope here per the issue title), I lefttask.createdunwired: the dispatcher'sdispatch(event_name, payload)API is generic, so wiring it up is a one-lineevent_bus.subscribe(...)once that feature exists. Flagging this as an open question rather than guessing at a shape.How it's configured
See
config-example.yamlfor a documented example. Endpoints are opt-in per-event via theeventslist, so one config can fan out to multiple receivers with different subscriptions.What I validated
tests/server/services/test_webhook.py(HMAC sign/verify incl. tampered-body and wrong-secret rejection, signed dispatch to subscribed endpoints only, no-signature-header when no secret,NoteUpdatedEvent→note.sync_completedend-to-end through the realLocalEventBus, disabled-webhooks no-op, no-raise on repeated 5xx / timeout, no-retry on 4xx, recovery after a transient failure, and that the secret never leaks into logs) plustests/server/test_config.pyadditions for the new config section and env override. All HTTP is mocked (a small fakeaiohttp.ClientSession) — no real network calls.uv run ruff check ./uv run ruff format— clean.uv run ty check— clean.uv run pytest -n auto -m "not integration"— 585 passed (568 baseline + 17 new), 0 failed. Compared against agit stashbaseline run to confirm no pre-existing failures were masked or introduced.pre-commit run(incl. codespell, yamllint) — clean.git statusclean,uv.lockuntouched.🤖 Generated with Claude Code