coolhand 0.5.1 → 0.6.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.
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "openssl"
4
4
 
5
+ require_relative "webhook_id_store"
6
+
5
7
  module Coolhand
6
8
  module OpenAi
7
9
  class WebhookValidator
@@ -23,7 +25,7 @@ module Coolhand
23
25
  secret_bytes = extract_secret_bytes
24
26
  webhook_signature, webhook_timestamp, webhook_id = extract_webhook_headers
25
27
 
26
- return validate_headers_in_non_production_env unless webhook_signature && webhook_timestamp
28
+ return validate_headers_in_non_production_env unless webhook_signature && webhook_timestamp && webhook_id
27
29
 
28
30
  verify_signature(webhook_signature, webhook_timestamp, webhook_id, secret_bytes)
29
31
  end
@@ -40,7 +42,8 @@ module Coolhand
40
42
  return true if @payload
41
43
 
42
44
  if should_enforce_strict_validation?
43
- @errors << "Empty webhook payload - rejecting webhook in production/staging"
45
+ @errors << "Empty webhook payload - rejecting webhook (Rails.env=#{Rails.env.inspect} " \
46
+ "not in development/test allowlist)"
44
47
  Rails.logger.error(@errors.last)
45
48
  false
46
49
  else
@@ -51,7 +54,8 @@ module Coolhand
51
54
 
52
55
  def validate_in_non_production_env
53
56
  if should_enforce_strict_validation?
54
- @errors << "OpenAI webhook secret not configured - rejecting webhook in production/staging"
57
+ @errors << "OpenAI webhook secret not configured - rejecting webhook (Rails.env=#{Rails.env.inspect} " \
58
+ "not in development/test allowlist)"
55
59
  Rails.logger.error(@errors.last)
56
60
  false
57
61
  else
@@ -80,8 +84,8 @@ module Coolhand
80
84
 
81
85
  def validate_headers_in_non_production_env
82
86
  if should_enforce_strict_validation?
83
- @errors << "Missing OpenAI webhook signature or timestamp headers - " \
84
- "rejecting webhook in production/staging"
87
+ @errors << "Missing OpenAI webhook signature, timestamp, or id headers - " \
88
+ "rejecting webhook (Rails.env=#{Rails.env.inspect} not in development/test allowlist)"
85
89
  Rails.logger.error(@errors.last)
86
90
  false
87
91
  else
@@ -96,13 +100,43 @@ module Coolhand
96
100
 
97
101
  signature_valid = webhook_signature.start_with?("v1,") &&
98
102
  secure_compare(webhook_signature[3..], expected_signature)
99
- if signature_valid
100
- true
101
- else
103
+
104
+ unless signature_valid
102
105
  @errors << "OpenAI webhook signature verification failed"
103
106
  Rails.logger.error(@errors.last)
104
- false
107
+ return false
105
108
  end
109
+
110
+ return false unless timestamp_fresh?(webhook_timestamp)
111
+ return false unless webhook_id_unused?(webhook_id)
112
+
113
+ true
114
+ end
115
+
116
+ # Checked only after the signature is confirmed valid, so an
117
+ # unsigned/forged request can't poison the id-dedup store (or fail a
118
+ # freshness check) and DoS a later legitimate webhook with the same id.
119
+ def timestamp_fresh?(webhook_timestamp)
120
+ tolerance = Coolhand.configuration.webhook_replay_tolerance_seconds
121
+ age = (Time.now.to_i - webhook_timestamp.to_i).abs
122
+ return true if age <= tolerance
123
+
124
+ @errors << "OpenAI webhook timestamp outside replay-protection tolerance window"
125
+ Rails.logger.error(@errors.last)
126
+ false
127
+ end
128
+
129
+ def webhook_id_unused?(webhook_id)
130
+ tolerance = Coolhand.configuration.webhook_replay_tolerance_seconds
131
+ return true if id_store.claim!(webhook_id, tolerance)
132
+
133
+ @errors << "OpenAI webhook id already processed (replay protection)"
134
+ Rails.logger.error(@errors.last)
135
+ false
136
+ end
137
+
138
+ def id_store
139
+ Coolhand.configuration.webhook_id_store
106
140
  end
107
141
 
108
142
  def secure_compare(a, b)
@@ -124,7 +158,7 @@ module Coolhand
124
158
  end
125
159
 
126
160
  def should_enforce_strict_validation?
127
- ["production", "staging"].include?(Rails.env)
161
+ !%w[development test].include?(Rails.env)
128
162
  end
129
163
  end
