didww-otp_verification 1.0.0 → 1.2.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: 95cc4c0b5d246e12eeb76854405106c8ef277a4790dbbfee8620f6984a6074a8
4
- data.tar.gz: 5d157aba75750f9b9a605b1d114dfdd289dbc9ea949b3d1d2a0a5636da12c0a9
3
+ metadata.gz: 9ff9c9fc68b3da5a2d330d5ddc3ef599f65feb79674fe93d302c393dabe6d5b3
4
+ data.tar.gz: 639dc28bfb09c2af89e98ec9845c0a5fb422d09b21e9e717b30cd6f28039b387
5
5
  SHA512:
6
- metadata.gz: fa0921c7b444d6efaba56f12c050d68b408c98f956a21fd3656c3672eabc8c73415a8d3fb5e16bc9c32f7bbcfca930856d5cc1c80fabc0f98a758e40bfb22d59
7
- data.tar.gz: aa033d4b455850aa7ed1c5a79861bfb35a303fd8e677387ebf1d77be9ccb78468cbb5bcc4d7fe1438995a3277faa2eae67c07d689068aa5fa531bd54627cab62
6
+ metadata.gz: 7de05b4e0d44499d58f885db44190a09c199a014091efbce7060c2127fccd23c3ef2981832a298ed5ab52720a5de135e6f32b7eb4ffd1f859da77854c0bddb99
7
+ data.tar.gz: dca7699a26fc3dee4e717470be7d778cd0591acffb282ec70be84e8b880c963023cec1a8ae54f80f199238bac3913f97eb7a6d5223dfe15b79115ed08bb7f337
data/CHANGELOG.md CHANGED
@@ -6,6 +6,93 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version
6
6
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html): from 1.0.0 onwards a breaking change
7
7
  to the public surface requires a major version.
8
8
 
