opentrust 0.1.2 → 0.3.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 +4 -4
- data/README.md +25 -3
- data/lib/opentrust/client.rb +91 -10
- data/lib/opentrust/errors.rb +4 -0
- data/lib/opentrust/phone.rb +48 -0
- data/lib/opentrust/version.rb +1 -1
- data/lib/opentrust.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 810e3839ac5fe409bc140e8bf26e34bafaf795ee9f3cc5601f64d616c5774b60
|
|
4
|
+
data.tar.gz: 5c828d92c69579e6e191629ef860d0404179dd410f20b8c9f9aba13330c972c0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 55bbf1b388e709db9e4d7676cd47eebdef55e06a2d9b0a0ab81beabd788b7a424f70e8b899c6e5a8f142f76e247d686eb06d71c918d921554e94a8b39b424cf9
|
|
7
|
+
data.tar.gz: 765c55288178d3c0780c981b8042e469fcbb0eef305c6e0d8a1319829c2f5098191a8bca61f46ff5e690a770d1be0f6b0a5ad57128750ef21300e73993492e68
|
data/README.md
CHANGED
|
@@ -15,15 +15,37 @@ client = OpenTrust::Client.new(api_key: ENV["OPENTRUST_API_KEY"]) # or just set
|
|
|
15
15
|
result = client.lookup("98765 43210") # uses 1 check
|
|
16
16
|
result.reported? # => true / false
|
|
17
17
|
result.report_count # => 2
|
|
18
|
-
result.reports.each { |r| puts "#{r.reason_code}: #{r.notes}" }
|
|
18
|
+
result.reports.each { |r| puts "#{r.reason_code} (#{r.incident_at}): #{r.notes}" } # other merchants' reports only
|
|
19
|
+
result.own_report? # => true if you have shared this number yourself
|
|
19
20
|
result.notice # keep this in front of anyone who sees the result
|
|
20
21
|
result.checks_remaining
|
|
21
22
|
|
|
22
23
|
client.lookup_many(%w[9876543210 9000000001]) # up to 100, all or nothing
|
|
23
|
-
client.report(phone: "9876543210", reason_code: "
|
|
24
|
+
client.report(phone: "9876543210", reason_code: "chargeback", notes: "Disputed 3 card payments in March", incident_at: Date.new(2026, 3, 14),
|
|
25
|
+
confirmed_by: "bank", evidence_ref: "your-ticket-123") # optional: who confirmed it (shown to others), and a private reference
|
|
26
|
+
|
|
27
|
+
# up to 100 at once; one bad row never stops the rest
|
|
28
|
+
results = client.report_many([{ phone: "9876543210", reason_code: "chargeback", incident_at: "2026-08-14" }, { phone: "9812345678", reason_code: "fake_order" }])
|
|
29
|
+
results.reject(&:recorded?).each { |r| warn "row #{r.index}: #{r.status} #{r.error_code}" }
|
|
30
|
+
|
|
31
|
+
client.withdraw_report("9876543210") # take your own report back (raises NotFoundError if there is none)
|
|
32
|
+
client.reason_codes # => [#<struct code="chargeback", group=..., label=..., description=...>, ...]
|
|
24
33
|
client.me # => { "checks_remaining" => ..., ... }
|
|
25
34
|
```
|
|
26
35
|
|
|
36
|
+
## Send hashes instead of phone numbers
|
|
37
|
+
|
|
38
|
+
Pass `hash_numbers: true` and the library sends the SHA-256 of each number instead of the number, so OpenTrust never receives it. It also sends `phone_length`, the length of the text it hashed (always 13), which OpenTrust checks to catch a hash of a differently written number. A report also sends the last four digits, so merchants and admins have something to recognise it by.
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
client = OpenTrust::Client.new(hash_numbers: true)
|
|
42
|
+
client.lookup("98765 43210") # sends only the hash and its length (always 13)
|
|
43
|
+
OpenTrust.hash_phone("98765 43210") # => "f3a47ce5ce3d4ca8ad15225a245b2759022f79489f5c62719b8c9490f7aab90e"
|
|
44
|
+
client.verify_hashing("98765 43210") # development aid: asks OpenTrust to compare (sends the number: use a test number)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The format is exact, and a hash computed from a differently written number just finds nothing, so use the helper instead of hashing by hand. It is tested against the shared [test vectors](https://github.com/parity/opentrust/blob/main/sdks/phone-hash-vectors.json). A hash of a phone number can in principle be reversed by trying every possible number: this limits what is sent and stored, it does not make a number anonymous.
|
|
48
|
+
|
|
27
49
|
## Please don't block checkout on OpenTrust
|
|
28
50
|
|
|
29
51
|
Results are unverified observations from individual merchants. Use them as one input, have a person review decisions that matter, and fail open if OpenTrust is unreachable:
|
|
@@ -37,7 +59,7 @@ rescue OpenTrust::Error => e
|
|
|
37
59
|
end
|
|
38
60
|
```
|
|
39
61
|
|
|
40
|
-
Errors: `AuthenticationError` (401), `NoChecksError` (402, write to us and we'll raise your limit), `NotApprovedError` (403), `DuplicateReportError` (409), `InvalidRequestError` (422), `RateLimitError` (429), `ServerError` (5xx), `ConnectionError` (network/timeout). All inherit from `OpenTrust::Error` and carry `status` and `code`.
|
|
62
|
+
Errors: `AuthenticationError` (401), `NoChecksError` (402, write to us and we'll raise your limit), `NotApprovedError` (403), `NotFoundError` (404, withdrawing a report you don't have), `DuplicateReportError` (409), `InvalidRequestError` (422), `RateLimitError` (429), `ServerError` (5xx), `ConnectionError` (network/timeout). All inherit from `OpenTrust::Error` and carry `status` and `code`.
|
|
41
63
|
|
|
42
64
|
Timeout defaults to 5 seconds (`timeout:`). Docs: https://opentrust.in/docs
|
|
43
65
|
|
data/lib/opentrust/client.rb
CHANGED
|
@@ -3,59 +3,139 @@ require "net/http"
|
|
|
3
3
|
require "uri"
|
|
4
4
|
|
|
5
5
|
module OpenTrust
|
|
6
|
-
Report = Struct.new(:reason_code, :reason, :notes, :reported_at, :reporter_verified, keyword_init: true)
|
|
6
|
+
Report = Struct.new(:reason_code, :reason, :notes, :incident_at, :confirmed_by, :reported_at, :reporter_verified, keyword_init: true)
|
|
7
7
|
|
|
8
8
|
# The result of checking one number. Reports are unverified observations from individual merchants,
|
|
9
9
|
# not a finding about anyone. Use them as one input among others.
|
|
10
|
-
|
|
10
|
+
#
|
|
11
|
+
# `reports` and `report_count` only hold OTHER merchants' reports. `own_report` says whether you have shared this number yourself.
|
|
12
|
+
# `phone_sha256` is set when the number was sent as a hash (then `phone_last4` is empty: we never saw the number).
|
|
13
|
+
Lookup = Struct.new(:phone_last4, :phone_sha256, :report_count, :reports, :own_report, :notice, :checks_remaining, keyword_init: true) do
|
|
11
14
|
def reported?
|
|
12
15
|
report_count.to_i.positive?
|
|
13
16
|
end
|
|
17
|
+
|
|
18
|
+
def own_report?
|
|
19
|
+
own_report == true
|
|
20
|
+
end
|
|
14
21
|
end
|
|
15
22
|
|
|
16
23
|
Receipt = Struct.new(:id, :phone_last4, keyword_init: true)
|
|
17
24
|
|
|
25
|
+
# One row of a bulk report. `status` is "recorded", "duplicate" (you already shared it; treat as done) or "invalid".
|
|
26
|
+
BatchResult = Struct.new(:index, :status, :id, :phone_last4, :error_code, :error_message, keyword_init: true) do
|
|
27
|
+
def recorded?
|
|
28
|
+
status == "recorded"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def duplicate?
|
|
32
|
+
status == "duplicate"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def invalid?
|
|
36
|
+
status == "invalid"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
Withdrawal = Struct.new(:phone_last4, :status, keyword_init: true)
|
|
41
|
+
ReasonCode = Struct.new(:code, :group, :label, :description, :use_when, :not_when, keyword_init: true)
|
|
42
|
+
|
|
18
43
|
class Client
|
|
19
44
|
DEFAULT_BASE_URL = "https://opentrust.in/api/v1".freeze
|
|
20
45
|
|
|
46
|
+
# hash_numbers: send the SHA-256 of each number (see OpenTrust::Phone) instead of the number, so OpenTrust never receives it.
|
|
47
|
+
# A hash can in principle be reversed by brute force, so this limits what is sent; it does not make a number anonymous.
|
|
21
48
|
# adapter: optional callable (method, url, headers, body) -> [status, body_string], mainly for tests.
|
|
22
|
-
def initialize(api_key: ENV["OPENTRUST_API_KEY"], base_url: ENV.fetch("OPENTRUST_BASE_URL", DEFAULT_BASE_URL), timeout: 5, adapter: nil)
|
|
49
|
+
def initialize(api_key: ENV["OPENTRUST_API_KEY"], base_url: ENV.fetch("OPENTRUST_BASE_URL", DEFAULT_BASE_URL), timeout: 5, adapter: nil, hash_numbers: false)
|
|
23
50
|
raise ArgumentError, "api_key is required (or set OPENTRUST_API_KEY)" if api_key.to_s.empty?
|
|
24
51
|
|
|
25
52
|
@api_key = api_key
|
|
26
53
|
@base_url = base_url.chomp("/")
|
|
27
54
|
@timeout = timeout
|
|
28
55
|
@adapter = adapter
|
|
56
|
+
@hash_numbers = hash_numbers
|
|
29
57
|
end
|
|
30
58
|
|
|
31
59
|
# Check one number. Uses one check from your allowance.
|
|
32
60
|
def lookup(phone)
|
|
33
|
-
lookup_from(request(:post, "/lookups", phone
|
|
61
|
+
lookup_from(request(:post, "/lookups", number_fields(phone)))
|
|
34
62
|
end
|
|
35
63
|
|
|
36
64
|
# Check up to 100 numbers. All or nothing: if you don't have enough checks, none are used.
|
|
37
65
|
def lookup_many(phones)
|
|
38
|
-
|
|
66
|
+
list = Array(phones)
|
|
67
|
+
payload = @hash_numbers ? { phone_sha256s: list.map { |p| Phone.sha256(p) }, phone_length: Phone::HASHED_LENGTH } : { phones: list }
|
|
68
|
+
body = request(:post, "/lookups/batch", payload)
|
|
39
69
|
body.fetch("results").map { |r| lookup_from(r.merge("notice" => body["notice"], "checks_remaining" => body["checks_remaining"])) }
|
|
40
70
|
end
|
|
41
71
|
|
|
42
72
|
# Share an experience with a number. Optional, and doesn't change your allowance.
|
|
43
|
-
|
|
44
|
-
|
|
73
|
+
# incident_at: when it happened (a Date, a Time or an ISO 8601 string); can't be in the future.
|
|
74
|
+
# confirmed_by: "bank", "payment_gateway" or "law_enforcement", only if they told you the activity was confirmed (shown to other
|
|
75
|
+
# merchants, not verified). evidence_ref: a private reference for administrators, never shown; needs confirmed_by.
|
|
76
|
+
def report(phone:, reason_code:, notes: nil, incident_at: nil, confirmed_by: nil, evidence_ref: nil)
|
|
77
|
+
body = request(:post, "/reports", report_payload(phone: phone, reason_code: reason_code, notes: notes, incident_at: incident_at,
|
|
78
|
+
confirmed_by: confirmed_by, evidence_ref: evidence_ref))
|
|
45
79
|
Receipt.new(id: body["id"], phone_last4: body["phone_last4"])
|
|
46
80
|
end
|
|
47
81
|
|
|
82
|
+
# Share up to 100 experiences at once. Each is handled on its own, so one bad row never stops the rest: the result is a
|
|
83
|
+
# BatchResult per report, in order. reports: [{ phone:, reason_code:, notes:, incident_at: }, ...]
|
|
84
|
+
def report_many(reports)
|
|
85
|
+
list = Array(reports)
|
|
86
|
+
raise ArgumentError, "report_many takes 1 to 100 reports" unless list.size.between?(1, 100)
|
|
87
|
+
|
|
88
|
+
body = request(:post, "/reports/batch", reports: list.map { |r| report_payload(**r.transform_keys(&:to_sym)) })
|
|
89
|
+
body.fetch("results").map do |r|
|
|
90
|
+
BatchResult.new(index: r["index"], status: r["status"], id: r["id"], phone_last4: r["phone_last4"],
|
|
91
|
+
error_code: r.dig("error", "code"), error_message: r.dig("error", "message"))
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Withdraw your own report for a number. Raises NotFoundError if you have no active report for it.
|
|
96
|
+
def withdraw_report(phone)
|
|
97
|
+
body = request(:delete, "/reports", number_fields(phone))
|
|
98
|
+
Withdrawal.new(phone_last4: body["phone_last4"], status: body["status"])
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The reason codes with their labels and descriptions.
|
|
102
|
+
def reason_codes
|
|
103
|
+
request(:get, "/reason_codes").fetch("reason_codes").map { |c| ReasonCode.new(code: c["code"], group: c["group"], label: c["label"], description: c["description"], use_when: c["use_when"], not_when: c["not_when"]) }
|
|
104
|
+
end
|
|
105
|
+
|
|
48
106
|
# Your remaining checks and usage, as a Hash.
|
|
49
107
|
def me
|
|
50
108
|
request(:get, "/me")
|
|
51
109
|
end
|
|
52
110
|
|
|
111
|
+
# A development aid: asks OpenTrust whether it computes the same SHA-256 as this library for a number. Use a test number, not a
|
|
112
|
+
# customer's: it sends the number. Returns a Hash with "match" (true or false). It costs no checks.
|
|
113
|
+
def verify_hashing(test_phone)
|
|
114
|
+
request(:post, "/phone_hash/check", phone: test_phone, phone_sha256: Phone.sha256(test_phone))
|
|
115
|
+
end
|
|
116
|
+
|
|
53
117
|
private
|
|
54
118
|
|
|
119
|
+
def report_payload(phone:, reason_code:, notes: nil, incident_at: nil, confirmed_by: nil, evidence_ref: nil)
|
|
120
|
+
incident_at = incident_at.iso8601 if incident_at.respond_to?(:iso8601)
|
|
121
|
+
number_fields(phone, with_last4: true).merge(reason_code: reason_code, notes: notes, incident_at: incident_at, confirmed_by: confirmed_by, evidence_ref: evidence_ref).compact
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# The number, or (with hash_numbers) its SHA-256 and, for a report, its last four digits.
|
|
125
|
+
def number_fields(phone, with_last4: false)
|
|
126
|
+
return { phone: phone } unless @hash_numbers
|
|
127
|
+
|
|
128
|
+
fields = { phone_sha256: Phone.sha256(phone), phone_length: Phone.hashed_length(phone) }
|
|
129
|
+
fields[:phone_last4] = Phone.last4(phone) if with_last4
|
|
130
|
+
fields
|
|
131
|
+
end
|
|
132
|
+
|
|
55
133
|
def lookup_from(h)
|
|
56
134
|
Lookup.new(
|
|
57
|
-
phone_last4: h["phone_last4"], report_count: h["report_count"], notice: h["notice"], checks_remaining: h["checks_remaining"],
|
|
58
|
-
reports: Array(h["reports"]).map
|
|
135
|
+
phone_last4: h["phone_last4"], phone_sha256: h["phone_sha256"], report_count: h["report_count"], own_report: h["own_report"], notice: h["notice"], checks_remaining: h["checks_remaining"],
|
|
136
|
+
reports: Array(h["reports"]).map do |r|
|
|
137
|
+
Report.new(reason_code: r["reason_code"], reason: r["reason"], notes: r["notes"], incident_at: r["incident_at"], confirmed_by: r["confirmed_by"], reported_at: r["reported_at"], reporter_verified: r["reporter_verified"])
|
|
138
|
+
end
|
|
59
139
|
)
|
|
60
140
|
end
|
|
61
141
|
|
|
@@ -72,7 +152,7 @@ module OpenTrust
|
|
|
72
152
|
return @adapter.call(verb, url, headers, body) if @adapter
|
|
73
153
|
|
|
74
154
|
uri = URI(url)
|
|
75
|
-
klass =
|
|
155
|
+
klass = { get: Net::HTTP::Get, post: Net::HTTP::Post, delete: Net::HTTP::Delete }.fetch(verb)
|
|
76
156
|
req = klass.new(uri, headers)
|
|
77
157
|
req.body = body if body
|
|
78
158
|
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https", open_timeout: @timeout, read_timeout: @timeout) { |http| http.request(req) }
|
|
@@ -88,6 +168,7 @@ module OpenTrust
|
|
|
88
168
|
when 401 then AuthenticationError
|
|
89
169
|
when 402 then NoChecksError
|
|
90
170
|
when 403 then NotApprovedError
|
|
171
|
+
when 404 then NotFoundError
|
|
91
172
|
when 409 then DuplicateReportError
|
|
92
173
|
when 422 then InvalidRequestError
|
|
93
174
|
when 429 then RateLimitError
|
data/lib/opentrust/errors.rb
CHANGED
|
@@ -10,10 +10,14 @@ module OpenTrust
|
|
|
10
10
|
end
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
+
# Raised before anything is sent: the number is not an Indian mobile number, so it cannot be checked or hashed.
|
|
14
|
+
class InvalidPhoneError < ArgumentError; end
|
|
15
|
+
|
|
13
16
|
class ConnectionError < Error; end # network failure or timeout
|
|
14
17
|
class AuthenticationError < Error; end # 401: missing or invalid API key
|
|
15
18
|
class NoChecksError < Error; end # 402: allowance used up
|
|
16
19
|
class NotApprovedError < Error; end # 403: account not approved or suspended
|
|
20
|
+
class NotFoundError < Error; end # 404: no active report of yours for that number (withdrawing)
|
|
17
21
|
class DuplicateReportError < Error; end # 409: you already shared this number
|
|
18
22
|
class InvalidRequestError < Error; end # 422: bad number, reason code or note
|
|
19
23
|
class RateLimitError < Error; end # 429
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
|
|
3
|
+
module OpenTrust
|
|
4
|
+
# The one place the number format is defined. OpenTrust accepts either a phone number or its SHA-256, and a hash only matches
|
|
5
|
+
# if it is computed exactly like this:
|
|
6
|
+
#
|
|
7
|
+
# 1. keep the digits, drop a leading 91 (12 digits) or 0 (11 digits), leaving 10 digits starting 6, 7, 8 or 9
|
|
8
|
+
# 2. prefix "+91", for example "+919876543210"
|
|
9
|
+
# 3. SHA-256 of that text, as 64 lowercase hex characters
|
|
10
|
+
#
|
|
11
|
+
# A hash that does not match simply finds nothing, so use this helper (and the shared test vectors) rather than hashing by hand.
|
|
12
|
+
module Phone
|
|
13
|
+
HASHED_LENGTH = 13 # +91 and ten digits
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# "+91" followed by the ten digits. Raises InvalidPhoneError for anything that is not an Indian mobile number.
|
|
18
|
+
def normalize(raw)
|
|
19
|
+
digits = raw.to_s.gsub(/\D/, "")
|
|
20
|
+
digits = digits[2..-1] if digits.length == 12 && digits.start_with?("91")
|
|
21
|
+
digits = digits[1..-1] if digits.length == 11 && digits.start_with?("0")
|
|
22
|
+
raise InvalidPhoneError, "not a valid Indian mobile number" unless digits.match?(/\A[6-9]\d{9}\z/)
|
|
23
|
+
|
|
24
|
+
"+91#{digits}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The 64 lowercase hex characters OpenTrust expects as phone_sha256.
|
|
28
|
+
def sha256(raw)
|
|
29
|
+
Digest::SHA256.hexdigest(normalize(raw))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The length of the text that is hashed (13), sent as phone_length. OpenTrust refuses any other value, which catches hashes of a
|
|
33
|
+
# differently written number (10 = just the digits, 11 = leading 0, 12 = 91 without the +).
|
|
34
|
+
def hashed_length(raw)
|
|
35
|
+
normalize(raw).length
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The last four digits, sent with a hashed report so merchants and admins have something to recognise it by.
|
|
39
|
+
def last4(raw)
|
|
40
|
+
normalize(raw)[-4..-1]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Hash a number the way OpenTrust expects. Same as Phone.sha256.
|
|
45
|
+
def self.hash_phone(raw)
|
|
46
|
+
Phone.sha256(raw)
|
|
47
|
+
end
|
|
48
|
+
end
|
data/lib/opentrust/version.rb
CHANGED
data/lib/opentrust.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: opentrust
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- ParityCube
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-14 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: Check mobile numbers against experiences shared by other Indian merchants,
|
|
14
14
|
and share your own. A small, dependency-free client for https://opentrust.in.
|
|
@@ -22,6 +22,7 @@ files:
|
|
|
22
22
|
- lib/opentrust.rb
|
|
23
23
|
- lib/opentrust/client.rb
|
|
24
24
|
- lib/opentrust/errors.rb
|
|
25
|
+
- lib/opentrust/phone.rb
|
|
25
26
|
- lib/opentrust/version.rb
|
|
26
27
|
homepage: https://opentrust.in/docs
|
|
27
28
|
licenses:
|