130
164
  end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coolhand
4
+ # Paging state for a v2 list endpoint. These send a bare JSON array and carry paging in the
5
+ # `X-Page`, `X-Per-Page`, `X-Total-Count` and `X-Total-Pages` headers, never in the body.
6
+ Pagination = Struct.new(
7
+ :current_page,
8
+ :per_page,
9
+ :total_count,
10
+ :total_pages,
11
+ :has_next_page,
12
+ :has_prev_page,
13
+ keyword_init: true
14
+ )
15
+
16
+ class Pagination
17
+ # Mirrors of the v2 controllers' values, used only to fill a header the server did not send.
18
+ DEFAULT_PER_PAGE = 25
19
+ MAX_PER_PAGE = 100
20
+
21
+ class << self
22
+ def from_headers(response, items:, page: nil, per: nil)
23
+ requested_page = positive_int(page) || 1
24
+ requested_per = [positive_int(per) || DEFAULT_PER_PAGE, MAX_PER_PAGE].min
25
+
26
+ current_page = header_int(response, "X-Page") || requested_page
27
+ per_page = header_int(response, "X-Per-Page") || requested_per
28
+ reported_total_pages = header_int(response, "X-Total-Pages")
29
+ total_count = header_int(response, "X-Total-Count") || fallback_total_count(current_page, per_page, items)
30
+ total_pages = reported_total_pages || fallback_total_pages(total_count, per_page)
31
+
32
+ new(
33
+ current_page: current_page,
34
+ per_page: per_page,
35
+ total_count: total_count,
36
+ total_pages: total_pages,
37
+ has_next_page: next_page?(reported_total_pages, current_page, per_page, items),
38
+ has_prev_page: current_page > 1
39
+ ).freeze
40
+ end
41
+
42
+ private
43
+
44
+ # Falling back to the computed totals here would report "no next page" for a full page, and
45
+ # silently truncate a caller's loop.
46
+ def next_page?(reported_total_pages, current_page, per_page, items)
47
+ return current_page < reported_total_pages if reported_total_pages
48
+ return false unless per_page.positive?
49
+
50
+ items.size >= per_page
51
+ end
52
+
53
+ # A lower bound, not a count: every earlier page assumed full, plus this page.
54
+ def fallback_total_count(current_page, per_page, items)
55
+ return items.size unless per_page.positive?
56
+
57
+ [((current_page - 1) * per_page) + items.size, items.size].max
58
+ end
59
+
60
+ def fallback_total_pages(total_count, per_page)
61
+ return total_count.positive? ? 1 : 0 unless per_page.positive?
62
+
63
+ (total_count.to_f / per_page).ceil
64
+ end
65
+
66
+ # Neither `Integer()` nor `to_i` is safe alone: the first raises on `""`, the second turns
67
+ # `"3.5"` into `3` and `"nonsense"` into `0` — a fabricated, legitimate-looking count.
68
+ def header_int(response, name)
69
+ raw = response[name]
70
+ return nil if raw.nil?
71
+
72
+ value = raw.strip
73
+ value.match?(/\A\d+\z/) ? value.to_i : nil
74
+ end
75
+
76
+ def positive_int(value)
77
+ integer = Integer(value, exception: false)
78
+ integer&.positive? ? integer : nil
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require_relative "errors"
7
+
8
+ module Coolhand
9
+ # The GET half of {ApiService}, split out to keep that class inside this repo's 200-line budget.
10
+ #
11
+ # Reads raise where writes log-and-return-nil, on purpose: a write is instrumentation inline in
12
+ # the host app's request, while a read's caller must tell a 404 from a timeout from an empty result.
13
+ module ReadRequests
14
+ # 60s is deliberate, not a slip: writes allow 5, but the server bounds each *statement* at 10s
15
+ # and one response runs several. A tighter read timeout pre-empts the 504 callers should retry.
16
+ READ_OPEN_TIMEOUT = 5
17
+ READ_TIMEOUT = 60
18
+
19
+ # ERROR_BODY_LIMIT caps the message; this caps the body the exception object itself carries.
20
+ RETAINED_ERROR_BODY_LIMIT = 8_000
21
+
22
+ protected
23
+
24
+ def get_json(url, noun)
25
+ body, = get_json_with_headers(url, noun)
26
+ body
27
+ end
28
+
29
+ # Also returns the response, for endpoints that carry pagination in headers rather than the body.
30
+ def get_json_with_headers(url, noun)
31
+ raise Error, "#{noun} request failed: an API key is required" unless Coolhand.required_field?(api_key)
32
+
33
+ response = perform_get(url, noun)
34
+
35
+ unless response.is_a?(Net::HTTPSuccess)
36
+ raise HttpError.new(
37
+ "#{noun} request failed (#{response.code}): #{format_error_body(response.body)}",
38
+ status: response.code.to_i,
39
+ body: retained_error_body(response.body)
40
+ )
41
+ end
42
+
43
+ [parse_json_body(response.body, noun), response]
44
+ end
45
+
46
+ private
47
+
48
+ def perform_get(url, noun)
49
+ uri = url.is_a?(URI::Generic) ? url : URI.parse(url.to_s)
50
+ http = Net::HTTP.new(uri.host, uri.port)
51
+ http.use_ssl = (uri.scheme == "https")
52
+ http.open_timeout = READ_OPEN_TIMEOUT
53
+ http.read_timeout = READ_TIMEOUT
54
+
55
+ request = Net::HTTP::Get.new(uri.request_uri)
56
+ apply_headers(request, "Accept" => "application/json", "X-API-Key" => api_key)
57
+
58
+ # Net::HTTP does not follow redirects, so a 3xx raises rather than replaying the API key at
59
+ # an unapproved host. without_capture is the same recursion guard send_request uses.
60
+ Coolhand.without_capture { http.request(request) }
61
+ rescue StandardError => e
62
+ raise Error, "#{noun} request failed: #{e.message}"
63
+ end
64
+
65
+ def retained_error_body(body)
66
+ return body if body.nil? || body.length <= RETAINED_ERROR_BODY_LIMIT
67
+
68
+ "#{body[0, RETAINED_ERROR_BODY_LIMIT]}... [truncated]"
69
+ end
70
+
71
+ def parse_json_body(body, noun)
72
+ JSON.parse(body.to_s, symbolize_names: true)
73
+ rescue JSON::ParserError
74
+ raise Error, "#{noun} response was not valid JSON: #{format_error_body(body)}"
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require_relative "api_service"
5
+ require_relative "pagination"
6
+
7
+ module Coolhand
8
+ # Rows stay plain Symbol-keyed Hashes so a field the server adds later is not dropped in transit.
9
+ TemplateSearchResult = Struct.new(:templates, :pagination, keyword_init: true)
10
+
11
+ # Read-only. Requires the client's **private** key — the public key is write-only here and is
12
+ # rejected like an invalid one.
13
+ #
14
+ # Not a port of the MCP `search_templates` tool and does not agree with its `log_count`.
15
+ # See docs/template-search.md.
16
+ class TemplateService < ApiService
17
+ ERROR_NOUN = "Template"
18
+ BLANK_ID_MESSAGE = "get_template: id must be a non-empty template hashid"
19
+
20
+ def initialize
21
+ super("v2/llm_request_templates")
22
+ end
23
+
24
+ # Filters, error semantics and server behaviour: docs/template-search.md.
25
+ def search_templates(search: nil, workload_id: nil, status: nil, include_deprecated: nil,
26
+ include_system: nil, page: nil, per: nil)
27
+ query = {
28
+ search: search,
29
+ workload_id: workload_id,
30
+ status: status,
31
+ include_deprecated: include_deprecated,
32
+ include_system: include_system,
33
+ page: page,
34
+ per: per
35
+ }.compact
36
+
37
+ templates, response = get_json_with_headers(list_url(query), ERROR_NOUN)
38
+ raise Error, "#{ERROR_NOUN} response was not a JSON array" unless templates.is_a?(Array)
39
+
40
+ TemplateSearchResult.new(
41
+ templates: templates,
42
+ pagination: Pagination.from_headers(response, items: templates, page: page, per: per)
43
+ ).freeze
44
+ end
45
+
46
+ # Adds `user_prompt_pattern` / `system_prompt_pattern`, which the list omits, and unlike the
47
+ # list reaches deprecated and system templates by id with no opt-in flag.
48
+ def get_template(id)
49
+ get_json(resource_url(id), ERROR_NOUN)
50
+ end
51
+
52
+ private
53
+
54
+ def list_url(query)
55
+ uri = URI.parse(api_endpoint)
56
+ uri.query = URI.encode_www_form(query) unless query.empty?
57
+ uri
58
+ end
59
+
60
+ def resource_url(id)
61
+ raise Error, BLANK_ID_MESSAGE unless id.is_a?(String)
62
+
63
+ trimmed = id.strip
64
+ # A blank id resolves to the index route (bare array, not one template); a bare dot segment
65
+ # retargets the request at another path. Neither would 404, so both are rejected here.
66
+ raise Error, BLANK_ID_MESSAGE if trimmed.empty? || [".", ".."].include?(trimmed)
67
+
68
+ URI.parse("#{api_endpoint}/#{escape_path_segment(trimmed)}")
69
+ end
70
+
71
+ # Escapes to RFC 3986 unreserved, so an id carrying `/`, `?` or `#` cannot retarget the request.
72
+ def escape_path_segment(value)
73
+ URI::DEFAULT_PARSER.escape(value, /[^A-Za-z0-9\-._~]/)
74
+ end
75
+ end
76
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Coolhand
4
- VERSION = "0.5.1"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/coolhand.rb CHANGED
@@ -6,6 +6,7 @@ require "json"
6
6
  require "base64"
