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 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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Procteye
4
+ VERSION = "2.1.0"
5
+
6
+ # The dated API version pinned by this SDK. Sent as the `PE-Version` header.
7
+ API_VERSION = "2026-07-01"
8
+ 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: []