9
+ ## [1.2.0] — 2026-10
10
+
11
+ ### Added
12
+
13
+ - **`custom:` on `start_verification`**, up to 4096 characters of free text sent
14
+ as top-level `data.custom` and delivered unchanged to your callback server in
15
+ the `verification_request` body (the key is absent when unset). It is not
16
+ returned in API responses. A longer value is refused with the new
17
+ `custom_too_long` error code.
18
+ - **`sms: {autofill: ...}`** with type `app_hash`, `domain` or `none`.
19
+ `Verification#sms_autofill` reads back the stored marker, `app_hash` or
20
+ `domain`, whose `value` is the hash or the domain. New error codes
21
+ `autofill_invalid` and `sms_domain_not_configured`.
22
+ - **`template_group` in the `sms:` and `callout:` options**, to pick a template
23
+ group instead of the application's default. New readers
24
+ `Verification#sms_template_group`/`#callout_template_group`,
25
+ `#sms_template_group_used`/`#callout_template_group_used` and
26
+ `#sms_template_fallback_reason`/`#callout_template_fallback_reason`; the last
27
+ is an open set (`language_missing`, `version_pending`, `version_rejected`,
28
+ `audio_not_ready`, `group_missing`). An unknown group is refused with the new
29
+ `template_group_not_found` error code.
30
+ - **Secret rotation support in callback verification.** During a secret
31
+ rotation the service also sends an `x-signature-previous` header, signed with
32
+ the previous secret. `CallbackVerifier#valid?` takes an optional
33
+ `previous_signature:` and accepts the callback when either signature matches
34
+ the configured secret. `RailsCallbackVerifier` reads the header itself.
35
+ Upgrade the SDK before rotating a secret.
36
+ - **New 429 error codes:** `rate_limit_per_ip` (too many requests from this IP
37
+ address), `rate_limit_failed_auth` (too many failed authentication attempts
38
+ from this IP address), `rate_limit_per_account` (too many requests for this
39
+ account), `rate_limit_public_per_account` (too many public requests for this
40
+ account) and `volume_limit_reached` (the application reached its daily or
41
+ monthly verification limit; `Retry-After` can be days, so surface it instead
42
+ of retrying). All raise `RateLimitedError`.
43
+ - **New outcome codes:** `application_paused` and `destination_country_disabled`
44
+ (status `denied`).
45
+
46
+ ### Changed
47
+
48
+ - **`destination_not_supported_for_channel` is now an outcome, not a 422.** A
49
+ start for a number with no supported country, no delivery for the requested
50
+ method, or in a blocked range returns `201` with status `failed` and this
51
+ `error_code`. Code that rescued the 422 now gets a failed verification
52
+ instead. The error code stays in the list for compatibility.
53
+
54
+ - **Supported Ruby is now 3.3+** (`required_ruby_version >= 3.3.0`). Ruby 3.1
55
+ went EOL on 2025-03-26 and 3.2 on 2026-03-31. CI tests 3.3, 3.4 and 4.0.
56
+
57
+ ### Deprecated
58
+
59
+ - **The `sms: {app_hash: ...}` option and `Verification#sms_app_hash`.** Use
60
+ `autofill`. The option is sent as `autofill: {type: "app_hash", value: ...}`
61
+ with a `:deprecated` warning; passing it together with a non-nil `autofill`
62
+ raises `ArgumentError`.
63
+ - **The `stale_dispatch` outcome code.** The service no longer sends it and
64
+ reports these verifications as `dispatch_failed`. It stays documented so
65
+ existing matches keep working, and will be dropped from the docs in the next
66
+ major version.
67
+
68
+ ### Docs
69
+
70
+ - A Testing section on the sandbox environment, which sends nothing and gives
71
+ a predefined result only for the reserved test numbers.
72
+
73
+ ## [1.1.0] — 2026-10
74
+
75
+ ### Added
76
+
77
+ - **A User-Agent header.** Every request now sends
78
+ `User-Agent: didww-verification-ruby/<version>`.
79
+ - **`code_length` on the `sms` and `callout` blocks**, readable via
80
+ `Verification#sms_code_length`/`#callout_code_length` — the length of the
81
+ generated code, 4–8 digits, set per application.
82
+ - **`RateLimitedError` (429, `destination_in_cooldown`)**, raised when
83
+ `start_verification` repeats for the same app and destination inside the
84
+ API's cooldown window. `#retry_after` reads the `Retry-After` header in
85
+ seconds (`nil` if missing or unparseable). The SDK never auto-retries it.
86
+
87
+ ### Changed
88
+
89
+ - Corrected README claims that the verification lifetime is a fixed 120 s /
90
+ 2-minute window: it is the per-app `code_expiry` setting (60–600 s, default
91
+ 300), which `sms_interception_timeout` also equals.
92
+ - A 429 now raises `RateLimitedError`, an `APIError` subclass, instead of the
93
+ `APIError` base class. `rescue APIError` still catches it, but a strict
94
+ `e.instance_of?(APIError)` check no longer matches a 429.
95
+
9
96
  ## [1.0.0] — 2026-09
10
97
 
11
98
  First public release.
data/README.md CHANGED
@@ -33,7 +33,8 @@ verification.status # => "pending"
33
33
  verification.pending? # => true
34
34
  verification.to_h # => raw response data Hash with string keys
35
35
 
36
- # Report the code the user entered (counts as an attempt; max 3, expires in 2 min)
36
+ # Report the code the user entered (counts as an attempt; max 3, expires per
37
+ # the app's code lifetime)
37
38
  result = client.report_verification(
38
39
  verification.id, delivery_method: "sms", code: "1234"
39
40
  )
@@ -64,15 +65,23 @@ reads only the block matching `delivery_method` and ignores the others.
64
65
  | Field | Description |
65
66
  | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
66
67
  | `languages` | Preferred template languages as BCP 47 tags, most preferred first. Matched exactly, so the region subtag is required — `"pl"` does not match `pl-PL`. Unmatched tags fall back to `en-US`. |
67
- | `app_hash` | Android SMS Retriever hash: exactly 11 characters of `[A-Za-z0-9+/]`. The delivered message is then prefixed with `<#> ` and the hash appended as its last token, so the handset can auto-fill the code. Omit it on every other platform. |
68
+ | `autofill` | Auto-fill marker for the delivered message. `{type: "app_hash", value: "..."}` is the Android SMS Retriever hash: exactly 11 characters of `[A-Za-z0-9+/]`. The message is then prefixed with `<#> ` and the hash appended as its last token, so the handset can auto-fill the code. `{type: "domain"}` adds a domain-bound line for the application's configured SMS domain. `{type: "none"}` explicitly asks for no marker. Omit it to use the application default. |
69
+ | `template_group` | Name of one of your template groups, used instead of the application's default. |
68
70
 
