ankusa-sdk 0.3.0 → 0.6.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: 3dbf23058e4a0799746070c44c18c750554e2298a2ae1dbe149d25bc9f9f4b22
4
+ data.tar.gz: 3aeaae91dbb2d4530678e9da87a365aafc5df73ab684047066aefae45b802ce6
5
5
  SHA512:
6
- metadata.gz: 25f553755bd7f33506aba3992f103c69a82e3e1fa11ffd81909f4a4e4df8fb5a649d0eaa658ec285dffac01dfb6f14b3d057257d8f55ec7b6d667c54b51856b6
7
- data.tar.gz: bf270c6e0dcec34696106eaf96cb320004094126f8390e42ff84cd065d874c26b01fc653462aae47eb3960b4ab5a174f49d0e2bbae9fb7f339e4acf1f0956302
6
+ metadata.gz: 78397c0e96fc065f7a4d30d0bcf533b07d86dec71d9c185d55fdd415f0ee5b1373e83e9af43bcc70ddb89b5a71d48970bfd38954e5cc2afce0ddcf0e06a0fd90
7
+ data.tar.gz: 0d0cc364cd091f83daa7425bd1df37e7a41b1cadaefc00b00776a193dbef97a3d5ea527f15ba80a01a5f97bca77a37d7c6c5162bca9849f8cbd247ad1135a3b9
data/CHANGELOG.md CHANGED
@@ -6,6 +6,90 @@ this project follows [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.6.0] - 2026-10-28
10
+
11
+ ### Added
12
+
13
+ - `Message#traceparent` and `Message#tracestate`: the hook's W3C trace context
14
+ from the message, verbatim (`nil` when none); a non-string value is
15
+ `invalid_field`.
16
+
17
+ - `verification` on parsed headers, decoded messages and received deliveries:
18
+ how Ankusa verified the hook at ingest (`{status, flagged}`, `null` from a
19
+ node older than the field).
20
+ - `Ankusa.parse_claim_ref` and `ClaimCheckClient#redeem` accept signed,
21
+ expiring v2 refs
22
+ (`urn:ankusa:claim:v2:<tenant>:<claim_id>:<exp_unix>:<signature>`): the
23
+ parsed `path` carries `?exp=&sig=` and `redeem` requests it as is. The
24
+ gateway's `400 bad_signature` and `410 ref_expired` are a
25
+ `ClaimRejectedError`. The SDK never checks the signature or the expiry.
26
+ - `Ankusa.receive_delivery(headers, body, secrets: [], allow_unsigned: false,
27
+ tolerance_seconds: 300, now: nil)`, the receiver-side decision on one
28
+ HTTP-sink delivery. The sink signs every delivery unless configured
29
+ `allow_unsigned: true`, so a receiver is closed by default: with `secrets`
30
+ the delivery must verify (as `verify_signature`); with none and no
31
+ `allow_unsigned: true` it raises `InvalidSignatureError` (`invalid_secret`).
32
+ The signature is judged before the hook id. Returns a `ReceivedDelivery`
33
+ (`headers`, `verified`).
34
+ - `scope` on `AdminRejectedError` and `RoutesRejectedError`: the scope a `403
35
+ forbidden` says the credential lacks (control planes require a credential
36
+ since Ankusa 0.6).
37
+ - `Admin#list_audit(**params)`: `GET /v1/audit`, the audit trail (newest first,
38
+ paged by `cursor`/`limit`, filtered by `since`, `until`, `actor`, `action`,
39
+ `tenant`).
40
+ - **Delivery metadata (CON-4).** `HookHeaders#received_at` (when the edge accepted the hook, unix milliseconds, from `x-ankusa-received-at`) and `HookHeaders#body_sha256` (the lowercase hex SHA-256 of the body sent, from `x-ankusa-body-sha256`): nil when the header is absent, empty or malformed, never an error.
41
+
42
+ - **Wire-format evolution path (CON-2).** `Ankusa.decode_message` accepts every
43
+ `v` from `Ankusa::MIN_SUPPORTED_VERSION` to `Ankusa::MAX_SUPPORTED_VERSION`
44
+ (1 and 2) into the one `Message`, with `v` as the wire said it: a `v: 2`
45
+ message is a `v: 1` message plus additive fields, so the keys it does not know
46
+ are ignored. `Message#instance` and `Message#region`: the name of the Ankusa
47
+ instance that published the message and the region it is configured with (nil
48
+ when null or absent). `Ankusa.decode_lifecycle` and `Ankusa::LifecycleEvent`
49
+ decode the CloudEvents lifecycle events of the instance's lifecycle sinks.
50
+
51
+ - `Message#trusted?`, `HookHeaders#trusted?` and `Verification#trusted?`: true
52
+ only when the node verified the hook at ingest (status `"ok"`) and did not
53
+ flag it. A flagged, `"failed"` or `"skipped"` hook and one with no
54
+ `verification` are untrusted.
55
+
56
+ ## [0.5.0] - 2026-10-14
57
+
58
+ ### Added
59
+
60
+ - `Ankusa.verify_signature(headers, body, secrets, tolerance_seconds: 300, now: nil)`
61
+ verifies the Standard Webhooks signature an HTTP sink with a `secret` adds,
62
+ comparing with `OpenSSL.fixed_length_secure_compare`; failures raise
63
+ `Ankusa::InvalidSignatureError` (`code`, `field`, never retryable).
64
+ - `Ankusa::Message` and `Ankusa.decode_message(data)`, plus
65
+ `Ankusa::InvalidMessageError` (with `code`, `field` and `retryable?` false):
66
+ decode and validate the v1 queue message every sink delivers — the inline
67
+ (`body_base64`) and claim-check (`claim`) body forms, `sha256`,
68
+ `dedupe_key`, `replay_id`, and the forwarded `headers`, ignoring unknown
69
+ keys.
70
+ - `Message#idempotency_key` / `HookHeaders#idempotency_key`, the key to store
71
+ in a processed-ids table: the key Ankusa shipped (the message's
72
+ `idempotency_key` field, or the `x-ankusa-idempotency-key` header), and for a
73
+ message or delivery that predates it `tenant:source_id:dedupe_key` (tenant
74
+ `default` when there is none) when a `dedupe_key` is set, else `id`, with an
75
+ optional `#replay:<replay_id>` suffix (`include_replay: true`). The decoded
76
+ field stays in `to_h[:idempotency_key]`.
77
+ - `HookHeaders#dedupe_key` / `#replay_id` / `#idempotency_key`'s field, parsed
78
+ from `x-ankusa-dedupe-key` / `x-ankusa-replay-id` / `x-ankusa-idempotency-key`.
79
+ - `AdminClient#create_replay`, `#get_replay`, `#list_replays` and
80
+ `#update_replay` for the `POST/GET/PATCH /v1/replays` replay-job API (kinds
81
+ `dlq`, `archive`, `quarantine`).
82
+
83
+ ### Changed
84
+
85
+ - `ClaimCheckClient#redeem` raises `ClaimCheckUnavailableError` (retryable)
86
+ for a gateway `408` or `429` instead of `ClaimRejectedError`.
87
+
88
+ ### Removed
89
+
90
+ - `AdminClient#replay_dead_letters`, replaced by the replay-job API above.
91
+ `POST /v1/dlq/replay` no longer exists.
92
+
9
93
  ## [0.3.0] - 2026-10-01
