ankusa-sdk 0.3.0 → 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 99e44d64d5de0bc0b009a85ef6108009f93c42c90a4b28d39c8d39426421d588
4
- data.tar.gz: 5084fce56a8a86cc4e1259c1b96a25c2fb1fa599b42e21cbc0248e7f179bd0b8
3
+ metadata.gz: 817a62bfe2b9d6f6f4dc513d32cb6a4e4091de4e79bd37ef9b5432a44eca6a60
4
+ data.tar.gz: 74ac90d9dccf5b681519f6870221bcddc11a96d015d945cffc68200606e2c808
5
5
  SHA512:
6
- metadata.gz: 25f553755bd7f33506aba3992f103c69a82e3e1fa11ffd81909f4a4e4df8fb5a649d0eaa658ec285dffac01dfb6f14b3d057257d8f55ec7b6d667c54b51856b6
7
- data.tar.gz: bf270c6e0dcec34696106eaf96cb320004094126f8390e42ff84cd065d874c26b01fc653462aae47eb3960b4ab5a174f49d0e2bbae9fb7f339e4acf1f0956302
6
+ metadata.gz: ca0aa04bed9460cc20cc1db4b262ff711c65762b284b60486bdcf49825800e03e4173ca319553eaa3ab21f4151aa68fce7a5350e861bb3626d29e48028c02b91
7
+ data.tar.gz: 290a4cd6343778e9236772c3394877302ce97180c751c80a80420127698bfede89a859c413ea919280870bf57f355f6019628740cf4427c76ad2a39e70e1457b
data/CHANGELOG.md CHANGED
@@ -6,6 +6,43 @@ this project follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.5.0] - 2026-10-14
10
+
11
+ ### Added
12
+
13
+ - `Ankusa.verify_signature(headers, body, secrets, tolerance_seconds: 300, now: nil)`
14
+ verifies the Standard Webhooks signature an HTTP sink with a `secret` adds,
15
+ comparing with `OpenSSL.fixed_length_secure_compare`; failures raise
16
+ `Ankusa::InvalidSignatureError` (`code`, `field`, never retryable).
17
+ - `Ankusa::Message` and `Ankusa.decode_message(data)`, plus
18
+ `Ankusa::InvalidMessageError` (with `code`, `field` and `retryable?` false):
19
+ decode and validate the v1 queue message every sink delivers — the inline
20
+ (`body_base64`) and claim-check (`claim`) body forms, `sha256`,
21
+ `dedupe_key`, `replay_id`, and the forwarded `headers`, ignoring unknown
22
+ keys.
23
+ - `Message#idempotency_key` / `HookHeaders#idempotency_key`, the key to store
24
+ in a processed-ids table: the key Ankusa shipped (the message's
25
+ `idempotency_key` field, or the `x-ankusa-idempotency-key` header), and for a
26
+ message or delivery that predates it `tenant:source_id:dedupe_key` (tenant
27
+ `default` when there is none) when a `dedupe_key` is set, else `id`, with an
28
+ optional `#replay:<replay_id>` suffix (`include_replay: true`). The decoded
29
+ field stays in `to_h[:idempotency_key]`.
30
+ - `HookHeaders#dedupe_key` / `#replay_id` / `#idempotency_key`'s field, parsed
31
+ from `x-ankusa-dedupe-key` / `x-ankusa-replay-id` / `x-ankusa-idempotency-key`.
32
+ - `AdminClient#create_replay`, `#get_replay`, `#list_replays` and
33
+ `#update_replay` for the `POST/GET/PATCH /v1/replays` replay-job API (kinds
34
+ `dlq`, `archive`, `quarantine`).
35
+
36
+ ### Changed
37
+
38
+ - `ClaimCheckClient#redeem` raises `ClaimCheckUnavailableError` (retryable)
39
+ for a gateway `408` or `429` instead of `ClaimRejectedError`.
40
+
41
+ ### Removed
42
+
43
+ - `AdminClient#replay_dead_letters`, replaced by the replay-job API above.
44
+ `POST /v1/dlq/replay` no longer exists.
45
+
9
46
  ## [0.3.0] - 2026-10-01
10
47
 
11
48
  ### Added
@@ -37,5 +74,6 @@ this project follows [Semantic Versioning](https://semver.org/).
37
74
  family, which carries `.status` and `.body` instead), so a consumer needs
38
75
  exactly one bit to route a failure to dead-letter or retry.
39
76
 
40
- [Unreleased]: https://github.com/jamescarr/ankusa/compare/sdk-ruby-v0.3.0...HEAD
77
+ [Unreleased]: https://github.com/jamescarr/ankusa/compare/sdk-ruby-v0.5.0...HEAD
78
+ [0.5.0]: https://github.com/jamescarr/ankusa/compare/sdk-ruby-v0.3.0...sdk-ruby-v0.5.0
41
79
  [0.3.0]: https://github.com/jamescarr/ankusa/releases/tag/sdk-ruby-v0.3.0
data/README.md CHANGED
@@ -5,8 +5,8 @@ deployments: one gem meant to bundle everything a non-Elixir consumer needs to
5
5
  talk to an Ankusa deployment. Today that's the
