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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 31a232efbd35fb3655663acd558ed31ce7593925761fbae6b04f83c88dc407f8
4
- data.tar.gz: 3d14f0051077e2aa3f7d8b1ffda27e4307080ade525f42f334de8fe9f4ec6eb5
3
+ metadata.gz: 810e3839ac5fe409bc140e8bf26e34bafaf795ee9f3cc5601f64d616c5774b60
4
+ data.tar.gz: 5c828d92c69579e6e191629ef860d0404179dd410f20b8c9f9aba13330c972c0
5
5
  SHA512:
6
- metadata.gz: d2c9f85cf69fb252ae1658764444764544d5dd95be76340ae3ef7e2d49951ac5db6d14a44f1a2a162b4a51f5892651d3948b56c8172677884ad9842b6fea8d22
7
- data.tar.gz: c63e013d5d85d07adf22b96364e851cc2d32988780119ab1ec54d2ea802ffe08bf11e56fe07fec2a06bedf89477cf8a378b04e902fb189cbd32d65597d502c0f
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: "cod_refusal", notes: "Refused 3 COD orders in March")
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
 
@@ -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
- Lookup = Struct.new(:phone_last4, :report_count, :reports, :notice, :checks_remaining, keyword_init: true) do
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: 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
- body = request(:post, "/lookups/batch", phones: Array(phones))
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
- def report(phone:, reason_code:, notes: nil)
44
- body = request(:post, "/reports", { phone: phone, reason_code: reason_code, notes: notes }.compact)
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 { |r| Report.new(reason_code: r["reason_code"], reason: r["reason"], notes: r["notes"], reported_at: r["reported_at"], reporter_verified: r["reporter_verified"]) }
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 = verb == :get ? Net::HTTP::Get : Net::HTTP::Post
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
@@ -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
@@ -1,3 +1,3 @@
1
1
  module OpenTrust
2
- VERSION = "0.1.2"
2
+ VERSION = "0.3.0"
3
3
  end
data/lib/opentrust.rb CHANGED
@@ -1,3 +1,4 @@
1
1
  require_relative "opentrust/version"
2
2
  require_relative "opentrust/errors"
3
+ require_relative "opentrust/phone"
3
4
  require_relative "opentrust/client"
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.1.2
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-09 00:00:00.000000000 Z
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: