dinie-sdk-sandbox 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +40 -0
  3. data/LICENSE +21 -0
  4. data/README.md +280 -0
  5. data/lib/dinie/generated/api_version.rb +8 -0
  6. data/lib/dinie/generated/client.rb +96 -0
  7. data/lib/dinie/generated/errors/registry.rb +40 -0
  8. data/lib/dinie/generated/events/base.rb +11 -0
  9. data/lib/dinie/generated/events/credit_offer.rb +56 -0
  10. data/lib/dinie/generated/events/customer_created.rb +42 -0
  11. data/lib/dinie/generated/events/customer_denied.rb +39 -0
  12. data/lib/dinie/generated/events/customer_kyc_updated.rb +36 -0
  13. data/lib/dinie/generated/events/customer_status.rb +48 -0
  14. data/lib/dinie/generated/events/deserializers.rb +35 -0
  15. data/lib/dinie/generated/events/loan_active.rb +35 -0
  16. data/lib/dinie/generated/events/loan_created.rb +38 -0
  17. data/lib/dinie/generated/events/loan_payment_received.rb +46 -0
  18. data/lib/dinie/generated/events/loan_processing.rb +37 -0
  19. data/lib/dinie/generated/events/loan_signature_received.rb +48 -0
  20. data/lib/dinie/generated/events/loan_status.rb +73 -0
  21. data/lib/dinie/generated/events.rb +4 -0
  22. data/lib/dinie/generated/resources/banks.rb +25 -0
  23. data/lib/dinie/generated/resources/biometrics.rb +27 -0
  24. data/lib/dinie/generated/resources/credentials.rb +56 -0
  25. data/lib/dinie/generated/resources/credit_offers.rb +59 -0
  26. data/lib/dinie/generated/resources/customers.rb +200 -0
  27. data/lib/dinie/generated/resources/loans.rb +70 -0
  28. data/lib/dinie/generated/resources/webhook_endpoints.rb +97 -0
  29. data/lib/dinie/generated/resources.rb +9 -0
  30. data/lib/dinie/generated/types/bank.rb +17 -0
  31. data/lib/dinie/generated/types/biometrics_session.rb +16 -0
  32. data/lib/dinie/generated/types/biometrics_session_exchange_response.rb +23 -0
  33. data/lib/dinie/generated/types/credential.rb +52 -0
  34. data/lib/dinie/generated/types/credit_offer.rb +62 -0
  35. data/lib/dinie/generated/types/customer.rb +46 -0
  36. data/lib/dinie/generated/types/customer_bank_account.rb +33 -0
  37. data/lib/dinie/generated/types/ids.rb +18 -0
  38. data/lib/dinie/generated/types/kyc.rb +458 -0
  39. data/lib/dinie/generated/types/kyc_attachment_response.rb +16 -0
  40. data/lib/dinie/generated/types/loan.rb +51 -0
  41. data/lib/dinie/generated/types/money.rb +4 -0
  42. data/lib/dinie/generated/types/simulation.rb +35 -0
  43. data/lib/dinie/generated/types/transaction.rb +43 -0
  44. data/lib/dinie/generated/types/webhook_endpoint.rb +52 -0
  45. data/lib/dinie/generated/types/webhook_secret_rotation.rb +17 -0
  46. data/lib/dinie/generated/types.rb +18 -0
  47. data/lib/dinie/runtime/errors.rb +295 -0
  48. data/lib/dinie/runtime/http.rb +327 -0
  49. data/lib/dinie/runtime/idempotency.rb +34 -0
  50. data/lib/dinie/runtime/logger.rb +326 -0
  51. data/lib/dinie/runtime/model.rb +162 -0
  52. data/lib/dinie/runtime/multipart.rb +77 -0
  53. data/lib/dinie/runtime/paginator.rb +164 -0
  54. data/lib/dinie/runtime/rate_limit.rb +150 -0
  55. data/lib/dinie/runtime/request_options.rb +112 -0
  56. data/lib/dinie/runtime/retry.rb +74 -0
  57. data/lib/dinie/runtime/token_manager.rb +341 -0
  58. data/lib/dinie/runtime/webhooks.rb +194 -0
  59. data/lib/dinie/version.rb +7 -0
  60. data/lib/dinie.rb +37 -0
  61. data/sig/_external/faraday.rbs +44 -0
  62. data/sig/dinie/generated/client.rbs +45 -0
  63. data/sig/dinie/generated/errors/registry.rbs +40 -0
  64. data/sig/dinie/generated/events/base.rbs +17 -0
  65. data/sig/dinie/generated/events/credit_offer.rbs +33 -0
  66. data/sig/dinie/generated/events/customer_created.rbs +27 -0
  67. data/sig/dinie/generated/events/customer_denied.rbs +25 -0
  68. data/sig/dinie/generated/events/customer_kyc_updated.rbs +21 -0
  69. data/sig/dinie/generated/events/customer_status.rbs +26 -0
  70. data/sig/dinie/generated/events/deserializers.rbs +9 -0
  71. data/sig/dinie/generated/events/loan_active.rbs +20 -0
  72. data/sig/dinie/generated/events/loan_created.rbs +23 -0
  73. data/sig/dinie/generated/events/loan_payment_received.rbs +28 -0
  74. data/sig/dinie/generated/events/loan_processing.rbs +23 -0
  75. data/sig/dinie/generated/events/loan_signature_received.rbs +30 -0
  76. data/sig/dinie/generated/events/loan_status.rbs +40 -0
  77. data/sig/dinie/generated/resources/banks.rbs +15 -0
  78. data/sig/dinie/generated/resources/credentials.rbs +21 -0
  79. data/sig/dinie/generated/resources/credit_offers.rbs +19 -0
  80. data/sig/dinie/generated/resources/customers.rbs +58 -0
  81. data/sig/dinie/generated/resources/loans.rbs +26 -0
  82. data/sig/dinie/generated/resources/webhook_endpoints.rbs +35 -0
  83. data/sig/dinie/generated/types/bank.rbs +12 -0
  84. data/sig/dinie/generated/types/biometrics_session.rbs +11 -0
  85. data/sig/dinie/generated/types/credential.rbs +26 -0
  86. data/sig/dinie/generated/types/credit_offer.rbs +24 -0
  87. data/sig/dinie/generated/types/customer.rbs +25 -0
  88. data/sig/dinie/generated/types/customer_bank_account.rbs +26 -0
  89. data/sig/dinie/generated/types/enums.rbs +66 -0
  90. data/sig/dinie/generated/types/ids.rbs +21 -0
  91. data/sig/dinie/generated/types/kyc/attachment.rbs +14 -0
  92. data/sig/dinie/generated/types/kyc/common.rbs +42 -0
  93. data/sig/dinie/generated/types/kyc/requirements.rbs +117 -0
  94. data/sig/dinie/generated/types/kyc/submitted.rbs +21 -0
  95. data/sig/dinie/generated/types/kyc/uploads.rbs +24 -0
  96. data/sig/dinie/generated/types/loan.rbs +32 -0
  97. data/sig/dinie/generated/types/money.rbs +6 -0
  98. data/sig/dinie/generated/types/simulation.rbs +28 -0
  99. data/sig/dinie/generated/types/transaction.rbs +24 -0
  100. data/sig/dinie/generated/types/webhook_endpoint.rbs +38 -0
  101. data/sig/dinie/runtime/errors.rbs +106 -0
  102. data/sig/dinie/runtime/http.rbs +59 -0
  103. data/sig/dinie/runtime/idempotency.rbs +15 -0
  104. data/sig/dinie/runtime/logger.rbs +89 -0
  105. data/sig/dinie/runtime/model.rbs +51 -0
  106. data/sig/dinie/runtime/multipart.rbs +25 -0
  107. data/sig/dinie/runtime/paginator.rbs +50 -0
  108. data/sig/dinie/runtime/rate_limit.rbs +46 -0
  109. data/sig/dinie/runtime/request_options.rbs +35 -0
  110. data/sig/dinie/runtime/retry.rbs +29 -0
  111. data/sig/dinie/runtime/token_manager.rbs +51 -0
  112. data/sig/dinie/runtime/webhooks.rbs +31 -0
  113. data/sig/dinie/version.rbs +7 -0
  114. metadata +316 -0
