procteye 2.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/CHANGELOG.md +13 -0
- data/LICENSE +21 -0
- data/README.md +163 -0
- data/lib/procteye/client.rb +303 -0
- data/lib/procteye/errors.rb +87 -0
- data/lib/procteye/sessions.rb +159 -0
- data/lib/procteye/version.rb +8 -0
- data/lib/procteye/webhooks.rb +137 -0
- data/lib/procteye.rb +21 -0
- metadata +53 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: f437f923e995ca9130bcdce2108540800ab2a9009342ea994a01fbcf802dd808
|
|
4
|
+
data.tar.gz: 6453d38bb38657edd487304f2a4996f81feb14e012dc0776ff83c5679a53af77
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 66d038677bd0b42406a6e2e3c9df828e0a79d21fd2102bb09ad648fdd4b8c96dad03fa13a68983e7a90c52b0b7c149bfcf942a474567c1507c619b55b56967d3
|
|
7
|
+
data.tar.gz: 346e5693a9fc62a1ab784e3a8d3f8ba66ef9eb3727408c38bff17d45dd07f3a6a3dc6fbb49311fc3e5a0b12ca4f3a8ed5f05145f3dcfbbb0fea8007e4412a8b5
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 2.1.0 (2026-11-13)
|
|
4
|
+
|
|
5
|
+
- Document the `session.canceled` webhook, sent after a successful cancel, and add a signed test vector for it. Document that canceling a session that is already final returns 409 `session_not_active` as an invalid request error, which is not retried.
|
|
6
|
+
- Correct the `starting_after` docs: the cursor is server-side and returns the sessions created before the given id (older ones). Fix the `launch.mode` example, which showed a mode the API never returns.
|
|
7
|
+
- Load without the base64 gem. Ruby 3.4 made base64 a bundled gem, so `require "procteye"` raised LoadError under Bundler; the webhook verifier now uses `Array#pack("m0")`. CI also runs the specs on Ruby 3.4.
|
|
8
|
+
- Turn off Net::HTTP's own retry, so a timeout with `max_retries: 0` makes exactly one attempt instead of two.
|
|
9
|
+
- Map HTTP 403 (`insufficient_scope`) to the new `Procteye::PermissionError`, a subclass of `AuthenticationError`, instead of `InvalidRequestError`.
|
|
10
|
+
- Retry a 409 `idempotency_request_in_progress` with the same `Idempotency-Key` and the usual backoff, counting toward `max_retries`. Other 409s are not retried.
|
|
11
|
+
- README: fix the billing example that retried forever, describe the `starting_after` cursor correctly, and document which HTTP status maps to which error class.
|
|
12
|
+
- Update the webhook examples to the event types and payloads the API sends.
|
|
13
|
+
- Point the gem homepage at https://docs.procteye.com and drop the source_code_uri for a repository that is not public.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Procteye
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# procteye (Ruby)
|
|
2
|
+
|
|
3
|
+
Official **server-side** Ruby SDK for the [Procteye](https://procteye.com) Integrity API. Use it from your backend to create and manage integrity sessions and to verify incoming webhooks.
|
|
4
|
+
|
|
5
|
+
> This is a secret-key SDK. Never ship your `pe_sk_...` key to a browser or mobile client.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
gem install procteye
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or in a `Gemfile`:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
gem "procteye"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Requires Ruby 2.7+. No runtime dependencies (uses only the standard library).
|
|
20
|
+
|
|
21
|
+
## Quickstart
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
require "procteye"
|
|
25
|
+
|
|
26
|
+
client = Procteye::Client.new(ENV["PROCTEYE_API_KEY"])
|
|
27
|
+
|
|
28
|
+
session = client.sessions.create(
|
|
29
|
+
assurance_level: "HIGH",
|
|
30
|
+
external_user_ref: "user_42",
|
|
31
|
+
candidate_email: "candidate@example.com"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
puts session.id # method access
|
|
35
|
+
puts session["client_secret"] # or hash access -> hand this to the browser
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Configuration
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
client = Procteye::Client.new(
|
|
42
|
+
"pe_sk_live_...", # or ENV["PROCTEYE_API_KEY"]
|
|
43
|
+
base_url: "https://api.procteye.com", # or ENV["PROCTEYE_BASE_URL"]
|
|
44
|
+
timeout: 30.0, # per-request timeout (seconds)
|
|
45
|
+
max_retries: 2 # retries on 429/5xx + transport errors
|
|
46
|
+
)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Sessions
|
|
50
|
+
|
|
51
|
+
Every response is a `Procteye::ProcteyeObject` supporting both `session.id` and `session["id"]` access (`session.to_h` for the raw Hash).
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
client.sessions.create(assurance_level: "HIGH") # POST /external/v1/sessions
|
|
55
|
+
client.sessions.retrieve(id) # GET /external/v1/sessions/:id
|
|
56
|
+
client.sessions.complete(id) # POST .../complete
|
|
57
|
+
client.sessions.cancel(id) # POST .../cancel
|
|
58
|
+
|
|
59
|
+
# A single raw page (the list envelope { object: "list", data: [...] }):
|
|
60
|
+
page = client.sessions.list(limit: 50)
|
|
61
|
+
page.data.each { |s| puts s.id }
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Pagination
|
|
65
|
+
|
|
66
|
+
The API paginates with a server-side cursor. Sessions come back newest first
|
|
67
|
+
(by creation time, then id), up to 100 per page. Pass `starting_after:` with a
|
|
68
|
+
session id to get the sessions created before it; `has_more` is true while
|
|
69
|
+
more pages exist. An id that is malformed or not visible to your key returns a
|
|
70
|
+
400. `auto_paging_each` follows the cursor for you, passing the last session id
|
|
71
|
+
of each page as `starting_after` until `has_more` is false.
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
client.sessions.auto_paging_each(limit: 50) do |session|
|
|
75
|
+
puts "#{session.id} #{session.status}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Without a block it returns an Enumerator:
|
|
79
|
+
first_ten = client.sessions.auto_paging_each(limit: 50).first(10)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Idempotency
|
|
83
|
+
|
|
84
|
+
Every write automatically carries an `Idempotency-Key` header (a fresh UUID per call). Pass your own to make a retry safe end-to-end:
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
client.sessions.create(assurance_level: "HIGH", idempotency_key: "order_1234")
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Because writes are idempotent, the SDK safely **retries** on `429`/`5xx` and transport errors with exponential backoff and full jitter. It honors `Retry-After` up to a 60-second ceiling so a malformed upstream response cannot suspend the caller indefinitely. A `409` with code `idempotency_request_in_progress` (the first request with the same key is still running) is retried the same way, with the same `Idempotency-Key`, and counts toward `max_retries`. Any other `409`, such as `idempotency_key_in_use`, is not retried. Net::HTTP's own automatic retry is turned off, so `max_retries: 0` means exactly one attempt.
|
|
91
|
+
|
|
92
|
+
## Errors
|
|
93
|
+
|
|
94
|
+
All non-2xx responses raise a typed error exposing `code`, `message`, `http_status`, `request_id`, and `doc_url`.
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
begin
|
|
98
|
+
client.sessions.create(assurance_level: "HIGH")
|
|
99
|
+
rescue Procteye::BillingError => e
|
|
100
|
+
# Retrying straight away would fail the same way: top up first.
|
|
101
|
+
notify_billing(e.request_id) if e.code == "insufficient_credits"
|
|
102
|
+
rescue Procteye::RateLimitError
|
|
103
|
+
# back off
|
|
104
|
+
rescue Procteye::InvalidRequestError => e
|
|
105
|
+
warn "#{e.message} (request_id=#{e.request_id})"
|
|
106
|
+
end
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
| HTTP status | Class | Retried |
|
|
110
|
+
| --- | --- | --- |
|
|
111
|
+
| 400 / 404 / 422 | `Procteye::InvalidRequestError` | no |
|
|
112
|
+
| 401 | `Procteye::AuthenticationError` | no |
|
|
113
|
+
| 402 | `Procteye::BillingError` | no |
|
|
114
|
+
| 403 | `Procteye::PermissionError` (subclass of `AuthenticationError`; code `insufficient_scope`) | no |
|
|
115
|
+
| 409 | `Procteye::InvalidRequestError` (code `idempotency_request_in_progress`, `idempotency_key_in_use`, or `session_not_active` from cancel on a session that is already final) | only `idempotency_request_in_progress` |
|
|
116
|
+
| 429 | `Procteye::RateLimitError` | yes |
|
|
117
|
+
| 5xx | `Procteye::ApiError` | yes |
|
|
118
|
+
| other 4xx | `Procteye::InvalidRequestError` | no |
|
|
119
|
+
| network / timeout | `Procteye::ConnectionError` | yes |
|
|
120
|
+
|
|
121
|
+
All inherit from `Procteye::ProcteyeError`.
|
|
122
|
+
|
|
123
|
+
## Webhook verification
|
|
124
|
+
|
|
125
|
+
Verify webhook signatures with the **raw** request body ([Standard Webhooks](https://www.standardwebhooks.com) compatible). Never pass a re-serialized object — the signature is computed over the exact bytes.
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
require "procteye"
|
|
129
|
+
|
|
130
|
+
ENDPOINT_SECRET = ENV["PROCTEYE_WEBHOOK_SECRET"] # whsec_...
|
|
131
|
+
|
|
132
|
+
post "/webhooks/procteye" do
|
|
133
|
+
raw = request.body.read # raw bytes/string, not a parsed hash
|
|
134
|
+
begin
|
|
135
|
+
event = Procteye::Webhooks.construct_event(raw, request.env, ENDPOINT_SECRET)
|
|
136
|
+
rescue Procteye::WebhookVerificationError
|
|
137
|
+
halt 400
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
case event["type"]
|
|
141
|
+
when "session.completed"
|
|
142
|
+
# The runtime session ended; finalize the verdict.
|
|
143
|
+
client.sessions.complete(event["data"]["id"])
|
|
144
|
+
when "session.canceled"
|
|
145
|
+
# Sent after a successful cancel.
|
|
146
|
+
puts "Session canceled: #{event["data"]["id"]}"
|
|
147
|
+
end
|
|
148
|
+
status 200
|
|
149
|
+
end
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The verifier rejects any request with a missing header, a bad signature, or a timestamp older than 5 minutes (tunable via `tolerance:`). It reads the `webhook-id`, `webhook-timestamp`, and `webhook-signature` headers (case-insensitive) and interoperates with the gateway and the Node/Python SDKs.
|
|
153
|
+
|
|
154
|
+
See [`examples/`](examples) for a runnable Sinatra webhook handler and a create-session script.
|
|
155
|
+
|
|
156
|
+
## Links
|
|
157
|
+
|
|
158
|
+
- Documentation: https://docs.procteye.com
|
|
159
|
+
- RubyGems: https://rubygems.org/gems/procteye
|
|
160
|
+
|
|
161
|
+
## License
|
|
162
|
+
|
|
163
|
+
MIT
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
require_relative "version"
|
|
9
|
+
require_relative "errors"
|
|
10
|
+
require_relative "sessions"
|
|
11
|
+
|
|
12
|
+
module Procteye
|
|
13
|
+
DEFAULT_BASE_URL = "https://api.procteye.com"
|
|
14
|
+
DEFAULT_TIMEOUT = 30.0
|
|
15
|
+
DEFAULT_MAX_RETRIES = 2
|
|
16
|
+
# Error code for a 409 the SDK retries: the first request with the same
|
|
17
|
+
# Idempotency-Key is still in progress.
|
|
18
|
+
IDEMPOTENCY_IN_PROGRESS = "idempotency_request_in_progress"
|
|
19
|
+
MAX_RETRY_AFTER_SECONDS = 60.0
|
|
20
|
+
|
|
21
|
+
# A thin wrapper over an API JSON object that supports both method-style and
|
|
22
|
+
# hash-style access:
|
|
23
|
+
#
|
|
24
|
+
# session.id # => "sess_123"
|
|
25
|
+
# session["id"] # => "sess_123"
|
|
26
|
+
# session.launch.mode # => "browser+mobile"
|
|
27
|
+
# session.to_h # => the underlying Hash
|
|
28
|
+
#
|
|
29
|
+
# Nested hashes and arrays are wrapped lazily. Missing keys return +nil+ via
|
|
30
|
+
# method access; use +key?+ to distinguish absent from nil.
|
|
31
|
+
class ProcteyeObject
|
|
32
|
+
def initialize(data)
|
|
33
|
+
@data = data.is_a?(Hash) ? data : {}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Wrap a raw value: Hash -> ProcteyeObject, Array -> mapped, else as-is.
|
|
37
|
+
def self.wrap(value)
|
|
38
|
+
case value
|
|
39
|
+
when Hash then new(value)
|
|
40
|
+
when Array then value.map { |v| wrap(v) }
|
|
41
|
+
else value
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def [](key)
|
|
46
|
+
str_key = key.to_s
|
|
47
|
+
value = @data.key?(str_key) ? @data[str_key] : @data[key]
|
|
48
|
+
ProcteyeObject.wrap(value)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def key?(key)
|
|
52
|
+
@data.key?(key.to_s) || @data.key?(key)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The underlying raw Hash (unwrapped, string keys).
|
|
56
|
+
def to_h
|
|
57
|
+
@data
|
|
58
|
+
end
|
|
59
|
+
alias to_hash to_h
|
|
60
|
+
|
|
61
|
+
def keys
|
|
62
|
+
@data.keys
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def each(&block)
|
|
66
|
+
@data.each(&block)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def ==(other)
|
|
70
|
+
other.is_a?(ProcteyeObject) ? to_h == other.to_h : to_h == other
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def inspect
|
|
74
|
+
"#<Procteye::ProcteyeObject #{@data.inspect}>"
|
|
75
|
+
end
|
|
76
|
+
alias to_s inspect
|
|
77
|
+
|
|
78
|
+
def respond_to_missing?(name, include_private = false)
|
|
79
|
+
key?(name) || super
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def method_missing(name, *args)
|
|
83
|
+
key = name.to_s
|
|
84
|
+
if @data.key?(key)
|
|
85
|
+
raise ArgumentError, "wrong number of arguments (#{args.length} for 0)" unless args.empty?
|
|
86
|
+
|
|
87
|
+
ProcteyeObject.wrap(@data[key])
|
|
88
|
+
else
|
|
89
|
+
super
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Server-side client for the Procteye Integrity API.
|
|
95
|
+
#
|
|
96
|
+
# @example
|
|
97
|
+
# client = Procteye::Client.new(ENV["PROCTEYE_API_KEY"])
|
|
98
|
+
# session = client.sessions.create(assurance_level: "HIGH")
|
|
99
|
+
# puts session.id, session.client_secret
|
|
100
|
+
class Client
|
|
101
|
+
attr_reader :sessions
|
|
102
|
+
|
|
103
|
+
# Rejects any base URL that would send the secret API key over plaintext.
|
|
104
|
+
# Only +https://+ is accepted, with an exception for loopback hosts
|
|
105
|
+
# (+localhost+ / +127.0.0.1+ / +::1+) so local development and test
|
|
106
|
+
# servers keep working over plain +http://+.
|
|
107
|
+
#
|
|
108
|
+
# @param base_url [String]
|
|
109
|
+
# @return [String] the same +base_url+, when valid
|
|
110
|
+
# @raise [ProcteyeError] when the scheme would leak the API key in cleartext
|
|
111
|
+
def self.validate_base_url!(base_url)
|
|
112
|
+
uri = URI.parse(base_url)
|
|
113
|
+
return base_url if uri.scheme == "https"
|
|
114
|
+
return base_url if uri.scheme == "http" && %w[localhost 127.0.0.1 ::1].include?(uri.host)
|
|
115
|
+
|
|
116
|
+
raise ProcteyeError,
|
|
117
|
+
"Invalid base_url #{base_url.inspect}: must use https:// " \
|
|
118
|
+
"(plain http:// is only permitted for localhost). " \
|
|
119
|
+
"Refusing to send the API key over an unencrypted connection."
|
|
120
|
+
rescue URI::InvalidURIError => e
|
|
121
|
+
raise ProcteyeError, "Invalid base_url #{base_url.inspect}: #{e.message}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# @param api_key [String] secret key (+pe_sk_live_...+ / +pe_sk_test_...+).
|
|
125
|
+
# Defaults to +ENV["PROCTEYE_API_KEY"]+.
|
|
126
|
+
# @param base_url [String, nil] API base URL; falls back to
|
|
127
|
+
# +ENV["PROCTEYE_BASE_URL"]+ then the production default.
|
|
128
|
+
# @param timeout [Float] per-request timeout in seconds.
|
|
129
|
+
# @param max_retries [Integer] retries on 429/5xx and transport errors.
|
|
130
|
+
# @raise [Procteye::ProcteyeError] no API key can be resolved, or +base_url+ is invalid
|
|
131
|
+
def initialize(api_key = ENV["PROCTEYE_API_KEY"], base_url: nil, timeout: DEFAULT_TIMEOUT,
|
|
132
|
+
max_retries: DEFAULT_MAX_RETRIES)
|
|
133
|
+
if api_key.nil? || api_key.to_s.empty?
|
|
134
|
+
raise ProcteyeError, "No API key provided. Pass it as the first argument or set PROCTEYE_API_KEY."
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
@api_key = api_key
|
|
138
|
+
@base_url = self.class.validate_base_url!(base_url || ENV["PROCTEYE_BASE_URL"] || DEFAULT_BASE_URL)
|
|
139
|
+
.sub(%r{/+\z}, "")
|
|
140
|
+
@timeout = timeout
|
|
141
|
+
@max_retries = max_retries
|
|
142
|
+
|
|
143
|
+
@sessions = SessionsResource.new(self)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Low-level request primitive used by resource classes. Handles auth
|
|
147
|
+
# headers, idempotency keys, JSON encoding, typed error mapping, and
|
|
148
|
+
# automatic retries with exponential backoff + full jitter on 429/5xx and
|
|
149
|
+
# transport errors, honoring +Retry-After+.
|
|
150
|
+
#
|
|
151
|
+
# @return [Array(Object, String, Integer)] parsed body, request id, status.
|
|
152
|
+
# @api private
|
|
153
|
+
def request(method, path, query: nil, body: nil, idempotency_key: nil, headers: nil)
|
|
154
|
+
uri = build_uri(path, query)
|
|
155
|
+
is_write = %w[POST DELETE].include?(method)
|
|
156
|
+
|
|
157
|
+
req_headers = {
|
|
158
|
+
"Authorization" => "Bearer #{@api_key}",
|
|
159
|
+
"Accept" => "application/json",
|
|
160
|
+
"PE-Version" => API_VERSION,
|
|
161
|
+
"User-Agent" => "procteye-ruby/#{VERSION}"
|
|
162
|
+
}
|
|
163
|
+
req_headers.merge!(headers) if headers
|
|
164
|
+
|
|
165
|
+
body_str = nil
|
|
166
|
+
unless body.nil?
|
|
167
|
+
req_headers["Content-Type"] = "application/json"
|
|
168
|
+
body_str = JSON.generate(body)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
req_headers["Idempotency-Key"] = idempotency_key || SecureRandom.uuid if is_write
|
|
172
|
+
|
|
173
|
+
last_error = nil
|
|
174
|
+
attempt = 0
|
|
175
|
+
while attempt <= @max_retries
|
|
176
|
+
begin
|
|
177
|
+
status, resp_headers, raw = dispatch(uri, method, req_headers, body_str)
|
|
178
|
+
rescue ConnectionError => e
|
|
179
|
+
if attempt < @max_retries
|
|
180
|
+
last_error = e
|
|
181
|
+
sleep(backoff(attempt, nil))
|
|
182
|
+
attempt += 1
|
|
183
|
+
next
|
|
184
|
+
end
|
|
185
|
+
raise
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
request_id = header_value(resp_headers, "x-request-id")
|
|
189
|
+
|
|
190
|
+
if status >= 200 && status < 300
|
|
191
|
+
return [parse_success_json(raw, request_id), request_id, status]
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
error = Procteye.error_from_response(status, parse_json(raw), request_id)
|
|
195
|
+
|
|
196
|
+
if retryable?(status, error.code) && attempt < @max_retries
|
|
197
|
+
last_error = error
|
|
198
|
+
sleep(backoff(attempt, header_value(resp_headers, "retry-after")))
|
|
199
|
+
attempt += 1
|
|
200
|
+
next
|
|
201
|
+
end
|
|
202
|
+
raise error
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
raise(last_error || ConnectionError.new("Request failed after retries."))
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
private
|
|
209
|
+
|
|
210
|
+
def dispatch(uri, method, headers, body)
|
|
211
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
212
|
+
http.use_ssl = (uri.scheme == "https")
|
|
213
|
+
http.open_timeout = @timeout
|
|
214
|
+
http.read_timeout = @timeout
|
|
215
|
+
http.write_timeout = @timeout if http.respond_to?(:write_timeout=)
|
|
216
|
+
# Net::HTTP retries idempotent requests once on its own after a timeout
|
|
217
|
+
# or a dropped connection. Disable it so only the SDK's retry policy
|
|
218
|
+
# (max_retries, backoff) applies.
|
|
219
|
+
http.max_retries = 0 if http.respond_to?(:max_retries=)
|
|
220
|
+
|
|
221
|
+
request_class = case method
|
|
222
|
+
when "GET" then Net::HTTP::Get
|
|
223
|
+
when "POST" then Net::HTTP::Post
|
|
224
|
+
when "DELETE" then Net::HTTP::Delete
|
|
225
|
+
else raise ProcteyeError, "Unsupported HTTP method: #{method}"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
req = request_class.new(uri)
|
|
229
|
+
headers.each { |k, v| req[k] = v }
|
|
230
|
+
req.body = body if body
|
|
231
|
+
|
|
232
|
+
response = http.request(req)
|
|
233
|
+
[response.code.to_i, response.to_hash, response.body]
|
|
234
|
+
rescue Timeout::Error => e
|
|
235
|
+
raise ConnectionError.new("Request timed out after #{@timeout}s: #{e.message}")
|
|
236
|
+
rescue SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError => e
|
|
237
|
+
raise ConnectionError.new("Network error contacting Procteye: #{e.message}")
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def build_uri(path, query)
|
|
241
|
+
uri = URI.parse(@base_url + path)
|
|
242
|
+
if query
|
|
243
|
+
pairs = query.reject { |_, v| v.nil? }
|
|
244
|
+
uri.query = URI.encode_www_form(pairs) unless pairs.empty?
|
|
245
|
+
end
|
|
246
|
+
uri
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def parse_json(raw)
|
|
250
|
+
return nil if raw.nil? || raw.empty?
|
|
251
|
+
|
|
252
|
+
JSON.parse(raw)
|
|
253
|
+
rescue JSON::ParserError
|
|
254
|
+
nil
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Parses a 2xx response body, raising instead of silently returning +nil+
|
|
258
|
+
# when the body is present but not valid JSON.
|
|
259
|
+
def parse_success_json(raw, request_id)
|
|
260
|
+
return nil if raw.nil? || raw.empty?
|
|
261
|
+
|
|
262
|
+
JSON.parse(raw)
|
|
263
|
+
rescue JSON::ParserError => e
|
|
264
|
+
raise ProcteyeError.new(
|
|
265
|
+
"Procteye returned a successful response with a malformed " \
|
|
266
|
+
"(non-JSON) body: #{e.message}",
|
|
267
|
+
request_id: request_id
|
|
268
|
+
)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def retryable?(status, code = nil)
|
|
272
|
+
# A 409 idempotency_request_in_progress means the first request with
|
|
273
|
+
# this Idempotency-Key is still running; retrying with the same key
|
|
274
|
+
# returns its result. Every other 409 (e.g. idempotency_key_in_use) is
|
|
275
|
+
# final.
|
|
276
|
+
return code == IDEMPOTENCY_IN_PROGRESS if status == 409
|
|
277
|
+
|
|
278
|
+
status == 429 || status >= 500
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Exponential backoff with full jitter; honors +Retry-After+ when present,
|
|
282
|
+
# capped so an untrusted proxy cannot suspend a caller indefinitely.
|
|
283
|
+
def backoff(attempt, retry_after)
|
|
284
|
+
if retry_after
|
|
285
|
+
seconds = Float(retry_after) rescue nil
|
|
286
|
+
return [seconds, MAX_RETRY_AFTER_SECONDS].min if seconds && seconds >= 0
|
|
287
|
+
end
|
|
288
|
+
base = [0.5 * (2**attempt), 8.0].min
|
|
289
|
+
rand * base
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Net::HTTP#to_hash returns lowercased keys mapping to Array<String>.
|
|
293
|
+
def header_value(headers, name)
|
|
294
|
+
lower = name.downcase
|
|
295
|
+
headers.each do |key, value|
|
|
296
|
+
next unless key.to_s.downcase == lower
|
|
297
|
+
|
|
298
|
+
return value.is_a?(Array) ? value.first : value
|
|
299
|
+
end
|
|
300
|
+
nil
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Procteye
|
|
4
|
+
# Base class for every error raised by the SDK.
|
|
5
|
+
#
|
|
6
|
+
# Each error exposes the machine-readable +code+, the HTTP +http_status+, the
|
|
7
|
+
# correlation +request_id+, the error +type+, and a +doc_url+ when the API
|
|
8
|
+
# provides one.
|
|
9
|
+
class ProcteyeError < StandardError
|
|
10
|
+
attr_reader :code, :type, :request_id, :http_status, :doc_url
|
|
11
|
+
|
|
12
|
+
def initialize(message, code: nil, type: nil, request_id: nil, http_status: nil, doc_url: nil)
|
|
13
|
+
super(message)
|
|
14
|
+
@message = message
|
|
15
|
+
@code = code
|
|
16
|
+
@type = type
|
|
17
|
+
@request_id = request_id
|
|
18
|
+
@http_status = http_status
|
|
19
|
+
@doc_url = doc_url
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# A readable one-line summary including the code/request id when present.
|
|
23
|
+
def to_s
|
|
24
|
+
parts = [@message]
|
|
25
|
+
parts << "code=#{@code}" if @code
|
|
26
|
+
parts << "request_id=#{@request_id}" if @request_id
|
|
27
|
+
parts.join(" ")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# 401 - the API key is missing, malformed, or revoked.
|
|
32
|
+
class AuthenticationError < ProcteyeError; end
|
|
33
|
+
|
|
34
|
+
# 403 - the API key is valid but lacks the scope for this request
|
|
35
|
+
# (+code == "insufficient_scope"+). Subclasses AuthenticationError, so an
|
|
36
|
+
# existing +rescue Procteye::AuthenticationError+ still catches it.
|
|
37
|
+
class PermissionError < AuthenticationError; end
|
|
38
|
+
|
|
39
|
+
# 400 / 404 / 409 / 422 - the request was rejected as invalid.
|
|
40
|
+
class InvalidRequestError < ProcteyeError; end
|
|
41
|
+
|
|
42
|
+
# 402 - a billing problem, e.g. +code == "insufficient_credits"+.
|
|
43
|
+
class BillingError < ProcteyeError; end
|
|
44
|
+
|
|
45
|
+
# 429 - rate limited. Retried automatically up to +max_retries+.
|
|
46
|
+
class RateLimitError < ProcteyeError; end
|
|
47
|
+
|
|
48
|
+
# 5xx - an unexpected error on the Procteye side. Retried automatically.
|
|
49
|
+
class ApiError < ProcteyeError; end
|
|
50
|
+
|
|
51
|
+
# Network failure, timeout, or aborted request (no HTTP response).
|
|
52
|
+
class ConnectionError < ProcteyeError; end
|
|
53
|
+
|
|
54
|
+
# Raised by +Procteye::Webhooks.construct_event+ when a payload cannot be
|
|
55
|
+
# verified.
|
|
56
|
+
class WebhookVerificationError < ProcteyeError; end
|
|
57
|
+
|
|
58
|
+
# Build the appropriate typed error from an HTTP status + parsed envelope.
|
|
59
|
+
#
|
|
60
|
+
# @param status [Integer] the HTTP status code
|
|
61
|
+
# @param payload [Hash, nil] the parsed JSON body (expects an +"error"+ key)
|
|
62
|
+
# @param request_id [String, nil] fallback request id from +X-Request-Id+
|
|
63
|
+
# @return [ProcteyeError]
|
|
64
|
+
def self.error_from_response(status, payload, request_id)
|
|
65
|
+
err = payload.is_a?(Hash) ? (payload["error"] || {}) : {}
|
|
66
|
+
err = {} unless err.is_a?(Hash)
|
|
67
|
+
|
|
68
|
+
message = err["message"] || "Procteye request failed with status #{status}"
|
|
69
|
+
opts = {
|
|
70
|
+
code: err["code"],
|
|
71
|
+
type: err["type"],
|
|
72
|
+
doc_url: err["doc_url"],
|
|
73
|
+
request_id: err["request_id"] || request_id,
|
|
74
|
+
http_status: status
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
case status
|
|
78
|
+
when 401 then AuthenticationError.new(message, **opts)
|
|
79
|
+
when 403 then PermissionError.new(message, **opts)
|
|
80
|
+
when 402 then BillingError.new(message, **opts)
|
|
81
|
+
when 400, 404, 422 then InvalidRequestError.new(message, **opts)
|
|
82
|
+
when 429 then RateLimitError.new(message, **opts)
|
|
83
|
+
else
|
|
84
|
+
status >= 500 ? ApiError.new(message, **opts) : InvalidRequestError.new(message, **opts)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Procteye
|
|
4
|
+
# Operations on integrity sessions: the core resource of the API.
|
|
5
|
+
#
|
|
6
|
+
# Reachable as +client.sessions+.
|
|
7
|
+
class SessionsResource
|
|
8
|
+
# Fields accepted by {#create}; anything else is dropped.
|
|
9
|
+
CREATE_FIELDS = %i[
|
|
10
|
+
assurance_level duration_minutes external_user_ref
|
|
11
|
+
candidate_name candidate_email evidence_mode metadata
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
def initialize(client)
|
|
15
|
+
@client = client
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Create a new integrity session.
|
|
19
|
+
#
|
|
20
|
+
# A fresh +Idempotency-Key+ is sent automatically; pass +idempotency_key:+
|
|
21
|
+
# to make a retry return the original session.
|
|
22
|
+
#
|
|
23
|
+
# @param assurance_level [String, nil] BASIC | STANDARD | HIGH
|
|
24
|
+
# @param duration_minutes [Integer, nil] 1..600
|
|
25
|
+
# @param external_user_ref [String, nil]
|
|
26
|
+
# @param candidate_name [String, nil]
|
|
27
|
+
# @param candidate_email [String, nil]
|
|
28
|
+
# @param evidence_mode [String, nil] NONE | THUMBNAILS | CLIPS
|
|
29
|
+
# @param metadata [Hash, nil]
|
|
30
|
+
# @param idempotency_key [String, nil]
|
|
31
|
+
# @return [Procteye::ProcteyeObject]
|
|
32
|
+
# @raise [Procteye::ProcteyeError] the API rejected the request or an unrecoverable
|
|
33
|
+
# transport error occurred
|
|
34
|
+
def create(idempotency_key: nil, **params)
|
|
35
|
+
body = {}
|
|
36
|
+
CREATE_FIELDS.each do |field|
|
|
37
|
+
value = params[field]
|
|
38
|
+
body[field.to_s] = value unless value.nil?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
data, = @client.request("POST", "/external/v1/sessions", body: body, idempotency_key: idempotency_key)
|
|
42
|
+
ProcteyeObject.wrap(data)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Retrieve a single session by id.
|
|
46
|
+
# @param id [String] the session id
|
|
47
|
+
# @return [Procteye::ProcteyeObject]
|
|
48
|
+
# @raise [Procteye::InvalidRequestError] no session exists with the given id
|
|
49
|
+
# @raise [Procteye::ProcteyeError] any other API or transport failure
|
|
50
|
+
def retrieve(id)
|
|
51
|
+
data, = @client.request("GET", "/external/v1/sessions/#{encode(id)}")
|
|
52
|
+
ProcteyeObject.wrap(data)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Fetch a single raw page of sessions (the list envelope
|
|
56
|
+
# +{object: "list", data: [...]}+).
|
|
57
|
+
#
|
|
58
|
+
# @param limit [Integer, nil]
|
|
59
|
+
# @param starting_after [String, nil] cursor: returns the sessions created before this id
|
|
60
|
+
# @return [Procteye::ProcteyeObject]
|
|
61
|
+
# @raise [Procteye::ProcteyeError] the API rejected the request or an unrecoverable
|
|
62
|
+
# transport error occurred
|
|
63
|
+
def list(limit: nil, starting_after: nil)
|
|
64
|
+
data, = @client.request(
|
|
65
|
+
"GET",
|
|
66
|
+
"/external/v1/sessions",
|
|
67
|
+
query: { "limit" => limit, "starting_after" => starting_after }
|
|
68
|
+
)
|
|
69
|
+
ProcteyeObject.wrap(data)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Auto-paginating iterator over sessions.
|
|
73
|
+
#
|
|
74
|
+
# The API exposes no server-side cursor, so pages are fetched client-side
|
|
75
|
+
# using the last session id as a +starting_after+ query parameter. Stops
|
|
76
|
+
# when the API signals +has_more: false+, or when a page returns fewer rows
|
|
77
|
+
# than +limit+. Returns an +Enumerator+ when no block is given.
|
|
78
|
+
#
|
|
79
|
+
# @example
|
|
80
|
+
# client.sessions.auto_paging_each(limit: 50) do |session|
|
|
81
|
+
# puts session.id, session.status
|
|
82
|
+
# end
|
|
83
|
+
#
|
|
84
|
+
# @param limit [Integer] page size
|
|
85
|
+
# @param starting_after [String, nil] initial cursor
|
|
86
|
+
# @return [Enumerator] when no block is given
|
|
87
|
+
# @raise [Procteye::ProcteyeError] a page fetch failed with an API or transport error
|
|
88
|
+
def auto_paging_each(limit: 20, starting_after: nil)
|
|
89
|
+
return enum_for(:auto_paging_each, limit: limit, starting_after: starting_after) unless block_given?
|
|
90
|
+
|
|
91
|
+
cursor = starting_after
|
|
92
|
+
previous_first_id = nil
|
|
93
|
+
loop do
|
|
94
|
+
page = list(limit: limit, starting_after: cursor)
|
|
95
|
+
rows = page.key?(:data) ? Array(page.to_h["data"]) : []
|
|
96
|
+
|
|
97
|
+
return if rows.empty?
|
|
98
|
+
|
|
99
|
+
has_more = page.to_h["has_more"]
|
|
100
|
+
first_row = rows.first
|
|
101
|
+
first_id = first_row.is_a?(Hash) ? first_row["id"] : nil
|
|
102
|
+
# A server that predates starting_after support ignores the cursor
|
|
103
|
+
# and always returns the newest `limit` rows with no has_more field.
|
|
104
|
+
# For an org with more than `limit` sessions that page is never
|
|
105
|
+
# shorter than `limit`, so the "fewer rows than limit" stop below
|
|
106
|
+
# never fires either. Detect the repeat before yielding it so we
|
|
107
|
+
# never spin on page 1 forever or re-yield the same rows.
|
|
108
|
+
return if has_more.nil? && !previous_first_id.nil? && first_id == previous_first_id
|
|
109
|
+
|
|
110
|
+
previous_first_id = first_id
|
|
111
|
+
|
|
112
|
+
rows.each { |row| yield ProcteyeObject.wrap(row) }
|
|
113
|
+
|
|
114
|
+
return if has_more == false
|
|
115
|
+
return if has_more.nil? && rows.length < limit
|
|
116
|
+
|
|
117
|
+
cursor = rows.last.is_a?(Hash) ? rows.last["id"] : nil
|
|
118
|
+
return if cursor.nil? || cursor.to_s.empty?
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Mark a session complete (no further violations accepted).
|
|
123
|
+
# @param id [String] the session id
|
|
124
|
+
# @param idempotency_key [String, nil] overrides the auto-generated idempotency key
|
|
125
|
+
# @return [Procteye::ProcteyeObject]
|
|
126
|
+
# @raise [Procteye::InvalidRequestError] no session exists with the given id, or it
|
|
127
|
+
# cannot be completed from its current state
|
|
128
|
+
# @raise [Procteye::ProcteyeError] any other API or transport failure
|
|
129
|
+
def complete(id, idempotency_key: nil)
|
|
130
|
+
data, = @client.request(
|
|
131
|
+
"POST", "/external/v1/sessions/#{encode(id)}/complete", body: {}, idempotency_key: idempotency_key
|
|
132
|
+
)
|
|
133
|
+
ProcteyeObject.wrap(data)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Cancel a session before it completes. A successful cancel sends the
|
|
137
|
+
# +session.canceled+ webhook.
|
|
138
|
+
# @param id [String] the session id
|
|
139
|
+
# @param idempotency_key [String, nil] overrides the auto-generated idempotency key
|
|
140
|
+
# @return [Procteye::ProcteyeObject]
|
|
141
|
+
# @raise [Procteye::InvalidRequestError] no session exists with the given id, or it
|
|
142
|
+
# is already final (completed, canceled or expired): HTTP 409 with code
|
|
143
|
+
# +session_not_active+, which is not retried
|
|
144
|
+
# @raise [Procteye::ProcteyeError] any other API or transport failure
|
|
145
|
+
def cancel(id, idempotency_key: nil)
|
|
146
|
+
data, = @client.request(
|
|
147
|
+
"POST", "/external/v1/sessions/#{encode(id)}/cancel", body: {}, idempotency_key: idempotency_key
|
|
148
|
+
)
|
|
149
|
+
ProcteyeObject.wrap(data)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
def encode(value)
|
|
155
|
+
require "erb"
|
|
156
|
+
ERB::Util.url_encode(value.to_s)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
|
|
6
|
+
require_relative "errors"
|
|
7
|
+
|
|
8
|
+
module Procteye
|
|
9
|
+
# Webhook signature verification implementing the Standard Webhooks spec
|
|
10
|
+
# (https://www.standardwebhooks.com), matching the gateway's
|
|
11
|
+
# +standardwebhooks+ signer and the Node/Python SDKs.
|
|
12
|
+
module Webhooks
|
|
13
|
+
# Default tolerance for the webhook timestamp: 5 minutes.
|
|
14
|
+
DEFAULT_TOLERANCE_SECONDS = 5 * 60
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Verify a webhook signature and return the parsed event.
|
|
19
|
+
#
|
|
20
|
+
# @param payload [String] the exact raw request body. Do NOT pass a
|
|
21
|
+
# re-serialized object -- the signature is over the raw bytes.
|
|
22
|
+
# @param headers [Hash] the incoming request headers. Lookup is
|
|
23
|
+
# case-insensitive and accepts +Array+ values (first element is used):
|
|
24
|
+
# +webhook-id+, +webhook-timestamp+, +webhook-signature+.
|
|
25
|
+
# @param secret [String] the endpoint secret, e.g. +whsec_...+.
|
|
26
|
+
# @param tolerance [Integer] max allowed age of the webhook, in seconds.
|
|
27
|
+
# @return [Hash] the parsed event.
|
|
28
|
+
# @raise [WebhookVerificationError] on any missing header, bad signature,
|
|
29
|
+
# or a timestamp outside the tolerance window.
|
|
30
|
+
def construct_event(payload, headers, secret, tolerance: DEFAULT_TOLERANCE_SECONDS)
|
|
31
|
+
if secret.nil? || !secret.is_a?(String) || secret.strip.empty?
|
|
32
|
+
raise WebhookVerificationError, "Webhook secret must be a non-empty string."
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
webhook_id = header_value(headers, "webhook-id")
|
|
36
|
+
timestamp = header_value(headers, "webhook-timestamp")
|
|
37
|
+
signature_header = header_value(headers, "webhook-signature")
|
|
38
|
+
|
|
39
|
+
if webhook_id.to_s.empty? || timestamp.to_s.empty? || signature_header.to_s.empty?
|
|
40
|
+
raise WebhookVerificationError,
|
|
41
|
+
"Missing required webhook headers (webhook-id, webhook-timestamp, webhook-signature)."
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
verify_timestamp(timestamp, tolerance)
|
|
45
|
+
|
|
46
|
+
body = payload.is_a?(String) ? payload : payload.to_s
|
|
47
|
+
signed_content = "#{webhook_id}.#{timestamp}.#{body}"
|
|
48
|
+
expected = sign(signed_content, secret)
|
|
49
|
+
|
|
50
|
+
# The header may carry a space-delimited list of `v1,<sig>` versions.
|
|
51
|
+
passed = signature_header.split(" ").any? do |part|
|
|
52
|
+
part = part.strip
|
|
53
|
+
next false if part.empty?
|
|
54
|
+
|
|
55
|
+
version, value = part.split(",", 2)
|
|
56
|
+
next false if version != "v1" || value.to_s.empty?
|
|
57
|
+
|
|
58
|
+
secure_compare(value, expected)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
raise WebhookVerificationError, "Webhook signature verification failed." unless passed
|
|
62
|
+
|
|
63
|
+
begin
|
|
64
|
+
JSON.parse(body)
|
|
65
|
+
rescue JSON::ParserError
|
|
66
|
+
raise WebhookVerificationError, "Webhook payload is not valid JSON."
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Compute the base64 HMAC-SHA256 of +signed_content+.
|
|
71
|
+
#
|
|
72
|
+
# Per Standard Webhooks, the portion after +whsec_+ is base64 and is the
|
|
73
|
+
# raw HMAC key.
|
|
74
|
+
def sign(signed_content, secret)
|
|
75
|
+
if secret.nil? || !secret.is_a?(String) || secret.strip.empty?
|
|
76
|
+
raise WebhookVerificationError, "Webhook secret must be a non-empty string."
|
|
77
|
+
end
|
|
78
|
+
key_b64 = secret.start_with?("whsec_") ? secret[6..] : secret
|
|
79
|
+
if key_b64.nil? || key_b64.strip.empty?
|
|
80
|
+
raise WebhookVerificationError, "Webhook secret is invalid or empty."
|
|
81
|
+
end
|
|
82
|
+
begin
|
|
83
|
+
# "m0" is strict base64 (RFC 4648, no line feeds); it raises
|
|
84
|
+
# ArgumentError on malformed input. Array#pack/String#unpack1 are
|
|
85
|
+
# core Ruby, so the SDK does not need the base64 gem, which is no
|
|
86
|
+
# longer a default gem from Ruby 3.4.
|
|
87
|
+
key = key_b64.unpack1("m0")
|
|
88
|
+
rescue ArgumentError
|
|
89
|
+
raise WebhookVerificationError, "Invalid base64 secret."
|
|
90
|
+
end
|
|
91
|
+
if key.bytesize < 32
|
|
92
|
+
raise WebhookVerificationError, "Webhook secret key must be at least 32 bytes."
|
|
93
|
+
end
|
|
94
|
+
digest = OpenSSL::HMAC.digest("SHA256", key, signed_content)
|
|
95
|
+
[digest].pack("m0")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def verify_timestamp(timestamp, tolerance)
|
|
99
|
+
then_ts = Integer(timestamp)
|
|
100
|
+
now = Time.now.to_i
|
|
101
|
+
if (now - then_ts).abs > tolerance
|
|
102
|
+
raise WebhookVerificationError,
|
|
103
|
+
"Webhook timestamp is outside the allowed tolerance (#{tolerance}s)."
|
|
104
|
+
end
|
|
105
|
+
rescue ArgumentError, TypeError
|
|
106
|
+
raise WebhookVerificationError, "Invalid webhook-timestamp header."
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Case-insensitive header lookup returning "" when absent.
|
|
110
|
+
def header_value(headers, name)
|
|
111
|
+
lower = name.downcase
|
|
112
|
+
headers.each do |key, value|
|
|
113
|
+
next unless key.to_s.downcase == lower
|
|
114
|
+
|
|
115
|
+
return value.is_a?(Array) ? value.first.to_s : value.to_s
|
|
116
|
+
end
|
|
117
|
+
""
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Constant-time comparison of two signature strings.
|
|
121
|
+
def secure_compare(a, b)
|
|
122
|
+
a = a.to_s
|
|
123
|
+
b = b.to_s
|
|
124
|
+
if OpenSSL.respond_to?(:fixed_length_secure_compare)
|
|
125
|
+
return false unless a.bytesize == b.bytesize
|
|
126
|
+
|
|
127
|
+
OpenSSL.fixed_length_secure_compare(a, b)
|
|
128
|
+
else
|
|
129
|
+
return false unless a.bytesize == b.bytesize
|
|
130
|
+
|
|
131
|
+
res = 0
|
|
132
|
+
a.bytes.zip(b.bytes) { |x, y| res |= x ^ y }
|
|
133
|
+
res.zero?
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
data/lib/procteye.rb
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# procteye - official server-side SDK for the Procteye Integrity API.
|
|
4
|
+
#
|
|
5
|
+
# Uses only the Ruby standard library (net/http, openssl, json, securerandom,
|
|
6
|
+
# base64) so there are no runtime dependencies.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# require "procteye"
|
|
10
|
+
#
|
|
11
|
+
# client = Procteye::Client.new(ENV["PROCTEYE_API_KEY"])
|
|
12
|
+
# session = client.sessions.create(assurance_level: "HIGH")
|
|
13
|
+
# puts session.id, session.client_secret
|
|
14
|
+
module Procteye
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
require_relative "procteye/version"
|
|
18
|
+
require_relative "procteye/errors"
|
|
19
|
+
require_relative "procteye/sessions"
|
|
20
|
+
require_relative "procteye/client"
|
|
21
|
+
require_relative "procteye/webhooks"
|
metadata
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: procteye
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 2.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Procteye
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Create and manage integrity sessions and verify Standard Webhooks signatures.
|
|
13
|
+
Pure Ruby standard library, no runtime dependencies.
|
|
14
|
+
email:
|
|
15
|
+
- support@procteye.com
|
|
16
|
+
executables: []
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files: []
|
|
19
|
+
files:
|
|
20
|
+
- CHANGELOG.md
|
|
21
|
+
- LICENSE
|
|
22
|
+
- README.md
|
|
23
|
+
- lib/procteye.rb
|
|
24
|
+
- lib/procteye/client.rb
|
|
25
|
+
- lib/procteye/errors.rb
|
|
26
|
+
- lib/procteye/sessions.rb
|
|
27
|
+
- lib/procteye/version.rb
|
|
28
|
+
- lib/procteye/webhooks.rb
|
|
29
|
+
homepage: https://docs.procteye.com
|
|
30
|
+
licenses:
|
|
31
|
+
- MIT
|
|
32
|
+
metadata:
|
|
33
|
+
homepage_uri: https://docs.procteye.com
|
|
34
|
+
documentation_uri: https://docs.procteye.com
|
|
35
|
+
rubygems_mfa_required: 'true'
|
|
36
|
+
rdoc_options: []
|
|
37
|
+
require_paths:
|
|
38
|
+
- lib
|
|
39
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
40
|
+
requirements:
|
|
41
|
+
- - ">="
|
|
42
|
+
- !ruby/object:Gem::Version
|
|
43
|
+
version: 2.7.0
|
|
44
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '0'
|
|
49
|
+
requirements: []
|
|
50
|
+
rubygems_version: 3.6.9
|
|
51
|
+
specification_version: 4
|
|
52
|
+
summary: Official server-side Ruby SDK for the Procteye Integrity API.
|
|
53
|
+
test_files: []
|