7
7
  require "time"
8
8
 
9
+ require_relative "coolhand/errors"
9
10
  require_relative "coolhand/version"
10
11
  require_relative "coolhand/configuration"
11
12
  require_relative "coolhand/collector"
@@ -14,13 +15,12 @@ require_relative "coolhand/net_http_interceptor"
14
15
  require_relative "coolhand/api_service"
15
16
  require_relative "coolhand/logger_service"
16
17
  require_relative "coolhand/feedback_service"
18
+ require_relative "coolhand/template_service"
17
19
  require_relative "coolhand/webhook_interceptor"
18
20
 
19
21
  # The main module for the Coolhand gem.
20
22
  # It provides the configuration interface and initializes the patching.
21
23
  module Coolhand
22
- class Error < StandardError; end
23
-
24
24
  # Class-level instance variables to hold the configuration
25
25
  @configuration = Configuration.new
26
26
 
@@ -62,12 +62,11 @@ module Coolhand
62
62
 
63
63
  return yield unless configuration.enabled
64
64
 
65
- patched = NetHttpInterceptor.patched?
66
65
  NetHttpInterceptor.patch!
67
66
  begin
68
67
  yield
69
68
  ensure
70
- NetHttpInterceptor.unpatch! unless patched
69
+ NetHttpInterceptor.unpatch!
71
70
  end
