Skip to content

Commit d4fc6d8

Browse files
feat: implement webhook signing and delivery (#482)
* feat: implement webhook signing and delivery - Introduced Karya::OutboundEvents module to handle versioned outbound events. - Added WebhookSigner and WebhookVerifier classes for signing and verifying webhook payloads. - Implemented CloudEvents-compatible JSON envelope for outbound events. - Created Dispatcher class to manage delivery of signed events. - Enhanced Worker and WorkerSupervisor runtimes to support outbound event dispatching. - Added tests for outbound event functionality, including signing, verification, and error handling. - Updated documentation to include outbound events and webhook signing conventions. closes: #372
1 parent a68c444 commit d4fc6d8

48 files changed

Lines changed: 3336 additions & 48 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

core/karya/lib/karya.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
require_relative 'karya/job'
1818
require_relative 'karya/retry_policy'
1919
require_relative 'karya/retry_policy_set'
20+
require_relative 'karya/outbound_events'
2021
require_relative 'karya/reservation'
2122
require_relative 'karya/queue_store'
2223
require_relative 'karya/workflow'

core/karya/lib/karya/base.rb

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,28 @@ class Error < StandardError; end
1919
# Raised when runtime code requires a configured queue store but none has been set.
2020
class MissingQueueStoreConfigurationError < Error; end
2121

22+
# Raised when outbound event input cannot be normalized into a supported contract.
23+
class InvalidOutboundEventError < Error; end
24+
25+
# Raised when a caller asks for an outbound event that is not part of the supported contract.
26+
class UnsupportedOutboundEventError < Error; end
27+
28+
# Raised when a webhook signature cannot be parsed or verified.
29+
class InvalidWebhookSignatureError < Error; end
30+
2231
class << self
23-
attr_reader :instrumenter
32+
attr_reader :instrumenter, :outbound_event_dispatcher
2433

2534
def configure_instrumenter(instrumenter)
2635
# Process-wide default used when a runtime does not receive an explicit instrumenter.
2736
@instrumenter = instrumenter
2837
end
2938

39+
def configure_outbound_event_dispatcher(outbound_event_dispatcher)
40+
# Process-wide default used when a runtime does not receive an explicit outbound event dispatcher.
41+
@outbound_event_dispatcher = outbound_event_dispatcher
42+
end
43+
3044
def configure_logger(logger)
3145
# Process-wide default used when a runtime does not receive an explicit logger.
3246
@logger = logger
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# frozen_string_literal: true
2+
3+
# Copyright Codevedas Inc. 2025-present
4+
#
5+
# This source code is licensed under the MIT license found in the
6+
# LICENSE file in the root directory of this source tree.
7+
8+
module Karya
9+
module Internal
10+
# Shares runtime hook payload normalization and dispatch flow.
11+
class HookDispatch
12+
def self.instrument(
13+
event:,
14+
payload:,
15+
payload_keywords:,
16+
payload_given:,
17+
instrumenter:,
18+
dispatch_outbound:,
19+
error_class:,
20+
mixed_payload_message:,
21+
emit_instrumentation:,
22+
emit_outbound_event:
23+
)
24+
return nil unless instrumenter || dispatch_outbound
25+
26+
normalized_payload = PayloadInput.new(
27+
payload,
28+
payload_keywords,
29+
payload_given:,
30+
error_class:,
31+
mixed_payload_message:
32+
).to_h
33+
34+
if instrumenter && dispatch_outbound
35+
instrumentation_payload, outbound_payload = ImmutableHookPayload.snapshot_pair(
36+
normalized_payload,
37+
error_class:
38+
)
39+
emit_instrumentation.call(event, instrumentation_payload)
40+
emit_outbound_event.call(event, outbound_payload)
41+
return nil
42+
end
43+
44+
snapshot = ImmutableHookPayload.snapshot(normalized_payload, error_class:)
45+
emit_instrumentation.call(event, snapshot) if instrumenter
46+
emit_outbound_event.call(event, snapshot) if dispatch_outbound
47+
nil
48+
end
49+
end
50+
end
51+
end
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# frozen_string_literal: true
2+
3+
# Copyright Codevedas Inc. 2025-present
4+
#
5+
# This source code is licensed under the MIT license found in the
6+
# LICENSE file in the root directory of this source tree.
7+
8+
module Karya
9+
module Internal
10+
# Builds immutable snapshots for runtime hook payloads.
11+
class ImmutableHookPayload
12+
def self.snapshot(payload, error_class:)
13+
new(payload, error_class:).snapshot
14+
end
15+
16+
def self.snapshot_pair(payload, error_class:)
17+
snapshot = snapshot(payload, error_class:)
18+
[snapshot, shallow_snapshot(snapshot)].freeze
19+
end
20+
21+
def self.snapshot_key(value)
22+
return value if value.is_a?(Symbol)
23+
return value.frozen? ? value : value.dup.freeze if value.is_a?(String)
24+
25+
raise ArgumentError, 'payload keys must be Symbols or Strings'
26+
end
27+
private_class_method :snapshot_key
28+
29+
def initialize(payload, error_class:)
30+
@payload = payload
31+
@error_class = error_class
32+
end
33+
34+
def snapshot
35+
snapshot_hash(payload)
36+
end
37+
38+
private
39+
40+
attr_reader :error_class, :payload
41+
42+
def self.shallow_snapshot(snapshot)
43+
snapshot.each_with_object({}) do |(key, value), duplicated|
44+
duplicated[key] = value
45+
end.freeze
46+
end
47+
private_class_method :shallow_snapshot
48+
49+
def snapshot_hash(value)
50+
value.each_with_object({}) do |(key, item), duplicated|
51+
duplicated[self.class.send(:snapshot_key, key)] = snapshot_value(item)
52+
rescue ArgumentError => e
53+
raise error_class, e.message
54+
end.freeze
55+
end
56+
57+
def snapshot_array(value)
58+
value.map { |item| snapshot_value(item) }.freeze
59+
end
60+
61+
def snapshot_value(value)
62+
case value
63+
when Hash
64+
snapshot_hash(value)
65+
when Array
66+
snapshot_array(value)
67+
when String, Time
68+
value.frozen? ? value : value.dup.freeze
69+
when NilClass, TrueClass, FalseClass, Numeric, Symbol
70+
value
71+
else
72+
raise error_class, 'payload values must be nil, booleans, numerics, strings, symbols, times, arrays, or hashes'
73+
end
74+
end
75+
end
76+
end
77+
end
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# frozen_string_literal: true
2+
3+
# Copyright Codevedas Inc. 2025-present
4+
#
5+
# This source code is licensed under the MIT license found in the
6+
# LICENSE file in the root directory of this source tree.
7+
8+
module Karya
9+
module Internal
10+
# Normalizes positional and keyword payload inputs into one Hash.
11+
class PayloadInput
12+
# Unique sentinel for omitted positional payload arguments.
13+
class Absent
14+
def self.instance
15+
@instance ||= new.freeze
16+
end
17+
18+
private_class_method :new
19+
end
20+
private_constant :Absent
21+
22+
ABSENT = Absent.instance
23+
24+
def initialize(payload, payload_keywords, payload_given:, error_class:, mixed_payload_message:)
25+
@payload = payload
26+
@payload_keywords = payload_keywords
27+
@payload_given = payload_given
28+
@error_class = error_class
29+
@mixed_payload_message = mixed_payload_message
30+
end
31+
32+
def to_h
33+
return payload_keywords unless payload_given
34+
35+
payload_is_hash = payload.is_a?(Hash)
36+
37+
if payload_keywords.empty?
38+
raise error_class, 'payload must be a Hash' unless payload_is_hash
39+
40+
return payload
41+
end
42+
43+
raise error_class, mixed_payload_message unless payload_is_hash
44+
45+
payload.merge(payload_keywords)
46+
end
47+
48+
private
49+
50+
attr_reader :error_class, :mixed_payload_message, :payload, :payload_given, :payload_keywords
51+
end
52+
end
53+
end
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# frozen_string_literal: true
2+
3+
# Copyright Codevedas Inc. 2025-present
4+
#
5+
# This source code is licensed under the MIT license found in the
6+
# LICENSE file in the root directory of this source tree.
7+
8+
require_relative 'base'
9+
require_relative 'outbound_events/values'
10+
require_relative 'outbound_events/delivery'
11+
require_relative 'outbound_events/dispatcher'
12+
require_relative 'outbound_events/event'
13+
require_relative 'outbound_events/schema'
14+
require_relative 'outbound_events/schema_catalog'
15+
require_relative 'outbound_events/webhook_signature'
16+
require_relative 'outbound_events/webhook_signer'
17+
require_relative 'outbound_events/webhook_verifier'
18+
19+
module Karya
20+
# Shared outbound event contracts for external delivery and verification.
21+
module OutboundEvents
22+
end
23+
end
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# frozen_string_literal: true
2+
3+
require_relative 'event'
4+
require_relative 'webhook_signature'
5+
6+
# Copyright Codevedas Inc. 2025-present
7+
#
8+
# This source code is licensed under the MIT license found in the
9+
# LICENSE file in the root directory of this source tree.
10+
11+
module Karya
12+
module OutboundEvents
13+
# Immutable serialized outbound delivery with canonical headers and body.
14+
class Delivery
15+
CONTENT_TYPE = 'application/cloudevents+json'
16+
17+
attr_reader :body, :event, :headers, :signature
18+
19+
def initialize(event:, signature: nil, body: nil)
20+
@event = normalize_event(event)
21+
@body = normalize_body(body)
22+
@signature = normalize_signature(signature)
23+
@headers = build_headers.freeze
24+
freeze
25+
end
26+
27+
private
28+
29+
def normalize_event(value)
30+
return value if value.is_a?(Event)
31+
32+
raise InvalidOutboundEventError, 'event must be Karya::OutboundEvents::Event'
33+
end
34+
35+
def normalize_signature(value)
36+
return nil if [nil].include?(value)
37+
return value if value.is_a?(WebhookSignature)
38+
39+
raise InvalidOutboundEventError, 'signature must be Karya::OutboundEvents::WebhookSignature'
40+
end
41+
42+
def normalize_body(value)
43+
Body.new(value, event: @event).normalize
44+
end
45+
46+
def build_headers
47+
{ 'Content-Type' => CONTENT_TYPE }.merge(signature&.headers || {})
48+
end
49+
50+
# Normalizes one optional serialized body value for an outbound delivery.
51+
class Body
52+
def initialize(value, event:)
53+
@value = value
54+
@event = event
55+
end
56+
57+
def normalize
58+
return event.to_json.freeze if [nil].include?(value)
59+
60+
string_value = value if value.is_a?(String)
61+
return string_value if string_value&.frozen?
62+
return string_value.dup.freeze if string_value
63+
64+
raise InvalidOutboundEventError, 'body must be a String'
65+
end
66+
67+
private
68+
69+
attr_reader :event, :value
70+
end
71+
72+
private_constant :Body
73+
end
74+
end
75+
end
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# frozen_string_literal: true
2+
3+
require 'securerandom'
4+
require_relative '../internal/payload_input'
5+
require_relative '../primitives/callable'
6+
require_relative 'delivery'
7+
require_relative 'schema_catalog'
8+
require_relative 'webhook_signer'
9+
10+
# Copyright Codevedas Inc. 2025-present
11+
#
12+
# This source code is licensed under the MIT license found in the
13+
# LICENSE file in the root directory of this source tree.
14+
15+
module Karya
16+
module OutboundEvents
17+
# Builds canonical outbound deliveries from runtime instrumentation events.
18+
class Dispatcher
19+
def initialize(delivery_handler:, signer: nil, clock: -> { Time.now.utc }, event_id_generator: -> { SecureRandom.uuid })
20+
@delivery_handler = Primitives::Callable.new(:delivery_handler, delivery_handler, error_class: InvalidOutboundEventError).normalize
21+
@signer = normalize_signer(signer)
22+
@clock = Primitives::Callable.new(:clock, clock, error_class: InvalidOutboundEventError).normalize
23+
@event_id_generator = Primitives::Callable.new(
24+
:event_id_generator,
25+
event_id_generator,
26+
error_class: InvalidOutboundEventError
27+
).normalize
28+
end
29+
30+
def call(event_name, payload = Internal::PayloadInput::ABSENT, **payload_keywords)
31+
return nil unless SchemaCatalog.supported?(event_name)
32+
33+
occurred_at = clock.call
34+
raise InvalidOutboundEventError, 'clock must return a Time' unless occurred_at.is_a?(Time)
35+
36+
payload_given = !payload.equal?(Internal::PayloadInput::ABSENT)
37+
38+
event = SchemaCatalog.build_event(
39+
event_name:,
40+
payload: Internal::PayloadInput.new(
41+
payload_given ? payload : nil,
42+
payload_keywords,
43+
payload_given:,
44+
error_class: InvalidOutboundEventError,
45+
mixed_payload_message: 'payload must be a Hash when keyword payload is also given'
46+
).to_h,
47+
occurred_at:,
48+
event_id: event_id_generator.call
49+
)
50+
body = event.to_json.freeze
51+
signature = signer&.sign(body:, now: occurred_at)
52+
delivery = Delivery.new(event:, signature:, body:)
53+
delivery_handler.call(delivery)
54+
delivery
55+
end
56+
57+
private
58+
59+
attr_reader :clock, :delivery_handler, :event_id_generator, :signer
60+
61+
def normalize_signer(value)
62+
return nil if [nil].include?(value)
63+
return value if value.is_a?(WebhookSigner)
64+
65+
raise InvalidOutboundEventError, 'signer must be Karya::OutboundEvents::WebhookSigner'
66+
end
67+
end
68+
end
69+
end

0 commit comments

Comments
 (0)