-
-
Notifications
You must be signed in to change notification settings - Fork 0
feat: implement webhook signing and delivery #482
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
niteshpurohit
merged 52 commits into
main
from
feat/versioned-outbound-event-schemas-and-webhook-signing-conventions
May 2, 2026
Merged
Changes from all commits
Commits
Show all changes
52 commits
Select commit
Hold shift + click to select a range
c6b43e0
feat: implement webhook signing and delivery
niteshpurohit d0ae1c3
feat: enhance outbound event serialization and attributes
niteshpurohit db0b38e
feat: enhance outbound event schema definition
niteshpurohit 2192309
feat: enhance webhook signing and validation
niteshpurohit 80e02f9
feat: update outbound event handling and documentation
niteshpurohit d5bde0f
feat: enhance outbound event delivery and validation
niteshpurohit 0c33665
feat: improve outbound event handling and serialization
niteshpurohit 6d3de32
feat: enhance delivery and dispatcher normalization
niteshpurohit e68e8d0
feat: implement versioned outbound event schemas and webhook signing
niteshpurohit 0c8895b
feat: add error classes for outbound events and webhooks
niteshpurohit 477e748
feat: implement optional outbound event dispatcher
niteshpurohit 97474ad
feat: enhance webhook verification and dispatcher classes
niteshpurohit 90b3a1f
feat: update logger usage in runtime specs
niteshpurohit f07dbef
feat: update callable types for forker and normalization
niteshpurohit 60204e9
feat: enhance webhook verifier and tests
niteshpurohit 6d7b946
chore: update private constants in webhook verifier
niteshpurohit e58e743
feat: update outbound event payload types
niteshpurohit 249baf2
Merge branch 'main' into feat/versioned-outbound-event-schemas-and-we…
niteshpurohit 71d359f
feat: add forker normalization and update types
niteshpurohit a0ae5a3
feat: deep-freeze schema definitions and required keys
niteshpurohit 7e16481
feat: implement immutable hook payload snapshots
niteshpurohit 8a481b7
feat: implement forker validation and enhance webhook signing
niteshpurohit 6a9aedb
feat: enhance PresentString normalization logic
niteshpurohit 63f4520
feat: update context_payload type for enhanced flexibility
niteshpurohit cc407ec
feat: enhance webhook timestamp validation and error handling
niteshpurohit 2910921
feat: enhance outbound event delivery and verification
niteshpurohit 3064612
feat: enhance snapshot_key method and add tests
niteshpurohit 28d4ea4
feat: enhance outbound event handling and validation
niteshpurohit 6f01aff
feat: update instrumentation to use hash syntax
niteshpurohit 533f652
feat: implement PayloadInput for enhanced event handling
niteshpurohit b7ab3be
feat: implement versioned outbound event schemas and webhook signing
niteshpurohit c973e7f
feat: enhance PayloadInput and runtime instrumentation
niteshpurohit ff05546
feat: refactor ABSENT constant in PayloadInput
niteshpurohit 9ce270a
feat: enhance validation for outbound event payloads
niteshpurohit 028571f
feat: enhance webhook signature verification
niteshpurohit 9f37357
feat: update webhook verifier to support signing scheme
niteshpurohit e709048
feat: enhance ImmutableHookPayload for error handling
niteshpurohit f518039
feat: implement unique sentinel for omitted payloads
niteshpurohit ae4f1c2
feat: add string key normalization for outbound events
niteshpurohit f64a281
feat: enhance event handling and payload validation
niteshpurohit e3fda28
feat: update time handling in event serialization
niteshpurohit d0be948
feat: enhance webhook signing for non-UTF-8 bodies
niteshpurohit 816e66c
feat: update outbound events and workers documentation
niteshpurohit 1a35e74
feat: implement hook dispatch for event instrumentation
niteshpurohit 4b02008
feat: enhance snapshot pair creation and testing
niteshpurohit cdb0d5d
feat: add direct require support for worker and supervisor runtimes
niteshpurohit d750ae2
feat: remove unused methods from forker and dispatcher classes
niteshpurohit 613f020
feat: enhance outbound event dispatcher behavior
niteshpurohit 951532e
feat: implement versioned outbound event schemas and webhook signing
niteshpurohit edbf0f4
feat: add outbound events module dependencies
niteshpurohit 6d2e5f7
feat: implement generic callable and dispatcher classes
niteshpurohit 689cba3
feat: update outbound event delivery handler types
niteshpurohit File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| # Copyright Codevedas Inc. 2025-present | ||
| # | ||
| # This source code is licensed under the MIT license found in the | ||
| # LICENSE file in the root directory of this source tree. | ||
|
|
||
| module Karya | ||
| module Internal | ||
| # Shares runtime hook payload normalization and dispatch flow. | ||
| class HookDispatch | ||
| def self.instrument( | ||
| event:, | ||
| payload:, | ||
| payload_keywords:, | ||
| payload_given:, | ||
| instrumenter:, | ||
| dispatch_outbound:, | ||
| error_class:, | ||
| mixed_payload_message:, | ||
| emit_instrumentation:, | ||
| emit_outbound_event: | ||
| ) | ||
| return nil unless instrumenter || dispatch_outbound | ||
|
|
||
| normalized_payload = PayloadInput.new( | ||
| payload, | ||
| payload_keywords, | ||
| payload_given:, | ||
| error_class:, | ||
| mixed_payload_message: | ||
| ).to_h | ||
|
|
||
| if instrumenter && dispatch_outbound | ||
| instrumentation_payload, outbound_payload = ImmutableHookPayload.snapshot_pair( | ||
| normalized_payload, | ||
| error_class: | ||
| ) | ||
| emit_instrumentation.call(event, instrumentation_payload) | ||
| emit_outbound_event.call(event, outbound_payload) | ||
| return nil | ||
| end | ||
|
|
||
| snapshot = ImmutableHookPayload.snapshot(normalized_payload, error_class:) | ||
| emit_instrumentation.call(event, snapshot) if instrumenter | ||
| emit_outbound_event.call(event, snapshot) if dispatch_outbound | ||
| nil | ||
| end | ||
| end | ||
| end | ||
| end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| # Copyright Codevedas Inc. 2025-present | ||
| # | ||
| # This source code is licensed under the MIT license found in the | ||
| # LICENSE file in the root directory of this source tree. | ||
|
|
||
| module Karya | ||
| module Internal | ||
| # Builds immutable snapshots for runtime hook payloads. | ||
| class ImmutableHookPayload | ||
| def self.snapshot(payload, error_class:) | ||
| new(payload, error_class:).snapshot | ||
| end | ||
|
|
||
| def self.snapshot_pair(payload, error_class:) | ||
| snapshot = snapshot(payload, error_class:) | ||
| [snapshot, shallow_snapshot(snapshot)].freeze | ||
| end | ||
|
|
||
| def self.snapshot_key(value) | ||
| return value if value.is_a?(Symbol) | ||
| return value.frozen? ? value : value.dup.freeze if value.is_a?(String) | ||
|
|
||
| raise ArgumentError, 'payload keys must be Symbols or Strings' | ||
| end | ||
| private_class_method :snapshot_key | ||
|
|
||
| def initialize(payload, error_class:) | ||
| @payload = payload | ||
| @error_class = error_class | ||
| end | ||
|
|
||
| def snapshot | ||
| snapshot_hash(payload) | ||
| end | ||
|
|
||
| private | ||
|
|
||
| attr_reader :error_class, :payload | ||
|
|
||
| def self.shallow_snapshot(snapshot) | ||
| snapshot.each_with_object({}) do |(key, value), duplicated| | ||
| duplicated[key] = value | ||
| end.freeze | ||
| end | ||
| private_class_method :shallow_snapshot | ||
|
|
||
| def snapshot_hash(value) | ||
| value.each_with_object({}) do |(key, item), duplicated| | ||
| duplicated[self.class.send(:snapshot_key, key)] = snapshot_value(item) | ||
| rescue ArgumentError => e | ||
| raise error_class, e.message | ||
| end.freeze | ||
| end | ||
|
|
||
| def snapshot_array(value) | ||
| value.map { |item| snapshot_value(item) }.freeze | ||
| end | ||
|
|
||
| def snapshot_value(value) | ||
| case value | ||
| when Hash | ||
| snapshot_hash(value) | ||
| when Array | ||
| snapshot_array(value) | ||
| when String, Time | ||
| value.frozen? ? value : value.dup.freeze | ||
| when NilClass, TrueClass, FalseClass, Numeric, Symbol | ||
| value | ||
| else | ||
| raise error_class, 'payload values must be nil, booleans, numerics, strings, symbols, times, arrays, or hashes' | ||
| end | ||
| end | ||
| end | ||
| end | ||
| end | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| # Copyright Codevedas Inc. 2025-present | ||
| # | ||
| # This source code is licensed under the MIT license found in the | ||
| # LICENSE file in the root directory of this source tree. | ||
|
|
||
| module Karya | ||
| module Internal | ||
| # Normalizes positional and keyword payload inputs into one Hash. | ||
| class PayloadInput | ||
| # Unique sentinel for omitted positional payload arguments. | ||
| class Absent | ||
| def self.instance | ||
| @instance ||= new.freeze | ||
| end | ||
|
|
||
| private_class_method :new | ||
| end | ||
| private_constant :Absent | ||
|
|
||
| ABSENT = Absent.instance | ||
|
|
||
| def initialize(payload, payload_keywords, payload_given:, error_class:, mixed_payload_message:) | ||
| @payload = payload | ||
| @payload_keywords = payload_keywords | ||
| @payload_given = payload_given | ||
| @error_class = error_class | ||
| @mixed_payload_message = mixed_payload_message | ||
| end | ||
|
|
||
| def to_h | ||
| return payload_keywords unless payload_given | ||
|
|
||
| payload_is_hash = payload.is_a?(Hash) | ||
|
|
||
| if payload_keywords.empty? | ||
| raise error_class, 'payload must be a Hash' unless payload_is_hash | ||
|
|
||
| return payload | ||
| end | ||
|
|
||
| raise error_class, mixed_payload_message unless payload_is_hash | ||
|
|
||
| payload.merge(payload_keywords) | ||
| end | ||
|
niteshpurohit marked this conversation as resolved.
|
||
|
|
||
| private | ||
|
|
||
| attr_reader :error_class, :mixed_payload_message, :payload, :payload_given, :payload_keywords | ||
| end | ||
| end | ||
| end | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| # Copyright Codevedas Inc. 2025-present | ||
| # | ||
| # This source code is licensed under the MIT license found in the | ||
| # LICENSE file in the root directory of this source tree. | ||
|
|
||
| require_relative 'base' | ||
| require_relative 'outbound_events/values' | ||
| require_relative 'outbound_events/delivery' | ||
| require_relative 'outbound_events/dispatcher' | ||
| require_relative 'outbound_events/event' | ||
| require_relative 'outbound_events/schema' | ||
| require_relative 'outbound_events/schema_catalog' | ||
| require_relative 'outbound_events/webhook_signature' | ||
| require_relative 'outbound_events/webhook_signer' | ||
| require_relative 'outbound_events/webhook_verifier' | ||
|
niteshpurohit marked this conversation as resolved.
|
||
|
|
||
| module Karya | ||
| # Shared outbound event contracts for external delivery and verification. | ||
| module OutboundEvents | ||
| end | ||
| end | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| require_relative 'event' | ||
| require_relative 'webhook_signature' | ||
|
|
||
| # Copyright Codevedas Inc. 2025-present | ||
| # | ||
| # This source code is licensed under the MIT license found in the | ||
| # LICENSE file in the root directory of this source tree. | ||
|
|
||
| module Karya | ||
| module OutboundEvents | ||
| # Immutable serialized outbound delivery with canonical headers and body. | ||
| class Delivery | ||
| CONTENT_TYPE = 'application/cloudevents+json' | ||
|
|
||
| attr_reader :body, :event, :headers, :signature | ||
|
|
||
| def initialize(event:, signature: nil, body: nil) | ||
| @event = normalize_event(event) | ||
| @body = normalize_body(body) | ||
| @signature = normalize_signature(signature) | ||
| @headers = build_headers.freeze | ||
| freeze | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def normalize_event(value) | ||
| return value if value.is_a?(Event) | ||
|
|
||
| raise InvalidOutboundEventError, 'event must be Karya::OutboundEvents::Event' | ||
| end | ||
|
|
||
| def normalize_signature(value) | ||
| return nil if [nil].include?(value) | ||
| return value if value.is_a?(WebhookSignature) | ||
|
|
||
| raise InvalidOutboundEventError, 'signature must be Karya::OutboundEvents::WebhookSignature' | ||
| end | ||
|
|
||
| def normalize_body(value) | ||
| Body.new(value, event: @event).normalize | ||
| end | ||
|
niteshpurohit marked this conversation as resolved.
|
||
|
|
||
| def build_headers | ||
| { 'Content-Type' => CONTENT_TYPE }.merge(signature&.headers || {}) | ||
| end | ||
|
|
||
| # Normalizes one optional serialized body value for an outbound delivery. | ||
| class Body | ||
| def initialize(value, event:) | ||
| @value = value | ||
| @event = event | ||
| end | ||
|
|
||
| def normalize | ||
| return event.to_json.freeze if [nil].include?(value) | ||
|
|
||
| string_value = value if value.is_a?(String) | ||
| return string_value if string_value&.frozen? | ||
| return string_value.dup.freeze if string_value | ||
|
|
||
| raise InvalidOutboundEventError, 'body must be a String' | ||
| end | ||
|
|
||
| private | ||
|
|
||
| attr_reader :event, :value | ||
| end | ||
|
|
||
| private_constant :Body | ||
| end | ||
| end | ||
| end | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| # frozen_string_literal: true | ||
|
|
||
| require 'securerandom' | ||
| require_relative '../internal/payload_input' | ||
| require_relative '../primitives/callable' | ||
|
niteshpurohit marked this conversation as resolved.
|
||
| require_relative 'delivery' | ||
| require_relative 'schema_catalog' | ||
| require_relative 'webhook_signer' | ||
|
|
||
| # Copyright Codevedas Inc. 2025-present | ||
| # | ||
| # This source code is licensed under the MIT license found in the | ||
| # LICENSE file in the root directory of this source tree. | ||
|
|
||
| module Karya | ||
| module OutboundEvents | ||
| # Builds canonical outbound deliveries from runtime instrumentation events. | ||
| class Dispatcher | ||
| def initialize(delivery_handler:, signer: nil, clock: -> { Time.now.utc }, event_id_generator: -> { SecureRandom.uuid }) | ||
| @delivery_handler = Primitives::Callable.new(:delivery_handler, delivery_handler, error_class: InvalidOutboundEventError).normalize | ||
|
niteshpurohit marked this conversation as resolved.
|
||
| @signer = normalize_signer(signer) | ||
| @clock = Primitives::Callable.new(:clock, clock, error_class: InvalidOutboundEventError).normalize | ||
| @event_id_generator = Primitives::Callable.new( | ||
| :event_id_generator, | ||
|
niteshpurohit marked this conversation as resolved.
|
||
| event_id_generator, | ||
| error_class: InvalidOutboundEventError | ||
| ).normalize | ||
| end | ||
|
|
||
| def call(event_name, payload = Internal::PayloadInput::ABSENT, **payload_keywords) | ||
| return nil unless SchemaCatalog.supported?(event_name) | ||
|
|
||
| occurred_at = clock.call | ||
| raise InvalidOutboundEventError, 'clock must return a Time' unless occurred_at.is_a?(Time) | ||
|
|
||
| payload_given = !payload.equal?(Internal::PayloadInput::ABSENT) | ||
|
|
||
| event = SchemaCatalog.build_event( | ||
| event_name:, | ||
| payload: Internal::PayloadInput.new( | ||
| payload_given ? payload : nil, | ||
| payload_keywords, | ||
| payload_given:, | ||
| error_class: InvalidOutboundEventError, | ||
| mixed_payload_message: 'payload must be a Hash when keyword payload is also given' | ||
| ).to_h, | ||
| occurred_at:, | ||
| event_id: event_id_generator.call | ||
| ) | ||
| body = event.to_json.freeze | ||
| signature = signer&.sign(body:, now: occurred_at) | ||
| delivery = Delivery.new(event:, signature:, body:) | ||
| delivery_handler.call(delivery) | ||
| delivery | ||
| end | ||
|
|
||
| private | ||
|
|
||
| attr_reader :clock, :delivery_handler, :event_id_generator, :signer | ||
|
|
||
| def normalize_signer(value) | ||
| return nil if [nil].include?(value) | ||
| return value if value.is_a?(WebhookSigner) | ||
|
|
||
| raise InvalidOutboundEventError, 'signer must be Karya::OutboundEvents::WebhookSigner' | ||
| end | ||
|
niteshpurohit marked this conversation as resolved.
|
||
| end | ||
| end | ||
| end | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.