72
71
  end
73
72
 
@@ -104,6 +103,12 @@ module Coolhand
104
103
  LoggerService.new
105
104
  end
106
105
 
106
+ # Creates a new TemplateService instance, for reading LLM request templates back out of
107
+ # Coolhand. Needs the private API key - the public key is write-only on this API.
108
+ def template_service
109
+ TemplateService.new
110
+ end
111
+
107
112
  def required_field?(value)
108
113
  return false if value.nil?
109
114
  return false if value.respond_to?(:empty?) && value.empty?
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: coolhand
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michael Carroll
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2026-08-02 00:00:00.000000000 Z
12
+ date: 2026-09-13 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: base64
@@ -39,6 +39,7 @@ files:
39
39
  - ".rspec"
40
40
  - ".rubocop.yml"
41
41
  - ".simplecov"
42
+ - AGENTS.harness.md
42
43
  - CHANGELOG.md
43
44
  - LICENSE
44
45
  - README.md
@@ -49,6 +50,7 @@ files:
49
50
  - docs/elevenlabs.md
50
51
  - docs/feedback.md
51
52
  - docs/openai.md
53
+ - docs/template-search.md
52
54
  - docs/vertex.md
53
55
  - lib/coolhand.rb
54
56
  - lib/coolhand/api_service.rb
@@ -57,11 +59,17 @@ files:
57
59
  - lib/coolhand/configuration.rb
58
60
  - lib/coolhand/default_exclude_api_patterns.yml
59
61
  - lib/coolhand/default_intercept_addresses.yml
62
+ - lib/coolhand/default_intercept_path_patterns.yml
63
+ - lib/coolhand/errors.rb
60
64
  - lib/coolhand/feedback_service.rb
61
65
  - lib/coolhand/logger_service.rb
62
66
  - lib/coolhand/net_http_interceptor.rb
63
67
  - lib/coolhand/open_ai/batch_result_processor.rb
68
+ - lib/coolhand/open_ai/webhook_id_store.rb
64
69
  - lib/coolhand/open_ai/webhook_validator.rb
70
+ - lib/coolhand/pagination.rb
71
+ - lib/coolhand/read_requests.rb
72
+ - lib/coolhand/template_service.rb
65
73
  - lib/coolhand/version.rb
66
74
  - lib/coolhand/vertex/batch_result_processor.rb
67
75
  - lib/coolhand/webhook_interceptor.rb