6
6
  [claim-check gateway](https://github.com/jamescarr/ankusa/blob/main/docs/claim-check.md)
7
7
  client, the route-management and operator (admin) clients, the
8
- source-management client, and a webhook-receiving header helper; more clients
9
- (ingest) land here as they're built.
8
+ source-management client, a webhook-receiving header helper, and the queue
9
+ message decoder; more clients (ingest) land here as they're built.
10
10
 
11
11
  ## Install
12
12
 
@@ -81,9 +81,9 @@ consumer needs exactly one bit to decide dead-letter vs. retry:
81
81
  | --- | --- | --- |
82
82
  | `InvalidClaimRefError` | `false` | `ref` isn't a well-formed claim-check URN, or `sha256` isn't 64-char lowercase hex |
83
83
  | `ClaimNotFoundError` | `false` | gateway `404`: expired by retention, or never written |
84
- | `ClaimRejectedError` | `false` | gateway `4xx` other than `404` (`.status`, `.body`) |
84
+ | `ClaimRejectedError` | `false` | gateway `4xx` other than `404`, `408`, `429` (`.status`, `.body`) |
85
85
  | `ClaimIntegrityError` | `false` | sha256 of the returned bytes doesn't match |
86
- | `ClaimCheckUnavailableError` | `true` | gateway `5xx`/`503`, or unreachable |
86
+ | `ClaimCheckUnavailableError` | `true` | gateway `5xx`, `408`, `429`, or unreachable |
87
87
 
88
88
  `health` hits `GET /health` for a liveness probe. There's no `close`: the
89
89
  default transport opens one connection per request.
@@ -124,11 +124,103 @@ def call(env)
124
124
  end
125
125
  ```
126
126
 
127
- `HookHeaders#id` is what a receiver dedupes on: delivery is at-least-once (see
127
+ `HookHeaders#idempotency_key` is what a receiver dedupes on, not the hook id
128
+ (`x-ankusa-id` stays required): delivery is at-least-once (see
128
129
  "HTTP handoff" in
129
130
  [`docs/integrations.md`](https://github.com/jamescarr/ankusa/blob/main/docs/integrations.md)),
130
- so the same hook can arrive twice after a retry. Header lookup is always
131
- case-insensitive, regardless of whether the mapping passed in already is.
131
+ so the same hook can arrive twice after a retry. When the source extracted the
132
+ provider's own event key, `HookHeaders#dedupe_key` carries it (and `replay_id`
133
+ marks a replay); `HookHeaders#idempotency_key` returns the tenant-scoped key
134
+ Ankusa computed and shipped in `x-ankusa-idempotency-key` — see "Consuming
135
+ queue messages". Header lookup is always case-insensitive, regardless of
136
+ whether the mapping passed in already is.
137
+
138
+ ### Verifying signed deliveries
139
+
140
+ An HTTP sink with a `secret` signs every delivery the
141
+ [Standard Webhooks](https://www.standardwebhooks.com/) way:
142
+
143
+ ```ruby
144
+ # Inside a Rack app's `call(env)`, before anything parses the body:
145
+ begin
146
+ body = env["rack.input"].read # the raw request body, exactly as received
147
+ Ankusa.verify_signature(header_map(env), body, [ENV.fetch("ANKUSA_WHSEC")])
148
+ rescue Ankusa::InvalidSignatureError => e
149
+ return [401, {"content-type" => "application/json"},
150
+ [JSON.generate(error: "invalid_signature", code: e.code)]]
151
+ end
152
+ ```
153
+
154
+ Secrets are `whsec_` + base64, or any other string used as its own bytes;
155
+ pass several during a rotation. `tolerance_seconds:` (default 300) bounds the
156
+ `webhook-timestamp` window. Failures are `Ankusa::InvalidSignatureError` with
157
+ `code` and `field`, never retryable.
158
+
159
+ ## Consuming queue messages
160
+
161
+ Every message sink — RabbitMQ, Kafka, NATS, Redis — delivers one JSON message
162
+ per hook (the HTTP sink sends the original body plus `x-ankusa-*` headers
163
+ instead):
164
+ the identity fields, the body (inline `body_base64` or a claim-check `claim`),
165
+ `sha256`, and, when present, `dedupe_key`, `replay_id`, `idempotency_key` and
166
+ the forwarded provider `headers`. `Ankusa.decode_message` validates all of it
167
+ and `Message#idempotency_key` gives the value to store in a processed-ids
168
+ table:
169
+
170
+ ```ruby
171
+ require "ankusa/sdk"
172
+
173
+ CLAIM_CHECK = Ankusa::ClaimCheckClient.new(ENV.fetch("CLAIM_CHECK_URL", "http://localhost:4001"))
174
+ DB = PG.connect(ENV.fetch("DATABASE_URL"))
175
+ DB.exec("CREATE TABLE IF NOT EXISTS processed (key text primary key)")
176
+
177
+ def consume(raw)
178
+ begin
179
+ message = Ankusa.decode_message(raw)
180
+ rescue Ankusa::InvalidMessageError => e
181
+ # Poison message: dead-letter, never requeue. `e.retryable?` is false;
182
+ # `e.code` is e.g. "integrity"; `e.field` names the offending key.
183
+ raise
184
+ end
185
+
186
+ key = message.idempotency_key # the key Ankusa shipped (tenant:source_id:dedupe_key, else id)
187
+ return if DB.exec_params("SELECT 1 FROM processed WHERE key = $1", [key]).any?
188
+
189
+ body = message.claim ? CLAIM_CHECK.redeem(message.claim, message.sha256) : message.body
190
+ handle(body)
191
+ DB.exec_params("INSERT INTO processed (key) VALUES ($1)", [key])
192
+ end
193
+ ```
194
+
195
+ - `idempotency_key` is the key Ankusa computed once for the hook:
196
+ `tenant:source_id:dedupe_key` when the source extracted the provider's own
197
+ event key, else `id` — so a provider retry that arrives with a fresh Ankusa
198
+ `id` still collapses onto the same row, and two tenants that share a provider
199
+ event id do not. For a message from a node that predates the field the
200
+ helper computes the same key itself (tenant `default` when there is none).
201
+ The decoded field alone is `message.to_h[:idempotency_key]`.
202
+ - A replayed delivery is dropped by default. To reprocess replays instead, pass
203
+ `include_replay: true`: the key then ends in `#replay:<replay_id>`.
204
+ - `message.body` is the decoded inline bytes (nil for the claim form);
205
+ `message.headers` holds the forwarded provider request headers (lowercased).
206
+ `message.sha256` is checked against the inline bytes by `decode_message`
207
+ itself, and against the claim's bytes by `CLAIM_CHECK.redeem`.
208
+ - `decode_message` raises `InvalidMessageError` before you act on a message:
209
+ bad JSON, a non-object, an unsupported `v`, a bad field type, both or neither
210
+ body form, invalid base64, a length/sha256 mismatch, or a claim whose tenant
211
+ differs from `tenant_id`.
212
+
213
+ A webhook receiver can take the same shortcut straight off the HTTP sink's
214
+ headers with `Ankusa.parse_headers(headers).idempotency_key`, which reads
215
+ `x-ankusa-idempotency-key` (falling back to computing it, where `source` and
216
+ `tenant` play `source_id` and `tenant_id`).
217
+
218
+ When a sink has grown a backlog, or a downstream processor failed after the
219
+ sink accepted a batch, re-drive it with a replay job over the admin client (see
220
+ "Admin client" below): `"kind" => "dlq"` re-sends rows that dead-lettered,
221
+ `"kind" => "archive"` re-sends hooks over a time window, `"kind" => "quarantine"`
222
+ re-verifies held hooks and releases those that pass. Replays keep the
223
+ original `id` and `dedupe_key` and add `replay_id`.
132
224
 
133
225
  ## Routes client
134
226
 
@@ -162,8 +254,8 @@ unfollowed redirect, a non-JSON success body, or unreachable; retryable).
162
254
  ## Admin client
163
255
 
164
256
  The operator API on `admin.port` (default 4002): health, Prometheus metrics, the
165
- redacted config, the DLQ, and the quarantine list — the `operations`, `dlq`, and
166
- `quarantine` tags of `admin.v1.yaml`.
257
+ redacted config, the DLQ, replay jobs, and the quarantine list — the
258
+ `operations`, `dlq`, `replays`, and `quarantine` tags of `admin.v1.yaml`.
167
259
 
168
260
  ```ruby
169
261
  require "ankusa/sdk"
@@ -172,14 +264,24 @@ admin = Ankusa::AdminClient.new(ENV.fetch("ADMIN_URL", "http://localhost:4002"))
172
264
 
173
265
  admin.health # {"status" => "ok", "instance" => ..., "roles" => [...]}
174
266
  admin.list_dead_letters({"limit" => 10}) # {"total" => ..., "entries" => [...]}
175
- admin.replay_dead_letters({"source_id" => "demo"})
176
267
  admin.list_quarantined
268
+
269
+ job = admin.create_replay({"kind" => "dlq", "source_id" => "stripe", "rate" => 500})
270
+ admin.get_replay(job["id"])
271
+ admin.list_replays # {"replays" => [Replay, ...]}, newest first
272
+ admin.update_replay(job["id"], {"state" => "paused"}) # resume, pause or cancel
177
273
  ```
178
274
 
275
+ `create_replay` is idempotent for retries: a second POST of the same filter
276
+ while the job is `running`/`paused` returns the existing job (`200`) instead of
277
+ starting another (`202`). `list_replays` returns `{"replays" => [...]}` exactly
278
+ as the API sends it.
279
+
179
280
  Methods: `health`, `metrics` (Prometheus text), `config`, `list_dead_letters`,
180
- `replay_dead_letters`, `list_quarantined`. Failures are `AdminError` subclasses:
181
- `RoleNotEnabledError` (409 `role_not_enabled`, carrying `role`),
182
- `AdminRejectedError` (any other 4xx, carrying `code`), and
281
+ `create_replay`, `get_replay`, `list_replays`, `update_replay`,
282
+ `list_quarantined`. Failures are `AdminError` subclasses: `RoleNotEnabledError`
283
+ (409 `role_not_enabled`, carrying `role`), `AdminRejectedError` (any other 4xx —
284
+ a missing replay is a 404 with `code` `replay_not_found`), and
183
285
  `AdminUnavailableError` (5xx, an unfollowed redirect, or unreachable;
184
286
  retryable).
185
287
 
@@ -236,15 +338,16 @@ lib/ankusa/
236
338
  connection.rb # shared URL/query/body/JSON rules (@api private)
237
339
  webhook.rb # x-ankusa-* header parsing for HTTP-sink receivers
238
340
  claim_check.rb # the claim-check gateway client
341
+ message.rb # decode the v1 queue message + the idempotency key
239
342
  routes.rb # the route-management client (routes.admin.port)
240
343
  admin.rb # the operator client (admin.port)
241
344
  sources.rb # the tenant-scoped source-management client (admin.port)
242
345
  test/
243
346
  test_helper.rb
244
- test_conformance.rb # runs the language-neutral vectors in conformance/
245
- test_routes.rb
246
- test_admin.rb
247
- test_sources.rb
347
+ conformance_test.rb # runs the language-neutral vectors in conformance/
348
+ routes_test.rb
349
+ admin_test.rb
350
+ sources_test.rb
248
351
  ```
249
352
 
250
353
  ## Develop
data/lib/ankusa/admin.rb CHANGED
@@ -81,12 +81,33 @@ module Ankusa
81
81
  json(request("GET", "/v1/dlq", query: params))
82
82
  end
83
83
 
84
- # POST /v1/dlq/replay -> {replayed}.
84
+ # POST /v1/replays -> the created Replay, as a Hash.
85
85
  #
86
- # `filter` is a filter, not a payload; omitted or empty (`{}`) replays
87
- # everything.
88
- def replay_dead_letters(filter = nil)
89
- json(request("POST", "/v1/dlq/replay", json: filter || {}))
86
+ # `spec` is the replay spec: `{"kind" => "dlq", ...}`,
87
+ # `{"kind" => "archive", "from" => ms, "to" => ms, ...}` or
88
+ # `{"kind" => "quarantine", ...}`. A retried POST
89
+ # with the same normalized filter returns the running job with 200 instead
90
+ # of starting a second one.
91
+ def create_replay(spec)
92
+ json(request("POST", "/v1/replays", json: spec))
93
+ end
94
+
95
+ # GET /v1/replays/{id} -> the Replay, as a Hash.
96
+ def get_replay(id)
97
+ json(request("GET", "/v1/replays/#{replay_path(id)}"))
98
+ end
99
+
100
+ # GET /v1/replays -> {"replays" => [Replay, ...]}, newest first.
101
+ def list_replays
102
+ json(request("GET", "/v1/replays"))
103
+ end
104
+
105
+ # PATCH /v1/replays/{id} -> the updated Replay, as a Hash.
106
+ #
107
+ # `patch` may carry `state` (`running`/`paused`/`cancelled`), `rate` and
108
+ # `max_lag_ms`.
109
+ def update_replay(id, patch)
110
+ json(request("PATCH", "/v1/replays/#{replay_path(id)}", json: patch))
90
111
  end
91
112
 
92
113
  # GET /v1/quarantine -> recent quarantined hooks, newest first.
@@ -99,6 +120,12 @@ module Ankusa
99
120
 
100
121
  private
101
122
 
123
+ # Percent-encodes a replay id as one path segment, so a `/`, `?` or `#` in
124
+ # an id can't reshape the URL.
125
+ def replay_path(id)
126
+ id.to_s.b.gsub(/[^A-Za-z0-9_.~-]/) { |char| format("%%%02X", char.ord) }
127
+ end
128
+
102
129
  def request(http_method, path, query: nil, json: nil)
103
130
  response = @connection.request(http_method, path, query: query, json: json)
104
131
  raise_for_status(response)
@@ -11,8 +11,9 @@ module Ankusa
11
11
  # right.
12
12
  #
13
13
  # Non-retryable: the ref or expected sha256 is malformed, the gateway said
14
- # 404/other 4xx, or the bytes that came back don't match the sha256.
15
- # Retryable: the gateway said 5xx/503, or the request never completed
14
+ # 404 or another 4xx (except 408/429), or the bytes that came back don't
15
+ # match the sha256.
16
+ # Retryable: the gateway said 5xx, 408 or 429, or the request never completed
16
17
  # (network error, timeout).
17
18
  class ClaimCheckError < Error
18
19
  def retryable? = false
@@ -28,7 +29,7 @@ module Ankusa
28
29
  class ClaimNotFoundError < ClaimCheckError
29
30
  end
30
31
 
31
- # The gateway rejected the request (400 or any other non-404 4xx).
32
+ # The gateway rejected the request (400 or any other 4xx but 404, 408 and 429).
32
33
  class ClaimRejectedError < ClaimCheckError
33
34
  attr_reader :status, :body
34
35
 
@@ -46,7 +47,7 @@ module Ankusa
46
47
  class ClaimIntegrityError < ClaimCheckError
47
48
  end
48
49
 
49
- # The gateway is unreachable, or answered 5xx/503. Safe to retry.
50
+ # The gateway is unreachable, or answered 5xx, 408 or 429. Safe to retry.
50
51
  class ClaimCheckUnavailableError < ClaimCheckError
51
52
  def retryable? = true
52
53
  end
@@ -133,6 +134,13 @@ module Ankusa
133
134
  raise ClaimNotFoundError, "claim not found: #{parsed.tenant_id}/#{parsed.claim_id}"
134
135
  end
135
136
 
137
+ # A gateway (or a proxy in front of it) that is throttling or timing out
138
+ # is telling the caller to come back, not that the claim is gone.
139
+ if [408, 429].include?(status)
140
+ raise ClaimCheckUnavailableError,
141
+ "claim-check gateway busy (#{status}): #{Connection.error_body(response).inspect}"
142
+ end
143
+
136
144
  if status >= 400 && status < 500
137
145
  body = Connection.error_body(response)
138
146
  raise ClaimRejectedError.new(
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module Ankusa
7
+ # Raised by `Ankusa.decode_message` when a queue message fails one of the v1
8
+ # decode rules. Always worth dead-lettering, never worth retrying: the bytes
9
+ # will not change on a redelivery.
10
+ #
11
+ # `code` names the rule that failed (`invalid_json`, `unsupported_version`,
12
+ # `invalid_field`, `ambiguous_body`, `missing_body`, `invalid_body_base64`,
13
+ # `size_mismatch`, `integrity`, `tenant_mismatch`); `field` names the offending
14
+ # key when the rule is about one, and is nil otherwise.
15
+ class InvalidMessageError < Error
16
+ attr_reader :code, :field
17
+
18
+ def initialize(message, code:, field: nil)
19
+ super(message)
20
+ @code = code
21
+ @field = field
22
+ end
23
+
24
+ def retryable? = false
25
+ end
26
+
27
+ # The one idempotency-key rule, shared by `Message` and `HookHeaders` so it
28
+ # lives in one place.
29
+ #
30
+ # The key Ankusa shipped (`shipped`) when it is a non-empty string; otherwise,
31
+ # for a hook from a node that predates the field, `tenant:source:dedupe_key`
32
+ # (tenant "default" when nil) for a non-empty `dedupe_key`, else `id`. With
33
+ # `include_replay` and a `replay_id`, `#replay:<replay_id>` is appended.
34
+ module IdempotencyKey
35
+ def self.build(shipped:, tenant:, source:, dedupe_key:, id:, replay_id:, include_replay:)
36
+ key =
37
+ if !shipped.nil? && !shipped.empty?
38
+ shipped
39
+ elsif !dedupe_key.nil? && !dedupe_key.empty?
40
+ "#{tenant || "default"}:#{source}:#{dedupe_key}"
41
+ else
42
+ id
43
+ end
44
+ key += "#replay:#{replay_id}" if include_replay && !replay_id.nil?
45
+ key
46
+ end
47
+ end
48
+ private_constant :IdempotencyKey
49
+
50
+ # One decoded v1 queue message. `body` holds the inline bytes (or nil for the
51
+ # claim form, where the bytes are fetched with `Ankusa::ClaimCheckClient`);
52
+ # `claim` holds the ref string in that case. `headers` is the forwarded
53
+ # provider request headers, `{}` when the message carried none.
54
+ #
55
+ # `idempotency_key` is the one value a consumer should dedupe on, as
56
+ # `Message#idempotency_key` (the field is nil only for a message from a node
57
+ # that predates it); see `Ankusa.decode_message` for the decode rules.
58
+ Message = Data.define(
59
+ :v, :id, :source_id, :tenant_id, :received_at, :content_type, :size,
60
+ :body, :claim, :sha256, :dedupe_key, :replay_id, :idempotency_key, :headers
61
+ ) do
62
+ # `Data.define` made `idempotency_key` the reader of the decoded field; keep
63
+ # that value reachable here and let the helper below take over the name.
64
+ # The decoded field itself is still in `to_h[:idempotency_key]`.
65
+ alias_method :shipped_idempotency_key, :idempotency_key
66
+ private :shipped_idempotency_key
67
+
68
+ # The idempotency key for this message: the key Ankusa shipped in the
69
+ # `idempotency_key` field when it is non-empty; for a message that predates
70
+ # the field, `tenant:source_id:dedupe_key` (tenant "default" when there is
71
+ # none) when a non-empty `dedupe_key` is set, else `id`.
72
+ #
73
+ # A replay keeps the original `id` and `dedupe_key` and adds `replay_id`, so
74
+ # by default a replay produces the same key as the delivery it replays and a
75
+ # consumer that already processed it drops it. Pass `include_replay: true`
76
+ # to reprocess replays instead.
77
+ def idempotency_key(include_replay: false)
78
+ IdempotencyKey.build(
79
+ shipped: shipped_idempotency_key, tenant: tenant_id, source: source_id,
80
+ dedupe_key: dedupe_key, id: id, replay_id: replay_id, include_replay: include_replay
81
+ )
82
+ end
83
+ end
84
+
85
+ # The optional string-keyed fields that may be a string, null, or absent.
86
+ # Checked in this order, so the first offending key is the one reported.
87
+ MESSAGE_STRING_FIELDS = %w[tenant_id content_type dedupe_key replay_id idempotency_key].freeze
88
+ private_constant :MESSAGE_STRING_FIELDS
89
+
90
+ # Decodes one v1 queue message (the JSON body a sink or broker delivers) into
91
+ # a `Message`, applying every rule in "Consuming queue messages" of the SDK
92
+ # README in order; the first failure wins.
93
+ #
94
+ # Raises `InvalidMessageError` for anything malformed. The `sha256` is checked
95
+ # against the inline bytes here, but a claim's bytes are only checked by
96
+ # `ClaimCheckClient#redeem`, after it fetches them.
97
+ def self.decode_message(data)
98
+ parsed =
99
+ begin
100
+ JSON.parse(data)
101
+ rescue JSON::ParserError
102
+ raise InvalidMessageError.new("message is not JSON", code: "invalid_json")
103
+ end
104
+
105
+ unless parsed.is_a?(Hash)
106
+ raise InvalidMessageError.new("message is not a JSON object", code: "not_an_object")
107
+ end
108
+
109
+ version = parsed["v"]
110
+ unless version.is_a?(Integer) && version == 1
111
+ raise InvalidMessageError.new("unsupported message version: #{version.inspect}", code: "unsupported_version")
112
+ end
113
+
114
+ id = parsed["id"]
115
+ invalid_field("id") unless id.is_a?(String) && !id.empty?
116
+
117
+ source_id = parsed["source_id"]
118
+ invalid_field("source_id") unless source_id.is_a?(String)
119
+
120
+ received_at = parsed["received_at"]
121
+ invalid_field("received_at") unless received_at.is_a?(Integer)
122
+
123
+ size = parsed["size"]
124
+ invalid_field("size") unless size.is_a?(Integer) && size >= 0
125
+
126
+ MESSAGE_STRING_FIELDS.each do |field|
127
+ value = parsed[field]
128
+ invalid_field(field) unless value.nil? || value.is_a?(String)
129
+ end
130
+
131
+ headers = parsed.key?("headers") ? parsed["headers"] : {}
132
+ invalid_field("headers") unless headers.is_a?(Hash) && headers.each_value.all? { |value| value.is_a?(String) }
133
+
134
+ sha256 = parsed["sha256"]
135
+ valid_sha256 = sha256.nil? || (sha256.is_a?(String) && SHA256_PATTERN.match?(sha256))
136
+ invalid_field("sha256") unless valid_sha256
137
+
138
+ body_base64 = parsed["body_base64"]
139
+ claim = parsed["claim"]
140
+
141
+ unless body_base64.nil? || claim.nil?
142
+ raise InvalidMessageError.new("message has both body_base64 and claim", code: "ambiguous_body")
143
+ end
144
+
145
+ if body_base64.nil? && claim.nil?
146
+ raise InvalidMessageError.new("message has neither body_base64 nor claim", code: "missing_body")
147
+ end
148
+
149
+ body = nil
150
+ parsed_claim = nil
151
+
152
+ if body_base64.nil?
153
+ parsed_claim = parse_claim(claim)
154
+ invalid_field("sha256") if sha256.nil?
155
+ else
156
+ body = decode_body(body_base64)
157
+ if body.bytesize != size
158
+ raise InvalidMessageError.new("body is #{body.bytesize} bytes, size says #{size}", code: "size_mismatch")
159
+ end
160
+
161
+ if !sha256.nil? && Digest::SHA256.hexdigest(body) != sha256
162
+ raise InvalidMessageError.new("body sha256 does not match", code: "integrity")
163
+ end
164
+ end
165
+
166
+ tenant_id = parsed["tenant_id"]
167
+ if !parsed_claim.nil? && !tenant_id.nil? && parsed_claim.tenant_id != tenant_id
168
+ raise InvalidMessageError.new(
169
+ "claim tenant #{parsed_claim.tenant_id} does not match tenant_id #{tenant_id}",
170
+ code: "tenant_mismatch"
171
+ )
172
+ end
173
+
174
+ Message.new(
175
+ v: 1,
176
+ id: id,
177
+ source_id: source_id,
178
+ tenant_id: tenant_id,
179
+ received_at: received_at,
180
+ content_type: parsed["content_type"],
181
+ size: size,
182
+ body: body,
183
+ claim: claim,
184
+ sha256: sha256,
185
+ dedupe_key: parsed["dedupe_key"],
186
+ replay_id: parsed["replay_id"],
187
+ idempotency_key: parsed["idempotency_key"],
188
+ headers: headers
189
+ )
190
+ end
191
+
192
+ def self.invalid_field(field)
193
+ raise InvalidMessageError.new("invalid message field: #{field}", code: "invalid_field", field: field)
194
+ end
195
+ private_class_method :invalid_field
196
+
197
+ def self.decode_body(body_base64)
198
+ unless body_base64.is_a?(String)
199
+ raise InvalidMessageError.new("body_base64 is not a string", code: "invalid_body_base64")
200
+ end
201
+
202
+ body_base64.unpack1("m0")
203
+ rescue ArgumentError
204
+ raise InvalidMessageError.new("body_base64 is not valid base64", code: "invalid_body_base64")
205
+ end
206
+ private_class_method :decode_body
207
+
208
+ # A claim that doesn't parse is an invalid *field*, not an invalid claim-ref
209
+ # error: the message itself is what a consumer rejects, so it carries the same
210
+ # `invalid_field` code as every other bad key.
211
+ def self.parse_claim(claim)
212
+ Ankusa.parse_claim_ref(claim)
213
+ rescue InvalidClaimRefError
214
+ invalid_field("claim")
215
+ end
216
+ private_class_method :parse_claim
217
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ankusa
4
4
  module SDK
5
- VERSION = "0.3.0"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
  end
data/lib/ankusa/sdk.rb CHANGED
@@ -5,7 +5,9 @@ require_relative "error"
5
5
  require_relative "transport"
6
6
  require_relative "connection"
7
7
  require_relative "webhook"
8
+ require_relative "signature"
8
9
  require_relative "claim_check"
10
+ require_relative "message"
9
11
  require_relative "routes"
10
12
  require_relative "admin"
11
13
  require_relative "sources"
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Ankusa
6
+ # A delivery whose Standard Webhooks signature does not verify. Never
7
+ # retryable: answer `401`; the sender retries with the same bytes.
8
+ #
9
+ # `code` is one of `invalid_secret`, `missing_header`, `invalid_timestamp`,
10
+ # `timestamp_out_of_tolerance`, `no_matching_signature`; `field` names the
11
+ # header at fault (nil for `invalid_secret`).
12
+ class InvalidSignatureError < Error
13
+ attr_reader :code, :field
14
+
15
+ def initialize(message, code:, field: nil)
16
+ super(message)
17
+ @code = code
18
+ @field = field
19
+ end
20
+
21
+ def retryable? = false
22
+ end
23
+
24
+ # A verified delivery's `webhook-id` and `webhook-timestamp`.
25
+ VerifiedSignature = Data.define(:id, :timestamp)
26
+
27
+ BASE64_PATTERN = %r{\A(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?\z}
28
+ private_constant :BASE64_PATTERN
29
+
30
+ # Verifies the Standard Webhooks signature an HTTP sink with a `secret` adds
31
+ # (https://www.standardwebhooks.com/).
32
+ #
33
+ # `webhook-signature` holds space-separated `v1,<base64>` entries, each an
34
+ # HMAC-SHA256 over `<webhook-id>.<webhook-timestamp>.<body>`. Any `v1` entry
35
+ # matching any secret (`whsec_` + base64, or any other string used as its own
36
+ # bytes) passes, compared in constant time, provided `webhook-timestamp` is
37
+ # within `tolerance_seconds` of `now` (the clock by default). `body` must be
38
+ # the raw bytes received. Raises `InvalidSignatureError`.
39
+ def self.verify_signature(headers, body, secrets, tolerance_seconds: 300, now: nil)
40
+ keys = signature_keys(Array(secrets))
41
+ lowered = headers.to_h { |key, value| [key.to_s.downcase, value] }
42
+
43
+ required = lambda do |name|
44
+ value = lowered[name]
45
+ raise InvalidSignatureError.new("missing #{name} header", code: "missing_header", field: name) if value.nil? || value.empty?
46
+
47
+ value
48
+ end
49
+
50
+ hook_id = required.call("webhook-id")
51
+ raw_timestamp = required.call("webhook-timestamp")
52
+ signature = required.call("webhook-signature")
53
+
54
+ unless raw_timestamp.match?(/\A[0-9]+\z/)
55
+ raise InvalidSignatureError.new("webhook-timestamp is not a unix time",
56
+ code: "invalid_timestamp", field: "webhook-timestamp")
57
+ end
58
+
59
+ timestamp = Integer(raw_timestamp, 10)
60
+ current = now || Time.now.to_i
61
+ if (current - timestamp).abs > tolerance_seconds
62
+ raise InvalidSignatureError.new("webhook-timestamp is outside the tolerance window",
63
+ code: "timestamp_out_of_tolerance", field: "webhook-timestamp")
64
+ end
65
+
66
+ signed = "#{hook_id}.#{raw_timestamp}.".b + body.to_s.b
67
+ candidates = signature.split(" ").filter_map { |entry| entry.delete_prefix("v1,") if entry.start_with?("v1,") }
68
+
69
+ keys.each do |key|
70
+ expected = [OpenSSL::HMAC.digest("SHA256", key, signed)].pack("m0")
71
+ candidates.each do |candidate|
72
+ next unless candidate.bytesize == expected.bytesize
73
+
74
+ return VerifiedSignature.new(id: hook_id, timestamp: timestamp) if OpenSSL.fixed_length_secure_compare(candidate, expected)
75
+ end
76
+ end
77
+
78
+ raise InvalidSignatureError.new("no webhook-signature entry matches",
79
+ code: "no_matching_signature", field: "webhook-signature")
80
+ end
81
+
82
+ def self.signature_keys(secrets)
83
+ raise InvalidSignatureError.new("no secret configured", code: "invalid_secret") if secrets.empty?
84
+
85
+ secrets.map do |secret|
86
+ if secret.start_with?("whsec_")
87
+ encoded = secret.delete_prefix("whsec_")
88
+ unless !encoded.empty? && BASE64_PATTERN.match?(encoded)
89
+ raise InvalidSignatureError.new("a whsec_ secret is not valid base64", code: "invalid_secret")
90
+ end
91
+
92
+ encoded.unpack1("m0")
93
+ elsif secret.empty?
94
+ raise InvalidSignatureError.new("an empty secret", code: "invalid_secret")
95
+ else
96
+ secret.b
97
+ end
98
+ end
99
+ end
100
+ private_class_method :signature_keys
101
+ end
@@ -21,7 +21,9 @@ module Ankusa
21
21
  class SourceNotFoundError < SourcesError
22
22
  end
23
23
 
24
- # 409 source_exists: a source with that name already exists.
24
+ # 409 source_exists: a source with that name already exists; or
25
+ # 409 source_has_deliveries: a delete found the source still has undelivered
26
+ # hooks. The body's "error" says which.
25
27
  class SourceConflictError < SourcesError
26
28
  end
27
29
 
@@ -169,7 +171,10 @@ module Ankusa
169
171
  # Delete a source: DELETE /v1/tenants/<tenant>/sources/<name>.
170
172
  #
171
173
  # Succeeds with no return value (the server answers 204 with an empty body);
172
- # a missing source raises `SourceNotFoundError`.
174
+ # a missing source raises `SourceNotFoundError`. A source that still has
175
+ # undelivered hooks on the server's node raises `SourceConflictError` whose
176
+ # body's "error" is "source_has_deliveries" (with "pending"/"inflight"
177
+ # counts); this method never sends the admin API's ?deliveries=dead_letter.
173
178
  def delete_source(tenant, name)
174
179
  validate_tenant(tenant)
175
180
  validate_name(name)
@@ -4,9 +4,36 @@ module Ankusa
4
4
  # The identity of one HTTP-sink delivery: the headers Ankusa's HTTP sink
5
5
  # attaches to every delivery.
6
6
  #
7
- # `content_type` and `tenant` are nil when the delivery had no such header
8
- # (a tenant only travels when the source has one).
9
- HookHeaders = Data.define(:id, :source, :tenant, :content_type)
7
+ # `content_type`, `tenant`, `dedupe_key`, `replay_id` and `idempotency_key` are
8
+ # nil when the delivery had no such header (a tenant only travels when the
9
+ # source has one; `dedupe_key` only when the source declared a dedupe key;
10
+ # `replay_id` only when the delivery is a replay; `idempotency_key` is
11
+ # missing only from a sender that predates the header).
12
+ HookHeaders = Data.define(
13
+ :id, :source, :tenant, :content_type, :dedupe_key, :replay_id, :idempotency_key
14
+ ) do
15
+ # `Data.define` made `idempotency_key` the reader of the parsed header; keep
16
+ # that value reachable here and let the helper below take over the name.
17
+ # The parsed header itself is still in `to_h[:idempotency_key]`.
18
+ alias_method :shipped_idempotency_key, :idempotency_key
19
+ private :shipped_idempotency_key
20
+
21
+ # The idempotency key for this delivery: the key Ankusa shipped in
22
+ # `x-ankusa-idempotency-key` when it is non-empty; for a delivery that
23
+ # predates the header, `tenant:source:dedupe_key` (tenant "default" when
24
+ # there is none) when a non-empty `dedupe_key` is set, else `id`.
25
+ #
26
+ # A replay keeps the original `id` and `dedupe_key` and adds `replay_id`, so
27
+ # by default a replay produces the same key as the delivery it replays and a
28
+ # receiver that already processed it drops it. Pass `include_replay: true`
29
+ # to reprocess replays instead.
30
+ def idempotency_key(include_replay: false)
31
+ IdempotencyKey.build(
32
+ shipped: shipped_idempotency_key, tenant: tenant, source: source,
33
+ dedupe_key: dedupe_key, id: id, replay_id: replay_id, include_replay: include_replay
34
+ )
35
+ end
36
+ end
10
37
 
11
38
  # Raised by `Ankusa.parse_headers` when `x-ankusa-id` is absent or empty.
12
39
  #
@@ -32,7 +59,17 @@ module Ankusa
32
59
  id: hook_id,
33
60
  source: lowered.fetch("x-ankusa-source", ""),
34
61
  tenant: lowered["x-ankusa-tenant"],
35
- content_type: lowered["content-type"]
62
+ content_type: lowered["content-type"],
63
+ dedupe_key: presence(lowered["x-ankusa-dedupe-key"]),
64
+ replay_id: presence(lowered["x-ankusa-replay-id"]),
65
+ idempotency_key: presence(lowered["x-ankusa-idempotency-key"])
36
66
  )
37
67
  end
68
+
69
+ # An empty header counts as absent: Ankusa only sends these when it has a
70
+ # value, and a proxy that rewrites a header to "" should not fabricate one.
71
+ def self.presence(value)
72
+ (value.nil? || value.empty?) ? nil : value
73
+ end
74
+ private_class_method :presence
38
75
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ankusa-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - James Carr
@@ -24,9 +24,11 @@ files:
24
24
  - lib/ankusa/claim_check.rb
25
25
  - lib/ankusa/connection.rb
26
26
  - lib/ankusa/error.rb
27
+ - lib/ankusa/message.rb
27
28
  - lib/ankusa/routes.rb
28
29
  - lib/ankusa/sdk.rb
29
30
  - lib/ankusa/sdk/version.rb
31
+ - lib/ankusa/signature.rb
30
32
  - lib/ankusa/sources.rb
31
33
  - lib/ankusa/transport.rb
32
34
  - lib/ankusa/webhook.rb