axn-webhooks 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +176 -0
  3. data/DESIGN-NOTES.md +241 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +1042 -0
  6. data/lib/axn/webhooks/dispatch.rb +129 -0
  7. data/lib/axn/webhooks/errors.rb +48 -0
  8. data/lib/axn/webhooks/handler.rb +15 -0
  9. data/lib/axn/webhooks/header_value.rb +29 -0
  10. data/lib/axn/webhooks/inbound/build_request.rb +23 -0
  11. data/lib/axn/webhooks/inbound/challenge.rb +37 -0
  12. data/lib/axn/webhooks/inbound/challenge_required.rb +35 -0
  13. data/lib/axn/webhooks/inbound/dsl.rb +240 -0
  14. data/lib/axn/webhooks/inbound/endpoint.rb +221 -0
  15. data/lib/axn/webhooks/inbound/parsers.rb +20 -0
  16. data/lib/axn/webhooks/inbound/respond_context.rb +17 -0
  17. data/lib/axn/webhooks/inbound/router.rb +104 -0
  18. data/lib/axn/webhooks/inbound.rb +124 -0
  19. data/lib/axn/webhooks/outbound/callable_arity.rb +99 -0
  20. data/lib/axn/webhooks/outbound/config.rb +442 -0
  21. data/lib/axn/webhooks/outbound/deliver.rb +425 -0
  22. data/lib/axn/webhooks/outbound/dsl.rb +121 -0
  23. data/lib/axn/webhooks/outbound/emit.rb +181 -0
  24. data/lib/axn/webhooks/outbound/envelope.rb +23 -0
  25. data/lib/axn/webhooks/outbound/signer.rb +376 -0
  26. data/lib/axn/webhooks/outbound/subscriber.rb +152 -0
  27. data/lib/axn/webhooks/outbound/target_policy.rb +135 -0
  28. data/lib/axn/webhooks/outbound/transport.rb +59 -0
  29. data/lib/axn/webhooks/outbound.rb +73 -0
  30. data/lib/axn/webhooks/request.rb +230 -0
  31. data/lib/axn/webhooks/resolvers.rb +43 -0
  32. data/lib/axn/webhooks/respond.rb +26 -0
  33. data/lib/axn/webhooks/response.rb +116 -0
  34. data/lib/axn/webhooks/signature.rb +268 -0
  35. data/lib/axn/webhooks/static_respond.rb +22 -0
  36. data/lib/axn/webhooks/vendor_facet.rb +25 -0
  37. data/lib/axn/webhooks/verifiers/basic_auth.rb +128 -0
  38. data/lib/axn/webhooks/verifiers/hmac.rb +58 -0
  39. data/lib/axn/webhooks/verifiers/standard_webhooks.rb +129 -0
  40. data/lib/axn/webhooks/verifiers.rb +50 -0
  41. data/lib/axn/webhooks/verify.rb +106 -0
  42. data/lib/axn/webhooks/version.rb +7 -0
  43. data/lib/axn/webhooks.rb +61 -0
  44. data/lib/axn-webhooks.rb +3 -0
  45. metadata +128 -0
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Webhooks
5
+ module Verifiers
6
+ # Parametric HMAC strategy. Resolves each option against the request at verify time
7
+ # and delegates to the constant-time Signature primitive.
8
+ register(:hmac) do |secret:, signature:, signing_string: :raw_body, digest: :sha256,
9
+ encoding: :hex, prefix: nil, replay: nil|
10
+ if replay
11
+ # Compare stringified keys so a HashWithIndifferentAccess (string keys) isn't
12
+ # misclassified as entirely unsupported.
13
+ allowed = %w[timestamp within unit]
14
+ unknown = replay.keys.reject { |key| allowed.include?(key.to_s) }
15
+ raise ArgumentError, "unsupported replay: key(s): #{unknown.map(&:inspect).join(', ')}" if unknown.any?
16
+
17
+ # Declaring `replay:` at all is an explicit request FOR replay protection, so a blank or
18
+ # non-positive `within:` is unambiguously a mistake — and one that silently disabled the
19
+ # guard rather than failing (security audit). Caught at boot, like every other declaration
20
+ # error, rather than on the first replayed request nobody notices.
21
+ within = replay[:within] || replay["within"]
22
+ unless within.is_a?(Numeric) && within.positive?
23
+ raise ArgumentError, "verify :hmac replay: `within:` must be a positive number of seconds (got #{within.inspect})"
24
+ end
25
+ end
26
+
27
+ # A literal secret is knowable now; a callable/Resolver is checked per request below.
28
+ literal_secret = !Resolvers.deferred?(secret)
29
+ Verifiers.require_secret!("verify :hmac", secret, error: ArgumentError) if literal_secret
30
+
31
+ lambda do |request|
32
+ timestamp = replay && Resolvers.resolve(replay.fetch(:timestamp), request)
33
+ # hmac_check (not hmac): Verify reads the returned Signature::Check to report WHY the
34
+ # request was rejected, so a replay-window miss is separable from an HMAC mismatch.
35
+ Signature.hmac_check(
36
+ # Resolve THEN guard, on every request: a resolver can miss (an unset env var, an
37
+ # absent header, a tenant lookup the ATTACKER names) long after boot, and "" is a legal
38
+ # HMAC key rather than a failure.
39
+ secret: Verifiers.require_secret!("verify :hmac", Resolvers.resolve(secret, request)),
40
+ payload: Resolvers.resolve(signing_string, request),
41
+ signature: Resolvers.resolve(signature, request),
42
+ digest:,
43
+ encoding:,
44
+ prefix:,
45
+ timestamp:,
46
+ # Omitted (not nil) when no replay is declared: Signature distinguishes "no replay
47
+ # check requested" from "a blank tolerance arrived from somewhere", and only the
48
+ # former is legitimate.
49
+ tolerance: replay ? replay.fetch(:within) { replay.fetch("within") } : Signature::NO_TOLERANCE,
50
+ # Default only when `unit:` is absent — an explicit `unit: nil`/`false` (e.g. an
51
+ # unset env var) must still hit Signature's ArgumentError, not silently become :auto.
52
+ unit: replay&.key?(:unit) ? replay[:unit] : Signature::AUTO,
53
+ )
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+
5
+ module Axn
6
+ module Webhooks
7
+ module Verifiers
8
+ # Standard Webhooks (Svix) scheme. Secret is `whsec_<base64>`; the signed string is
9
+ # `id.timestamp.body`; the signature header holds space-separated `v1,<base64sig>`
10
+ # candidates; a ±tolerance replay window applies.
11
+ module StandardWebhooks
12
+ module_function
13
+
14
+ # NOT `secret.to_s` (Codex round-4 finding): coercing here is what turned a nil secret —
15
+ # an unset ENV var, or a `header(...)` resolver on an absent header — into "", which is
16
+ # valid Base64 and decodes to an EMPTY HMAC key. That is an authentication bypass, since
17
+ # anyone who knows the credential is missing can sign with the empty key. Callers must hand
18
+ # this a String; `secret_key` guards the type, and `require_secret_key!` is the safe entry
19
+ # point for anything resolved at request time.
20
+ def decode_secret(secret)
21
+ raise ArgumentError, "secret must be a String (got #{secret.class})" unless secret.is_a?(String)
22
+
23
+ Base64.strict_decode64(secret.delete_prefix("whsec_"))
24
+ end
25
+
26
+ # The raw key for a secret resolved at REQUEST time, raising if it isn't usable. Loud on
27
+ # purpose: a secret that has gone missing is a misconfiguration worth paging on, and must
28
+ # never degrade into a quiet :signature_mismatch that reads like a rotated key.
29
+ def require_secret_key!(secret)
30
+ secret_key(secret) || raise(Axn::Webhooks::Error, invalid_secret_message("verify :standard_webhooks", secret))
31
+ end
32
+
33
+ # The raw HMAC key behind a `whsec_<base64>` secret, or nil if the value isn't one.
34
+ # The single source of truth for "is this a usable Standard Webhooks secret", shared by
35
+ # inbound's declaration-time check and outbound's (Outbound::Signer), so the two can't drift.
36
+ #
37
+ # The `whsec_` prefix check is what carries this: an unprefixed secret is very often still
38
+ # VALID Base64 — a 32-char hex secret is, and that's a common shape — so it would decode
39
+ # silently to the wrong key rather than raising. The rescue is scoped to the decode alone;
40
+ # a caller that RESOLVES a secret (from a callable or a secret store) must do so outside
41
+ # this method, or its own ArgumentError would be swallowed and rewritten.
42
+ def secret_key(secret)
43
+ return nil unless secret.is_a?(String) && secret.start_with?("whsec_")
44
+
45
+ key = decode_secret(secret)
46
+ key.empty? ? nil : key
47
+ rescue ArgumentError
48
+ nil
49
+ end
50
+
51
+ # Describes a rejected secret's SHAPE for an error message, never its bytes: this can be
52
+ # raised per delivery attempt on the outbound side, and would otherwise flow the live
53
+ # signing credential into whatever Axn.config.on_exception is wired to.
54
+ def describe_secret(secret)
55
+ return secret.class.name unless secret.is_a?(String)
56
+ return "a #{secret.length}-char String not prefixed with whsec_" unless secret.start_with?("whsec_")
57
+
58
+ "a whsec_-prefixed String that failed to decode"
59
+ end
60
+
61
+ def invalid_secret_message(declaration, secret)
62
+ "#{declaration} secret must be a whsec_<base64> value (got #{describe_secret(secret)})"
63
+ end
64
+
65
+ # Keep only `v1,<sig>` candidates, stripped to the bare base64 signature.
66
+ # Done here (not via Signature's generic splitter) because that splitter treats
67
+ # the comma as a separator and would break `v1,<sig>` into two tokens.
68
+ def extract_v1(header)
69
+ header.to_s.split(/\s+/).select { |t| t.start_with?("v1,") }.map { |t| t.delete_prefix("v1,") }
70
+ end
71
+ end
72
+
73
+ register(:standard_webhooks) do |secret:, tolerance: 300,
74
+ id: Resolvers.header("webhook-id"),
75
+ timestamp: Resolvers.header("webhook-timestamp"),
76
+ signature: Resolvers.header("webhook-signature")|
77
+ # A LITERAL secret is fully knowable now, so the whsec_ format is checked once here (this
78
+ # block runs at `inbound` declaration) instead of failing every request forever. Symmetric
79
+ # with outbound `sign :standard_webhooks`, and ArgumentError for the same reason: a
80
+ # declaration mistake, not a runtime condition.
81
+ #
82
+ # Worth the eager check because BOTH request-time failure modes are near-undiagnosable: a
83
+ # non-Base64 raw secret raises (reported as a verifier crash, no `reason` on the result),
84
+ # and a raw secret that IS valid Base64 decodes silently to the wrong key — a quiet
85
+ # :signature_mismatch, nothing reported anywhere, indistinguishable from a rotated key.
86
+ #
87
+ # Exempt CALLABLES (a lambda, or a Resolver like `header("X-Secret")`) — deliberately not
88
+ # resolved here, since they may read a secret store or an env var set after boot, so their
89
+ # value stays a per-request concern. Everything ELSE is validated now.
90
+ #
91
+ # Keyed on respond_to?(:call), NOT on is_a?(String) (Codex round-3 finding): a `nil` secret
92
+ # — an unset ENV var being the obvious way to get one — is neither a String nor a callable,
93
+ # so a String-keyed check waved it through to request time, where `decode_secret` coerced it
94
+ # with #to_s and Base64-decoded "" into an EMPTY HMAC key. That is an authentication bypass,
95
+ # not a mismatch: anyone who knows the secret is unset can compute a signature with the empty
96
+ # key and be verified. Same fail-closed-on-blank stance verify :basic_auth already takes.
97
+ # An explicitly blank tolerance would silently disable the replay window; the 300s default
98
+ # applies only when the caller omits it entirely (security audit).
99
+ unless tolerance.is_a?(Numeric) && tolerance.positive?
100
+ raise ArgumentError,
101
+ "verify :standard_webhooks tolerance: must be a positive number of seconds (got #{tolerance.inspect})"
102
+ end
103
+
104
+ unless Resolvers.deferred?(secret) || StandardWebhooks.secret_key(secret)
105
+ raise ArgumentError, StandardWebhooks.invalid_secret_message("verify :standard_webhooks", secret)
106
+ end
107
+
108
+ lambda do |request|
109
+ ts = Resolvers.resolve(timestamp, request)
110
+ payload = "#{Resolvers.resolve(id, request)}.#{ts}.#{request.raw_body}"
111
+ candidates = StandardWebhooks.extract_v1(Resolvers.resolve(signature, request))
112
+
113
+ # hmac_check (not hmac): returns a Signature::Check so Verify can name the cause.
114
+ Signature.hmac_check(
115
+ # Resolve THEN validate. A declaration-time check can't cover this: a callable or
116
+ # Resolver is resolved fresh per request and can go missing at any point after boot.
117
+ secret: StandardWebhooks.require_secret_key!(Resolvers.resolve(secret, request)),
118
+ payload:,
119
+ signature: candidates.join(" "),
120
+ digest: :sha256,
121
+ encoding: :base64,
122
+ timestamp: ts,
123
+ tolerance:,
124
+ )
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Webhooks
5
+ # Builds a verifier callable (->(request){ Boolean }) from a `verify` declaration.
6
+ # A custom block is used verbatim; a strategy symbol is looked up in STRATEGIES
7
+ # (populated by verifiers/*.rb).
8
+ module Verifiers
9
+ STRATEGIES = {} # rubocop:disable Style/MutableConstant
10
+
11
+ module_function
12
+
13
+ def register(name, &builder) = STRATEGIES[name.to_sym] = builder
14
+
15
+ # THE shared secret guard. Every strategy in this gem routes its secret/credential through
16
+ # here, in both directions, so a new strategy cannot quietly reintroduce the bug this exists
17
+ # for — which has now recurred four times, each fix having been applied only where it was
18
+ # noticed (literal whsec_, resolved whsec_, `verify :hmac`, `verify :basic_auth`).
19
+ #
20
+ # The bug: a blank or absent secret is not a failure, it is a WEAK KEY. `""` is a perfectly
21
+ # legal HMAC key, so an empty secret makes the expected signature a value any stranger can
22
+ # compute — an authentication bypass, not a mismatch. `nil` happens to fail closed only by
23
+ # accident (OpenSSL raises TypeError on it), which is precisely why checking nil alone gives
24
+ # false confidence.
25
+ #
26
+ # Raises rather than returning false: a 401 meaning "we are misconfigured" is indistinguishable
27
+ # from one meaning "you are not the vendor", and would otherwise present as an unexplained
28
+ # outage. Names the value's TYPE or emptiness only — never its bytes, since this can fire on
29
+ # every request and would otherwise flow the live credential into logs and error trackers.
30
+ # `error:` follows this gem's misconfiguration split: ArgumentError for a DECLARATION mistake
31
+ # caught at boot, Axn::Webhooks::Error for a value that only goes bad at request time.
32
+ def require_secret!(declaration, value, label: "secret", error: Axn::Webhooks::Error)
33
+ return value if value.is_a?(String) && !value.empty?
34
+
35
+ raise error,
36
+ "#{declaration} #{label} must be a non-empty String " \
37
+ "(got #{value.is_a?(String) ? 'an empty String' : value.class})"
38
+ end
39
+
40
+ def build(strategy:, opts:, block:)
41
+ return block if block
42
+
43
+ builder = STRATEGIES.fetch(strategy&.to_sym) do
44
+ raise Axn::Webhooks::Error, "unknown verify strategy #{strategy.inspect}"
45
+ end
46
+ builder.call(**opts)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Webhooks
5
+ # The verify stage, as an Axn. A signature mismatch is a quiet failure (`fail!` →
6
+ # 401 later, no on_exception page); a verifier that raises is a loud exception
7
+ # (reported to Axn.config.on_exception). The first two rows of the staged-outcome model.
8
+ class Verify
9
+ include Axn
10
+ include Axn::Webhooks::VendorFacet
11
+
12
+ # Why the request was rejected, keyed by Signature::REASONS. The HTTP response is a bare
13
+ # 401 in every case — this exists so the two causes are separable in logs and metrics
14
+ # (PRO-3141: a replay-window miss and an HMAC mismatch were byte-identical in the logs,
15
+ # and the replay case reported "signature mismatch" for a request whose signature was valid).
16
+ MESSAGES = {
17
+ replay_window: ->(check) { "replay window exceeded (timestamp skew #{check.skew}s)" },
18
+ replay_timestamp_invalid: ->(_check) { "replay timestamp missing or unparseable" },
19
+ signature_missing: ->(_check) { "signature missing" },
20
+ signature_mismatch: ->(_check) { "signature mismatch" },
21
+ # A genuine anomaly, and worth alerting on: the client presented an `Authorization` header,
22
+ # so it meant to authenticate, but not a Basic one. The bare handshake leg — which used to
23
+ # land here, once per *successful* webhook, making this the highest-volume value of the
24
+ # dimension — is answered with the challenge before Verify runs at all now (PRO-3148), so
25
+ # the message names what is actually left rather than the leg it no longer reports.
26
+ credentials_missing: lambda { |_check|
27
+ "no Basic credentials offered — a non-Basic Authorization scheme (the bare handshake leg " \
28
+ "is challenged before Verify)"
29
+ },
30
+ credentials_mismatch: ->(_check) { "Basic credentials rejected" },
31
+ }.freeze
32
+
33
+ expects :request, type: Axn::Webhooks::Request, sensitive: true
34
+ # A verifier closes over or holds the vendor's secret — that's its whole job — so it must
35
+ # never be rendered into the per-call log line. The built-in strategies redact themselves
36
+ # too (see Verifiers::BasicAuth#inspect), but this is the boundary that has to hold: a
37
+ # custom `verify` block or a future strategy can't be relied on to have thought about it.
38
+ expects :verifier, sensitive: true
39
+ exposes :reason, allow_blank: true, default: nil
40
+ exposes :skew, allow_blank: true, default: nil
41
+ exposes :suggested_unit, allow_blank: true, default: nil
42
+ # Deliberately mechanism-neutral: this prefixes every reason's message, and `verify :basic_auth`
43
+ # rejects requests on endpoints where no signature exists — "signature verification failed:
44
+ # Basic credentials rejected" would reintroduce, in the very first words an operator reads,
45
+ # the misdirection `reason` was added to end. The signature cases lose nothing, since their
46
+ # own half of the message still names the signature (see MESSAGES).
47
+ error "Webhook verification failed"
48
+
49
+ # A bounded enum (4 values), so unlike :vendor it's stamped unconditionally rather than
50
+ # gated behind Axn::Webhooks.config.vendor_facet — separating the causes is the reason
51
+ # this facet exists, and a default install needs it as much as a configured one.
52
+ # `from: :result` because the reason isn't known until the body has run.
53
+ dimension :reason, -> { @reason }, from: :result
54
+
55
+ # Also bounded (3 scales + absent), and set only when a pinned `unit:` — not a real replay —
56
+ # is what pushed the timestamp out of the window. Its presence alone splits the misconfigured
57
+ # half of :replay_window from the genuine half; its value names the fix (PRO-3142).
58
+ dimension :suggested_unit, -> { @suggested_unit }, from: :result
59
+
60
+ def call
61
+ check = verifier.call(request)
62
+ return if verified?(check)
63
+
64
+ # Set before fail! so the result-phase dimension resolvers can read them.
65
+ rejection = check.is_a?(Signature::Check) ? check : Signature::MISMATCH
66
+ @reason = rejection.reason
67
+ @skew = rejection.skew
68
+ @suggested_unit = rejection.suggested_unit
69
+ fail!(message_for(rejection), reason: @reason, skew: @skew, suggested_unit: @suggested_unit)
70
+ end
71
+
72
+ private
73
+
74
+ # The reason's own message, plus the suggested unit when one applies — "would fit as
75
+ # unit: :ms" alongside a 56-year skew names a misconfiguration outright, where the skew
76
+ # alone still reads as a possible replay. A timestamp is not a secret and the HTTP
77
+ # response is a bare 401 either way, so this discloses nothing to the sender.
78
+ def message_for(rejection)
79
+ message = MESSAGES.fetch(rejection.reason).call(rejection)
80
+ return message unless rejection.suggested_unit
81
+
82
+ "#{message} — would fit as unit: #{rejection.suggested_unit.inspect}"
83
+ end
84
+
85
+ # Anything that reports its OWN verdict via #ok? is asked; anything else (a custom verifier
86
+ # block, per the documented `->(request) { Boolean }` contract) is read for truthiness.
87
+ #
88
+ # Duck-typed on #ok? rather than `is_a?(Signature::Check)` because the truthiness fallback is
89
+ # a silent authentication-disabled bug for any verdict object: a REJECTING one is still a
90
+ # truthy Ruby object, so it verified and dispatched every rejected request while recording no
91
+ # verify failure anywhere. `Axn::Result` is the one that actually bites — in an axn-consuming
92
+ # app, returning `MyCheck.call(request:)` from a `verify` block is the obvious thing to write.
93
+ #
94
+ # Order matters: #ok? is asked FIRST, since every such object is truthy regardless of verdict.
95
+ # A truthy object with no #ok? still means verified — plenty of custom blocks end in a lookup
96
+ # returning a record rather than a boolean — and nil/false still mean rejected, since neither
97
+ # responds to #ok?.
98
+ #
99
+ # NOTE this reads the object's own notion of "ok", which for an Axn::Result means the action
100
+ # SUCCEEDED, not necessarily that the signature was valid. An action that returns ok while
101
+ # carrying its verdict in an exposure is still mis-read; see the README's custom-verify
102
+ # section. Fixing the always-verifies case does not make every Result shape safe.
103
+ def verified?(check) = check.respond_to?(:ok?) ? check.ok? : !!check
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Axn
4
+ module Webhooks
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "axn"
4
+ require "active_support/deprecation"
5
+
6
+ require_relative "webhooks/errors"
7
+ require_relative "webhooks/handler"
8
+ require_relative "webhooks/version"
9
+ require_relative "webhooks/header_value"
10
+ require_relative "webhooks/request"
11
+ require_relative "webhooks/response"
12
+ require_relative "webhooks/signature"
13
+ require_relative "webhooks/resolvers"
14
+ require_relative "webhooks/vendor_facet"
15
+ require_relative "webhooks/verify"
16
+ require_relative "webhooks/verifiers"
17
+ require_relative "webhooks/verifiers/basic_auth"
18
+ require_relative "webhooks/verifiers/hmac"
19
+ require_relative "webhooks/verifiers/standard_webhooks"
20
+ require_relative "webhooks/inbound"
21
+ require_relative "webhooks/inbound/challenge"
22
+ require_relative "webhooks/inbound/parsers"
23
+ require_relative "webhooks/inbound/build_request"
24
+ require_relative "webhooks/inbound/challenge_required"
25
+ require_relative "webhooks/inbound/respond_context"
26
+ require_relative "webhooks/respond"
27
+ require_relative "webhooks/static_respond"
28
+ require_relative "webhooks/dispatch"
29
+ require_relative "webhooks/outbound"
30
+
31
+ module Axn
32
+ module Webhooks
33
+ extend Axn::Configurable
34
+
35
+ # Per-gem config namespace (Axn::Configurable, PRO-2880), so settings declared here don't
36
+ # collide with another adapter configured on the same action.
37
+ config_namespace :webhooks
38
+
39
+ # Per-vendor observability facet (spec Decision 7 / PRO-2818). Off by default; a consuming app
40
+ # (Teamshares: :dimension) opts in. See Axn::Webhooks::VendorFacet for the runtime mechanism.
41
+ setting :vendor_facet, default: false, one_of: [false, :dimension, :tag]
42
+
43
+ # The HTTP status an inbound endpoint returns when a VERIFIED request's body doesn't parse
44
+ # (PRO-3143). 200 by default, because retrying can never fix a malformed body and 2xx is the only
45
+ # answer every vendor reads as "stop redelivering": Lob (5 days, then it disables the endpoint),
46
+ # Stripe, Slack and Shopify all retry non-2xx, and the last two also disable an endpoint after
47
+ # sustained failures — so a semantically-tidy 400 buys a retry loop from most senders. Set 400 for
48
+ # a vendor that does treat 4xx as terminal (honest status codes in their delivery dashboard), or
49
+ # 500 to restore the pre-PRO-3143 behavior. Per-endpoint override: `dispatch unparseable_status:`.
50
+ setting :unparseable_status,
51
+ default: 200,
52
+ validate: ->(value) { Response.valid_status?(value) || "must be an Integer HTTP status between 200 and 599" }
53
+
54
+ # A dedicated deprecator instance, so a consuming Rails app can register it
55
+ # (Rails.application.deprecators[:webhooks] = Axn::Webhooks.deprecator) and govern
56
+ # its behavior (silence in test, raise in CI, etc.).
57
+ def self.deprecator
58
+ @deprecator ||= ActiveSupport::Deprecation.new("1.0", "axn-webhooks")
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "axn/webhooks"
metadata ADDED
@@ -0,0 +1,128 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: axn-webhooks
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Kali Donovan
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: axn
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 0.1.0.pre.alpha.5
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: 0.2.0
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: 0.1.0.pre.alpha.5
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: 0.2.0
32
+ - !ruby/object:Gem::Dependency
33
+ name: rack
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '3.0'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '4'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '3.0'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '4'
52
+ description: 'Webhook handling for axn, both directions: verify/dispatch/acknowledge
53
+ inbound, and emit signed, self-retrying deliveries outbound. Rails-optional.'
54
+ email:
55
+ - kali@teamshares.com
56
+ executables: []
57
+ extensions: []
58
+ extra_rdoc_files: []
59
+ files:
60
+ - CHANGELOG.md
61
+ - DESIGN-NOTES.md
62
+ - LICENSE.txt
63
+ - README.md
64
+ - lib/axn-webhooks.rb
65
+ - lib/axn/webhooks.rb
66
+ - lib/axn/webhooks/dispatch.rb
67
+ - lib/axn/webhooks/errors.rb
68
+ - lib/axn/webhooks/handler.rb
69
+ - lib/axn/webhooks/header_value.rb
70
+ - lib/axn/webhooks/inbound.rb
71
+ - lib/axn/webhooks/inbound/build_request.rb
72
+ - lib/axn/webhooks/inbound/challenge.rb
73
+ - lib/axn/webhooks/inbound/challenge_required.rb
74
+ - lib/axn/webhooks/inbound/dsl.rb
75
+ - lib/axn/webhooks/inbound/endpoint.rb
76
+ - lib/axn/webhooks/inbound/parsers.rb
77
+ - lib/axn/webhooks/inbound/respond_context.rb
78
+ - lib/axn/webhooks/inbound/router.rb
79
+ - lib/axn/webhooks/outbound.rb
80
+ - lib/axn/webhooks/outbound/callable_arity.rb
81
+ - lib/axn/webhooks/outbound/config.rb
82
+ - lib/axn/webhooks/outbound/deliver.rb
83
+ - lib/axn/webhooks/outbound/dsl.rb
84
+ - lib/axn/webhooks/outbound/emit.rb
85
+ - lib/axn/webhooks/outbound/envelope.rb
86
+ - lib/axn/webhooks/outbound/signer.rb
87
+ - lib/axn/webhooks/outbound/subscriber.rb
88
+ - lib/axn/webhooks/outbound/target_policy.rb
89
+ - lib/axn/webhooks/outbound/transport.rb
90
+ - lib/axn/webhooks/request.rb
91
+ - lib/axn/webhooks/resolvers.rb
92
+ - lib/axn/webhooks/respond.rb
93
+ - lib/axn/webhooks/response.rb
94
+ - lib/axn/webhooks/signature.rb
95
+ - lib/axn/webhooks/static_respond.rb
96
+ - lib/axn/webhooks/vendor_facet.rb
97
+ - lib/axn/webhooks/verifiers.rb
98
+ - lib/axn/webhooks/verifiers/basic_auth.rb
99
+ - lib/axn/webhooks/verifiers/hmac.rb
100
+ - lib/axn/webhooks/verifiers/standard_webhooks.rb
101
+ - lib/axn/webhooks/verify.rb
102
+ - lib/axn/webhooks/version.rb
103
+ homepage: https://github.com/teamshares/axn-webhooks
104
+ licenses:
105
+ - MIT
106
+ metadata:
107
+ homepage_uri: https://github.com/teamshares/axn-webhooks
108
+ source_code_uri: https://github.com/teamshares/axn-webhooks
109
+ changelog_uri: https://github.com/teamshares/axn-webhooks/blob/main/CHANGELOG.md
110
+ rubygems_mfa_required: 'true'
111
+ rdoc_options: []
112
+ require_paths:
113
+ - lib
114
+ required_ruby_version: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: 3.2.1
119
+ required_rubygems_version: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0'
124
+ requirements: []
125
+ rubygems_version: 3.6.8
126
+ specification_version: 4
127
+ summary: "Axn + webhooks = \U0001F525"
128
+ test_files: []