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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0a3051fcfcab7839036fb801ec2c2570395dc8f77b4657b48f9ff181c011c727
4
+ data.tar.gz: ab157610976b2dd2232f5fd1d7a3287efca2d7d866912c9dbf13b4d8dd68e302
5
+ SHA512:
6
+ metadata.gz: 1619b839b83c2118f0b56482c186b08ebe11ceb7cc0b6ed9692249b08d50ec5395a5355b5cb75649277be5c086933e312af16d9e7b4682faa8410d7802172545
7
+ data.tar.gz: 65dcffd819b63e19a3bd84d59e96c398eab90b5ccee08cf08e819cfd8eb31354eac974dbd569c9c97654c91d9c5cb90559b2c5842d69b6f5ae3123834494ee97
@@ -0,0 +1,52 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ tags: ["v*"]
7
+ pull_request:
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ ruby: ["3.1", "3.2", "3.3"]
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: ruby/setup-ruby@v1
18
+ with:
19
+ ruby-version: ${{ matrix.ruby }}
20
+ bundler-cache: true
21
+ - run: bundle exec rspec
22
+
23
+ publish:
24
+ if: startsWith(github.ref, 'refs/tags/v')
25
+ needs: test
26
+ runs-on: ubuntu-latest
27
+ permissions:
28
+ contents: read
29
+ id-token: write
30
+ steps:
31
+ - uses: actions/checkout@v4
32
+ - uses: ruby/setup-ruby@v1
33
+ with:
34
+ ruby-version: "3.3"
35
+ bundler-cache: true
36
+ - name: Configure trusted publisher credentials (OIDC)
37
+ id: oidc
38
+ uses: rubygems/configure-rubygems-credentials@v1.0.0
39
+ continue-on-error: true
40
+ - name: Configure API key fallback
41
+ if: steps.oidc.outcome == 'failure'
42
+ run: |
43
+ mkdir -p ~/.gem
44
+ cat > ~/.gem/credentials <<EOF
45
+ ---
46
+ :rubygems_api_key: ${RUBYGEMS_API_KEY}
47
+ EOF
48
+ chmod 600 ~/.gem/credentials
49
+ env:
50
+ RUBYGEMS_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
51
+ - run: gem build seekmodo-sdk.gemspec
52
+ - run: gem push seekmodo-sdk-*.gem
data/.gitignore ADDED
@@ -0,0 +1,12 @@
1
+ *.gem
2
+ *.rbc
3
+ /.config
4
+ /coverage/
5
+ /InstalledFiles
6
+ /pkg/
7
+ /spec/reports/
8
+ /spec/examples.txt
9
+ /test/tmp/
10
+ /test/version_tmp/
11
+ /tmp/
12
+ Gemfile.lock
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --require spec_helper
2
+ --format documentation
data/.rubocop.yml ADDED
@@ -0,0 +1,32 @@
1
+ AllCops:
2
+ NewCops: enable
3
+ TargetRubyVersion: 3.1
4
+ Exclude:
5
+ - "vendor/**/*"
6
+ - "spec/**/*"
7
+
8
+ Style/Documentation:
9
+ Enabled: false
10
+
11
+ Style/StringLiterals:
12
+ EnforcedStyle: double_quotes
13
+
14
+ Gemspec/DevelopmentDependencies:
15
+ Enabled: false
16
+
17
+ Layout/LineLength:
18
+ Max: 140
19
+
20
+ Metrics/BlockLength:
21
+ Exclude:
22
+ - "spec/**/*"
23
+ - "seekmodo-sdk.gemspec"
24
+
25
+ Metrics/MethodLength:
26
+ Max: 30
27
+
28
+ Metrics/AbcSize:
29
+ Max: 35
30
+
31
+ Metrics/ClassLength:
32
+ Max: 200
data/Gemfile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec
6
+
7
+ group :development, :test do
8
+ gem "rspec", "~> 3.13"
9
+ gem "rubocop", "~> 1.60"
10
+ gem "webmock", "~> 3.23"
11
+ end
data/README.md ADDED
@@ -0,0 +1,135 @@
1
+ # Seekmodo Ruby SDK
2
+
3
+ Shared Ruby SDK for building [Seekmodo](https://seekmodo.com) storefront connectors, admin automation, and MCP agents. The gem ships four clients behind one namespace (`Seekmodo::Sdk`):
4
+
5
+ | Client | Auth | Base URL | Use when |
6
+ |--------|------|----------|----------|
7
+ | `Connector::Client` | HMAC (`tenant_id` + `shared_secret`) | `https://mcp.seekmodo.com` | Server-side connector: index catalog, batch events, handshake, mint browser tokens |
8
+ | `Storefront::Client` | JWT Bearer (`get_token` callback) | `https://gateway.seekmodo.com` | Storefront widgets, headless apps, anything that runs in the browser or on a token-minting server |
9
+ | `Admin::Client` | `X-Seekmodo-Admin-Key` | `https://mcp.seekmodo.com` | Operator/admin automation: synonyms, pins, LTR, analytics |
10
+ | `Mcp::Client` | HMAC **or** operator bearer | `https://mcp.seekmodo.com/mcp` | JSON-RPC MCP (`initialize`, `tools/list`, `tools/call`) for AI agents |
11
+
12
+ **Status**: 0.5.0 · Ruby 3.1+
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ gem install seekmodo-sdk
18
+ ```
19
+
20
+ Or in Bundler:
21
+
22
+ ```ruby
23
+ gem "seekmodo-sdk", "~> 0.5"
24
+ ```
25
+
26
+ Runtime dependencies: `faraday`, `jwt`.
27
+
28
+ ## Quick starts
29
+
30
+ ### Connector (HMAC)
31
+
32
+ ```ruby
33
+ require "seekmodo/sdk"
34
+
35
+ signer = Seekmodo::Sdk::HmacSigner.new("your-tenant-id", "your-shared-secret")
36
+ breaker = Seekmodo::Sdk::CircuitBreaker.new(Seekmodo::Sdk::Storage::Memory::BreakerStore.new)
37
+ client = Seekmodo::Sdk::Connector::Client.new(signer, breaker: breaker)
38
+
39
+ results = client.search({ "q" => "red running shoes", "per_page" => 24 })
40
+ client.index(documents, action: "upsert") # auto-chunks at 500
41
+ client.tenant_snapshot # POST /v1/tenant.snapshot
42
+ ```
43
+
44
+ ### Storefront (JWT)
45
+
46
+ ```ruby
47
+ client = Seekmodo::Sdk::Storefront::Client.new(
48
+ tenant_id: "redline",
49
+ get_token: -> { connector.browser_token["token"] }
50
+ )
51
+
52
+ hits = client.search({ "q" => "spark plug" })
53
+ recs = client.recommend.related({ "source_doc_id" => "sku-123" })
54
+ bundles = client.bundle.suggest({ "source_doc_id" => "sku-123" })
55
+ ```
56
+
57
+ ### Admin
58
+
59
+ ```ruby
60
+ admin = Seekmodo::Sdk::Admin::Client.new(admin_key: ENV.fetch("MCP_ADMIN_KEY"))
61
+
62
+ synonyms = admin.list_synonyms("redline")
63
+ admin.add_synonym("redline", { "synonyms" => %w[spark plug] })
64
+ pins = admin.list_pins("redline")
65
+ ltr = admin.ltr_status("redline")
66
+ top = admin.analytics_top_queries("redline", window: "7d")
67
+ zeros = admin.analytics_zero_results("redline")
68
+ ```
69
+
70
+ ### MCP JSON-RPC
71
+
72
+ ```ruby
73
+ # HMAC (tenant connector)
74
+ mcp = Seekmodo::Sdk::Mcp::Client.new(signer: signer)
75
+ mcp.initialize_session({ "clientInfo" => { "name" => "my-agent" } })
76
+ tools = mcp.tools_list
77
+ result = mcp.tools_call("search", { "q" => "brake pads" })
78
+
79
+ # Operator bearer
80
+ mcp = Seekmodo::Sdk::Mcp::Client.new(
81
+ operator_token: ENV.fetch("SEEKMODO_OPERATOR_TOKEN"),
82
+ tenant_id: "redline"
83
+ )
84
+ mcp.tools_call("analytics.zero_results", { "limit" => 10 })
85
+ ```
86
+
87
+ ### Tools registry
88
+
89
+ ```ruby
90
+ registry = Seekmodo::Sdk::Tools::Registry.new(
91
+ connector: connector_client,
92
+ admin: admin_client,
93
+ tenant_id: "redline"
94
+ )
95
+ registry.call("search", { "q" => "oil filter" })
96
+ registry.call("synonyms.list", {})
97
+ ```
98
+
99
+ ## Public surface
100
+
101
+ | Module / class | What it does |
102
+ |----------------|--------------|
103
+ | `Connector::Client` | `search`, `index`, `events`, `tenant_handshake`, `tenant_snapshot`, `browser_token`, `tools`, `health` |
104
+ | `HmacSigner` | Builds the three `X-Seekmodo-*` headers |
105
+ | `CircuitBreaker` | Three-state FSM (closed/open/half_open) with pluggable storage |
106
+ | `TenantSnapshot` | Polls `tenant.snapshot` with stale-while-revalidate cache |
107
+ | `ModeFsm` | Resolves effective connector mode |
108
+ | `AutoPromoter` | Walks `active` tenants through shadow → enforce |
109
+ | `Pairing` | Verifies EdDSA pairing JWTs against Seekmodo JWKS |
110
+ | `BrowserToken` | Mints `/v1/tenants/token` browser JWTs with TTL-aware caching |
111
+ | `EventsQueue` | Batches events into single `POST /v1/events` calls |
112
+ | `Events::ClickBeacon` | Pure-function payload builders for beacons |
113
+ | `Storefront::Client` | Typed `search`, `suggest`, `search_by_image`, `chat`, `event`, `recommend.*`, `bundle.suggest` |
114
+ | `Admin::Client` | Typed admin tools + generic `call(tool, body, tenant_id:)` |
115
+ | `Mcp::Client` | `initialize_session`, `tools_list`, `tools_call`, `ping` |
116
+ | `Tools::Registry` | Routes normalized tool names to connector or admin |
117
+
118
+ ## Wire contract tests
119
+
120
+ Fixtures under `spec/contracts/fixtures/` mirror the PHP SDK contract pack.
121
+
122
+ ```bash
123
+ bundle install
124
+ bundle exec rspec
125
+ bundle exec rubocop
126
+ ```
127
+
128
+ ## Versioning
129
+
130
+ Follows semver. Breaking wire or type changes are major bumps; new methods and optional fields are minors; bug fixes are patches.
131
+
132
+ ## Links
133
+
134
+ - [Python SDK (reference parity)](https://github.com/numinix/seekmodo-python-sdk)
135
+ - [PHP SDK (wire fixtures source)](https://github.com/numinix/seekmodo-php-sdk)
@@ -0,0 +1,12 @@
1
+ # Tool catalog manifest
2
+
3
+ Regenerate from the Seekmodo monorepo when gateway tools change:
4
+
5
+ ```bash
6
+ cd seekmodo.com/seekmodo
7
+ composer install -d services/mcp-gateway
8
+ php tools/export_tool_catalog.php --out=../seekmodo-ruby-sdk/docs/tool-catalog.json
9
+ php tools/export_tool_catalog.php --out=../seekmodo-go-sdk/docs/tool-catalog.json
10
+ ```
11
+
12
+ The manifest drives SDK docs and optional codegen for `Tools::Registry` typed helpers.
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "faraday"
5
+
6
+ module Seekmodo
7
+ module Sdk
8
+ module Admin
9
+ class Error < StandardError
10
+ attr_reader :status, :body
11
+
12
+ def initialize(status, body)
13
+ @status = status
14
+ @body = body
15
+ super("gateway #{status}: #{body.to_s[0, 200]}")
16
+ end
17
+ end
18
+
19
+ class Client
20
+ DEFAULT_GATEWAY_URL = "https://mcp.seekmodo.com"
21
+
22
+ def initialize(admin_key:, gateway_url: DEFAULT_GATEWAY_URL, connection: nil, user_agent: "Seekmodo-Admin/1.0")
23
+ @admin_key = admin_key
24
+ @gateway_url = gateway_url.to_s.delete_suffix("/")
25
+ @user_agent = user_agent
26
+ @connection = connection || Faraday.new do |f|
27
+ f.adapter Faraday.default_adapter
28
+ end
29
+ end
30
+
31
+ def call(tool, body, tenant_id:)
32
+ path = tool.start_with?("/") ? tool : "/v1/admin/#{normalize_tool(tool)}"
33
+ call_rest(path, body.merge("tenant_id" => tenant_id), tenant_id: tenant_id)
34
+ end
35
+
36
+ def list_synonyms(tenant_id)
37
+ result = call("synonyms.list", {}, tenant_id: tenant_id)
38
+ result.fetch("synonyms", [])
39
+ end
40
+
41
+ def add_synonym(tenant_id, body)
42
+ call("synonyms.add", body, tenant_id: tenant_id)
43
+ end
44
+
45
+ def remove_synonym(tenant_id, id)
46
+ call("synonyms.remove", { "id" => id }, tenant_id: tenant_id)
47
+ end
48
+
49
+ def list_pins(tenant_id)
50
+ result = call("pins.list", {}, tenant_id: tenant_id)
51
+ result.fetch("pins", [])
52
+ end
53
+
54
+ def set_pins(tenant_id, body)
55
+ call("pins.set", body, tenant_id: tenant_id)
56
+ end
57
+
58
+ def ltr_status(tenant_id, history_limit: nil, history_offset: nil)
59
+ params = {}
60
+ if history_limit
61
+ params["history_limit"] = [[history_limit, 1].max, 50].min
62
+ end
63
+ if history_offset
64
+ params["history_offset"] = [[history_offset, 0].max, 10_000].min
65
+ end
66
+ call("ltr.status", params, tenant_id: tenant_id)
67
+ end
68
+
69
+ def ltr_retrain(tenant_id)
70
+ call("ltr.retrain", {}, tenant_id: tenant_id)
71
+ end
72
+
73
+ def analytics_top_queries(tenant_id, window: "7d", limit: 20)
74
+ result = call(
75
+ "analytics.top_queries",
76
+ { "window" => window, "limit" => limit },
77
+ tenant_id: tenant_id
78
+ )
79
+ result.fetch("rows", [])
80
+ end
81
+
82
+ def analytics_zero_results(tenant_id, window: "7d", limit: 20)
83
+ result = call(
84
+ "analytics.zero_results",
85
+ { "window" => window, "limit" => limit },
86
+ tenant_id: tenant_id
87
+ )
88
+ result.fetch("rows", [])
89
+ end
90
+
91
+ private
92
+
93
+ def call_rest(path, body, tenant_id:)
94
+ response = @connection.post(@gateway_url + path) do |req|
95
+ req.headers.update(
96
+ "Content-Type" => "application/json",
97
+ "X-Seekmodo-Admin-Key" => @admin_key,
98
+ "X-Seekmodo-Tenant" => tenant_id,
99
+ "User-Agent" => @user_agent
100
+ )
101
+ req.body = JSON.generate(body)
102
+ end
103
+
104
+ text = response.body.to_s
105
+ unless response.status >= 200 && response.status < 300
106
+ raise Error.new(response.status, text)
107
+ end
108
+
109
+ text.empty? ? nil : JSON.parse(text)
110
+ rescue JSON::ParserError => e
111
+ raise Error.new(response.status, "invalid JSON: #{e.message}")
112
+ end
113
+
114
+ def normalize_tool(tool)
115
+ tool.to_s.tr("_", ".")
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "mode"
4
+ require_relative "circuit_breaker"
5
+ require_relative "tenant_snapshot"
6
+ require_relative "storage/protocols"
7
+
8
+ module Seekmodo
9
+ module Sdk
10
+ class AutoPromoter
11
+ STATE_KEY = "numinix.seekmodo.fsm_state"
12
+ DEFAULT_PROMOTE_AFTER_SECONDS = 3600
13
+ DEFAULT_DEMOTE_COOLDOWN_SECONDS = 900
14
+
15
+ def initialize(
16
+ snapshot,
17
+ breaker,
18
+ cache,
19
+ promote_after_seconds: DEFAULT_PROMOTE_AFTER_SECONDS,
20
+ demote_cooldown_seconds: DEFAULT_DEMOTE_COOLDOWN_SECONDS,
21
+ clock: nil
22
+ )
23
+ @snapshot = snapshot
24
+ @breaker = breaker
25
+ @cache = cache
26
+ @promote_after_seconds = promote_after_seconds
27
+ @demote_cooldown_seconds = demote_cooldown_seconds
28
+ @clock = clock || -> { Time.now.to_i }
29
+ end
30
+
31
+ def tick
32
+ config = @snapshot.get
33
+ configured_mode = config.fetch("mode", Mode::OFF).to_s
34
+ auto_promote = config.fetch("auto_promote", true)
35
+
36
+ if configured_mode != Mode::ACTIVE
37
+ return result(Mode::OFF, Mode::OFF, "held", "mode is not active; auto-promoter idle")
38
+ end
39
+ unless auto_promote
40
+ return result(Mode::OFF, Mode::OFF, "held", "auto_promote disabled")
41
+ end
42
+
43
+ state = load_state
44
+ current = state["current_state"].to_s
45
+ now = @clock.call
46
+ breaker_snapshot = @breaker.snapshot
47
+ breaker_open = breaker_snapshot["state"] == CircuitBreaker::STATE_OPEN
48
+
49
+ if breaker_open && current == Mode::ENFORCE
50
+ if now - state["last_transition_at"].to_i < @demote_cooldown_seconds
51
+ return result(current, current, "held", "demote cooldown active")
52
+ end
53
+ write_state(Mode::SHADOW, now)
54
+ return result(current, Mode::SHADOW, "demoted", "breaker open at enforce")
55
+ end
56
+
57
+ settled_for = now - state["last_transition_at"].to_i
58
+ if breaker_open
59
+ return result(current, current, "held", "breaker open")
60
+ end
61
+ if settled_for < @promote_after_seconds
62
+ return result(current, current, "held", "settled for #{settled_for}s, need #{@promote_after_seconds}s")
63
+ end
64
+
65
+ next_step = next_step_up(current)
66
+ if next_step == current
67
+ return result(current, current, "held", "already at enforce")
68
+ end
69
+ write_state(next_step, now)
70
+ result(current, next_step, "promoted", "breaker closed, settled")
71
+ end
72
+
73
+ def current_state
74
+ load_state
75
+ end
76
+
77
+ private
78
+
79
+ def next_step_up(current)
80
+ if [Mode::OFF, Mode::LEARNING].include?(current)
81
+ return Mode::SHADOW
82
+ end
83
+ return Mode::ENFORCE if current == Mode::SHADOW
84
+
85
+ current
86
+ end
87
+
88
+ def load_state
89
+ raw = @cache.get(STATE_KEY)
90
+ unless raw.is_a?(Hash)
91
+ now = @clock.call
92
+ return { "current_state" => Mode::SHADOW, "last_transition_at" => now }
93
+ end
94
+
95
+ current_raw = raw["current_state"].to_s
96
+ current = Mode.valid?(current_raw) ? current_raw : Mode::SHADOW
97
+ {
98
+ "current_state" => current,
99
+ "last_transition_at" => raw.fetch("last_transition_at", 0).to_i
100
+ }
101
+ end
102
+
103
+ def write_state(new_state, transitioned_at)
104
+ @cache.set(
105
+ STATE_KEY,
106
+ {
107
+ "current_state" => Mode.assert_mode(new_state),
108
+ "last_transition_at" => transitioned_at
109
+ },
110
+ 86400
111
+ )
112
+ end
113
+
114
+ def result(from_state, to_state, action, reason)
115
+ {
116
+ "from" => from_state,
117
+ "to" => to_state,
118
+ "action" => action,
119
+ "reason" => reason
120
+ }
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "connector/client"
4
+ require_relative "exceptions/client_error"
5
+ require_relative "storage/protocols"
6
+
7
+ module Seekmodo
8
+ module Sdk
9
+ class BrowserToken
10
+ CACHE_KEY = "numinix.seekmodo.browser_token"
11
+ SAFETY_MARGIN_SECONDS = 60
12
+
13
+ def initialize(client, cache, clock: nil)
14
+ @client = client
15
+ @cache = cache
16
+ @clock = clock || -> { Time.now.to_i }
17
+ end
18
+
19
+ def token(audience = nil, force: false)
20
+ cache_key = "#{CACHE_KEY}:#{audience || 'default'}"
21
+ unless force
22
+ cached = @cache.get(cache_key)
23
+ if cached.is_a?(Hash)
24
+ expires_at = cached.fetch("expires_at", 0).to_i
25
+ if expires_at > @clock.call + SAFETY_MARGIN_SECONDS
26
+ return {
27
+ "token" => cached["token"].to_s,
28
+ "expires_at" => expires_at,
29
+ "issued_at" => cached.fetch("issued_at", @clock.call).to_i
30
+ }
31
+ end
32
+ end
33
+ end
34
+
35
+ response = @client.browser_token(audience)
36
+ token = response["token"].to_s
37
+ expires_at = response.fetch("expires_at", 0).to_i
38
+ issued_at = response.fetch("issued_at", @clock.call).to_i
39
+
40
+ if token.empty? || expires_at.zero?
41
+ raise ClientError.new(
42
+ "Gateway tenants.token response missing token/expires_at fields.",
43
+ ClientError::KIND_BAD_RESPONSE
44
+ )
45
+ end
46
+
47
+ value = {
48
+ "token" => token,
49
+ "expires_at" => expires_at,
50
+ "issued_at" => issued_at
51
+ }
52
+ ttl = [expires_at - @clock.call - SAFETY_MARGIN_SECONDS, 1].max
53
+ @cache.set(cache_key, value, ttl)
54
+ value
55
+ end
56
+
57
+ def forget(audience = nil)
58
+ @cache.delete("#{CACHE_KEY}:#{audience || 'default'}")
59
+ end
60
+ end
61
+ end
62
+ end