preverus 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +324 -0
- data/lib/preverus/client.rb +24 -0
- data/lib/preverus/errors.rb +16 -0
- data/lib/preverus/http_client.rb +91 -0
- data/lib/preverus/resources.rb +99 -0
- data/lib/preverus/results.rb +82 -0
- data/lib/preverus.rb +6 -0
- data/preverus.gemspec +16 -0
- metadata +48 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 25c4aebb9cfa7bd4d2c05599cdd9feca312e5920ca06e18b2baad0f7382355ce
|
|
4
|
+
data.tar.gz: 3620728bc7d32c3056dcb2ddf27e1fe63d8309abebfc18f69579a2ab93850e9e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: d97656d46e9618553da42d1e358b500a69141c99693bd588994a518cd900eaa3d1112751aa75f2230c1b84d6b9a76c340e9cd57dd0d130cff4545023b2addb2f
|
|
7
|
+
data.tar.gz: a09e2707363bf1db60207b82745c47ea4f39680c232561be79b810f7c20599c3317ab9cc37694ea131ba5975bfa338bf3ed599626249cfe39ff1304479694851
|
data/README.md
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
# Preverus Ruby
|
|
2
|
+
|
|
3
|
+
Ruby client for Preverus backend enforcement.
|
|
4
|
+
|
|
5
|
+
Use this gem with Rails, Rack, Sinatra, Hanami, or any Ruby backend that loads the hosted Preverus browser script and needs trusted server-side fraud decisions.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Add to your Gemfile:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
gem "preverus"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Then run:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
bundle install
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Requires Ruby 3.0+.
|
|
22
|
+
|
|
23
|
+
## Browser And Server Flow
|
|
24
|
+
|
|
25
|
+
Add the hosted script to your layout:
|
|
26
|
+
|
|
27
|
+
```html
|
|
28
|
+
<script
|
|
29
|
+
src="https://cdn.preverus.com/v1/preverus.js"
|
|
30
|
+
data-preverus-key="pk_live_xxx"
|
|
31
|
+
data-preverus-auto="true"
|
|
32
|
+
data-preverus-track-forms="true"
|
|
33
|
+
></script>
|
|
34
|
+
|
|
35
|
+
<form method="POST" action="/register" data-preverus-action="signup">
|
|
36
|
+
<input name="email" type="email">
|
|
37
|
+
<button type="submit">Create account</button>
|
|
38
|
+
</form>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Before submit, the script attaches:
|
|
42
|
+
|
|
43
|
+
```text
|
|
44
|
+
preverus_fingerprint
|
|
45
|
+
preverus_visitor_id
|
|
46
|
+
preverus_risk_session_token
|
|
47
|
+
preverus_browser_session_event_id
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Your backend sends those values to Preverus with a private server key before approving sensitive actions.
|
|
51
|
+
|
|
52
|
+
## Quick Start
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
require "preverus"
|
|
56
|
+
|
|
57
|
+
client = Preverus::Client.new(server_key: ENV.fetch("PREVERUS_SERVER_KEY"))
|
|
58
|
+
|
|
59
|
+
decision = client.decisions.evaluate(
|
|
60
|
+
{
|
|
61
|
+
event_type: "signup",
|
|
62
|
+
user_id: "acct_42",
|
|
63
|
+
ip: request.remote_ip,
|
|
64
|
+
risk_session_token: params[:preverus_risk_session_token],
|
|
65
|
+
fingerprint: params[:preverus_fingerprint],
|
|
66
|
+
metadata: {
|
|
67
|
+
email: params[:email],
|
|
68
|
+
browser_session_event_id: params[:preverus_browser_session_event_id]
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
visitor_id: params[:preverus_visitor_id],
|
|
72
|
+
idempotency_key: "signup:acct_42:request-id"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if decision.block?
|
|
76
|
+
head :forbidden
|
|
77
|
+
return
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
if decision.review?
|
|
81
|
+
redirect_to verify_path
|
|
82
|
+
return
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Continue signup.
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Prefer `risk_session_token` when available. It links the backend action to the browser session collected moments earlier.
|
|
89
|
+
|
|
90
|
+
## Configuration
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
client = Preverus::Client.new(
|
|
94
|
+
server_key: ENV.fetch("PREVERUS_SERVER_KEY"),
|
|
95
|
+
endpoint: "https://api.preverus.com",
|
|
96
|
+
timeout: 1.5,
|
|
97
|
+
retries: 2,
|
|
98
|
+
retry_delay: 0.15,
|
|
99
|
+
max_retry_delay: 1.0
|
|
100
|
+
)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The gem retries transient network failures and retryable statuses:
|
|
104
|
+
|
|
105
|
+
```text
|
|
106
|
+
408, 409, 425, 429, 500, 502, 503, 504
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
It does not retry validation or authentication errors such as `400`, `401`, `403`, or `422`.
|
|
110
|
+
|
|
111
|
+
Use idempotency keys for retried POST requests.
|
|
112
|
+
|
|
113
|
+
## Rails Setup
|
|
114
|
+
|
|
115
|
+
Create an initializer:
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
# config/initializers/preverus.rb
|
|
119
|
+
PREVERUS = Preverus::Client.new(
|
|
120
|
+
server_key: ENV.fetch("PREVERUS_SERVER_KEY"),
|
|
121
|
+
timeout: 1.5,
|
|
122
|
+
retries: 2
|
|
123
|
+
)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Use it in a controller:
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
class RegistrationsController < ApplicationController
|
|
130
|
+
def create
|
|
131
|
+
decision = PREVERUS.decisions.evaluate(
|
|
132
|
+
{
|
|
133
|
+
event_type: "signup",
|
|
134
|
+
user_id: params[:user_id],
|
|
135
|
+
ip: request.remote_ip,
|
|
136
|
+
risk_session_token: params[:preverus_risk_session_token],
|
|
137
|
+
fingerprint: params[:preverus_fingerprint],
|
|
138
|
+
metadata: {
|
|
139
|
+
email: params[:email],
|
|
140
|
+
user_agent: request.user_agent,
|
|
141
|
+
browser_session_event_id: params[:preverus_browser_session_event_id]
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
visitor_id: params[:preverus_visitor_id],
|
|
145
|
+
idempotency_key: request.request_id
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
return head :forbidden if decision.block?
|
|
149
|
+
return redirect_to verify_path if decision.review?
|
|
150
|
+
|
|
151
|
+
# Continue registration.
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Sensitive Actions
|
|
157
|
+
|
|
158
|
+
Use decisions synchronously when your app needs an immediate allow/review/block answer:
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
decision = PREVERUS.decisions.evaluate(
|
|
162
|
+
{
|
|
163
|
+
event_type: "withdraw",
|
|
164
|
+
user_id: current_user.id,
|
|
165
|
+
ip: request.remote_ip,
|
|
166
|
+
risk_session_token: params[:preverus_risk_session_token],
|
|
167
|
+
metadata: {
|
|
168
|
+
payment_address: params[:payment_address]
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
visitor_id: params[:preverus_visitor_id],
|
|
172
|
+
idempotency_key: request.request_id
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
return head :forbidden if decision.block?
|
|
176
|
+
return redirect_to withdraw_review_path if decision.review?
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Good event names include:
|
|
180
|
+
|
|
181
|
+
```text
|
|
182
|
+
signup
|
|
183
|
+
login
|
|
184
|
+
checkout
|
|
185
|
+
password_reset
|
|
186
|
+
payout
|
|
187
|
+
withdraw
|
|
188
|
+
payment_change
|
|
189
|
+
account_update
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Decisions
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
decision.recommended_action
|
|
196
|
+
decision.allow?
|
|
197
|
+
decision.review?
|
|
198
|
+
decision.block?
|
|
199
|
+
decision.to_h
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Recommended handling:
|
|
203
|
+
|
|
204
|
+
```text
|
|
205
|
+
allow -> proceed
|
|
206
|
+
review -> step-up auth, hold, or manual review
|
|
207
|
+
block -> deny or hard challenge
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## Events
|
|
211
|
+
|
|
212
|
+
Use events for non-blocking fraud telemetry:
|
|
213
|
+
|
|
214
|
+
```ruby
|
|
215
|
+
event = PREVERUS.events.create(
|
|
216
|
+
{
|
|
217
|
+
event_type: "login",
|
|
218
|
+
user_id: "acct_42",
|
|
219
|
+
ip: "203.0.113.10",
|
|
220
|
+
fingerprint: "fp_hash",
|
|
221
|
+
metadata: { email: "person@example.com" }
|
|
222
|
+
},
|
|
223
|
+
visitor_id: "v_abc123"
|
|
224
|
+
)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
In Rails, send non-blocking events from Active Job or a background worker so customer requests do not wait for telemetry.
|
|
228
|
+
|
|
229
|
+
## Lookups
|
|
230
|
+
|
|
231
|
+
```ruby
|
|
232
|
+
visitor = PREVERUS.visitors.lookup(visitor_id: "v_abc123")
|
|
233
|
+
visitor = PREVERUS.visitors.lookup(fingerprint: "fp_hash")
|
|
234
|
+
|
|
235
|
+
metadata = PREVERUS.metadata.lookup("email", "person@example.com")
|
|
236
|
+
graph = PREVERUS.metadata.graph("v_abc123")
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Use lookups for investigation and context. Use `decisions.evaluate` for final enforcement.
|
|
240
|
+
|
|
241
|
+
## Webhook Verification
|
|
242
|
+
|
|
243
|
+
```ruby
|
|
244
|
+
class PreverusWebhooksController < ApplicationController
|
|
245
|
+
skip_before_action :verify_authenticity_token
|
|
246
|
+
|
|
247
|
+
def create
|
|
248
|
+
raw_body = request.raw_post
|
|
249
|
+
valid = PREVERUS.webhooks.verify(
|
|
250
|
+
raw_body: raw_body,
|
|
251
|
+
timestamp: request.headers["X-Fraud-Webhook-Timestamp"].to_s,
|
|
252
|
+
signature_header: request.headers["X-Fraud-Webhook-Signature"].to_s,
|
|
253
|
+
secret: ENV.fetch("PREVERUS_WEBHOOK_SECRET")
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
return head :bad_request unless valid
|
|
257
|
+
|
|
258
|
+
payload = JSON.parse(raw_body)
|
|
259
|
+
|
|
260
|
+
# Store payload["id"] or X-Fraud-Webhook-Id for idempotency.
|
|
261
|
+
|
|
262
|
+
head :no_content
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Webhook delivery is at-least-once. Dedupe by `X-Fraud-Webhook-Id` or payload `id`.
|
|
268
|
+
|
|
269
|
+
You can also verify, parse, and dispatch by event type:
|
|
270
|
+
|
|
271
|
+
```ruby
|
|
272
|
+
event = PREVERUS.webhooks.construct_event(
|
|
273
|
+
raw_body: request.raw_post,
|
|
274
|
+
headers: request.headers,
|
|
275
|
+
secret: ENV.fetch("PREVERUS_WEBHOOK_SECRET")
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
return head :no_content if already_processed?(event.id)
|
|
279
|
+
|
|
280
|
+
PREVERUS.webhooks.dispatch(event, {
|
|
281
|
+
"decision.high_risk" => ->(event) { open_case(event.to_h) },
|
|
282
|
+
"*" => ->(event) { Rails.logger.info("Preverus webhook #{event.type}") }
|
|
283
|
+
})
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
The gem verifies and parses the event, but your app should store processed event IDs in your database or cache.
|
|
287
|
+
|
|
288
|
+
## Failure Handling
|
|
289
|
+
|
|
290
|
+
The Ruby core gem raises exceptions for API and network failures:
|
|
291
|
+
|
|
292
|
+
```ruby
|
|
293
|
+
begin
|
|
294
|
+
decision = PREVERUS.decisions.evaluate(...)
|
|
295
|
+
rescue Preverus::ApiError => error
|
|
296
|
+
Rails.logger.warn("Preverus API error: #{error.status_code} #{error.error_code}")
|
|
297
|
+
rescue Preverus::NetworkError
|
|
298
|
+
# Apply your app's fail-open, fail-review, or fail-closed policy.
|
|
299
|
+
end
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
For high-risk flows like withdrawals and payouts, a common policy is fail-review. For signup/login/checkout, many businesses prefer fail-open so the site keeps working during transient network failures.
|
|
303
|
+
|
|
304
|
+
Example fail-review wrapper:
|
|
305
|
+
|
|
306
|
+
```ruby
|
|
307
|
+
def evaluate_or_review(input, **options)
|
|
308
|
+
PREVERUS.decisions.evaluate(input, **options)
|
|
309
|
+
rescue Preverus::Error => error
|
|
310
|
+
Rails.logger.warn("Preverus fallback review: #{error.class}")
|
|
311
|
+
Preverus::DecisionResult.fallback("review", error.class.name)
|
|
312
|
+
end
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
## Production Checklist
|
|
316
|
+
|
|
317
|
+
- Keep `PREVERUS_SERVER_KEY` private.
|
|
318
|
+
- Use a browser key only in templates/frontend code.
|
|
319
|
+
- Prefer `risk_session_token` when present.
|
|
320
|
+
- Include `visitor_id` through the client argument.
|
|
321
|
+
- Send your real customer account ID as `user_id`.
|
|
322
|
+
- Include IP and metadata such as email, phone, username, and payment address.
|
|
323
|
+
- Use idempotency keys for retried POST requests.
|
|
324
|
+
- Treat `review` as step-up/manual review, not as automatic allow.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
require_relative "http_client"
|
|
2
|
+
require_relative "resources"
|
|
3
|
+
|
|
4
|
+
module Preverus
|
|
5
|
+
class Client
|
|
6
|
+
attr_reader :decisions, :events, :visitors, :metadata, :webhooks
|
|
7
|
+
|
|
8
|
+
def initialize(server_key:, endpoint: "https://api.preverus.com", timeout: 1.5, retries: 2, retry_delay: 0.15, max_retry_delay: 1.0)
|
|
9
|
+
http = HttpClient.new(
|
|
10
|
+
server_key: server_key,
|
|
11
|
+
endpoint: endpoint,
|
|
12
|
+
timeout: timeout,
|
|
13
|
+
retries: retries,
|
|
14
|
+
retry_delay: retry_delay,
|
|
15
|
+
max_retry_delay: max_retry_delay
|
|
16
|
+
)
|
|
17
|
+
@decisions = Decisions.new(http)
|
|
18
|
+
@events = Events.new(http)
|
|
19
|
+
@visitors = Visitors.new(http)
|
|
20
|
+
@metadata = Metadata.new(http)
|
|
21
|
+
@webhooks = Webhooks.new
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module Preverus
|
|
2
|
+
class Error < StandardError; end
|
|
3
|
+
|
|
4
|
+
class NetworkError < Error; end
|
|
5
|
+
|
|
6
|
+
class ApiError < Error
|
|
7
|
+
attr_reader :status_code, :error_code, :response
|
|
8
|
+
|
|
9
|
+
def initialize(message, status_code: 0, error_code: "api_error", response: {})
|
|
10
|
+
super(message)
|
|
11
|
+
@status_code = status_code
|
|
12
|
+
@error_code = error_code
|
|
13
|
+
@response = response
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "uri"
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
|
|
7
|
+
module Preverus
|
|
8
|
+
class HttpClient
|
|
9
|
+
RETRYABLE_STATUSES = [408, 409, 425, 429, 500, 502, 503, 504].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(server_key:, endpoint:, timeout:, retries:, retry_delay:, max_retry_delay:)
|
|
12
|
+
@server_key = server_key.to_s.strip
|
|
13
|
+
@endpoint = endpoint.to_s.sub(%r{/+$}, "")
|
|
14
|
+
@timeout = timeout
|
|
15
|
+
@retries = [retries.to_i, 0].max
|
|
16
|
+
@retry_delay = retry_delay
|
|
17
|
+
@max_retry_delay = max_retry_delay
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def get(path, params = {}, headers = {})
|
|
21
|
+
query = URI.encode_www_form(params.reject { |_key, value| value.nil? || value == "" })
|
|
22
|
+
request("GET", query.empty? ? path : "#{path}?#{query}", nil, headers)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def post(path, body, headers = {})
|
|
26
|
+
request("POST", path, body, headers)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def request(method, path, body, headers)
|
|
32
|
+
raise ApiError.new("Missing Preverus server key.", error_code: "missing_server_key") if @server_key.empty?
|
|
33
|
+
|
|
34
|
+
attempts = @retries + 1
|
|
35
|
+
attempt = 1
|
|
36
|
+
begin
|
|
37
|
+
send_request(method, path, body, headers)
|
|
38
|
+
rescue NetworkError
|
|
39
|
+
raise if attempt >= attempts
|
|
40
|
+
|
|
41
|
+
sleep_before_retry(attempt)
|
|
42
|
+
attempt += 1
|
|
43
|
+
retry
|
|
44
|
+
rescue ApiError => error
|
|
45
|
+
raise if !RETRYABLE_STATUSES.include?(error.status_code) || attempt >= attempts
|
|
46
|
+
|
|
47
|
+
sleep_before_retry(attempt)
|
|
48
|
+
attempt += 1
|
|
49
|
+
retry
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def send_request(method, path, body, headers)
|
|
54
|
+
uri = URI.parse("#{@endpoint}/#{path.sub(%r{^/+}, "")}")
|
|
55
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
56
|
+
http.use_ssl = uri.scheme == "https"
|
|
57
|
+
http.open_timeout = @timeout
|
|
58
|
+
http.read_timeout = @timeout
|
|
59
|
+
|
|
60
|
+
request = method == "GET" ? Net::HTTP::Get.new(uri) : Net::HTTP::Post.new(uri)
|
|
61
|
+
request["Accept"] = "application/json"
|
|
62
|
+
request["Content-Type"] = "application/json"
|
|
63
|
+
request["User-Agent"] = "preverus-ruby/0.1"
|
|
64
|
+
request["X-API-Key"] = @server_key
|
|
65
|
+
headers.each { |key, value| request[key] = value if value && value != "" }
|
|
66
|
+
request.body = JSON.generate(body) if body
|
|
67
|
+
|
|
68
|
+
response = http.request(request)
|
|
69
|
+
decoded = response.body.to_s.empty? ? {} : JSON.parse(response.body)
|
|
70
|
+
status = response.code.to_i
|
|
71
|
+
raise api_error(status, decoded) unless status.between?(200, 299)
|
|
72
|
+
|
|
73
|
+
decoded
|
|
74
|
+
rescue JSON::ParserError
|
|
75
|
+
raise ApiError.new("Preverus returned invalid JSON.", error_code: "invalid_json")
|
|
76
|
+
rescue Timeout::Error, SocketError, SystemCallError, OpenSSL::SSL::SSLError => error
|
|
77
|
+
raise NetworkError, "Unable to connect to Preverus: #{error.message}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def api_error(status, decoded)
|
|
81
|
+
message = decoded.is_a?(Hash) && decoded["message"].is_a?(String) ? decoded["message"] : "Preverus API error."
|
|
82
|
+
code = decoded.is_a?(Hash) && decoded["code"].is_a?(String) ? decoded["code"] : "api_error"
|
|
83
|
+
ApiError.new(message, status_code: status, error_code: code, response: decoded.is_a?(Hash) ? decoded : {})
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def sleep_before_retry(attempt)
|
|
87
|
+
base = [@max_retry_delay, @retry_delay * (2**[attempt - 1, 0].max)].min
|
|
88
|
+
sleep(base + rand * (base / 2.0))
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
require "openssl"
|
|
2
|
+
require "json"
|
|
3
|
+
require_relative "results"
|
|
4
|
+
|
|
5
|
+
module Preverus
|
|
6
|
+
class Decisions
|
|
7
|
+
def initialize(http)
|
|
8
|
+
@http = http
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def evaluate(input, visitor_id: nil, idempotency_key: nil, headers: {})
|
|
12
|
+
request_headers = headers.dup
|
|
13
|
+
request_headers["X-Visitor-ID"] = visitor_id if visitor_id && visitor_id != ""
|
|
14
|
+
request_headers["X-Idempotency-Key"] = idempotency_key if idempotency_key && idempotency_key != ""
|
|
15
|
+
DecisionResult.new(@http.post("/v1/decision/evaluate", input, request_headers))
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class Events
|
|
20
|
+
def initialize(http)
|
|
21
|
+
@http = http
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def create(input, visitor_id: nil, idempotency_key: nil)
|
|
25
|
+
headers = {}
|
|
26
|
+
headers["X-Visitor-ID"] = visitor_id if visitor_id && visitor_id != ""
|
|
27
|
+
headers["X-Idempotency-Key"] = idempotency_key if idempotency_key && idempotency_key != ""
|
|
28
|
+
ApiResult.new(@http.post("/v1/events", input, headers))
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class Visitors
|
|
33
|
+
def initialize(http)
|
|
34
|
+
@http = http
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def lookup(visitor_id: nil, fingerprint: nil, ip: nil)
|
|
38
|
+
ApiResult.new(@http.get("/v1/score/visitors/lookup", { visitor_id: visitor_id, fingerprint: fingerprint, ip: ip }))
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class Metadata
|
|
43
|
+
def initialize(http)
|
|
44
|
+
@http = http
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def lookup(key, value, include_global: true, visitor_limit: 20)
|
|
48
|
+
ApiResult.new(@http.get("/v1/score/metadata/lookup", {
|
|
49
|
+
key: key,
|
|
50
|
+
value: value,
|
|
51
|
+
include_global: include_global ? "true" : "false",
|
|
52
|
+
visitor_limit: visitor_limit
|
|
53
|
+
}))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def graph(visitor_id, limit: 50)
|
|
57
|
+
ApiResult.new(@http.get("/v1/score/metadata/graph", { visitor_id: visitor_id, limit: limit }))
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
class Webhooks
|
|
62
|
+
def verify(raw_body:, timestamp:, signature_header:, secret:, tolerance_seconds: 300)
|
|
63
|
+
return false if timestamp.to_s !~ /\A\d+\z/ || signature_header.to_s.empty? || secret.to_s.empty?
|
|
64
|
+
return false if (Time.now.to_i - timestamp.to_i).abs > tolerance_seconds
|
|
65
|
+
|
|
66
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}")
|
|
67
|
+
received = signature_header.to_s.sub(/\Av1=/, "").strip
|
|
68
|
+
secure_compare(expected, received)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def construct_event(raw_body:, headers:, secret:, tolerance_seconds: 300)
|
|
72
|
+
timestamp = headers["X-Fraud-Webhook-Timestamp"] || headers["x-fraud-webhook-timestamp"] || ""
|
|
73
|
+
signature = headers["X-Fraud-Webhook-Signature"] || headers["x-fraud-webhook-signature"] || ""
|
|
74
|
+
unless verify(raw_body: raw_body, timestamp: timestamp, signature_header: signature, secret: secret, tolerance_seconds: tolerance_seconds)
|
|
75
|
+
raise ArgumentError, "Invalid Preverus webhook signature."
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
payload = JSON.parse(raw_body)
|
|
79
|
+
raise ArgumentError, "Invalid Preverus webhook JSON payload." unless payload.is_a?(Hash)
|
|
80
|
+
|
|
81
|
+
WebhookEvent.new(payload, raw_body)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def dispatch(event, handlers)
|
|
85
|
+
handler = handlers[event.type] || handlers["*"]
|
|
86
|
+
return nil unless handler.respond_to?(:call)
|
|
87
|
+
|
|
88
|
+
handler.call(event)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def secure_compare(expected, received)
|
|
94
|
+
return false unless expected.bytesize == received.bytesize
|
|
95
|
+
|
|
96
|
+
expected.bytes.zip(received.bytes).reduce(0) { |memo, (left, right)| memo | (left ^ right) }.zero?
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
module Preverus
|
|
2
|
+
class ApiResult
|
|
3
|
+
attr_reader :data
|
|
4
|
+
|
|
5
|
+
def initialize(data)
|
|
6
|
+
@data = data || {}
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def [](key)
|
|
10
|
+
@data[key.to_s] || @data[key.to_sym]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def to_h
|
|
14
|
+
@data.dup
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class DecisionResult < ApiResult
|
|
19
|
+
attr_reader :failure_reason
|
|
20
|
+
|
|
21
|
+
def initialize(data, fallback: false, failure_reason: nil)
|
|
22
|
+
super(data)
|
|
23
|
+
@fallback = fallback
|
|
24
|
+
@failure_reason = failure_reason
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.fallback(action, reason)
|
|
28
|
+
new(
|
|
29
|
+
{
|
|
30
|
+
"recommended_action" => action,
|
|
31
|
+
"risk_tier" => action == "allow" ? "unknown" : "review",
|
|
32
|
+
"reasons" => [
|
|
33
|
+
{
|
|
34
|
+
"code" => "preverus_unavailable",
|
|
35
|
+
"severity" => action == "allow" ? "low" : "medium",
|
|
36
|
+
"message" => "Preverus was unavailable; fallback policy was applied."
|
|
37
|
+
}
|
|
38
|
+
]
|
|
39
|
+
},
|
|
40
|
+
fallback: true,
|
|
41
|
+
failure_reason: reason
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def recommended_action
|
|
46
|
+
self["recommended_action"] || self["action"] || "review"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def allow?
|
|
50
|
+
recommended_action == "allow"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def review?
|
|
54
|
+
recommended_action == "review"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def block?
|
|
58
|
+
["block", "deny"].include?(recommended_action)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def fallback?
|
|
62
|
+
@fallback
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
class WebhookEvent < ApiResult
|
|
67
|
+
attr_reader :raw_body
|
|
68
|
+
|
|
69
|
+
def initialize(data, raw_body)
|
|
70
|
+
super(data)
|
|
71
|
+
@raw_body = raw_body
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def id
|
|
75
|
+
self["id"].to_s
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def type
|
|
79
|
+
self["type"].to_s
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
data/lib/preverus.rb
ADDED
data/preverus.gemspec
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Gem::Specification.new do |spec|
|
|
2
|
+
spec.name = "preverus"
|
|
3
|
+
spec.version = "0.1.0"
|
|
4
|
+
spec.authors = ["Preverus"]
|
|
5
|
+
spec.summary = "Ruby client for Preverus fraud decisions, events, lookups, and webhooks."
|
|
6
|
+
spec.description = "Framework-agnostic Ruby client for Preverus backend fraud enforcement."
|
|
7
|
+
spec.homepage = "https://preverus.com"
|
|
8
|
+
spec.license = "MIT"
|
|
9
|
+
spec.required_ruby_version = ">= 3.0"
|
|
10
|
+
spec.files = Dir["lib/**/*.rb", "README.md", "LICENSE*", "preverus.gemspec"]
|
|
11
|
+
spec.require_paths = ["lib"]
|
|
12
|
+
spec.metadata = {
|
|
13
|
+
"source_code_uri" => "https://github.com/preverus/preverus-ruby",
|
|
14
|
+
"documentation_uri" => "https://preverus.com/docs"
|
|
15
|
+
}
|
|
16
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: preverus
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Preverus
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Framework-agnostic Ruby client for Preverus backend fraud enforcement.
|
|
13
|
+
executables: []
|
|
14
|
+
extensions: []
|
|
15
|
+
extra_rdoc_files: []
|
|
16
|
+
files:
|
|
17
|
+
- README.md
|
|
18
|
+
- lib/preverus.rb
|
|
19
|
+
- lib/preverus/client.rb
|
|
20
|
+
- lib/preverus/errors.rb
|
|
21
|
+
- lib/preverus/http_client.rb
|
|
22
|
+
- lib/preverus/resources.rb
|
|
23
|
+
- lib/preverus/results.rb
|
|
24
|
+
- preverus.gemspec
|
|
25
|
+
homepage: https://preverus.com
|
|
26
|
+
licenses:
|
|
27
|
+
- MIT
|
|
28
|
+
metadata:
|
|
29
|
+
source_code_uri: https://github.com/preverus/preverus-ruby
|
|
30
|
+
documentation_uri: https://preverus.com/docs
|
|
31
|
+
rdoc_options: []
|
|
32
|
+
require_paths:
|
|
33
|
+
- lib
|
|
34
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '3.0'
|
|
39
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
40
|
+
requirements:
|
|
41
|
+
- - ">="
|
|
42
|
+
- !ruby/object:Gem::Version
|
|
43
|
+
version: '0'
|
|
44
|
+
requirements: []
|
|
45
|
+
rubygems_version: 4.0.10
|
|
46
|
+
specification_version: 4
|
|
47
|
+
summary: Ruby client for Preverus fraud decisions, events, lookups, and webhooks.
|
|
48
|
+
test_files: []
|