seekmodo-sdk 0.5.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.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +52 -0
  3. data/.gitignore +12 -0
  4. data/.rspec +2 -0
  5. data/.rubocop.yml +32 -0
  6. data/Gemfile +11 -0
  7. data/README.md +135 -0
  8. data/docs/tool-catalog.README.md +12 -0
  9. data/lib/seekmodo/sdk/admin/client.rb +120 -0
  10. data/lib/seekmodo/sdk/auto_promoter.rb +124 -0
  11. data/lib/seekmodo/sdk/browser_token.rb +62 -0
  12. data/lib/seekmodo/sdk/circuit_breaker.rb +123 -0
  13. data/lib/seekmodo/sdk/connector/client.rb +250 -0
  14. data/lib/seekmodo/sdk/events/click_beacon.rb +58 -0
  15. data/lib/seekmodo/sdk/events/events_queue.rb +50 -0
  16. data/lib/seekmodo/sdk/exceptions/breaker_open_error.rb +10 -0
  17. data/lib/seekmodo/sdk/exceptions/client_error.rb +66 -0
  18. data/lib/seekmodo/sdk/exceptions/over_quota_error.rb +10 -0
  19. data/lib/seekmodo/sdk/exceptions/seekmodo_error.rb +8 -0
  20. data/lib/seekmodo/sdk/exceptions/signature_mismatch_error.rb +10 -0
  21. data/lib/seekmodo/sdk/exceptions/tenant_unavailable_error.rb +10 -0
  22. data/lib/seekmodo/sdk/hmac_signer.rb +43 -0
  23. data/lib/seekmodo/sdk/mcp/client.rb +105 -0
  24. data/lib/seekmodo/sdk/mode.rb +41 -0
  25. data/lib/seekmodo/sdk/mode_fsm.rb +52 -0
  26. data/lib/seekmodo/sdk/pairing.rb +114 -0
  27. data/lib/seekmodo/sdk/signature_mismatch_tracker.rb +52 -0
  28. data/lib/seekmodo/sdk/storage/memory/stores.rb +100 -0
  29. data/lib/seekmodo/sdk/storage/protocols.rb +47 -0
  30. data/lib/seekmodo/sdk/storefront/client.rb +71 -0
  31. data/lib/seekmodo/sdk/storefront/transport.rb +198 -0
  32. data/lib/seekmodo/sdk/tenant_snapshot.rb +65 -0
  33. data/lib/seekmodo/sdk/tools/registry.rb +88 -0
  34. data/lib/seekmodo/sdk/version.rb +7 -0
  35. data/lib/seekmodo/sdk.rb +33 -0
  36. data/lib/seekmodo-sdk.rb +3 -0
  37. metadata +109 -0
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "faraday"
5
+
6
+ require_relative "../exceptions/seekmodo_error"
7
+ require_relative "../hmac_signer"
8
+
9
+ module Seekmodo
10
+ module Sdk
11
+ module Mcp
12
+ class Client
13
+ DEFAULT_GATEWAY_URL = "https://mcp.seekmodo.com"
14
+
15
+ def initialize(
16
+ gateway_url: DEFAULT_GATEWAY_URL,
17
+ signer: nil,
18
+ operator_token: nil,
19
+ tenant_id: nil,
20
+ connection: nil,
21
+ user_agent: "seekmodo-ruby-sdk/0.5.0"
22
+ )
23
+ @gateway_url = gateway_url.to_s.delete_suffix("/")
24
+ @signer = signer
25
+ @operator_token = operator_token
26
+ @tenant_id = tenant_id
27
+ @user_agent = user_agent
28
+ @connection = connection || Faraday.new do |f|
29
+ f.adapter Faraday.default_adapter
30
+ end
31
+ @request_id = 0
32
+
33
+ if @signer.nil? && @operator_token.nil?
34
+ raise ArgumentError, "MCP client requires signer (HMAC) or operator_token (bearer)"
35
+ end
36
+ end
37
+
38
+ def initialize_session(params = {})
39
+ rpc("initialize", params)
40
+ end
41
+
42
+ def tools_list
43
+ rpc("tools/list", {})
44
+ end
45
+
46
+ def tools_call(name, arguments = {})
47
+ rpc("tools/call", { "name" => name, "arguments" => arguments })
48
+ end
49
+
50
+ def ping
51
+ rpc("ping", {})
52
+ end
53
+
54
+ private
55
+
56
+ def rpc(method, params)
57
+ @request_id += 1
58
+ envelope = {
59
+ "jsonrpc" => "2.0",
60
+ "id" => @request_id,
61
+ "method" => method,
62
+ "params" => params
63
+ }
64
+ body = JSON.generate(envelope)
65
+ response = @connection.post(@gateway_url + "/mcp") do |req|
66
+ req.headers.update(build_headers(body))
67
+ req.body = body
68
+ end
69
+
70
+ parsed = JSON.parse(response.body)
71
+ unless response.status >= 200 && response.status < 300
72
+ raise SeekmodoError, "MCP gateway returned HTTP #{response.status}: #{response.body}"
73
+ end
74
+
75
+ if parsed["error"]
76
+ raise SeekmodoError, "MCP error #{parsed['error']}"
77
+ end
78
+
79
+ parsed["result"]
80
+ rescue JSON::ParserError => e
81
+ raise SeekmodoError, "MCP response was not valid JSON: #{e.message}"
82
+ end
83
+
84
+ def build_headers(body)
85
+ headers = {
86
+ "Content-Type" => "application/json",
87
+ "Accept" => "application/json",
88
+ "User-Agent" => @user_agent
89
+ }
90
+
91
+ if @operator_token
92
+ headers["Authorization"] = "Bearer #{@operator_token}"
93
+ if @tenant_id
94
+ headers[HmacSigner::HEADER_TENANT] = @tenant_id
95
+ end
96
+ elsif @signer
97
+ headers.merge!(@signer.headers(body))
98
+ end
99
+
100
+ headers
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Seekmodo
4
+ module Sdk
5
+ module Mode
6
+ OFF = "off"
7
+ LEARNING = "learning"
8
+ SHADOW = "shadow"
9
+ ACTIVE = "active"
10
+ ENFORCE = "enforce"
11
+
12
+ ALL = [OFF, LEARNING, SHADOW, ACTIVE, ENFORCE].freeze
13
+
14
+ module_function
15
+
16
+ def values
17
+ ALL
18
+ end
19
+
20
+ def valid?(mode)
21
+ ALL.include?(mode)
22
+ end
23
+
24
+ def assert_mode(mode)
25
+ unless valid?(mode)
26
+ raise ArgumentError, "Unknown Seekmodo mode '#{mode}'; expected one of: #{ALL.join(', ')}"
27
+ end
28
+
29
+ mode
30
+ end
31
+
32
+ def serves_search?(mode)
33
+ [SHADOW, ACTIVE, ENFORCE].include?(mode)
34
+ end
35
+
36
+ def mirrors_writes?(mode)
37
+ mode != OFF
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "mode"
4
+ require_relative "circuit_breaker"
5
+ require_relative "tenant_snapshot"
6
+
7
+ module Seekmodo
8
+ module Sdk
9
+ class ModeFsm
10
+ def initialize(snapshot, breaker: nil, default_mode: Mode::OFF)
11
+ @snapshot = snapshot
12
+ @breaker = breaker
13
+ @default_mode = Mode.assert_mode(default_mode)
14
+ end
15
+
16
+ def effective_mode
17
+ if @breaker && @breaker.state == CircuitBreaker::STATE_OPEN
18
+ return Mode::OFF
19
+ end
20
+
21
+ config = @snapshot.get
22
+ configured = config.fetch("mode", @default_mode).to_s
23
+ return @default_mode unless Mode.valid?(configured)
24
+
25
+ if configured != Mode::ACTIVE
26
+ return configured
27
+ end
28
+
29
+ fsm_state = config.dig("fsm", "current_state").to_s
30
+ if Mode.valid?(fsm_state) && fsm_state != Mode::ACTIVE
31
+ return fsm_state
32
+ end
33
+
34
+ Mode::SHADOW
35
+ end
36
+
37
+ def configured_mode
38
+ config = @snapshot.get
39
+ configured = config.fetch("mode", @default_mode).to_s
40
+ Mode.valid?(configured) ? configured : @default_mode
41
+ end
42
+
43
+ def serves_search?
44
+ Mode.serves_search?(effective_mode)
45
+ end
46
+
47
+ def mirrors_writes?
48
+ Mode.mirrors_writes?(effective_mode)
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "jwt"
6
+ require "openssl"
7
+ require "faraday"
8
+
9
+ require_relative "exceptions/seekmodo_error"
10
+ require_relative "storage/protocols"
11
+
12
+ module Seekmodo
13
+ module Sdk
14
+ class Pairing
15
+ DEFAULT_JWKS_URL = "https://seekmodo.com/.well-known/jwks.json"
16
+ KEYS_CACHE_KEY = "numinix.seekmodo.pairing.jwks"
17
+ KEYS_CACHE_TTL_SECONDS = 86400
18
+ MAX_TOKEN_AGE_SECONDS = 600
19
+
20
+ def initialize(connection, cache, jwks_url: DEFAULT_JWKS_URL, clock: nil)
21
+ @connection = connection
22
+ @cache = cache
23
+ @jwks_url = jwks_url
24
+ @clock = clock || -> { Time.now.to_i }
25
+ end
26
+
27
+ def verify_and_extract(jwt_token)
28
+ header, payload, _sig = JWT.decode(jwt_token, nil, false)
29
+ alg = header["alg"].to_s
30
+ raise SeekmodoError, "Unsupported pairing JWT alg \"#{alg}\"; expected EdDSA." unless alg == "EdDSA"
31
+
32
+ kid = header["kid"].to_s
33
+ raise SeekmodoError, "Pairing JWT is missing kid header." if kid.empty?
34
+
35
+ jwk = lookup_key(kid, refresh: false)
36
+ jwk = lookup_key(kid, refresh: true) if jwk.nil?
37
+ raise SeekmodoError, "No pairing JWKS key matches kid=\"#{kid}\"; rotation lag?" if jwk.nil?
38
+
39
+ public_key_raw = base64url_decode(jwk["x"].to_s)
40
+ raise SeekmodoError, "Pairing JWKS key has wrong byte length for Ed25519." unless public_key_raw.bytesize == 32
41
+
42
+ public_key = OpenSSL::PKey.read({
43
+ kty: "OKP",
44
+ crv: "Ed25519",
45
+ x: jwk["x"]
46
+ }.to_json)
47
+
48
+ JWT.decode(
49
+ jwt_token,
50
+ public_key,
51
+ true,
52
+ { algorithm: "EdDSA" }
53
+ )
54
+
55
+ now = @clock.call
56
+ exp = payload["exp"].to_i
57
+ iat = payload["iat"].to_i
58
+ raise SeekmodoError, "Pairing JWT has expired." if exp > 0 && exp < now
59
+ raise SeekmodoError, "Pairing JWT is older than the 10-minute replay window." if iat > 0 && now - iat > MAX_TOKEN_AGE_SECONDS
60
+
61
+ payload
62
+ rescue JWT::DecodeError => e
63
+ raise SeekmodoError, "Pairing JWT signature verification failed.", e.backtrace
64
+ end
65
+
66
+ def refresh_keys
67
+ fetch_keys
68
+ end
69
+
70
+ private
71
+
72
+ def lookup_key(kid, refresh:)
73
+ keys_doc = refresh ? fetch_keys : cached_keys
74
+ keys_doc.fetch("keys", []).find { |key| key.is_a?(Hash) && key["kid"] == kid }
75
+ end
76
+
77
+ def cached_keys
78
+ cached = @cache.get(KEYS_CACHE_KEY)
79
+ return cached if cached.is_a?(Hash)
80
+
81
+ fetch_keys
82
+ end
83
+
84
+ def fetch_keys
85
+ response = @connection.get(@jwks_url) do |req|
86
+ req.headers["Accept"] = "application/json"
87
+ end
88
+
89
+ raise SeekmodoError, "JWKS fetch returned HTTP #{response.status}" unless response.status == 200
90
+
91
+ body = JSON.parse(response.body)
92
+ unless body.is_a?(Hash) && body["keys"].is_a?(Array) && body["keys"].any?
93
+ raise SeekmodoError, 'JWKS response did not contain a "keys" array.'
94
+ end
95
+
96
+ @cache.set(KEYS_CACHE_KEY, body, KEYS_CACHE_TTL_SECONDS)
97
+ body
98
+ rescue Faraday::Error => e
99
+ raise SeekmodoError, "Failed to fetch Seekmodo pairing JWKS: #{e.message}"
100
+ rescue JSON::ParserError => e
101
+ raise SeekmodoError, "JWKS response was not valid JSON: #{e.message}"
102
+ end
103
+
104
+ def base64url_decode(segment)
105
+ padded = segment.tr("-_", "+/")
106
+ remainder = padded.length % 4
107
+ padded += "=" * (4 - remainder) if remainder.positive?
108
+ Base64.decode64(padded)
109
+ rescue ArgumentError => e
110
+ raise SeekmodoError, "Pairing JWT contains malformed base64url segment.", e.backtrace
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "storage/protocols"
4
+
5
+ module Seekmodo
6
+ module Sdk
7
+ class SignatureMismatchTracker
8
+ CACHE_KEY = "numinix.seekmodo.sigmismatch_failures"
9
+ DEFAULT_WINDOW_SECONDS = 300
10
+ DEFAULT_THRESHOLD = 3
11
+
12
+ def initialize(cache, window_seconds: DEFAULT_WINDOW_SECONDS, threshold: DEFAULT_THRESHOLD, clock: nil)
13
+ @cache = cache
14
+ @window_seconds = window_seconds
15
+ @threshold = threshold
16
+ @clock = clock || -> { Time.now.to_i }
17
+ end
18
+
19
+ def record_failure
20
+ now = @clock.call
21
+ rows = load_and_prune(now)
22
+ rows << now
23
+ @cache.set(CACHE_KEY, rows, [@window_seconds * 2, 60].max)
24
+ end
25
+
26
+ def clear
27
+ @cache.delete(CACHE_KEY)
28
+ end
29
+
30
+ def tripped?
31
+ load_and_prune(@clock.call).length >= @threshold
32
+ end
33
+
34
+ def failures_in_window
35
+ load_and_prune(@clock.call).length
36
+ end
37
+
38
+ private
39
+
40
+ def load_and_prune(now)
41
+ cutoff = now - @window_seconds
42
+ stored = @cache.get(CACHE_KEY, [])
43
+ stored = [] unless stored.is_a?(Array)
44
+ pruned = stored.select { |ts| ts.is_a?(Numeric) && ts.to_i >= cutoff }.map(&:to_i)
45
+ if pruned.length != stored.length
46
+ @cache.set(CACHE_KEY, pruned, [@window_seconds * 2, 60].max)
47
+ end
48
+ pruned
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../protocols"
4
+
5
+ module Seekmodo
6
+ module Sdk
7
+ module Storage
8
+ module Memory
9
+ DEFAULT_BREAKER_STATE = {
10
+ "state" => "closed",
11
+ "failures" => [],
12
+ "opened_at" => 0,
13
+ "probe_in_flight" => false
14
+ }.freeze
15
+
16
+ class BreakerStore
17
+ include Protocols::BreakerStateStore
18
+
19
+ def initialize
20
+ @rows = {}
21
+ end
22
+
23
+ def load(key)
24
+ state = @rows[key]
25
+ return DEFAULT_BREAKER_STATE.dup unless state
26
+
27
+ {
28
+ "state" => state["state"],
29
+ "failures" => state["failures"].dup,
30
+ "opened_at" => state["opened_at"].to_i,
31
+ "probe_in_flight" => state["probe_in_flight"]
32
+ }
33
+ end
34
+
35
+ def save(key, state, ttl_seconds)
36
+ @rows[key] = {
37
+ "state" => state["state"],
38
+ "failures" => state["failures"].dup,
39
+ "opened_at" => state["opened_at"].to_i,
40
+ "probe_in_flight" => state["probe_in_flight"]
41
+ }
42
+ end
43
+ end
44
+
45
+ class Cache
46
+ include Protocols::Cache
47
+
48
+ Entry = Struct.new(:value, :expires_at, keyword_init: true)
49
+
50
+ def initialize(clock = nil)
51
+ @rows = {}
52
+ @clock = clock || -> { Time.now.to_f }
53
+ end
54
+
55
+ def get(key, default = nil)
56
+ entry = @rows[key]
57
+ return default unless entry
58
+
59
+ if entry.expires_at < @clock.call
60
+ @rows.delete(key)
61
+ return default
62
+ end
63
+
64
+ entry.value
65
+ end
66
+
67
+ def set(key, value, ttl_seconds)
68
+ @rows[key] = Entry.new(value: value, expires_at: @clock.call + ttl_seconds)
69
+ end
70
+
71
+ def delete(key)
72
+ @rows.delete(key)
73
+ end
74
+ end
75
+
76
+ class EventQueueStore
77
+ include Protocols::EventQueueStore
78
+
79
+ def initialize
80
+ @events = []
81
+ end
82
+
83
+ def push(event)
84
+ @events << event.dup
85
+ end
86
+
87
+ def drain(max_events)
88
+ batch = @events.take(max_events)
89
+ @events = @events.drop(max_events)
90
+ batch
91
+ end
92
+
93
+ def count
94
+ @events.length
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Seekmodo
4
+ module Sdk
5
+ module Storage
6
+ module Protocols
7
+ module Cache
8
+ def get(key, default = nil)
9
+ raise NotImplementedError
10
+ end
11
+
12
+ def set(key, value, ttl_seconds)
13
+ raise NotImplementedError
14
+ end
15
+
16
+ def delete(key)
17
+ raise NotImplementedError
18
+ end
19
+ end
20
+
21
+ module BreakerStateStore
22
+ def load(key)
23
+ raise NotImplementedError
24
+ end
25
+
26
+ def save(key, state, ttl_seconds)
27
+ raise NotImplementedError
28
+ end
29
+ end
30
+
31
+ module EventQueueStore
32
+ def push(event)
33
+ raise NotImplementedError
34
+ end
35
+
36
+ def drain(max_events)
37
+ raise NotImplementedError
38
+ end
39
+
40
+ def count
41
+ raise NotImplementedError
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "transport"
4
+
5
+ module Seekmodo
6
+ module Sdk
7
+ module Storefront
8
+ class Client
9
+ attr_reader :transport, :recommend, :bundle
10
+
11
+ def initialize(config)
12
+ @transport = Transport.new(**config)
13
+ @recommend = RecommendSurface.new(@transport)
14
+ @bundle = BundleSurface.new(@transport)
15
+ end
16
+
17
+ def search(args = {}, opts = {})
18
+ @transport.call("search", args, opts)
19
+ end
20
+
21
+ def suggest(args = {}, opts = {})
22
+ @transport.call("suggest", args, opts)
23
+ end
24
+
25
+ def search_by_image(args = {}, opts = {})
26
+ @transport.call("search.byImage", args, opts)
27
+ end
28
+
29
+ def chat(args = {}, opts = {})
30
+ @transport.call("chat", args, opts)
31
+ end
32
+
33
+ def event(args = {}, opts = {})
34
+ @transport.call("events", args, opts)
35
+ end
36
+
37
+ class RecommendSurface
38
+ def initialize(transport)
39
+ @transport = transport
40
+ end
41
+
42
+ def related(args = {}, opts = {})
43
+ @transport.call("recommend.related", args, opts)
44
+ end
45
+
46
+ def also_bought(args = {}, opts = {})
47
+ @transport.call("recommend.also_bought", args, opts)
48
+ end
49
+
50
+ def also_viewed(args = {}, opts = {})
51
+ @transport.call("recommend.also_viewed", args, opts)
52
+ end
53
+
54
+ def trending(args = {}, opts = {})
55
+ @transport.call("recommend.trending", args, opts)
56
+ end
57
+ end
58
+
59
+ class BundleSurface
60
+ def initialize(transport)
61
+ @transport = transport
62
+ end
63
+
64
+ def suggest(args = {}, opts = {})
65
+ @transport.call("bundle.suggest", args, opts)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end