69
- `app_hash` is here because a Ruby backend often starts the verification on behalf
71
+ `autofill` is here because a Ruby backend often starts the verification on behalf
70
72
  of an Android app — the hash identifies that app, so only the app can compute it.
73
+ `{type: "domain"}` takes no `value`; on an application with no SMS domain it is
74
+ refused with `sms_domain_not_configured`. A malformed `autofill` is refused with
75
+ `autofill_invalid`.
76
+ The former `app_hash:` key still works as a deprecated alias for
77
+ `autofill: {type: "app_hash", value: ...}` (it prints a deprecation warning
78
+ when Ruby's `:deprecated` warnings are enabled). Passing both with non-nil values
79
+ raises `ArgumentError`; `autofill: nil` counts as absent.
71
80
 
72
81
  ```ruby
73
82
  client.start_verification(
74
83
  destination: "+4915112345678", delivery_method: "sms",
75
- sms: {languages: ["en-US"], app_hash: "A1b2C3d4E5f"}
84
+ sms: {languages: ["en-US"], autofill: {type: "app_hash", value: "A1b2C3d4E5f"}}
76
85
  )
77
86
  ```
78
87
 
@@ -81,6 +90,10 @@ client.start_verification(
81
90
  | Field | Description |
82
91
  | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
83
92
  | `languages` | Preferred announcement languages — the same tags, order and matching rules as the `sms:` ones, so the same list is valid for either method. It is not shared between them, though: each method reads only its own block, so the list goes under `callout:` here and under `sms:` there. The catalogues are separate too, so a tag that has a message template but no recording is accepted and falls back to `en-US`. |
93
+ | `template_group` | Name of one of your template groups, as for `sms:`. |
94
+
95
+ An unknown `template_group` on either method is refused with
96
+ `template_group_not_found`.
84
97
 
85
98
  ```ruby
86
99
  client.start_verification(
@@ -95,6 +108,20 @@ API ships it. The cost of that is no typo protection: an unrecognized option is
95
108
  ignored rather than rejected, and a misspelled key returns `201` with the
96
109
  defaults applied, not an error.
97
110
 
111
+ ### `custom:`
112
+
113
+ Optional free text, up to 4096 characters, sent as top-level `data.custom` (an
114
+ empty string counts as absent). It is not returned by the API; it reaches your
115
+ callback server as `data.custom` in the `verification_request` body; the key is
116
+ absent when unset. A longer value is refused with `custom_too_long`.
117
+
118
+ ```ruby
119
+ client.start_verification(
120
+ destination: "+4915112345678", delivery_method: "sms",
121
+ custom: "order-42"
122
+ )
123
+ ```
124
+
98
125
  ### Reading a method block back
99
126
 
100
127
  The response carries the block for the method that was used, readable field by
@@ -103,11 +130,19 @@ field or as a whole:
103
130
  ```ruby
104
131
  v.sms_template # => "Your code is {{CODE}}"
105
132
  v.sms_language # => "en-US"
106
- v.sms_interception_timeout # => 120
107
- v.sms_app_hash # => "A1b2C3d4E5f", or nil if none was stored
133
+ v.sms_interception_timeout # => 300
134
+ v.sms_code_length # => 6
135
+ v.sms_autofill # => {"type" => "app_hash", "value" => "A1b2C3d4E5f"}, or nil
136
+ v.sms_template_group # => "checkout", or nil if none applied
137
+ v.sms_template_group_used # => true
138
+ v.sms_template_fallback_reason # => nil, or e.g. "language_missing"
108
139
  v.sms # => the raw block, or nil on a callout verification
109
140
 
110
141
  v.callout_language # => "de-DE"
142
+ v.callout_code_length # => 6
143
+ v.callout_template_group # => "checkout", or nil if none applied
144
+ v.callout_template_group_used # => true
145
+ v.callout_template_fallback_reason # => nil, or e.g. "audio_not_ready"
111
146
  v.callout # => the raw block, or nil on an sms verification
112
147
  ```
113
148
 
@@ -119,10 +154,21 @@ because the two catalogues are separate, a tag honoured on `sms` can still fall
119
154
  back on `callout`.
120
155
 
121
156
  `sms_interception_timeout` is how many seconds a client should keep an on-device
122
- SMS listener armed. It is a fixed budget, not a countdown, and **not** a deadline
123
- for the verification — manual entry keeps working until `expires_at`.
124
- `sms_app_hash` is echoed back only when one was stored, so it reflects what was
125
- persisted rather than what was requested.
157
+ SMS listener armed. It equals the app's configured code lifetime (60–600 s,
158
+ default 300) and is **not** a deadline for the verification itself — manual
159
+ entry keeps working until `expires_at`.
160
+ `sms_code_length`/`callout_code_length` is the length of this verification's
161
+ code, 4–8 digits, set per application (default 6).
162
+ `sms_autofill` is echoed back only when a hash or domain marker was stored, so it
163
+ reflects what was persisted rather than what was requested; for `domain`, `value`
164
+ is the domain. `sms_app_hash` is a deprecated reader derived from it: the `value`
165
+ when the type is `app_hash`, otherwise `nil`.
166
+ `*_template_group_used` is true when the group's template was used. When it is
167
+ false, `*_template_fallback_reason` says why: `language_missing`,
168
+ `version_pending`, `version_rejected`, `audio_not_ready` or `group_missing` (the
169
+ group no longer exists; `*_template_group` is then `nil`). Both are false/`nil`
170
+ when no template group applies at all. Like error codes, the reasons are an open
171
+ set of plain strings.
126
172
 
127
173
  ### Address by phone number
128
174
 
@@ -161,6 +207,15 @@ DIDWW::OTPVerification.configure do |c|
161
207
  end
162
208
  ```
163
209
 
210
+ ## Testing
211
+
212
+ The sandbox sends no real SMS and places no real calls. Only the reserved test
213
+ numbers give a predefined result, such as delivered, failed or expired; a
214
+ delivered one has a fixed code. Any other number is never delivered to, and its
215
+ verification ends as failed. The test numbers, their outcomes and the code to
216
+ submit are listed in
217
+ [Testing on sandbox](https://doc.didww.com/otp-verification/testing.html).
218
+
164
219
  ## Configuration
165
220
 
166
221
  Set global defaults; every field is overridable per `Client`.
@@ -197,6 +252,8 @@ DIDWW::OTPVerification::Client.new(
197
252
  ) { |conn| conn.proxy = "http://proxy:3128" }
198
253
  ```
199
254
 
255
+ Every request carries `User-Agent: didww-verification-ruby/<version>`.
256
+
200
257
  ## Auth modes
201
258
 
202
259
  | Mode | Header | Secret | Notes |
@@ -265,15 +322,35 @@ ok = verifier.valid?(
265
322
  content_type: request.content_type,
266
323
  body: request.raw_post, # RAW body bytes — do not re-serialize
267
324
  timestamp: request.headers["x-timestamp"],
268
- signature: signature
325
+ signature: signature,
326
+ previous_signature: request.headers["x-signature-previous"] # optional
269
327
  )
270
328
 
271
329
  render json: { action: ok ? "allow" : "deny" }
272
330
  ```
273
331
 
332
+ The `verification_request` body carries the `custom` value you passed to
333
+ `start_verification` as `data.custom` (absent when unset). When the verification
334
+ is started from a mobile app, that value is client-controlled: validate it
335
+ against your own records rather than trusting it.
336
+
274
337
  The verifier also enforces a 5-minute timestamp window (configurable via
275
338
  `tolerance:`) to reject replays.
276
339
 
340
+ ### Rotating the application secret
341
+
342
+ While a rotated-out secret is still valid, DIDWW also sends an
343
+ `x-signature-previous` header: the same signature computed with the previous
344
+ secret. The verifier accepts a callback when either the `Authorization`
345
+ signature or `previous_signature` matches the secret you configured. A receiver
346
+ can therefore switch from the old secret to the new one at any point during the
347
+ grace period, with no failed callbacks. Without the header the verifier behaves
348
+ as before.
349
+
350
+ Upgrade the SDK before you rotate a secret, because an older version ignores
351
+ `x-signature-previous` and rejects callbacks signed with the new secret while
352
+ the old one is configured.
353
+
277
354
  ### Rails
278
355
 
279
356
  `RailsCallbackVerifier` pulls every signed field off an `ActionDispatch`
@@ -327,13 +404,23 @@ carries `error_code` (switch on it) and `error_detail` (display it). These are
327
404
  | ---------- | -------- | -------------------------------------------------------------------------------------------- |
328
405
  | `pending` | no | `nil` — on its way, or awaiting a report |
329
406
  | `verified` | yes | `nil` |
330
- | `failed` | yes | `too_many_attempts`, `dispatch_failed`, `stale_dispatch`, `superseded`, `application_deleted` |
331
- | `expired` | yes | `expired` — past the 2-minute window |
332
- | `denied` | yes | `denied_by_callback`, `denied_missing_callback_url`, `denied_invalid_callback_response` |
407
+ | `failed` | yes | `too_many_attempts`, `dispatch_failed`, `superseded`, `application_deleted`, `destination_not_supported_for_channel`, `stale_dispatch` (deprecated) |
408
+ | `expired` | yes | `expired` — past the application's code lifetime (60–600 s, default 300) |
409
+ | `denied` | yes | `denied_by_callback`, `denied_missing_callback_url`, `denied_invalid_callback_response`, `application_paused`, `destination_country_disabled` |
333
410
 
334
411
  `superseded` means a newer `start_verification` for the same number retired this
335
412
  one — only the newest verification per number stays active.
336
413
 
414
+ Some starts are refused as an outcome rather than an error: a `201` whose status
415
+ is `denied` (`application_paused`, `destination_country_disabled`) or `failed`
416
+ (`destination_not_supported_for_channel`: the number has no supported country,
417
+ can't be reached by this delivery method, or is in a blocked range). Earlier
418
+ versions returned the last one as a `422`; that code stays in the error list for
419
+ compatibility only.
420
+
421
+ `stale_dispatch` is deprecated: the service no longer sends it and reports these
422
+ verifications as `dispatch_failed`.
423
+
337
424
  ```ruby
338
425
  v = client.get_verification(id)
339
426
 
@@ -362,6 +449,7 @@ Non-2xx responses raise a typed error under `DIDWW::OTPVerification::Error`:
362
449
  | `BalanceInsufficientError` | 402 | `balance_insufficient` |
363
450
  | `NotFoundError` | 404 | `not_found` |
364
451
  | `ValidationError` | 400 / 422 | `parameter_missing` / per-field codes |
452
+ | `RateLimitedError` | 429 | `destination_in_cooldown`, `rate_limit_*`, `volume_limit_reached` |
365
453
  | `ServerError` | 5xx | `internal_error` |
366
454
 
367
455
  Every error carries the API's coded envelope: `#errors` is an array of
@@ -390,10 +478,27 @@ end
390
478
  The SDK does **not** auto-retry. If you add `faraday-retry`, exclude `POST`
391
479
  (double-charges) and `PATCH` (each report counts against the 3-attempt limit).
392
480
 
481
+ A 429 raises `RateLimitedError`, and its code says which limit was hit:
482
+
483
+ - `destination_in_cooldown`: a verification for this destination was requested
484
+ too recently.
485
+ - `rate_limit_per_ip`: too many requests from this IP address.
486
+ - `rate_limit_failed_auth`: too many failed authentication attempts from this
487
+ IP address.
488
+ - `rate_limit_per_account`: too many requests for this account.
489
+ - `rate_limit_public_per_account`: too many public requests for this account.
490
+ - `volume_limit_reached`: the application reached its daily or monthly
491
+ verification limit. `Retry-After` can be long (days) — surface it to the
492
+ user instead of retrying. Contact support to raise the limit.
493
+
494
+ `#retry_after` gives the wait in seconds (`nil` if the `Retry-After` header is
495
+ missing or unparseable) — wait that long before trying again yourself; never
496
+ auto-retry it.
497
+
393
498
  ## Development
394
499
 
395
500
  Development is pinned to the Ruby in `.ruby-version`; the gem itself supports
396
- 3.1+.
501
+ 3.3+.
397
502
 
398
503
  ```sh
399
504
  bundle install
@@ -19,7 +19,8 @@ module DIDWW
19
19
  # verifier.valid?(
20
20
  # method: "POST", path: "/callbacks/didww",
21
21
  # content_type: "application/json", body: raw_body,
22
- # timestamp: request.headers["x-timestamp"], signature: signature
22
+ # timestamp: request.headers["x-timestamp"], signature: signature,
23
+ # previous_signature: request.headers["x-signature-previous"]
23
24
  # )
24
25
  class CallbackVerifier
25
26
  # @param tolerance [Integer] max allowed clock skew, in seconds (server uses 5 minutes).
@@ -41,16 +42,21 @@ module DIDWW
41
42
  [key, signature]
42
43
  end
43
44
 
44
- # @return [Boolean] true when both the timestamp is fresh and the
45
- # signature matches (constant-time comparison).
46
- def valid?(method:, path:, content_type:, body:, timestamp:, signature:)
45
+ # @param previous_signature [String, nil] the `x-signature-previous` header, sent
46
+ # during a secret rotation and signed with the previous secret. Ignored when absent.
47
+ # @return [Boolean] true when the timestamp is fresh and the signature, or the
48
+ # previous signature, matches (constant-time comparison).
49
+ def valid?(method:, path:, content_type:, body:, timestamp:, signature:, previous_signature: nil)
47
50
  return false if Util.blank?(signature)
48
51
  return false unless fresh?(timestamp)
49
52
 
50
53
  expected = @signer.sign(
51
54
  method:, path:, content_type:, body:, timestamp: timestamp
52
55
  )
53
- secure_compare(expected, signature)
56
+ # Both comparisons always run, so timing does not reveal which header matched.
57
+ current = secure_compare(expected, signature)
58
+ previous = secure_compare(expected, previous_signature)
59
+ current || previous
54
60
  end
55
61
 
56
62
  private
@@ -63,6 +69,7 @@ module DIDWW
63
69
 
64
70
  # Constant-time string comparison via the audited stdlib implementation.
65
71
  def secure_compare(a, b)
72
+ return false if b.nil?
66
73
  return false unless a.bytesize == b.bytesize
67
74
 
68
75
  OpenSSL.fixed_length_secure_compare(a, b)
@@ -21,6 +21,7 @@ module DIDWW
21
21
  # Unspecified arguments fall back to DIDWW::OTPVerification.configuration.
22
22
  class Client
23
23
  API_PREFIX = "/api/v1".freeze
24
+ USER_AGENT = "didww-verification-ruby/#{VERSION}".freeze
24
25
 
25
26
  attr_reader :key, :auth_mode, :base_url
26
27
 
@@ -49,10 +50,21 @@ module DIDWW
49
50
  # reads only the block matching +delivery_method+ and ignores the others.
50
51
  # An unrecognized option is ignored rather than rejected, so a typo there
51
52
  # fails silently. Methods with no options of their own take no keyword.
52
- def start_verification(destination:, delivery_method:, sms: nil, callout: nil)
53
+ #
54
+ # +custom+ is free text (up to 4096 characters) delivered to your
55
+ # callback server as +data.custom+; it is not part of the response.
56
+ #
57
+ # The +sms+ key +app_hash+ is deprecated: it is sent as
58
+ # <tt>autofill: {type: "app_hash", value: ...}</tt> with a deprecation
59
+ # warning. Passing it together with +autofill+ raises ArgumentError,
60
+ # because the API rejects that combination. Both apply only when
61
+ # +delivery_method+ is +sms+, the only method that reads +autofill+.
62
+ def start_verification(destination:, delivery_method:, sms: nil, callout: nil, custom: nil)
53
63
  data = {destination:, delivery_method:}
64
+ data[:custom] = custom unless custom.nil?
54
65
  data[:sms] = sms unless sms.nil?
55
66
  data[:callout] = callout unless callout.nil?
67
+ data[:sms] = migrate_app_hash(data[:sms]) if delivery_method.to_s == "sms" && data[:sms].is_a?(Hash)
56
68
  request(:post, "#{API_PREFIX}/verifications", data)
57
69
  end
58
70
 
@@ -84,6 +96,26 @@ module DIDWW
84
96
 
85
97
  private
86
98
 
99
+ # Maps the deprecated sms +app_hash+ onto +autofill+ on a copy, so the
100
+ # caller's hash is left alone.
101
+ def migrate_app_hash(sms)
102
+ keys = [:app_hash, "app_hash"].select { |k| sms.key?(k) }
103
+ return sms if keys.empty?
104
+
105
+ # A nil hash counts as absent, as the API always treated it.
106
+ key = keys.find { |k| !sms[k].nil? }
107
+ return sms.except(*keys) unless key
108
+ # A nil autofill counts as absent too, as the API treats it.
109
+ autofill_keys = [:autofill, "autofill"].select { |k| sms.key?(k) }
110
+ if autofill_keys.any? { |k| !sms[k].nil? }
111
+ raise ArgumentError, "sms: pass either autofill or the deprecated app_hash, not both"
112
+ end
113
+
114
+ warn "DIDWW::OTPVerification: sms app_hash is deprecated; " \
115
+ "use sms: {autofill: {type: \"app_hash\", value: ...}}", category: :deprecated, uplevel: 2
116
+ sms.except(*keys, *autofill_keys).merge(autofill: {type: "app_hash", value: sms[key]})
117
+ end
118
+
87
119
  # Percent-encode a value for use as a single URL path segment. Notably
88
120
  # turns a leading "+" into "%2B" so it survives proxies that would
89
121
  # otherwise decode "+" to a space in the path.
@@ -110,11 +142,9 @@ module DIDWW
110
142
  end
111
143
 
112
144
  errors = parse_errors(body)
113
- raise error_class(response.status).new(
114
- status: response.status,
115
- errors: errors,
116
- response: response
117
- )
145
+ klass = error_class(response.status)
146
+ extra = (klass == RateLimitedError) ? {retry_after: parse_retry_after(response)} : {}
147
+ raise klass.new(status: response.status, errors: errors, response: response, **extra)
118
148
  end
119
149
 
120
150
  # Map the {"errors": [{"code", "detail"}]} envelope to ErrorItem objects.
@@ -131,12 +161,20 @@ module DIDWW
131
161
  end
132
162
  end
133
163
 
164
+ # @return [Integer, nil] the Retry-After header in seconds, nil if
165
+ # missing or not a plain non-negative integer (e.g. an HTTP-date).
166
+ def parse_retry_after(response)
167
+ value = response.headers["Retry-After"].to_s
168
+ Integer(value, 10) if value.match?(/\A\d+\z/)
169
+ end
170
+
134
171
  def error_class(status)
135
172
  case status
136
173
  when 401 then UnauthorizedError
137
174
  when 402 then BalanceInsufficientError
138
175
  when 404 then NotFoundError
139
176
  when 400, 422 then ValidationError
177
+ when 429 then RateLimitedError
140
178
  when 500..599 then ServerError
141
179
  else APIError
142
180
  end
@@ -147,6 +185,8 @@ module DIDWW
147
185
  conn.request :json
148
186
  conn.response :json, content_type: /\bjson$/
149
187
  @faraday_block&.call(conn)
188
+ # After the caller's block, so the block cannot replace it.
189
+ conn.headers["User-Agent"] = USER_AGENT
150
190
  # Auth (signing) must be installed last so it sees the final request
151
191
  # bytes/headers/path, after any user middleware from @faraday_block.
152
192
  apply_auth(conn)
@@ -48,6 +48,22 @@ module DIDWW
48
48
  # 400 Bad Request / 422 Unprocessable Content (validation errors in +errors+).
49
49
  class ValidationError < APIError; end
50
50
 
51
+ # 429 Too Many Requests. +code+ says which limit was hit:
52
+ # +destination_in_cooldown+ (this destination was requested too recently),
53
+ # a +rate_limit_*+ request limit, or +volume_limit_reached+ (a daily or
54
+ # monthly limit; +retry_after+ can be days).
55
+ # Never auto-retry this.
56
+ class RateLimitedError < APIError
57
+ # @return [Integer, nil] seconds to wait, from the Retry-After header.
58
+ # Nil if the header is missing or not a plain integer.
59
+ attr_reader :retry_after
60
+
61
+ def initialize(message = nil, status:, errors: [], response: nil, retry_after: nil)
62
+ @retry_after = retry_after
63
+ super(message, status: status, errors: errors, response: response)
64
+ end
65
+ end
66
+
51
67
  # 5xx Server Error (+internal_error+ code).
52
68
  class ServerError < APIError; end
53
69
  end
@@ -47,7 +47,8 @@ module DIDWW
47
47
  content_type: request.content_type,
48
48
  body: request.raw_post,
49
49
  timestamp: request.headers["x-timestamp"],
50
- signature: signature
50
+ signature: signature,
51
+ previous_signature: request.headers["x-signature-previous"]
51
52
  )
52
53
  end
53
54
  end
@@ -55,10 +55,46 @@ module DIDWW
55
55
  @sms && @sms["interception_timeout"]
56
56
  end
57
57
 
58
- # @return [String, nil] SMS Retriever hash, echoed back only when one was
59
- # stored on this verification.
58
+ # @return [Integer, nil] the length of this verification's code, 4..8
59
+ # (set per application). Only present for the +sms+ method.
60
+ def sms_code_length
61
+ @sms && @sms["code_length"]
62
+ end
63
+
64
+ # @return [Hash, nil] the stored autofill marker, +{"type", "value"}+ with
65
+ # type +app_hash+ (value: the hash) or +domain+ (value: the domain);
66
+ # +nil+ unless one was stored on this verification.
67
+ def sms_autofill
68
+ @sms && @sms["autofill"]
69
+ end
70
+
71
+ # @deprecated Use {#sms_autofill}.
72
+ # @return [String, nil] the SMS Retriever hash, when the stored autofill
73
+ # is of type +app_hash+.
60
74
  def sms_app_hash
61
- @sms && @sms["app_hash"]
75
+ autofill = sms_autofill
76
+ return @sms && @sms["app_hash"] unless autofill
77
+
78
+ autofill["value"] if autofill["type"] == "app_hash"
79
+ end
80
+
81
+ # @return [String, nil] the template group applied; nil when none applies
82
+ # or it no longer exists. Only present for the +sms+ method.
83
+ def sms_template_group
84
+ @sms && @sms["template_group"]
85
+ end
86
+
87
+ # @return [Boolean, nil] true when the group's template was used. Only
88
+ # present for the +sms+ method.
89
+ def sms_template_group_used
90
+ @sms && @sms["template_group_used"]
91
+ end
92
+
93
+ # @return [String, nil] why the group's template was not used, e.g.
94
+ # +language_missing+ (an open set); nil when it was or when no group
95
+ # applies. Only present for the +sms+ method.
96
+ def sms_template_fallback_reason
97
+ @sms && @sms["template_fallback_reason"]
62
98
  end
63
99
 
64
100
  # The tag the announcement is played in: the first requested language the
@@ -71,6 +107,31 @@ module DIDWW
71
107
  @callout && @callout["language"]
72
108
  end
73
109
 
110
+ # @return [Integer, nil] the length of this verification's code, 4..8
111
+ # (set per application). Only present for the +callout+ method.
112
+ def callout_code_length
113
+ @callout && @callout["code_length"]
114
+ end
115
+
116
+ # @return [String, nil] the template group applied; nil when none applies
117
+ # or it no longer exists. Only present for the +callout+ method.
118
+ def callout_template_group
119
+ @callout && @callout["template_group"]
120
+ end
121
+
122
+ # @return [Boolean, nil] true when the group's template was used. Only
123
+ # present for the +callout+ method.
124
+ def callout_template_group_used
125
+ @callout && @callout["template_group_used"]
126
+ end
127
+
128
+ # @return [String, nil] why the group's template was not used, e.g.
129
+ # +audio_not_ready+ (an open set); nil when it was or when no group
130
+ # applies. Only present for the +callout+ method.
131
+ def callout_template_fallback_reason
132
+ @callout && @callout["template_fallback_reason"]
133
+ end
134
+
74
135
  def pending? = status == "pending"
75
136
  def verified? = status == "verified"
76
137
  def failed? = status == "failed"
@@ -1,5 +1,5 @@
1
1
  module DIDWW
2
2
  module OTPVerification
3
- VERSION = "1.0.0"
3
+ VERSION = "1.2.0"
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: didww-otp_verification
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - DIDWW
@@ -85,7 +85,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
85
85
  requirements:
86
86
  - - ">="
87
87
  - !ruby/object:Gem::Version
88
- version: 3.1.0
88
+ version: 3.3.0
89
89
  required_rubygems_version: !ruby/object:Gem::Requirement
90
90
  requirements:
91
91
  - - ">="