@@ -0,0 +1,341 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "faraday"
5
+ require "faraday/net_http_persistent"
6
+
7
+ module Dinie
8
+ module Internal
9
+ # `TokenManager` — the OAuth2 *client_credentials* token cache (architecture §10, RB-T,
10
+ # `runtime-patterns.md` §4), porting `sdk-js` `src/runtime/token-manager.ts`. It acquires and
11
+ # transparently refreshes the Bearer token every request rides on, speaking RFC 6749:
12
+ #
13
+ # POST {base_url}/auth/token
14
+ # Authorization: Basic base64("{client_id}:{client_secret}")
15
+ # Content-Type: application/x-www-form-urlencoded
16
+ # body: grant_type=client_credentials
17
+ # → 200 { access_token, token_type: "bearer", expires_in }
18
+ #
19
+ # Three behaviours make it one of the risky-core modules:
20
+ #
21
+ # 1. **Proactive refresh** — the cached token is treated as stale {REFRESH_MARGIN_SECONDS}
22
+ # (300s) BEFORE its real expiry, so a live request never races the boundary.
23
+ # 2. **Concurrency lock** — a `Mutex` + `ConditionVariable` (+ an `@refreshing` flag)
24
+ # serialize refreshes: N simultaneous `#access_token` callers trigger exactly ONE token
25
+ # POST. The claim ("I will refresh") is made UNDER the mutex, so it is impossible for two
26
+ # threads to both start a refresh; the losers park on the condition variable and, after a
27
+ # **double-check** on wake, return the freshly-cached token. The POST itself runs OUTSIDE
28
+ # the lock so a slow handshake never blocks the validity check.
29
+ # 3. **401 invalidation** — `#invalidate!` drops the cached token. The 401 one-shot re-auth
30
+ # itself is orchestrated by {HttpClient} (story 003); this module only exposes the seam
31
+ # (`#access_token` / `#invalidate!`) and never loops on requests itself.
32
+ #
33
+ # ── DI seam (architecture §10, RB15) ──
34
+ # This is the REAL token source {HttpClient} expects via its injected `token_manager:`
35
+ # (`auth_headers` calls `#access_token`; the 401 one-shot calls `#invalidate!`). {Dinie::Client}
36
+ # builds ONE `Faraday::Connection` and injects it into BOTH the HttpClient and this manager, so
37
+ # the token POST rides the SAME connection pool as every other request — and, once story 005
38
+ # mounts the logging middleware on that shared connection, the `Authorization: Basic` header
39
+ # (which carries the client secret) is redacted there. URLs are absolute, so the connection's
40
+ # `url_prefix` is irrelevant (mirrors {HttpClient}). A `connection:` is optional: standalone use
41
+ # (and most specs) lets the manager build its own.
42
+ #
43
+ # ── runtime ↔ generated boundary ──
44
+ # Lives in `runtime/`, imports only `errors` (for {Dinie::OAuthError}) + Faraday, and is NOT
45
+ # part of the public barrel: {Dinie::Client}/{HttpClient} construct it internally.
46
+ #
47
+ # The ClassLength cop is disabled below: the token lifecycle (validity check → claim → POST →
48
+ # parse → cache → wake) is one cohesive concurrency unit; splitting it would scatter the
49
+ # invariant (the refresh claim and the cache mutation must share one mutex).
50
+ class TokenManager # rubocop:disable Metrics/ClassLength
51
+ # Bare token-endpoint path, appended to the configured base URL (which already carries the
52
+ # `/api/v3` version prefix, so the full URL is `…/api/v3/auth/token`).
53
+ TOKEN_PATH = "/auth/token"
54
+ # Session-exchange endpoint path — step 2 of the two-step session-mode flow. POSTed with
55
+ # the cc-bearer in `Authorization: Bearer …` and `{ code }` JSON body.
56
+ SESSION_EXCHANGE_PATH = "/biometrics/session-exchange"
57
+ # Refresh the token this many SECONDS before its stated expiry (300s, `runtime-patterns.md`
58
+ # §4). The margin absorbs clock skew and in-flight latency so a live request never carries a
59
+ # token that expires mid-flight.
60
+ REFRESH_MARGIN_SECONDS = 300
61
+ # The fixed `application/x-www-form-urlencoded` body of the client_credentials grant.
62
+ GRANT_BODY = "grant_type=client_credentials"
63
+ # `Content-Type` of the token request.
64
+ FORM_CONTENT_TYPE = "application/x-www-form-urlencoded"
65
+ # OAuthError messages for a malformed token payload (kept as constants so the guard clauses
66
+ # stay on one line and read cleanly).
67
+ MISSING_ACCESS_TOKEN = 'OAuth2 token response was missing a valid "access_token".'
68
+ # {Dinie::OAuthError} message for a token payload missing a valid `expires_in`.
69
+ MISSING_EXPIRES_IN = 'OAuth2 token response was missing a valid "expires_in".'
70
+
71
+ # @param client_id [String] OAuth2 client id (the Basic-auth username)
72
+ # @param client_secret [String] OAuth2 client secret (the Basic-auth password)
73
+ # @param base_url [String, nil] API base URL incl. the version prefix (default
74
+ # {HttpClient::DEFAULT_BASE_URL}); the bare {TOKEN_PATH} is appended to it
75
+ # @param connection [Faraday::Connection, nil] injected shared connection (pool-sharing seam);
76
+ # `nil` builds a standalone one. Requests use absolute URLs, so `url_prefix` is irrelevant
77
+ # @param refresh_margin_seconds [Integer] proactive-refresh margin (default
78
+ # {REFRESH_MARGIN_SECONDS}); parameterized for boundary tests
79
+ # @param adapter [Symbol, nil] Faraday adapter for the standalone connection (default
80
+ # `:net_http_persistent`)
81
+ # @param clock [#call, nil] monotonic-ish "seconds now" source (tests inject a controllable
82
+ # one to exercise the margin boundary); defaults to wall-clock seconds
83
+ # @param code [String, nil] session-mode authorization code (from the biometrics flow).
84
+ # When present the manager operates in **session mode**: `#access_token` performs a
85
+ # two-step exchange (cc-credentials → {SESSION_EXCHANGE_PATH}) exactly once. After the
86
+ # session token expires, {Dinie::SessionTokenExpiredError} is raised — the code is
87
+ # single-use and cannot be re-exchanged.
88
+ def initialize(client_id:, client_secret:, base_url: nil, connection: nil, # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength
89
+ refresh_margin_seconds: REFRESH_MARGIN_SECONDS, adapter: nil, clock: nil, code: nil)
90
+ @client_id = client_id
91
+ @client_secret = client_secret
92
+ @base_url = (base_url || HttpClient::DEFAULT_BASE_URL).sub(%r{/+\z}, "")
93
+ @refresh_margin_seconds = refresh_margin_seconds
94
+ @clock = clock || -> { ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) }
95
+ @connection = connection || build_connection(adapter)
96
+ @code = code
97
+ @exchanged = false
98
+
99
+ @mutex = Mutex.new
100
+ @condition = ConditionVariable.new
101
+ @refreshing = false
102
+ @access_token = nil
103
+ @expires_at = nil
104
+ end
105
+
106
+ # Return a valid Bearer access token, acquiring or refreshing transparently.
107
+ #
108
+ # Fast path: a cached token still inside the margin is returned without a request. Otherwise
109
+ # the FIRST caller claims the refresh under the mutex and runs the POST outside it; concurrent
110
+ # callers park on the condition variable and, after a double-check on wake, return the
111
+ # freshly-cached token. Under N concurrent callers exactly ONE token POST occurs.
112
+ #
113
+ # @return [String] a valid OAuth2 access token
114
+ # @raise [Dinie::OAuthError] the token handshake failed (transport, non-2xx, or malformed body)
115
+ def access_token
116
+ @mutex.synchronize do
117
+ loop do
118
+ return @access_token if token_valid?
119
+ break unless @refreshing
120
+
121
+ # Another thread is already refreshing — park until it broadcasts, then re-check
122
+ # (double-check pattern): it may have populated the cache, or its refresh may have failed.
123
+ @condition.wait(@mutex)
124
+ end
125
+ @refreshing = true # claim the refresh (under the mutex — only one thread can win)
126
+ end
127
+
128
+ refresh_and_cache
129
+ end
130
+
131
+ # Drop the cached token so the next {#access_token} re-authenticates. Called by {HttpClient}
132
+ # on a 401 (the one-shot re-auth is orchestrated there). Takes the mutex so it never races a
133
+ # concurrent validity check.
134
+ #
135
+ # @return [void]
136
+ def invalidate!
137
+ @mutex.synchronize do
138
+ @access_token = nil
139
+ @expires_at = nil
140
+ end
141
+ end
142
+
143
+ private
144
+
145
+ # True when a token is cached and still outside the refresh margin. Only ever called under
146
+ # the mutex, so reading `@access_token`/`@expires_at` together is race-free.
147
+ def token_valid?
148
+ !@access_token.nil? && now < @expires_at - @refresh_margin_seconds
149
+ end
150
+
151
+ # Run the refresh OUTSIDE the mutex (the POST is I/O — holding the lock would block the
152
+ # validity check for every other caller). Whatever happens, the `ensure` clears the claim and
153
+ # wakes the parked threads, so a failed handshake never leaves a permanently-stuck lock: the
154
+ # next waiter re-checks, finds no token, and retries.
155
+ def refresh_and_cache # rubocop:disable Metrics/MethodLength
156
+ access_token, expires_at = fetch_token
157
+ @mutex.synchronize do
158
+ @access_token = access_token
159
+ @expires_at = expires_at
160
+ end
161
+ access_token
162
+ ensure
163
+ @mutex.synchronize do
164
+ @refreshing = false
165
+ @condition.broadcast
166
+ end
167
+ end
168
+
169
+ # Dispatch to partner mode or session mode. Partner mode (no `code`) performs the standard
170
+ # client_credentials POST and caches until the margin. Session mode performs the two-step
171
+ # exchange exactly once; subsequent stale-token paths raise {Dinie::SessionTokenExpiredError}.
172
+ def fetch_token
173
+ return fetch_partner_token unless @code
174
+ raise Dinie::SessionTokenExpiredError if @exchanged
175
+
176
+ fetch_session_token
177
+ end
178
+
179
+ # Session mode step: cc-credentials → SESSION_EXCHANGE_PATH. Returns
180
+ # `[customer_access_token, absolute_expiry_seconds]`. Sets `@exchanged` AFTER the POST
181
+ # succeeds so T9 (exchange failure) leaves `@exchanged = false` and the caller can see
182
+ # the typed API error (no phantom expiry on the next call).
183
+ def fetch_session_token
184
+ cc_token = fetch_client_credentials_token
185
+ access_token, expires_in = exchange(cc_token, @code)
186
+ @exchanged = true
187
+ [access_token, now + expires_in]
188
+ end
189
+
190
+ # Partner mode: a single client_credentials POST, returns
191
+ # `[access_token, absolute_expiry_seconds]`. The original `fetch_token` body, extracted
192
+ # so `fetch_token` stays a clean dispatcher.
193
+ def fetch_partner_token
194
+ response = request_token
195
+ raise Dinie::OAuthError, status_failure(response.status, body_detail(response)) unless success?(response.status)
196
+
197
+ access_token, expires_in = parse_token_response(response.body)
198
+ [access_token, now + expires_in]
199
+ end
200
+
201
+ # Step 1 of the session two-step: obtain a cc-bearer (used as the Authorization header
202
+ # on the subsequent exchange POST). Returns only the access_token string.
203
+ def fetch_client_credentials_token
204
+ response = request_token
205
+ raise Dinie::OAuthError, status_failure(response.status, body_detail(response)) unless success?(response.status)
206
+
207
+ access_token, = parse_token_response(response.body)
208
+ access_token
209
+ end
210
+
211
+ # Step 2 of the session two-step: POST SESSION_EXCHANGE_PATH with the cc-bearer and
212
+ # the authorization code. Returns `[customer_access_token, expires_in]`. Non-2xx raises
213
+ # the typed API error from {Dinie::Internal::Errors.from_response} (e.g. AuthError on 401).
214
+ def exchange(cc_token, code)
215
+ response = request_exchange(cc_token, code)
216
+ unless success?(response.status)
217
+ raise Dinie::Internal::Errors.from_response(
218
+ status: response.status, headers: response.headers.to_h, body: response.body
219
+ )
220
+ end
221
+
222
+ parse_exchange_response(response.body)
223
+ end
224
+
225
+ # One Faraday round-trip to SESSION_EXCHANGE_PATH. Absolute URL. Transport failures
226
+ # become {Dinie::OAuthError} (no server response to dispatch on).
227
+ def request_exchange(cc_token, code)
228
+ @connection.run_request(
229
+ :post,
230
+ exchange_url,
231
+ JSON.generate(code: code),
232
+ "authorization" => "Bearer #{cc_token}",
233
+ "content-type" => "application/json",
234
+ "accept" => "application/json"
235
+ )
236
+ rescue Faraday::Error => e
237
+ raise Dinie::OAuthError, "Session exchange request failed before a response was received: #{e.message}"
238
+ end
239
+
240
+ # Validate + extract the exchange response, returning `[access_token, expires_in]`.
241
+ # Raises {Dinie::OAuthError} on structural problems (shape, missing fields).
242
+ def parse_exchange_response(raw_body)
243
+ parsed = parse_json(raw_body)
244
+ raise Dinie::OAuthError, "Session exchange response was not a JSON object." unless parsed.is_a?(Hash)
245
+
246
+ access_token = parsed[:access_token]
247
+ expires_in = parsed[:expires_in]
248
+ raise Dinie::OAuthError, MISSING_ACCESS_TOKEN unless valid_access_token?(access_token)
249
+ raise Dinie::OAuthError, MISSING_EXPIRES_IN unless valid_expires_in?(expires_in)
250
+
251
+ [access_token, expires_in]
252
+ end
253
+
254
+ def exchange_url
255
+ "#{@base_url}#{SESSION_EXCHANGE_PATH}"
256
+ end
257
+
258
+ # One Faraday round-trip to the token endpoint. Absolute URL ⇒ the connection's `url_prefix`
259
+ # is irrelevant. A transport failure (DNS, refused, timeout) becomes {Dinie::OAuthError}.
260
+ def request_token
261
+ @connection.run_request(:post, token_url, GRANT_BODY, token_request_headers)
262
+ rescue Faraday::Error => e
263
+ raise Dinie::OAuthError, "OAuth2 token request failed before a response was received: #{e.message}"
264
+ end
265
+
266
+ # Validate + extract the wire payload, returning `[access_token, expires_in]`. `token_type` is
267
+ # intentionally ignored: {HttpClient} always sends `Authorization: Bearer …`, so the wire
268
+ # value is informational. Any shape problem raises {Dinie::OAuthError}.
269
+ def parse_token_response(raw_body)
270
+ parsed = parse_json(raw_body)
271
+ raise Dinie::OAuthError, "OAuth2 token response was not a JSON object." unless parsed.is_a?(Hash)
272
+
273
+ access_token = parsed[:access_token]
274
+ expires_in = parsed[:expires_in]
275
+ raise Dinie::OAuthError, MISSING_ACCESS_TOKEN unless valid_access_token?(access_token)
276
+ raise Dinie::OAuthError, MISSING_EXPIRES_IN unless valid_expires_in?(expires_in)
277
+
278
+ [access_token, expires_in]
279
+ end
280
+
281
+ def parse_json(raw_body)
282
+ JSON.parse(raw_body.to_s, symbolize_names: true)
283
+ rescue JSON::ParserError
284
+ raise Dinie::OAuthError, "OAuth2 token response body was not valid JSON."
285
+ end
286
+
287
+ def valid_access_token?(value)
288
+ value.is_a?(String) && !value.empty?
289
+ end
290
+
291
+ def valid_expires_in?(value)
292
+ value.is_a?(Numeric) && value.positive?
293
+ end
294
+
295
+ def token_request_headers
296
+ {
297
+ "authorization" => "Basic #{basic_credentials}",
298
+ "content-type" => FORM_CONTENT_TYPE,
299
+ "accept" => "application/json"
300
+ }
301
+ end
302
+
303
+ # Base64 (RFC 4648) of `client_id:client_secret`, no line breaks (HTTP Basic).
304
+ def basic_credentials
305
+ Base64.strict_encode64("#{@client_id}:#{@client_secret}")
306
+ end
307
+
308
+ def token_url
309
+ "#{@base_url}#{TOKEN_PATH}"
310
+ end
311
+
312
+ def success?(status)
313
+ (200..299).cover?(status)
314
+ end
315
+
316
+ # Best-effort body text for a non-2xx failure message (never raises).
317
+ def body_detail(response)
318
+ response.body.to_s.strip
319
+ rescue StandardError
320
+ ""
321
+ end
322
+
323
+ def status_failure(status, detail)
324
+ suffix = detail.empty? ? "" : ": #{detail}"
325
+ "OAuth2 token request failed with status #{status}#{suffix}"
326
+ end
327
+
328
+ def now
329
+ @clock.call
330
+ end
331
+
332
+ # Standalone fallback connection (production injects the shared one from {Dinie::Client}).
333
+ # No logger seam here: the canonical, logged connection is the Client's shared one.
334
+ def build_connection(adapter)
335
+ Faraday.new(url: @base_url) do |faraday|
336
+ faraday.adapter(adapter || :net_http_persistent)
337
+ end
338
+ end
339
+ end
340
+ end
341
+ end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+
6
+ require_relative "errors"
7
+
8
+ module Dinie
9
+ # `Dinie::Webhooks.extract` — webhook verification + per-type deserialization, a module-function
10
+ # (RB11, architecture §8, §18.2). Implements the Standard Webhooks v1 contract Dinie inherited:
11
+ #
12
+ # signed_payload = "{webhook-id}.{webhook-timestamp}.{body}"
13
+ # expected = base64(HMAC-SHA256(decode_secret(secret), signed_payload))
14
+ # header = "v1,<sig1> v1,<sig2>" # space-separated, rotation-capable
15
+ #
16
+ # `extract` does verification AND deserialization in one call: it reads the three `webhook-*`
17
+ # headers (case-insensitive, symbol/string), enforces a bidirectional timestamp window (replay
18
+ # guard), decodes the secret (`whsec_` → base64), HMAC-SHA256s the signed payload, and compares —
19
+ # in CONSTANT time via {OpenSSL.secure_compare} — against every `v1,<sig>` in the header. Any
20
+ # match → {Dinie::Events::DESERIALIZERS}`[type]` hydrates the snake-native body into the typed
21
+ # {Dinie::Events} member; an unknown `type` → {Dinie::UnknownWebhookEventError}; no match →
22
+ # {Dinie::WebhookSignatureError}. It NEVER returns an unverified event (security by behavior — the
23
+ # name `extract` returns the event, not a boolean).
24
+ #
25
+ # Both `secret` and the signature header accept multiple values, so secret rotation works from
26
+ # either side. Verification needs no OAuth credentials, hence a module-function (not a client
27
+ # method). Mirrors `sdk-js` `src/runtime/webhooks.ts` (@ `19c9bca`), in Ruby (`openssl` stdlib;
28
+ # base64 via `String#unpack1("m")` / `Array#pack("m0")` — no extra dependency).
29
+ #
30
+ # ── runtime ↔ generated boundary (controlled inverse import — openapi-SoT, §4) ──
31
+ # The general rule is "runtime/ never imports generated/". `webhooks.rb` is one of two declared
32
+ # exceptions (the other is `http.rb → ERROR_REGISTRY`). The webhook event catalog's SoT is
33
+ # `openapi.yaml` (`webhooks:`), so the typed members AND the {Dinie::Events::DESERIALIZERS} table
34
+ # live in `generated/events/`. This module references the table at call time; the barrel
35
+ # (`lib/dinie.rb`) loads `generated/events` before any call. The reference is the forcing-function:
36
+ # an event not in openapi is not in `generated/events`, not in the table, so `extract` raises
37
+ # {Dinie::UnknownWebhookEventError} — forcing the contract conversation.
38
+ #
39
+ # ── Header normalization (deferred decision §21.5 — RESOLVED: case-insensitive + symbol/string) ──
40
+ # Standard Webhooks headers are lowercase dashed (`webhook-id`). Inbound frameworks vary in
41
+ # capitalization, so the lookup is case-insensitive and accepts both String and Symbol keys. The
42
+ # partner passes a Hash keyed by the `webhook-*` names; framework env normalization (Rack's
43
+ # `HTTP_WEBHOOK_ID`) is the partner's responsibility (a framework adapter is out of scope, §1):
44
+ #
45
+ # # Rails: Dinie::Webhooks.extract(headers: request.headers.to_h, body: request.raw_post, secret:)
46
+ # # Sinatra: Dinie::Webhooks.extract(headers: { "webhook-id" => request.env["HTTP_WEBHOOK_ID"],
47
+ # # "webhook-timestamp" => request.env["HTTP_WEBHOOK_TIMESTAMP"],
48
+ # # "webhook-signature" => request.env["HTTP_WEBHOOK_SIGNATURE"] }, body: request.body.read, secret:)
49
+ # # Rack: headers = env.select { |k, _| k.start_with?("HTTP_") }
50
+ # # .transform_keys { |k| k.delete_prefix("HTTP_").downcase.tr("_", "-") }
51
+ module Webhooks
52
+ # Default replay tolerance, in seconds (5 minutes), applied in both directions.
53
+ DEFAULT_TOLERANCE_SECONDS = 300
54
+ # Secrets carrying this prefix are base64-encoded; the remainder is decoded before HMAC.
55
+ WHSEC_PREFIX = "whsec_"
56
+ # Only `v1` signature tokens are honored.
57
+ SIGNATURE_VERSION = "v1"
58
+
59
+ module_function
60
+
61
+ # Verify a Standard Webhooks v1 payload and return the deserialized, typed event — a
62
+ # {Dinie::Events} member straight from openapi (`generated/events/`), hydrated per type so the
63
+ # snake-native surface is honest. It NEVER returns an unverified event.
64
+ #
65
+ # @param headers [Hash] inbound HTTP headers (case-insensitive lookup of the `webhook-*` trio)
66
+ # @param body [String] the RAW request body, exactly as received, BEFORE `JSON.parse`
67
+ # @param secret [String, Array<String>] signing secret(s); `whsec_`-prefixed → base64. An Array
68
+ # tries each secret (rotation)
69
+ # @param tolerance_seconds [Integer] replay window in seconds, applied in both directions
70
+ # @return [Dinie::Events::WebhookEventBase] the verified, typed event member
71
+ # @raise [Dinie::WebhookTimestampError] the timestamp header is missing, malformed, or outside
72
+ # the tolerance window (too old OR too far in the future)
73
+ # @raise [Dinie::WebhookSignatureError] a required header is missing, no usable secret was
74
+ # supplied, or no signature in the header matched
75
+ # @raise [Dinie::UnknownWebhookEventError] the signature verified but the payload's `type` is
76
+ # not in the openapi event catalog
77
+ def extract(headers:, body:, secret:, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS)
78
+ webhook_id = required_header(headers, "webhook-id", Dinie::WebhookSignatureError)
79
+ timestamp = required_header(headers, "webhook-timestamp", Dinie::WebhookTimestampError)
80
+ signature = required_header(headers, "webhook-signature", Dinie::WebhookSignatureError)
81
+
82
+ verify_timestamp!(timestamp, tolerance_seconds)
83
+ verify_signature!(signed_payload(webhook_id, timestamp, body), secret, signature)
84
+ deserialize_verified_event(body)
85
+ end
86
+
87
+ # @api private
88
+ # Build the byte-faithful signed payload `"{id}.{timestamp}.{body}"` (binary-concatenated so a
89
+ # non-UTF-8 body never raises an encoding error).
90
+ def signed_payload(webhook_id, timestamp, body)
91
+ "#{webhook_id}.#{timestamp}.".b + body.to_s.b
92
+ end
93
+
94
+ # @api private
95
+ # Fetch a required header or raise `error_class`. Missing and empty both fail.
96
+ def required_header(headers, name, error_class)
97
+ value = lookup_header(headers, name)
98
+ return value unless value.nil? || value == ""
99
+
100
+ raise error_class, "Missing required webhook header: #{name}."
101
+ end
102
+
103
+ # @api private
104
+ # Case-insensitive, symbol/string single-value header lookup (§21.5). A list value (rare) yields
105
+ # its first element.
106
+ def lookup_header(headers, name)
107
+ value = fetch_header(headers, name)
108
+ value.is_a?(Array) ? value.first : value
109
+ end
110
+
111
+ # @api private
112
+ def fetch_header(headers, name)
113
+ return headers[name] if headers.key?(name)
114
+ return headers[name.to_sym] if headers.key?(name.to_sym)
115
+
116
+ target = name.downcase
117
+ headers.each { |key, value| return value if key.to_s.downcase == target }
118
+ nil
119
+ end
120
+
121
+ # @api private
122
+ # Enforce the bidirectional replay window: reject a malformed, too-old, or too-far-future
123
+ # timestamp.
124
+ def verify_timestamp!(timestamp, tolerance_seconds)
125
+ seconds = Integer(timestamp.to_s.strip, 10, exception: false)
126
+ raise Dinie::WebhookTimestampError, "Invalid webhook timestamp: #{timestamp.inspect}." if seconds.nil?
127
+
128
+ skew = Time.now.to_i - seconds
129
+ raise Dinie::WebhookTimestampError, "Webhook timestamp is too old." if skew > tolerance_seconds
130
+ return unless skew < -tolerance_seconds
131
+
132
+ raise Dinie::WebhookTimestampError, "Webhook timestamp is too far in the future."
133
+ end
134
+
135
+ # @api private
136
+ # Raise unless any (secret, signature) pair matches (constant-time, multi-sig rotation).
137
+ def verify_signature!(payload, secret, signature_header)
138
+ secrets = normalize_secrets(secret)
139
+ raise Dinie::WebhookSignatureError, "No webhook secret provided." if secrets.empty?
140
+ return if signatures_match?(payload, secrets, parse_signature_header(signature_header))
141
+
142
+ raise Dinie::WebhookSignatureError,
143
+ "No matching webhook signature found; the payload may have been tampered with."
144
+ end
145
+
146
+ # @api private
147
+ def normalize_secrets(secret)
148
+ Array(secret).select { |candidate| candidate.is_a?(String) && !candidate.empty? }
149
+ end
150
+
151
+ # @api private
152
+ # Split the `webhook-signature` header into decoded `v1` signatures. Space-separated
153
+ # `v1,<base64>` tokens; non-`v1` and malformed tokens are dropped.
154
+ def parse_signature_header(header)
155
+ header.split.filter_map do |token|
156
+ version, separator, value = token.partition(",")
157
+ next if separator.empty? || value.empty? || version != SIGNATURE_VERSION
158
+
159
+ value.unpack1("m")
160
+ end
161
+ end
162
+
163
+ # @api private
164
+ # True when any (secret, signature) pair matches. {OpenSSL.secure_compare} is constant-time and
165
+ # safe on unequal-length inputs (it digests both first), so no length guard is needed.
166
+ def signatures_match?(payload, secrets, provided_signatures)
167
+ secrets.any? do |secret|
168
+ expected = OpenSSL::HMAC.digest("SHA256", decode_secret(secret), payload)
169
+ provided_signatures.any? { |candidate| OpenSSL.secure_compare(candidate, expected) }
170
+ end
171
+ end
172
+
173
+ # @api private
174
+ # `whsec_`-prefixed secrets are base64; everything else is treated as raw bytes.
175
+ def decode_secret(secret)
176
+ return secret.delete_prefix(WHSEC_PREFIX).unpack1("m") if secret.start_with?(WHSEC_PREFIX)
177
+
178
+ secret.b
179
+ end
180
+
181
+ # @api private
182
+ # Deserialize a VERIFIED body per type (reached only after a signature matched, so the bytes are
183
+ # authentic). Dispatch is table-driven through {Dinie::Events::DESERIALIZERS}; a `type` absent
184
+ # from the catalog raises {Dinie::UnknownWebhookEventError} (forces the contract conversation).
185
+ def deserialize_verified_event(body)
186
+ raw = JSON.parse(body, symbolize_names: true)
187
+ event_type = raw[:type]
188
+ deserializer = event_type.is_a?(String) ? Dinie::Events::DESERIALIZERS[event_type] : nil
189
+ raise Dinie::UnknownWebhookEventError, event_type.to_s if deserializer.nil?
190
+
191
+ deserializer.call(raw)
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dinie
4
+ # The gem version (semver). Surfaced in the `User-Agent` (`Dinie-SDK-Ruby/<VERSION>`) and the
5
+ # `X-Dinie-Sdk-Version` header. Generator-sourced in V0.5.
6
+ VERSION = "1.1.0"
7
+ end
data/lib/dinie.rb ADDED
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Curated public barrel — the single entry point consumers load via `require "dinie"`.
4
+ #
5
+ # It uses explicit `require_relative`s (no Zeitwerk — RB16) so load order is deterministic
6
+ # for the generated error/event registries. This mirrors `src/index.ts` in `sdk-js`:
7
+ # the runtime is required first (hand-written mechanism), then the generated indexes.
8
+ #
9
+ # At bootstrap only `version` exists. The runtime and generated layers land in later
10
+ # stories; their requires stay as commented TODOs so the smoke suite is green before
11
+ # those files exist. Uncomment each line in the story that introduces the file.
12
+
13
+ require_relative "dinie/version"
14
+
15
+ # --- generated api_version constant (required before runtime so http.rb can read it) ---
16
+ require_relative "dinie/generated/api_version"
17
+
18
+ # --- runtime (hand-written — CODEOWNER humans — stories 002–005) ---------------
19
+ require_relative "dinie/runtime/model"
20
+ require_relative "dinie/runtime/errors"
21
+ require_relative "dinie/runtime/request_options"
22
+ require_relative "dinie/runtime/idempotency"
23
+ require_relative "dinie/runtime/rate_limit"
24
+ require_relative "dinie/runtime/retry"
25
+ require_relative "dinie/runtime/http"
26
+ require_relative "dinie/runtime/token_manager"
27
+ require_relative "dinie/runtime/logger"
28
+ require_relative "dinie/runtime/paginator"
29
+ require_relative "dinie/runtime/multipart"
30
+ require_relative "dinie/runtime/webhooks"
31
+
32
+ # --- generated (hand-authored V0.3 — CODEOWNER bot — stories 006–011) ----------
33
+ require_relative "dinie/generated/errors/registry"
34
+ require_relative "dinie/generated/types"
35
+ require_relative "dinie/generated/events"
36
+ require_relative "dinie/generated/resources"
37
+ require_relative "dinie/generated/client"
@@ -0,0 +1,44 @@
1
+ # Vendored minimal Faraday stub (architecture §5.2, story 013 / §21.3).
2
+ #
3
+ # Faraday + net-http-persistent ship no bundled RBS and are not in `rbs collection` at a usable
4
+ # fidelity, so this stub declares only the Faraday surface the SDK references — entry points return
5
+ # `untyped`, so every downstream Faraday interaction (connection, request env, response) type-checks
6
+ # permissively without inventing precise third-party signatures. This is the documented seam that
7
+ # keeps `steep check` informative in v1 (the `# §21.3 decision` in the Steepfile): replace it with a
8
+ # real upstream Faraday RBS to promote the gate to blocking.
9
+ #
10
+ # Scope: only what `lib/dinie/runtime/{http,token_manager,logger,multipart}.rb` touch as Faraday
11
+ # constants. Everything else flows through `untyped`.
12
+
13
+ module Faraday
14
+ # `Faraday.new(url:) { |conn| ... }` — returns an untyped connection so `conn.request` /
15
+ # `conn.use` / `conn.adapter` / `conn.run_request` all resolve permissively.
16
+ def self.new: (*untyped, **untyped) ?{ (untyped) -> void } -> untyped
17
+
18
+ # Base for the custom logging middleware (`Internal::Middleware::Logging < Faraday::Middleware`).
19
+ class Middleware
20
+ def initialize: (untyped app) -> void
21
+ def call: (untyped env) -> untyped
22
+ end
23
+
24
+ # Transport error hierarchy the retry loop rescues + classifies.
25
+ class Error < StandardError
26
+ end
27
+
28
+ class TimeoutError < Error
29
+ end
30
+
31
+ class ConnectionFailed < Error
32
+ end
33
+
34
+ # Multipart parts the KYC upload wrapper builds / type-checks against.
35
+ module Multipart
36
+ class FilePart
37
+ def initialize: (*untyped) -> void
38
+ end
39
+
40
+ class ParamPart
41
+ def initialize: (*untyped) -> void
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,45 @@
1
+ # `Dinie::Client` — the SDK entry point (architecture §2 RB1, §3.2, §10). Mirrors
2
+ # `lib/dinie/generated/client.rb`. Owns a connection pool + an OAuth2 token cache; construct once.
3
+
4
+ module Dinie
5
+ # Per-call options accepted by every resource method: a normalized `RequestOptions` or a plain
6
+ # Hash (`{ timeout:, idempotency_key:, headers:, max_retries: }`). The methods default it to `{}`.
7
+ type request_opts = Dinie::Internal::RequestOptions | Hash[Symbol, untyped]
8
+
9
+ class Client
10
+ DEFAULT_TIMEOUT_SECONDS: Integer
11
+ DEFAULT_MAX_RETRIES: Integer
12
+
13
+ def initialize: (?client_id: String?, ?client_secret: String?, ?base_url: String?, ?timeout: Numeric, ?max_retries: Integer, ?idempotency: bool, ?log_level: Symbol, ?logger: untyped, ?adapter: Symbol?, ?token_manager: Dinie::Internal::TokenManager?, ?connection: untyped) -> void
14
+
15
+ # The customers resource.
16
+ def customers: () -> Dinie::Resources::Customers
17
+
18
+ # The credit-offers resource.
19
+ def credit_offers: () -> Dinie::Resources::CreditOffers
20
+
21
+ # The loans resource.
22
+ def loans: () -> Dinie::Resources::Loans
23
+
24
+ # The banks resource (flat `Array<Bank>` list).
25
+ def banks: () -> Dinie::Resources::Banks
26
+
27
+ # The credentials resource.
28
+ def credentials: () -> Dinie::Resources::Credentials
29
+
30
+ # The webhook-endpoints resource.
31
+ def webhook_endpoints: () -> Dinie::Resources::WebhookEndpoints
32
+
33
+ # The latest rate-limit snapshot; `nil` before the first call.
34
+ def rate_limit: () -> Dinie::RateLimit?
35
+
36
+ # Clone the client with merged config, sharing the same TokenManager + connection.
37
+ def with_options: (**untyped overrides) -> Dinie::Client
38
+
39
+ private
40
+
41
+ def build_token_manager: (String? client_id, String? client_secret) -> Dinie::Internal::TokenManager
42
+ def build_connection: (log_level: Symbol, logger: untyped, adapter: Symbol?) -> untyped
43
+ def first_present: (*untyped candidates) -> untyped
44
+ end
45
+ end