10
94
 
11
95
  ### Added
@@ -37,5 +121,7 @@ this project follows [Semantic Versioning](https://semver.org/).
37
121
  family, which carries `.status` and `.body` instead), so a consumer needs
38
122
  exactly one bit to route a failure to dead-letter or retry.
39
123
 
40
- [Unreleased]: https://github.com/jamescarr/ankusa/compare/sdk-ruby-v0.3.0...HEAD
124
+ [Unreleased]: https://github.com/jamescarr/ankusa/compare/sdk-ruby-v0.6.0...HEAD
125
+ [0.6.0]: https://github.com/jamescarr/ankusa/compare/sdk-ruby-v0.5.0...sdk-ruby-v0.6.0
126
+ [0.5.0]: https://github.com/jamescarr/ankusa/compare/sdk-ruby-v0.3.0...sdk-ruby-v0.5.0
41
127
  [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
 
@@ -26,14 +26,6 @@ then, in code:
26
26
  require "ankusa/sdk"
27
27
  ```
28
28
 
29
- Before the first RubyGems release, depend on it as a path gem the same way the
30
- Elixir packages in this monorepo depend on `ankusa` core before their first Hex
31
- release:
32
-
33
- ```ruby
34
- gem "ankusa-sdk", path: "packages/sdk-ruby"
35
- ```
36
-
37
29
  Everything the SDK exposes lives under `Ankusa::`.
38
30
 
39
31
  ## Claim-check client
@@ -81,9 +73,9 @@ consumer needs exactly one bit to decide dead-letter vs. retry:
81
73
  | --- | --- | --- |
82
74
  | `InvalidClaimRefError` | `false` | `ref` isn't a well-formed claim-check URN, or `sha256` isn't 64-char lowercase hex |
83
75
  | `ClaimNotFoundError` | `false` | gateway `404`: expired by retention, or never written |
84
- | `ClaimRejectedError` | `false` | gateway `4xx` other than `404` (`.status`, `.body`) |
76
+ | `ClaimRejectedError` | `false` | gateway `4xx` other than `404`, `408`, `429` (`.status`, `.body`) |
85
77
  | `ClaimIntegrityError` | `false` | sha256 of the returned bytes doesn't match |
86
- | `ClaimCheckUnavailableError` | `true` | gateway `5xx`/`503`, or unreachable |
78
+ | `ClaimCheckUnavailableError` | `true` | gateway `5xx`, `408`, `429`, or unreachable |
87
79
 
88
80
  `health` hits `GET /health` for a liveness probe. There's no `close`: the
89
81
  default transport opens one connection per request.
@@ -124,11 +116,149 @@ def call(env)
124
116
  end
125
117
  ```
126
118
 
127
- `HookHeaders#id` is what a receiver dedupes on: delivery is at-least-once (see
119
+ `HookHeaders#idempotency_key` is what a receiver dedupes on, not the hook id
120
+ (`x-ankusa-id` stays required): delivery is at-least-once (see
128
121
  "HTTP handoff" in
129
122
  [`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.
123
+ so the same hook can arrive twice after a retry. When the source extracted the
124
+ provider's own event key, `HookHeaders#dedupe_key` carries it (and `replay_id`
125
+ marks a replay); `HookHeaders#idempotency_key` returns the tenant-scoped key
126
+ Ankusa computed and shipped in `x-ankusa-idempotency-key` — see "Consuming
127
+ queue messages". Header lookup is always case-insensitive, regardless of
128
+ whether the mapping passed in already is.
129
+
130
+ ### Verifying signed deliveries
131
+
132
+ An HTTP sink with a `secret` signs every delivery the
133
+ [Standard Webhooks](https://www.standardwebhooks.com/) way:
134
+
135
+ ```ruby
136
+ # Inside a Rack app's `call(env)`, before anything parses the body:
137
+ begin
138
+ body = env["rack.input"].read # the raw request body, exactly as received
139
+ Ankusa.verify_signature(header_map(env), body, [ENV.fetch("ANKUSA_WHSEC")])
140
+ rescue Ankusa::InvalidSignatureError => e
141
+ return [401, {"content-type" => "application/json"},
142
+ [JSON.generate(error: "invalid_signature", code: e.code)]]
143
+ end
144
+ ```
145
+
146
+ Secrets are `whsec_` + base64, or any other string used as its own bytes;
147
+ pass several during a rotation. `tolerance_seconds:` (default 300) bounds the
148
+ `webhook-timestamp` window. Failures are `Ankusa::InvalidSignatureError` with
149
+ `code` and `field`, never retryable.
150
+
151
+ ### Receiving a delivery (signed by default)
152
+
153
+ Ankusa's HTTP sink signs every delivery unless it is configured
154
+ `allow_unsigned: true`, so a receiver is closed by default.
155
+ `Ankusa.receive_delivery` is the one call that decides: with the sink's
156
+ `secrets:` it verifies as above; with none, and no `allow_unsigned: true`, it
157
+ refuses every delivery (`Ankusa::InvalidSignatureError`, code
158
+ `invalid_secret`) — otherwise any host that can reach your URL could inject
159
+ hooks. A configured secret is never weakened by `allow_unsigned:`. The
160
+ signature is judged before the hook id, so a delivery that fails it is a `401`
161
+ even when it also lacks `x-ankusa-id` (`Ankusa::MissingHookIdError`, `400`).
162
+
163
+ ```ruby
164
+ begin
165
+ received = Ankusa.receive_delivery(header_map(env), body, secrets: [ENV.fetch("ANKUSA_WHSEC")])
166
+ # received.headers is the HookHeaders; received.verified is false only for an
167
+ # unsigned delivery `allow_unsigned: true` let through.
168
+ rescue Ankusa::InvalidSignatureError => e
169
+ return [401, {"content-type" => "application/json"},
170
+ [JSON.generate(error: "invalid_signature", code: e.code)]]
171
+ rescue Ankusa::MissingHookIdError
172
+ return [400, {"content-type" => "application/json"},
173
+ [JSON.generate(error: "missing x-ankusa-id")]]
174
+ end
175
+ ```
176
+
177
+ `hook.verification` (also on `Ankusa.parse_headers` and `Ankusa.decode_message`
178
+ results) says how the *node* verified the hook at ingest, `nil` from a node
179
+ older than the field. `hook.verification&.flagged == true` means the node
180
+ accepted a failed check (`on_verify_failure: accept_flag`) — treat it as
181
+ unverified. `hook.trusted?` (on messages and parsed headers) is that decision
182
+ in one call: true only for status `"ok"` with `flagged` false, so a flagged,
183
+ `"failed"` or `"skipped"` hook, or one from a node older than the field, is
184
+ untrusted.
185
+
186
+ ## Consuming queue messages
187
+
188
+ The message's `v` is the wire format's major version: `Ankusa.decode_message`
189
+ accepts every `v` from `Ankusa::MIN_SUPPORTED_VERSION` to
190
+ `Ankusa::MAX_SUPPORTED_VERSION` (1 and 2) into the one `Message`, with `v` as
191
+ the wire said it. A `v: 2` message is a `v: 1` message plus additive fields, so
192
+ a consumer reads it the same way; any other `v` is `unsupported_version`
193
+ (permanent: dead-letter it). `message.instance` and `message.region` are the
194
+ Ankusa instance that published the message and the region it is configured with
195
+ (nil for none). A message from the instance's lifecycle sinks carries a
196
+ CloudEvents event as its body: `Ankusa.decode_lifecycle(body)` returns an
197
+ `Ankusa::LifecycleEvent`.
198
+
199
+ Every message sink — RabbitMQ, Kafka, NATS, Redis — delivers one JSON message
200
+ per hook (the HTTP sink sends the original body plus `x-ankusa-*` headers
201
+ instead):
202
+ the identity fields, the body (inline `body_base64` or a claim-check `claim`),
203
+ `sha256`, and, when present, `dedupe_key`, `replay_id`, `idempotency_key` and
204
+ the forwarded provider `headers`. `Ankusa.decode_message` validates all of it
205
+ and `Message#idempotency_key` gives the value to store in a processed-ids
206
+ table:
207
+
208
+ ```ruby
209
+ require "ankusa/sdk"
210
+
211
+ CLAIM_CHECK = Ankusa::ClaimCheckClient.new(ENV.fetch("CLAIM_CHECK_URL", "http://localhost:4001"))
212
+ DB = PG.connect(ENV.fetch("DATABASE_URL"))
213
+ DB.exec("CREATE TABLE IF NOT EXISTS processed (key text primary key)")
214
+
215
+ def consume(raw)
216
+ begin
217
+ message = Ankusa.decode_message(raw)
218
+ rescue Ankusa::InvalidMessageError => e
219
+ # Poison message: dead-letter, never requeue. `e.retryable?` is false;
220
+ # `e.code` is e.g. "integrity"; `e.field` names the offending key.
221
+ raise
222
+ end
223
+
224
+ key = message.idempotency_key # the key Ankusa shipped (tenant:source_id:dedupe_key, else id)
225
+ return if DB.exec_params("SELECT 1 FROM processed WHERE key = $1", [key]).any?
226
+
227
+ body = message.claim ? CLAIM_CHECK.redeem(message.claim, message.sha256) : message.body
228
+ handle(body)
229
+ DB.exec_params("INSERT INTO processed (key) VALUES ($1)", [key])
230
+ end
231
+ ```
232
+
233
+ - `idempotency_key` is the key Ankusa computed once for the hook:
234
+ `tenant:source_id:dedupe_key` when the source extracted the provider's own
235
+ event key, else `id` — so a provider retry that arrives with a fresh Ankusa
236
+ `id` still collapses onto the same row, and two tenants that share a provider
237
+ event id do not. For a message from a node that predates the field the
238
+ helper computes the same key itself (tenant `default` when there is none).
239
+ The decoded field alone is `message.to_h[:idempotency_key]`.
240
+ - A replayed delivery is dropped by default. To reprocess replays instead, pass
241
+ `include_replay: true`: the key then ends in `#replay:<replay_id>`.
242
+ - `message.body` is the decoded inline bytes (nil for the claim form);
243
+ `message.headers` holds the forwarded provider request headers (lowercased).
244
+ `message.sha256` is checked against the inline bytes by `decode_message`
245
+ itself, and against the claim's bytes by `CLAIM_CHECK.redeem`.
246
+ - `decode_message` raises `InvalidMessageError` before you act on a message:
247
+ bad JSON, a non-object, an unsupported `v`, a bad field type, both or neither
248
+ body form, invalid base64, a length/sha256 mismatch, or a claim whose tenant
249
+ differs from `tenant_id`.
250
+
251
+ A webhook receiver can take the same shortcut straight off the HTTP sink's
252
+ headers with `Ankusa.parse_headers(headers).idempotency_key`, which reads
253
+ `x-ankusa-idempotency-key` (falling back to computing it, where `source` and
254
+ `tenant` play `source_id` and `tenant_id`).
255
+
256
+ When a sink has grown a backlog, or a downstream processor failed after the
257
+ sink accepted a batch, re-drive it with a replay job over the admin client (see
258
+ "Admin client" below): `"kind" => "dlq"` re-sends rows that dead-lettered,
259
+ `"kind" => "archive"` re-sends hooks over a time window, `"kind" => "quarantine"`
260
+ re-verifies held hooks and releases those that pass. Replays keep the
261
+ original `id` and `dedupe_key` and add `replay_id`.
132
262
 
133
263
  ## Routes client
134
264
 
@@ -162,8 +292,9 @@ unfollowed redirect, a non-JSON success body, or unreachable; retryable).
162
292
  ## Admin client
163
293
 
164
294
  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`.
295
+ redacted config, the DLQ, the audit trail, replay jobs, and the quarantine
296
+ list — the `operations`, `dlq`, `audit`, `replays`, and `quarantine` tags of
297
+ `admin.v1.yaml`.
167
298
 
168
299
  ```ruby
169
300
  require "ankusa/sdk"
@@ -172,14 +303,27 @@ admin = Ankusa::AdminClient.new(ENV.fetch("ADMIN_URL", "http://localhost:4002"))
172
303
 
173
304
  admin.health # {"status" => "ok", "instance" => ..., "roles" => [...]}
174
305
  admin.list_dead_letters({"limit" => 10}) # {"total" => ..., "entries" => [...]}
175
- admin.replay_dead_letters({"source_id" => "demo"})
176
306
  admin.list_quarantined
307
+ admin.list_audit({"tenant" => "acme", "limit" => 50}) # {"entries" => [...], "next_cursor" => ...}
308
+
309
+ job = admin.create_replay({"kind" => "dlq", "source_id" => "stripe", "rate" => 500})
310
+ admin.get_replay(job["id"])
311
+ admin.list_replays # {"replays" => [Replay, ...]}, newest first
312
+ admin.update_replay(job["id"], {"state" => "paused"}) # resume, pause or cancel
177
313
  ```
178
314
 
315
+ `create_replay` is idempotent for retries: a second POST of the same filter
316
+ while the job is `running`/`paused` returns the existing job (`200`) instead of
317
+ starting another (`202`). `list_replays` returns `{"replays" => [...]}` exactly
318
+ as the API sends it. `list_audit` returns the audit trail newest first,
319
+ `{"entries" => [...], "next_cursor" => ...}`, paged by `cursor`/`limit` and
320
+ filtered by `since`, `until`, `actor`, `action` and `tenant`.
321
+
179
322
  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
323
+ `list_audit`, `create_replay`, `get_replay`, `list_replays`, `update_replay`,
324
+ `list_quarantined`. Failures are `AdminError` subclasses: `RoleNotEnabledError`
325
+ (409 `role_not_enabled`, carrying `role`), `AdminRejectedError` (any other 4xx —
326
+ a missing replay is a 404 with `code` `replay_not_found`), and
183
327
  `AdminUnavailableError` (5xx, an unfollowed redirect, or unreachable;
184
328
  retryable).
185
329
 
@@ -236,15 +380,16 @@ lib/ankusa/
236
380
  connection.rb # shared URL/query/body/JSON rules (@api private)
237
381
  webhook.rb # x-ankusa-* header parsing for HTTP-sink receivers
238
382
  claim_check.rb # the claim-check gateway client
383
+ message.rb # decode the v1 queue message + the idempotency key
239
384
  routes.rb # the route-management client (routes.admin.port)
240
385
  admin.rb # the operator client (admin.port)
241
386
  sources.rb # the tenant-scoped source-management client (admin.port)
242
387
  test/
243
388
  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
389
+ conformance_test.rb # runs the language-neutral vectors in conformance/
390
+ routes_test.rb
391
+ admin_test.rb
392
+ sources_test.rb
248
393
  ```
249
394
 
250
395
  ## Develop
data/lib/ankusa/admin.rb CHANGED
@@ -34,14 +34,16 @@ module Ankusa
34
34
  # The listener rejected the request (any other 4xx).
35
35
  #
36
36
  # `status` is the HTTP status; `code` is the body's `error` field
37
- # (`invalid_filter`, ...).
37
+ # (`invalid_filter`, ...). `scope` is the scope a 403 forbidden says the
38
+ # credential lacks, nil when absent or not a 403.
38
39
  class AdminRejectedError < AdminError
39
- attr_reader :status, :code
40
+ attr_reader :status, :code, :scope
40
41
 
41
- def initialize(message, status:, code:)
42
+ def initialize(message, status:, code:, scope: nil)
42
43
  super(message)
43
44
  @status = status
44
45
  @code = code
46
+ @scope = scope
45
47
  end
46
48
  end
47
49
 
@@ -81,12 +83,42 @@ module Ankusa
81
83
  json(request("GET", "/v1/dlq", query: params))
82
84
  end
83
85
 
84
- # POST /v1/dlq/replay -> {replayed}.
86
+ # GET /v1/audit -> a page of the audit trail, newest first.
85
87
  #
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 || {}))
88
+ # `params` may carry `cursor`, `limit`, `since`, `until`, `actor`,
89
+ # `action` and `tenant`; absent keys are omitted from the query string
90
+ # rather than sent as `=`.
91
+ def list_audit(params = nil)
92
+ json(request("GET", "/v1/audit", query: params))
93
+ end
94
+
95
+ # POST /v1/replays -> the created Replay, as a Hash.
96
+ #
97
+ # `spec` is the replay spec: `{"kind" => "dlq", ...}`,
98
+ # `{"kind" => "archive", "from" => ms, "to" => ms, ...}` or
99
+ # `{"kind" => "quarantine", ...}`. A retried POST
100
+ # with the same normalized filter returns the running job with 200 instead
101
+ # of starting a second one.
102
+ def create_replay(spec)
103
+ json(request("POST", "/v1/replays", json: spec))
104
+ end
105
+
106
+ # GET /v1/replays/{id} -> the Replay, as a Hash.
107
+ def get_replay(id)
108
+ json(request("GET", "/v1/replays/#{replay_path(id)}"))
109
+ end
110
+
111
+ # GET /v1/replays -> {"replays" => [Replay, ...]}, newest first.
112
+ def list_replays
113
+ json(request("GET", "/v1/replays"))
114
+ end
115
+
116
+ # PATCH /v1/replays/{id} -> the updated Replay, as a Hash.
117
+ #
118
+ # `patch` may carry `state` (`running`/`paused`/`cancelled`), `rate` and
119
+ # `max_lag_ms`.
120
+ def update_replay(id, patch)
121
+ json(request("PATCH", "/v1/replays/#{replay_path(id)}", json: patch))
90
122
  end
91
123
 
92
124
  # GET /v1/quarantine -> recent quarantined hooks, newest first.
@@ -99,6 +131,12 @@ module Ankusa
99
131
 
100
132
  private
101
133
 
134
+ # Percent-encodes a replay id as one path segment, so a `/`, `?` or `#` in
135
+ # an id can't reshape the URL.
136
+ def replay_path(id)
137
+ id.to_s.b.gsub(/[^A-Za-z0-9_.~-]/) { |char| format("%%%02X", char.ord) }
138
+ end
139
+
102
140
  def request(http_method, path, query: nil, json: nil)
103
141
  response = @connection.request(http_method, path, query: query, json: json)
104
142
  raise_for_status(response)
@@ -126,10 +164,12 @@ module Ankusa
126
164
  end
127
165
 
128
166
  if status >= 400 && status < 500
167
+ scope = body.is_a?(Hash) ? body["scope"] : nil
129
168
  raise AdminRejectedError.new(
130
169
  "admin listener rejected the request (#{status}): #{body.inspect}",
131
170
  status: status,
132
- code: code
171
+ code: code,
172
+ scope: scope
133
173
  )
134
174
  end
135
175
 
@@ -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
@@ -57,24 +58,31 @@ module Ankusa
57
58
  # Mirrors `#/components/schemas/Ref` in priv/openapi/claim_check.v1.yaml --
58
59
  # keep the two in sync. A ref is one string:
59
60
  # urn:ankusa:claim:v1:<tenant>:<claim_id>
61
+ # urn:ankusa:claim:v2:<tenant>:<claim_id>:<exp_unix>:<signature>
62
+ # <exp_unix> is a decimal of 1-12 digits with no leading zero and <signature>
63
+ # 43 unpadded base64url characters. The gateway checks the signature; the
64
+ # SDK only carries it.
60
65
  #
61
66
  # \A...\z, never ^...$: Ruby's ^ and $ are line anchors, so a ref ending in a
62
67
  # newline would otherwise parse.
63
68
  CLAIM_REF_PATTERN = /\Aurn:ankusa:claim:v1:([A-Za-z0-9_-]{1,64}):([0-7][0-9A-HJKMNP-TV-Z]{25})\z/
69
+ CLAIM_REF_V2_PATTERN =
70
+ /\Aurn:ankusa:claim:v2:([A-Za-z0-9_-]{1,64}):([0-7][0-9A-HJKMNP-TV-Z]{25}):([1-9][0-9]{0,11}):([A-Za-z0-9_-]{43})\z/
64
71
 
65
72
  SHA256_PATTERN = /\A[0-9a-f]{64}\z/
66
73
 
67
74
  # Parses a claim-check ref (the `claim` field of a queue message) into the
68
- # tenant id, claim id, and GET /v1/claims/{tenant_id}/{claim_id} path. Raises
69
- # `InvalidClaimRefError` -- never worth retrying -- if `ref` isn't a
70
- # well-formed ref.
75
+ # tenant id, claim id, and GET /v1/claims/{tenant_id}/{claim_id} path (with
76
+ # ?exp=&sig= for a v2 ref). Raises `InvalidClaimRefError` -- never worth
77
+ # retrying -- if `ref` isn't a well-formed ref.
71
78
  def self.parse_claim_ref(ref)
72
- match = ref.is_a?(String) ? CLAIM_REF_PATTERN.match(ref) : nil
79
+ match = ref.is_a?(String) ? (CLAIM_REF_V2_PATTERN.match(ref) || CLAIM_REF_PATTERN.match(ref)) : nil
73
80
  raise InvalidClaimRefError, "invalid claim-check ref: #{ref}" if match.nil?
74
81
 
75
- tenant_id = match[1]
76
- claim_id = match[2]
77
- ParsedClaimRef.new(tenant_id: tenant_id, claim_id: claim_id, path: "/v1/claims/#{tenant_id}/#{claim_id}")
82
+ tenant_id, claim_id, exp, sig = match.captures
83
+ path = "/v1/claims/#{tenant_id}/#{claim_id}"
84
+ path += "?exp=#{exp}&sig=#{sig}" if sig
85
+ ParsedClaimRef.new(tenant_id: tenant_id, claim_id: claim_id, path: path)
78
86
  end
79
87
 
80
88
  # Redeem claim-check refs against a deployment's claim-check gateway.
@@ -133,6 +141,13 @@ module Ankusa
133
141
  raise ClaimNotFoundError, "claim not found: #{parsed.tenant_id}/#{parsed.claim_id}"
134
142
  end
135
143
 
144
+ # A gateway (or a proxy in front of it) that is throttling or timing out
145
+ # is telling the caller to come back, not that the claim is gone.
146
+ if [408, 429].include?(status)
147
+ raise ClaimCheckUnavailableError,
148
+ "claim-check gateway busy (#{status}): #{Connection.error_body(response).inspect}"
149
+ end
150
+
136
151
  if status >= 400 && status < 500
137
152
  body = Connection.error_body(response)
138
153
  raise ClaimRejectedError.new(
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ankusa
6
+ # One decoded lifecycle event: the CloudEvents 1.0 (structured mode) event an
7
+ # Ankusa instance publishes when a source or a route is created, updated or
8
+ # deleted, when a tenant is erased, or when the instance is restored from a
9
+ # backup. It arrives as the body of a queue message from the instance's
10
+ # lifecycle sinks (content type `application/cloudevents+json`, `source_id`
11
+ # `ankusa:lifecycle`): decode the message with `Ankusa.decode_message`,
12
+ # redeem its body, and hand the bytes to `Ankusa.decode_lifecycle`.
13
+ #
14
+ # Delivery is at least once and in commit order. `id` is derived from the
15
+ # change, so the same event always has the same id, and `revision` (per
16
+ # `subject`) lets a consumer ignore a stale update. `sequence` is the
17
+ # instance's audit sequence number, the decimal string it is on the wire.
18
+ # `data` is the entity as the admin API shows it, secrets redacted (nil when
19
+ # the event carries none). An optional field the event omitted or said `null`
20
+ # is nil.
21
+ LifecycleEvent = Data.define(
22
+ :specversion, :id, :source, :type, :subject, :time, :datacontenttype, :data, :revision,
23
+ :actor, :instance, :sequence, :replayid, :traceparent, :tracestate
24
+ )
25
+
26
+ LIFECYCLE_REQUIRED_FIELDS = %w[id source type subject time].freeze
27
+ LIFECYCLE_DECIMAL = /\A[0-9]+\z/
28
+ private_constant :LIFECYCLE_REQUIRED_FIELDS, :LIFECYCLE_DECIMAL
29
+
30
+ # Decodes one lifecycle event (the JSON of the CloudEvent) into a
31
+ # `LifecycleEvent`. The checks run in a fixed order and the first failure
32
+ # wins, raising `InvalidMessageError` (never retryable): `invalid_json`,
33
+ # `not_an_object`, `unsupported_version` (`specversion` is not "1.0") or
34
+ # `invalid_field` with the offending `field`. Keys and event types this SDK
35
+ # does not know are ignored, so a consumer keeps working when a newer node
36
+ # announces more.
37
+ def self.decode_lifecycle(data)
38
+ parsed =
39
+ begin
40
+ JSON.parse(data)
41
+ rescue JSON::ParserError
42
+ raise InvalidMessageError.new("event is not JSON", code: "invalid_json")
43
+ end
44
+
45
+ unless parsed.is_a?(Hash)
46
+ raise InvalidMessageError.new("event is not a JSON object", code: "not_an_object")
47
+ end
48
+
49
+ unless parsed["specversion"] == "1.0"
50
+ raise InvalidMessageError.new("unsupported specversion", code: "unsupported_version")
51
+ end
52
+
53
+ LIFECYCLE_REQUIRED_FIELDS.each do |field|
54
+ value = parsed[field]
55
+ lifecycle_invalid_field(field) unless value.is_a?(String) && !value.empty?
56
+ end
57
+
58
+ datacontenttype = lifecycle_optional_string(parsed, "datacontenttype")
59
+
60
+ body = parsed["data"]
61
+ lifecycle_invalid_field("data") unless body.nil? || body.is_a?(Hash)
62
+
63
+ revision = parsed["revision"]
64
+ lifecycle_invalid_field("revision") unless revision.nil? || lifecycle_revision?(revision)
65
+
66
+ actor = lifecycle_optional_string(parsed, "actor")
67
+ instance = lifecycle_optional_string(parsed, "instance")
68
+
69
+ sequence = parsed["sequence"]
70
+ lifecycle_invalid_field("sequence") unless sequence.nil? || lifecycle_decimal?(sequence)
71
+
72
+ LifecycleEvent.new(
73
+ specversion: "1.0",
74
+ id: parsed["id"],
75
+ source: parsed["source"],
76
+ type: parsed["type"],
77
+ subject: parsed["subject"],
78
+ time: parsed["time"],
79
+ datacontenttype: datacontenttype,
80
+ data: body,
81
+ revision: revision,
82
+ actor: actor,
83
+ instance: instance,
84
+ sequence: sequence,
85
+ replayid: lifecycle_optional_string(parsed, "replayid"),
86
+ traceparent: lifecycle_optional_string(parsed, "traceparent"),
87
+ tracestate: lifecycle_optional_string(parsed, "tracestate")
88
+ )
89
+ end
90
+
91
+ def self.lifecycle_optional_string(parsed, field)
92
+ value = parsed[field]
93
+ lifecycle_invalid_field(field) unless value.nil? || value.is_a?(String)
94
+ value
95
+ end
96
+ private_class_method :lifecycle_optional_string
97
+
98
+ def self.lifecycle_revision?(value)
99
+ value.is_a?(Integer) && value >= 1
100
+ end
101
+ private_class_method :lifecycle_revision?
102
+
103
+ def self.lifecycle_decimal?(value)
104
+ value.is_a?(String) && LIFECYCLE_DECIMAL.match?(value)
105
+ end
106
+ private_class_method :lifecycle_decimal?
107
+
108
+ def self.lifecycle_invalid_field(field)
109
+ raise InvalidMessageError.new("invalid or missing field: #{field}", code: "invalid_field", field: field)
110
+ end
111
+ private_class_method :lifecycle_invalid_field
112
+ end
@@ -0,0 +1,266 @@
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
+ # The message versions `Ankusa.decode_message` understands, both ends
51
+ # inclusive. `v` is the wire format's major version: a `v: 2` message is a
52
+ # `v: 1` message plus additive fields, decoded exactly like it (the keys this
53
+ # decoder does not know are ignored) into the one `Message` type, with `v` as
54
+ # the wire said it. Raising `MAX_SUPPORTED_VERSION` is the whole change an
55
+ # SDK needs for a producer that starts writing the next additive version.
56
+ MIN_SUPPORTED_VERSION = 1
57
+ MAX_SUPPORTED_VERSION = 2
58
+
59
+ # One decoded queue message; `v` is the wire's. `body` holds the inline bytes (or nil for the
60
+ # claim form, where the bytes are fetched with `Ankusa::ClaimCheckClient`);
61
+ # `claim` holds the ref string in that case. `headers` is the forwarded
62
+ # provider request headers, `{}` when the message carried none. `traceparent`
63
+ # and `tracestate` are the hook's W3C trace context, verbatim (nil when the
64
+ # message carries none): continue the trace from them. `instance` and `region`
65
+ # are the name of the Ankusa instance that published the message and the
66
+ # region it is configured with (nil when the message says null or predates
67
+ # the fields).
68
+ #
69
+ # `idempotency_key` is the one value a consumer should dedupe on, as
70
+ # `Message#idempotency_key` (the field is nil only for a message from a node
71
+ # that predates it); see `Ankusa.decode_message` for the decode rules.
72
+ Message = Data.define(
73
+ :v, :id, :source_id, :tenant_id, :received_at, :content_type, :size,
74
+ :body, :claim, :sha256, :dedupe_key, :replay_id, :idempotency_key, :headers, :verification,
75
+ :traceparent, :tracestate, :instance, :region
76
+ ) do
77
+ # `Data.define` made `idempotency_key` the reader of the decoded field; keep
78
+ # that value reachable here and let the helper below take over the name.
79
+ # The decoded field itself is still in `to_h[:idempotency_key]`.
80
+ alias_method :shipped_idempotency_key, :idempotency_key
81
+ private :shipped_idempotency_key
82
+
83
+ # The idempotency key for this message: the key Ankusa shipped in the
84
+ # `idempotency_key` field when it is non-empty; for a message that predates
85
+ # the field, `tenant:source_id:dedupe_key` (tenant "default" when there is
86
+ # none) when a non-empty `dedupe_key` is set, else `id`.
87
+ #
88
+ # A replay keeps the original `id` and `dedupe_key` and adds `replay_id`, so
89
+ # by default a replay produces the same key as the delivery it replays and a
90
+ # consumer that already processed it drops it. Pass `include_replay: true`
91
+ # to reprocess replays instead.
92
+ def idempotency_key(include_replay: false)
93
+ IdempotencyKey.build(
94
+ shipped: shipped_idempotency_key, tenant: tenant_id, source: source_id,
95
+ dedupe_key: dedupe_key, id: id, replay_id: replay_id, include_replay: include_replay
96
+ )
97
+ end
98
+
99
+ # Whether the node verified this message's hook at ingest and nothing flags
100
+ # it (see `Verification#trusted?`). A message with no `verification` (a node
101
+ # older than the field) is untrusted.
102
+ def trusted?
103
+ verification&.trusted? == true
104
+ end
105
+ end
106
+
107
+ # The optional string-keyed fields that may be a string, null, or absent.
108
+ # Checked in this order, so the first offending key is the one reported.
109
+ MESSAGE_STRING_FIELDS = %w[
110
+ tenant_id content_type dedupe_key replay_id idempotency_key traceparent tracestate instance region
111
+ ].freeze
112
+ private_constant :MESSAGE_STRING_FIELDS
113
+
114
+ # Decodes one queue message of any supported `v` (`MIN_SUPPORTED_VERSION` to
115
+ # `MAX_SUPPORTED_VERSION`; the JSON body a sink or broker delivers) into
116
+ # a `Message`, applying every rule in "Consuming queue messages" of the SDK
117
+ # README in order; the first failure wins.
118
+ #
119
+ # Raises `InvalidMessageError` for anything malformed. The `sha256` is checked
120
+ # against the inline bytes here, but a claim's bytes are only checked by
121
+ # `ClaimCheckClient#redeem`, after it fetches them.
122
+ def self.decode_message(data)
123
+ parsed =
124
+ begin
125
+ JSON.parse(data)
126
+ rescue JSON::ParserError
127
+ raise InvalidMessageError.new("message is not JSON", code: "invalid_json")
128
+ end
129
+
130
+ unless parsed.is_a?(Hash)
131
+ raise InvalidMessageError.new("message is not a JSON object", code: "not_an_object")
132
+ end
133
+
134
+ version = parsed["v"]
135
+ unless version.is_a?(Integer) && version.between?(MIN_SUPPORTED_VERSION, MAX_SUPPORTED_VERSION)
136
+ raise InvalidMessageError.new("unsupported message version: #{version.inspect}", code: "unsupported_version")
137
+ end
138
+
139
+ id = parsed["id"]
140
+ invalid_field("id") unless id.is_a?(String) && !id.empty?
141
+
142
+ source_id = parsed["source_id"]
143
+ invalid_field("source_id") unless source_id.is_a?(String)
144
+
145
+ received_at = parsed["received_at"]
146
+ invalid_field("received_at") unless received_at.is_a?(Integer)
147
+
148
+ size = parsed["size"]
149
+ invalid_field("size") unless size.is_a?(Integer) && size >= 0
150
+
151
+ MESSAGE_STRING_FIELDS.each do |field|
152
+ value = parsed[field]
153
+ invalid_field(field) unless value.nil? || value.is_a?(String)
154
+ end
155
+
156
+ headers = parsed.key?("headers") ? parsed["headers"] : {}
157
+ invalid_field("headers") unless headers.is_a?(Hash) && headers.each_value.all? { |value| value.is_a?(String) }
158
+
159
+ sha256 = parsed["sha256"]
160
+ valid_sha256 = sha256.nil? || (sha256.is_a?(String) && SHA256_PATTERN.match?(sha256))
161
+ invalid_field("sha256") unless valid_sha256
162
+
163
+ verification = decode_verification(parsed["verification"])
164
+
165
+ body_base64 = parsed["body_base64"]
166
+ claim = parsed["claim"]
167
+
168
+ unless body_base64.nil? || claim.nil?
169
+ raise InvalidMessageError.new("message has both body_base64 and claim", code: "ambiguous_body")
170
+ end
171
+
172
+ if body_base64.nil? && claim.nil?
173
+ raise InvalidMessageError.new("message has neither body_base64 nor claim", code: "missing_body")
174
+ end
175
+
176
+ body = nil
177
+ parsed_claim = nil
178
+
179
+ if body_base64.nil?
180
+ parsed_claim = parse_claim(claim)
181
+ invalid_field("sha256") if sha256.nil?
182
+ else
183
+ body = decode_body(body_base64)
184
+ if body.bytesize != size
185
+ raise InvalidMessageError.new("body is #{body.bytesize} bytes, size says #{size}", code: "size_mismatch")
186
+ end
187
+
188
+ if !sha256.nil? && Digest::SHA256.hexdigest(body) != sha256
189
+ raise InvalidMessageError.new("body sha256 does not match", code: "integrity")
190
+ end
191
+ end
192
+
193
+ tenant_id = parsed["tenant_id"]
194
+ if !parsed_claim.nil? && !tenant_id.nil? && parsed_claim.tenant_id != tenant_id
195
+ raise InvalidMessageError.new(
196
+ "claim tenant #{parsed_claim.tenant_id} does not match tenant_id #{tenant_id}",
197
+ code: "tenant_mismatch"
198
+ )
199
+ end
200
+
201
+ Message.new(
202
+ v: version,
203
+ id: id,
204
+ source_id: source_id,
205
+ tenant_id: tenant_id,
206
+ received_at: received_at,
207
+ content_type: parsed["content_type"],
208
+ size: size,
209
+ body: body,
210
+ claim: claim,
211
+ sha256: sha256,
212
+ dedupe_key: parsed["dedupe_key"],
213
+ replay_id: parsed["replay_id"],
214
+ idempotency_key: parsed["idempotency_key"],
215
+ headers: headers,
216
+ verification: verification,
217
+ traceparent: parsed["traceparent"],
218
+ tracestate: parsed["tracestate"],
219
+ instance: parsed["instance"],
220
+ region: parsed["region"]
221
+ )
222
+ end
223
+
224
+ # Absent or explicit `null` is `nil` (a node that predates the field).
225
+ # Anything else must be `{status: <non-empty string>, flagged: <boolean>}`;
226
+ # the status value itself is never validated, so unknown statuses pass
227
+ # through untouched.
228
+ def self.decode_verification(value)
229
+ return nil if value.nil?
230
+ invalid_field("verification") unless value.is_a?(Hash)
231
+
232
+ status = value["status"]
233
+ flagged = value["flagged"]
234
+ valid = status.is_a?(String) && !status.empty? && [true, false].include?(flagged)
235
+ invalid_field("verification") unless valid
236
+
237
+ Verification.new(status: status, flagged: flagged)
238
+ end
239
+ private_class_method :decode_verification
240
+
241
+ def self.invalid_field(field)
242
+ raise InvalidMessageError.new("invalid message field: #{field}", code: "invalid_field", field: field)
243
+ end
244
+ private_class_method :invalid_field
245
+
246
+ def self.decode_body(body_base64)
247
+ unless body_base64.is_a?(String)
248
+ raise InvalidMessageError.new("body_base64 is not a string", code: "invalid_body_base64")
249
+ end
250
+
251
+ body_base64.unpack1("m0")
252
+ rescue ArgumentError
253
+ raise InvalidMessageError.new("body_base64 is not valid base64", code: "invalid_body_base64")
254
+ end
255
+ private_class_method :decode_body
256
+
257
+ # A claim that doesn't parse is an invalid *field*, not an invalid claim-ref
258
+ # error: the message itself is what a consumer rejects, so it carries the same
259
+ # `invalid_field` code as every other bad key.
260
+ def self.parse_claim(claim)
261
+ Ankusa.parse_claim_ref(claim)
262
+ rescue InvalidClaimRefError
263
+ invalid_field("claim")
264
+ end
265
+ private_class_method :parse_claim
266
+ end
data/lib/ankusa/routes.rb CHANGED
@@ -37,18 +37,20 @@ module Ankusa
37
37
  # The listener rejected the request (400 or any other non-404 4xx).
38
38
  #
39
39
  # `code` is the body's `error` field (`invalid_route`, `duplicate_route`,
40
- # `too_many_routes`, ...); `field`, `message`, `conflicting_id` and
41
- # `max_routes` are carried through when the body supplies them.
40
+ # `too_many_routes`, ...); `field`, `message`, `conflicting_id`,
41
+ # `max_routes` and `scope` are carried through when the body supplies them.
42
+ # `scope` is the scope a 403 forbidden says the credential lacks.
42
43
  class RoutesRejectedError < RoutesError
43
- attr_reader :status, :code, :field, :conflicting_id, :max_routes
44
+ attr_reader :status, :code, :field, :conflicting_id, :max_routes, :scope
44
45
 
45
- def initialize(message, status:, code:, field: nil, conflicting_id: nil, max_routes: nil)
46
+ def initialize(message, status:, code:, field: nil, conflicting_id: nil, max_routes: nil, scope: nil)
46
47
  super(message)
47
48
  @status = status
48
49
  @code = code
49
50
  @field = field
50
51
  @conflicting_id = conflicting_id
51
52
  @max_routes = max_routes
53
+ @scope = scope
52
54
  end
53
55
  end
54
56
 
@@ -149,7 +151,8 @@ module Ankusa
149
151
  code: field(body, "error"),
150
152
  field: field(body, "field"),
151
153
  conflicting_id: field(body, "conflicting_id"),
152
- max_routes: field(body, "max_routes")
154
+ max_routes: field(body, "max_routes"),
155
+ scope: field(body, "scope")
153
156
  )
154
157
  end
155
158
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ankusa
4
4
  module SDK
5
- VERSION = "0.3.0"
5
+ VERSION = "0.6.0"
6
6
  end
7
7
  end
data/lib/ankusa/sdk.rb CHANGED
@@ -5,7 +5,10 @@ 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"
11
+ require_relative "lifecycle"
9
12
  require_relative "routes"
10
13
  require_relative "admin"
11
14
  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)
