lcp-sdk 1.0.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: 9214c176416eeacd74739b60d6fda64b3c97529a0a94e121ccdfb844235ea9e8
4
+ data.tar.gz: d5cd4a64fde12d0fcd42124e164b3642cad7668ebc84a87a475f4eba701ec824
5
+ SHA512:
6
+ metadata.gz: f638e4429a2c1fc51f5252474f86c792f5df11bda57b637cf6961b93ece3bc9bd63b60f78563e50ca5cfad6e98f10c6b24766d5e65963736dcc4e87ee921e518
7
+ data.tar.gz: a5da63d84d8b6169435550838d7a058406673957ade08e19b0c1ce6784c28960bb12a77786adfc16942a1e395bfeaf73c0d0d124c7b66f6b0b50b417f28e5cf6
data/README.md ADDED
@@ -0,0 +1,13 @@
1
+ # LCP Ruby SDK
2
+
3
+ Tier 2 reference SDK for Ruby 3.0+ publisher, buyer, and SaaS integrations.
4
+
5
+ ```bash
6
+ gem build lcp-sdk.gemspec
7
+ ruby test/shared_vector_test.rb
8
+ ```
9
+
10
+ The SDK provides generated payload models, `LcpSdk::SchemaValidator` full Draft 2020-12 validation, `LcpSdk.build_envelope`, envelope validation,
11
+ `LcpSdk::Signing` raw-body HMAC helpers, webhook verification, and
12
+ `LcpSdk::Client` operations for leads, calls, bids, status, capabilities, and
13
+ offers. See the shared [SDK contract](../../../docs/SDK-ROADMAP.md).
@@ -0,0 +1,13 @@
1
+ # GENERATED FROM schemas/ — do not edit manually.
2
+ module LcpSdk
3
+ MessageModel = Struct.new(:id, :type, :timestamp, :sender_id, :receiver_id, :correlation_id, :idempotency_key, :test, :security, keyword_init: true)
4
+ EnvelopeModel = Struct.new(:version, :message, :payload, keyword_init: true)
5
+ LeadPayload = Struct.new(:lead_id, :status, :channel, :consumer, :location, :attributes, :external_id, :submitted_at, :compliance, :provenance, :exclusivity, :contact_window, :lead_quality, :expiry, :attachments, keyword_init: true)
6
+ CallPayload = Struct.new(:lead_id, :status, :channel, :consumer, :location, :call, :external_id, :submitted_at, :compliance, :provenance, :attributes, :expiry, :exclusivity, :attachments, keyword_init: true)
7
+ PingPayload = Struct.new(:ping_id, :lead_reference, :phone_hash, :country_code, :vertical, :floor_price_cents, :currency, :publisher_id, :offer_id, :email_hash, :attributes, keyword_init: true)
8
+ PostPayload = Struct.new(:lead_id, :delivered_at, :price_cents, :currency, :buyer_id, :consumer, :location, :attributes, :submitted_at, :offer_id, :buyer_reference, :pricing, :compliance, :provenance, :call, :attachments, keyword_init: true)
9
+ BidPayload = Struct.new(:ping_id, :decision, :bid_price_cents, :currency, :estimated_contact_seconds, :buyer_reference, :reject_reason, :capacity_remaining, keyword_init: true)
10
+ AckPayload = Struct.new(:original_message_id, :status, :errors, :lead_id, :request_id, :rejection_reason, keyword_init: true)
11
+ EventPayload = Struct.new(:lead_id, :event, :timestamp, :details, :external_reference, keyword_init: true)
12
+ OfferModel = Struct.new(:offer_id, :buyer_id, :vertical, :countries, :floor_price_cents, :currency, :tenant_id, :active, :routing_mode, :schema_version, :extensions, :allowed_publisher_ids, :allowed_brand_ids, :attribute_equals, :attribute_in, :monthly_minimum_payable, :monthly_maximum_payable, :monthly_quota_timezone, :monthly_quota_policy, :payable_rules, :call_routing_mode, :connect_timeout_seconds, keyword_init: true)
13
+ end
data/lib/lcp_sdk.rb ADDED
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+ require "securerandom"
6
+ require "time"
7
+ require "net/http"
8
+ require "uri"
9
+ require_relative "schema_validator"
10
+ require_relative "generated_models"
11
+
12
+ module LcpSdk
13
+ Message = Struct.new(:id, :type, :timestamp, :sender_id, :receiver_id, :correlation_id, :idempotency_key, :test, keyword_init: true)
14
+
15
+ module Signing
16
+ module_function
17
+
18
+ def canonical_bytes(timestamp, idempotency_key, body)
19
+ "#{timestamp}\n#{idempotency_key || ""}\n".b + body.b
20
+ end
21
+
22
+ def sign(secret, timestamp, idempotency_key, body)
23
+ OpenSSL::HMAC.hexdigest("SHA256", secret, canonical_bytes(timestamp, idempotency_key, body))
24
+ end
25
+
26
+ def verify(secret, signature, timestamp, idempotency_key, body, max_skew_seconds: 300, now: Time.now.utc)
27
+ signed_at = Time.iso8601(timestamp)
28
+ raise ArgumentError, "timestamp outside replay window" if (now - signed_at).abs > max_skew_seconds
29
+ expected = sign(secret, timestamp, idempotency_key, body)
30
+ actual = signature.downcase
31
+ raise ArgumentError, "invalid signature" unless expected.bytesize == actual.bytesize && secure_equal?(expected, actual)
32
+ true
33
+ end
34
+
35
+ def secure_equal?(left, right)
36
+ difference = 0
37
+ left.bytes.zip(right.bytes) { |a, b| difference |= a ^ b }
38
+ difference.zero?
39
+ end
40
+
41
+ def verify_request(secret, headers, raw_body, max_skew_seconds: 300, now: Time.now.utc)
42
+ normalized = headers.to_h.transform_keys(&:downcase)
43
+ required = %w[x-lcp-signature x-lcp-timestamp x-lcp-idempotency-key]
44
+ missing = required.reject { |key| normalized[key] && !normalized[key].empty? }
45
+ raise ArgumentError, "missing required LCP headers: #{missing.join(", ")}" unless missing.empty?
46
+ verify(secret, normalized["x-lcp-signature"], normalized["x-lcp-timestamp"], normalized["x-lcp-idempotency-key"], raw_body, max_skew_seconds: max_skew_seconds, now: now)
47
+ end
48
+ end
49
+
50
+ module_function
51
+
52
+ def build_envelope(type, sender_id, receiver_id, payload, test: false)
53
+ id = SecureRandom.uuid
54
+ { "lcp" => { "version" => "1.0.0", "message" => { "id" => id, "type" => type, "timestamp" => Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"), "sender_id" => sender_id, "receiver_id" => receiver_id, "correlation_id" => nil, "idempotency_key" => "#{sender_id}-#{type}-#{SecureRandom.hex(16)}", "test" => test }, "payload" => payload } }
55
+ end
56
+
57
+ def validate_envelope(envelope)
58
+ message = envelope.dig("lcp", "message")
59
+ raise ArgumentError, "invalid LCP envelope" unless envelope.dig("lcp", "version").is_a?(String) && message.is_a?(Hash)
60
+ %w[id type timestamp sender_id receiver_id idempotency_key].each { |key| raise ArgumentError, "missing lcp.message.#{key}" unless message[key].is_a?(String) && !message[key].empty? }
61
+ raise ArgumentError, "missing lcp.payload" unless envelope.dig("lcp").key?("payload")
62
+ true
63
+ end
64
+
65
+ class Client
66
+ def initialize(endpoint, sender_id: nil, api_key: nil, hmac_secret: nil, max_retries: 2)
67
+ @endpoint = endpoint.delete_suffix("/"); @sender_id = sender_id; @api_key = api_key; @hmac_secret = hmac_secret; @max_retries = max_retries
68
+ end
69
+
70
+ def request(method, path, payload: nil, test: false)
71
+ body = payload.nil? ? "" : JSON.generate(payload)
72
+ key = payload&.dig("lcp", "message", "idempotency_key")
73
+ (0..@max_retries).each do |attempt|
74
+ uri = URI.join("#{@endpoint}/", path.delete_prefix("/")); request = Net::HTTP.const_get(method.capitalize).new(uri)
75
+ request["Content-Type"] = "application/json"; request["Accept"] = "application/json"; request["X-LCP-Sender-Id"] = @sender_id if @sender_id; request["Authorization"] = "Bearer #{@api_key}" if @api_key; request["X-LCP-Idempotency-Key"] = key if key; request["X-LCP-Test"] = "true" if test
76
+ if @hmac_secret; timestamp = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"); request["X-LCP-Timestamp"] = timestamp; request["X-LCP-Signature"] = Signing.sign(@hmac_secret, timestamp, key, body); end
77
+ request.body = body unless body.empty?
78
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
79
+ data = response.body.nil? || response.body.empty? ? {} : JSON.parse(response.body)
80
+ return data if response.is_a?(Net::HTTPSuccess)
81
+ raise "LCP HTTP #{response.code}: #{data}" unless [429, 500, 502, 503, 504].include?(response.code.to_i) && attempt < @max_retries
82
+ sleep(2**attempt)
83
+ end
84
+ raise "LCP request failed"
85
+ end
86
+
87
+ def submit_lead(envelope, test: false); LcpSdk.validate_envelope(envelope); request("POST", "/v1/lcp/leads", payload: envelope, test: test); end
88
+ def submit_call(envelope, test: false); LcpSdk.validate_envelope(envelope); request("POST", "/v1/lcp/calls", payload: envelope, test: test); end
89
+ def submit_bid(envelope, test: false); LcpSdk.validate_envelope(envelope); request("POST", "/v1/lcp/bids", payload: envelope, test: test); end
90
+ def query_lead_status(id); request("GET", "/v1/lcp/leads/#{URI.encode_www_form_component(id)}"); end
91
+ def get_capabilities; request("GET", "/v1/lcp/capabilities"); end
92
+ def list_offers(vertical = nil); request("GET", "/v1/lcp/offers#{vertical ? "?vertical=#{URI.encode_www_form_component(vertical)}" : ""}"); end
93
+ end
94
+ end