@@ -1,12 +1,65 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ankusa
4
+ # How Ankusa verified the hook at ingest. `status` is `ok`, `failed` or
5
+ # `skipped`, but other values pass through untouched (forward-compatible).
6
+ # `flagged` is true when a failed check was accepted
7
+ # (`on_verify_failure: accept_flag`): treat the delivery as unverified.
8
+ Verification = Data.define(:status, :flagged) do
9
+ # Whether the node verified the hook and nothing flags it: true only for
10
+ # status "ok" with `flagged` false. A flagged hook, a "failed" or "skipped"
11
+ # one, and any other status are untrusted.
12
+ def trusted?
13
+ status == "ok" && flagged == false
14
+ end
15
+ end
16
+
4
17
  # The identity of one HTTP-sink delivery: the headers Ankusa's HTTP sink
5
18
  # attaches to every delivery.
6
19
  #
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)
20
+ # `content_type`, `tenant`, `dedupe_key`, `replay_id` and `idempotency_key` are
21
+ # nil when the delivery had no such header (a tenant only travels when the
22
+ # source has one; `dedupe_key` only when the source declared a dedupe key;
23
+ # `replay_id` only when the delivery is a replay; `idempotency_key` is
24
+ # missing only from a sender that predates the header). `received_at` (when
25
+ # the edge accepted the hook, unix milliseconds, an Integer) and `body_sha256`
26
+ # (the lowercase hex SHA-256 of the body Ankusa sent) are advisory: nil when
27
+ # the header is absent, empty or malformed (not 1 to 15 decimal digits; not
28
+ # exactly 64 lowercase hex characters), never an error. `verification` is nil
29
+ # when `x-ankusa-verification` is absent or empty.
30
+ HookHeaders = Data.define(
31
+ :id, :source, :tenant, :content_type, :dedupe_key, :replay_id, :idempotency_key,
32
+ :received_at, :body_sha256, :verification
33
+ ) do
34
+ # `Data.define` made `idempotency_key` the reader of the parsed header; keep
35
+ # that value reachable here and let the helper below take over the name.
36
+ # The parsed header itself is still in `to_h[:idempotency_key]`.
37
+ alias_method :shipped_idempotency_key, :idempotency_key
38
+ private :shipped_idempotency_key
39
+
40
+ # The idempotency key for this delivery: the key Ankusa shipped in
41
+ # `x-ankusa-idempotency-key` when it is non-empty; for a delivery that
42
+ # predates the header, `tenant:source:dedupe_key` (tenant "default" when
43
+ # there is none) when a non-empty `dedupe_key` is set, else `id`.
44
+ #
45
+ # A replay keeps the original `id` and `dedupe_key` and adds `replay_id`, so
46
+ # by default a replay produces the same key as the delivery it replays and a
47
+ # receiver that already processed it drops it. Pass `include_replay: true`
48
+ # to reprocess replays instead.
49
+ def idempotency_key(include_replay: false)
50
+ IdempotencyKey.build(
51
+ shipped: shipped_idempotency_key, tenant: tenant, source: source,
52
+ dedupe_key: dedupe_key, id: id, replay_id: replay_id, include_replay: include_replay
53
+ )
54
+ end
55
+
56
+ # Whether the node verified this delivery at ingest and nothing flags it
57
+ # (see `Verification#trusted?`). A delivery with no `x-ankusa-verification`
58
+ # header (a node older than the field) is untrusted.
59
+ def trusted?
60
+ verification&.trusted? == true
61
+ end
62
+ end
10
63
 
11
64
  # Raised by `Ankusa.parse_headers` when `x-ankusa-id` is absent or empty.
12
65
  #
@@ -32,7 +85,81 @@ module Ankusa
32
85
  id: hook_id,
33
86
  source: lowered.fetch("x-ankusa-source", ""),
34
87
  tenant: lowered["x-ankusa-tenant"],
35
- content_type: lowered["content-type"]
88
+ content_type: lowered["content-type"],
89
+ dedupe_key: presence(lowered["x-ankusa-dedupe-key"]),
90
+ replay_id: presence(lowered["x-ankusa-replay-id"]),
91
+ idempotency_key: presence(lowered["x-ankusa-idempotency-key"]),
92
+ received_at: parse_received_at(lowered["x-ankusa-received-at"]),
93
+ body_sha256: parse_body_sha256(lowered["x-ankusa-body-sha256"]),
94
+ verification: parse_verification(lowered)
36
95
  )
37
96
  end
97
+
98
+ # An empty header counts as absent: Ankusa only sends these when it has a
99
+ # value, and a proxy that rewrites a header to "" should not fabricate one.
100
+ def self.presence(value)
101
+ (value.nil? || value.empty?) ? nil : value
102
+ end
103
+ private_class_method :presence
104
+
105
+ # An Integer for 1 to 15 decimal digits and nothing else, else nil.
106
+ def self.parse_received_at(value)
107
+ value.to_i if value&.match?(/\A[0-9]{1,15}\z/)
108
+ end
109
+ private_class_method :parse_received_at
110
+
111
+ # The value when it is exactly 64 lowercase hex characters, else nil.
112
+ def self.parse_body_sha256(value)
113
+ value if value&.match?(/\A[0-9a-f]{64}\z/)
114
+ end
115
+ private_class_method :parse_body_sha256
116
+
117
+ # `nil` when `x-ankusa-verification` is absent or empty; otherwise the
118
+ # header's value as `status`, and `flagged` true exactly when
119
+ # `x-ankusa-verification-flagged` is the literal string "true".
120
+ def self.parse_verification(lowered)
121
+ status = presence(lowered["x-ankusa-verification"])
122
+ return nil if status.nil?
123
+
124
+ Verification.new(status: status, flagged: lowered["x-ankusa-verification-flagged"] == "true")
125
+ end
126
+ private_class_method :parse_verification
127
+
128
+ # An accepted delivery: its identity (`HookHeaders`), and whether its
129
+ # signature verified (`false` only for an unsigned delivery `allow_unsigned`
130
+ # let through).
131
+ ReceivedDelivery = Data.define(:headers, :verified)
132
+
133
+ # Judges one HTTP-sink delivery: is it signed, and what is its identity.
134
+ #
135
+ # Ankusa's HTTP sink signs every delivery unless it is configured
136
+ # `allow_unsigned: true`, so a receiver is closed by default: without the
137
+ # sink's `secrets` (or an explicit `allow_unsigned: true`) it refuses every
138
+ # delivery, because any host that can reach the receiver's URL could
139
+ # otherwise inject hooks. With `secrets` the delivery MUST verify
140
+ # (`Ankusa.verify_signature`); `allow_unsigned` does not weaken that.
141
+ #
142
+ # `body` must be the raw bytes received. Raises `InvalidSignatureError`
143
+ # (answer 401) when there is no valid signature -- `invalid_secret` when no
144
+ # secret is configured and `allow_unsigned` is not set -- and
145
+ # `MissingHookIdError` (400) when `x-ankusa-id` is absent. The signature is
146
+ # judged first.
147
+ def self.receive_delivery(headers, body, secrets: [], allow_unsigned: false, tolerance_seconds: 300, now: nil)
148
+ configured = Array(secrets)
149
+ verified =
150
+ if !configured.empty?
151
+ verify_signature(headers, body, configured, tolerance_seconds: tolerance_seconds, now: now)
152
+ true
153
+ elsif allow_unsigned
154
+ false
155
+ else
156
+ raise InvalidSignatureError.new(
157
+ "no secret configured: deliveries are signed, so the receiver needs the sink's secret " \
158
+ "(or allow_unsigned: true)",
159
+ code: "invalid_secret"
160
+ )
161
+ end
162
+
163
+ ReceivedDelivery.new(headers: parse_headers(headers), verified: verified)
164
+ end
38
165
  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.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - James Carr
@@ -24,9 +24,12 @@ files:
24
24
  - lib/ankusa/claim_check.rb
25
25
  - lib/ankusa/connection.rb
26
26
  - lib/ankusa/error.rb
27
+ - lib/ankusa/lifecycle.rb
28
+ - lib/ankusa/message.rb
27
29
  - lib/ankusa/routes.rb
28
30
  - lib/ankusa/sdk.rb
29
31
  - lib/ankusa/sdk/version.rb
32
+ - lib/ankusa/signature.rb
30
33
  - lib/ankusa/sources.rb
31
34
  - lib/ankusa/transport.rb
32
35
  - lib/ankusa/webhook.rb