debugbundle 0.1.2 → 0.1.4

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: cd8a77046a7f20cf6971b6e73ca8e5c7557738706e32e3b30e741f3410f8d00a
4
- data.tar.gz: 0bd638cf69ff2c6894706b1bc996f801e849b6f5a5c134543a3a18d1c598725f
3
+ metadata.gz: e2430cc8a51d6a72fa6e52896ac7b7c1be9ccc810fab56e154aae1bc76bc4aa6
4
+ data.tar.gz: 04a32dd64f1bb5b39ebb636c5db76eee2148ae7be3b888d15e0e351734dd2084
5
5
  SHA512:
6
- metadata.gz: c9431211f33958cf178a8ace0e01b3842a2a2ff1bed8e11129e053ceb6b8685ff17499d03c80bc3cfe3fc7fd6839eced529ab01238760f04a65f4181e6685507
7
- data.tar.gz: f7a432f0dc42da84a44415c767610473d8f332577eeb658c1417e47e4227e2701f7143e67fb9a8fa40db700dfca9f9ba0f655fe270fbd779de0e499507dc6836
6
+ metadata.gz: 960d4640460f35d27c18cdf20529a9c57f0d68fcd1225d3d6d571579d6007103ecb9be21596db8946dc88e6e3aa857f8bdb191f9724fe76baa6dfd701d53d25f
7
+ data.tar.gz: c6fec66551b3b37eb6b84829f0bb567813d6ae81a16e752851f02cabffb6d5b4911b28576b07ad6ea6e024f6a0a3daa9e5db78bfd96e21e063521ae732554fe0
@@ -19,7 +19,7 @@ module DebugBundle
19
19
  )
20
20
 
21
21
  body = response.body ? JSON.generate(response.body) : ''
22
- [response.status, { 'Content-Type' => 'application/json' }, [body]]
22
+ [response.status, { 'Content-Type' => 'application/json' }.merge(response.headers || {}), [body]]
23
23
  end
24
24
 
25
25
  private
@@ -23,6 +23,9 @@ if defined?(Rails::Railtie)
23
23
  app.middleware.use(DebugBundle::Rack::Middleware, client: client)
24
24
  if DebugBundle::Rails.relay_route_enabled?(app)
25
25
  app.routes.append do
26
+ match DebugBundle::Rails.relay_path(app),
27
+ to: DebugBundle::Rails::RelayEndpoint.new(app: app),
28
+ via: :options
26
29
  post DebugBundle::Rails.relay_path(app), to: DebugBundle::Rails::RelayEndpoint.new(app: app)
27
30
  end
28
31
  end
@@ -16,7 +16,7 @@ module DebugBundle
16
16
  DEFAULT_MAX_BODY_BYTES = 262_144
17
17
  DEFAULT_RATE_LIMIT_PER_MINUTE = 60
18
18
 
19
- Response = Struct.new(:status, :body, keyword_init: true)
19
+ Response = Struct.new(:status, :body, :headers, keyword_init: true)
20
20
 
21
21
  class Handler
22
22
  def initialize(
@@ -51,57 +51,94 @@ module DebugBundle
51
51
  end
52
52
 
53
53
  def handle(request)
54
- return Response.new(status: 405, body: nil) unless request.fetch(:method, 'POST').to_s.upcase == 'POST'
55
-
54
+ method = request.fetch(:method, 'POST').to_s.upcase
56
55
  headers = normalize_headers(request[:headers] || {})
57
- return Response.new(status: 403, body: nil) unless origin_allowed?(headers)
58
- unless json_content_type?(headers['content-type'])
59
- return Response.new(status: 400,
60
- body: invalid_body('Relay requests must use Content-Type: application/json.'))
61
- end
62
-
63
56
  raw_body = request[:body].to_s
64
- return Response.new(status: 413, body: nil) if raw_body.bytesize > @max_body_bytes
65
- return Response.new(status: 429, body: nil) if rate_limited?(request[:ip_address] || request[:ip])
66
-
67
- decoded = JSON.parse(raw_body)
68
- batch = decoded.fetch('batch')
69
- unless batch.is_a?(Array)
70
- return Response.new(status: 400,
71
- body: invalid_body('Relay request body must include a batch array.'))
72
- end
57
+ return Response.new(status: 403, body: nil, headers: {}) unless origin_allowed?(headers)
73
58
 
74
- accepted = []
75
- errors = []
59
+ response_headers = source_origin(headers) ? cors_headers(source_origin(headers)) : {}
60
+ early_response = request_validation_response(
61
+ method: method,
62
+ headers: headers,
63
+ raw_body: raw_body,
64
+ ip_address: request[:ip_address] || request[:ip]
65
+ )
66
+ return with_headers(early_response, response_headers) if early_response
76
67
 
77
- batch.each_with_index do |candidate, index|
78
- sanitized = sanitize_event(candidate)
79
- if sanitized
80
- accepted << sanitized
81
- else
82
- errors << "batch[#{index}]: Invalid browser relay event payload."
83
- end
84
- end
68
+ accepted, errors = sanitize_batch(raw_body)
85
69
 
86
70
  deliver(accepted) unless accepted.empty?
87
71
 
88
- unless errors.empty?
89
- return Response.new(status: 400,
90
- body: { 'accepted' => accepted.length,
91
- 'rejected' => errors.length, 'errors' => errors })
92
- end
93
-
94
- Response.new(status: 202, body: { 'accepted' => accepted.length, 'rejected' => 0, 'errors' => [] })
72
+ with_headers(batch_response(accepted, errors), response_headers)
95
73
  rescue JSON::ParserError
96
- Response.new(status: 400, body: invalid_body('Relay request body must be valid JSON.'))
74
+ with_headers(invalid_json_response, response_headers || {})
97
75
  rescue KeyError
98
- Response.new(status: 400, body: invalid_body('Relay request body must include a batch array.'))
76
+ with_headers(missing_batch_response, response_headers || {})
99
77
  rescue StandardError
100
- Response.new(status: 500, body: nil)
78
+ with_headers(Response.new(status: 500, body: nil), response_headers || {})
101
79
  end
102
80
 
103
81
  private
104
82
 
83
+ def with_headers(response, headers)
84
+ Response.new(status: response.status, body: response.body, headers: headers.merge(response.headers || {}))
85
+ end
86
+
87
+ def request_validation_response(method:, headers:, raw_body:, ip_address:)
88
+ return Response.new(status: 204, body: nil) if method == 'OPTIONS'
89
+ return Response.new(status: 405, body: nil) unless method == 'POST'
90
+ return invalid_content_type_response unless json_content_type?(headers['content-type'])
91
+ return Response.new(status: 413, body: nil) if raw_body.bytesize > @max_body_bytes
92
+ return Response.new(status: 429, body: nil) if rate_limited?(ip_address)
93
+
94
+ nil
95
+ end
96
+
97
+ def invalid_content_type_response
98
+ Response.new(
99
+ status: 400,
100
+ body: invalid_body('Relay requests must use Content-Type: application/json.')
101
+ )
102
+ end
103
+
104
+ def invalid_json_response
105
+ Response.new(status: 400, body: invalid_body('Relay request body must be valid JSON.'))
106
+ end
107
+
108
+ def missing_batch_response
109
+ Response.new(status: 400, body: invalid_body('Relay request body must include a batch array.'))
110
+ end
111
+
112
+ def batch_response(accepted, errors)
113
+ return invalid_batch_response(accepted, errors) unless errors.empty?
114
+
115
+ Response.new(
116
+ status: 202,
117
+ body: { 'accepted' => accepted.length, 'rejected' => 0, 'errors' => [] }
118
+ )
119
+ end
120
+
121
+ def invalid_batch_response(accepted, errors)
122
+ Response.new(
123
+ status: 400,
124
+ body: {
125
+ 'accepted' => accepted.length,
126
+ 'rejected' => errors.length,
127
+ 'errors' => errors
128
+ }
129
+ )
130
+ end
131
+
132
+ def cors_headers(origin)
133
+ {
134
+ 'Access-Control-Allow-Origin' => origin,
135
+ 'Access-Control-Allow-Methods' => 'POST, OPTIONS',
136
+ 'Access-Control-Allow-Headers' => 'content-type',
137
+ 'Access-Control-Max-Age' => '600',
138
+ 'Vary' => 'Origin'
139
+ }
140
+ end
141
+
105
142
  def deliver(events)
106
143
  service_name = @service || events.first.dig('service', 'name') || 'service'
107
144
 
@@ -118,6 +155,21 @@ module DebugBundle
118
155
  end
119
156
  end
120
157
 
158
+ def sanitize_batch(raw_body)
159
+ decoded = JSON.parse(raw_body)
160
+ batch = decoded.fetch('batch')
161
+ raise KeyError unless batch.is_a?(Array)
162
+
163
+ batch.each_with_index.with_object([[], []]) do |(candidate, index), (accepted, errors)|
164
+ sanitized = sanitize_event(candidate)
165
+ if sanitized
166
+ accepted << sanitized
167
+ else
168
+ errors << "batch[#{index}]: Invalid browser relay event payload."
169
+ end
170
+ end
171
+ end
172
+
121
173
  def sanitize_event(candidate)
122
174
  return nil unless candidate.is_a?(Hash)
123
175
 
@@ -175,7 +227,7 @@ module DebugBundle
175
227
  end
176
228
 
177
229
  def origin_allowed?(headers)
178
- origin = headers['origin'] || origin_from_referer(headers['referer'])
230
+ origin = source_origin(headers)
179
231
  return false if origin.nil? || origin.empty?
180
232
 
181
233
  return @allowed_origins.include?(normalize_origin(origin)) if @allowed_origins.any?
@@ -188,6 +240,10 @@ module DebugBundle
188
240
  false
189
241
  end
190
242
 
243
+ def source_origin(headers)
244
+ headers['origin'] || origin_from_referer(headers['referer'])
245
+ end
246
+
191
247
  def origin_from_referer(referer)
192
248
  return nil if referer.to_s.empty?
193
249
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DebugBundle
4
- VERSION = '0.1.2'
4
+ VERSION = '0.1.4'
5
5
  end
data/spec/relay_spec.rb CHANGED
@@ -73,6 +73,46 @@ RSpec.describe DebugBundle::Relay::Handler do
73
73
  expect(response.status).to eq(403)
74
74
  end
75
75
 
76
+ it 'answers allowed cross-origin preflight without delivering events' do
77
+ handler = described_class.new(project_mode: :local_only, project_token: 'dbundle_proj_server',
78
+ allowed_origins: ['https://web.example.com'])
79
+ response = handler.handle(
80
+ method: 'OPTIONS',
81
+ headers: {
82
+ 'host' => 'api.example.com',
83
+ 'origin' => 'https://web.example.com',
84
+ 'access-control-request-method' => 'POST',
85
+ 'access-control-request-headers' => 'content-type'
86
+ },
87
+ body: '',
88
+ ip_address: '127.0.0.1'
89
+ )
90
+
91
+ expect(response.status).to eq(204)
92
+ expect(response.headers).to include(
93
+ 'Access-Control-Allow-Origin' => 'https://web.example.com',
94
+ 'Access-Control-Allow-Methods' => 'POST, OPTIONS'
95
+ )
96
+ end
97
+
98
+ it 'adds CORS headers to accepted cross-origin relay posts' do
99
+ handler = described_class.new(project_mode: :local_only, project_token: 'dbundle_proj_server',
100
+ allowed_origins: ['https://web.example.com'])
101
+ response = handler.handle(
102
+ method: 'POST',
103
+ headers: {
104
+ 'host' => 'api.example.com',
105
+ 'origin' => 'https://web.example.com',
106
+ 'content-type' => 'application/json'
107
+ },
108
+ body: JSON.generate('batch' => [browser_event]),
109
+ ip_address: '127.0.0.1'
110
+ )
111
+
112
+ expect(response.status).to eq(202)
113
+ expect(response.headers).to include('Access-Control-Allow-Origin' => 'https://web.example.com', 'Vary' => 'Origin')
114
+ end
115
+
76
116
  it 'supports a shared rate-limit store interface' do
77
117
  Dir.mktmpdir do |directory|
78
118
  store = Class.new do
@@ -172,7 +212,29 @@ RSpec.describe DebugBundle::Relay::Handler do
172
212
 
173
213
  expect(status).to eq(202)
174
214
  expect(headers).to include('Content-Type' => 'application/json')
215
+ expect(headers).to include('Access-Control-Allow-Origin' => 'https://app.example.com')
175
216
  expect(JSON.parse(body.fetch(0))).to include('accepted' => 1)
176
217
  end
177
218
  end
219
+
220
+ it 'exposes Rack preflight headers' do
221
+ middleware = DebugBundle::Rack::RelayMiddleware.new(
222
+ nil,
223
+ handler: described_class.new(project_mode: :local_only, project_token: 'dbundle_proj_server',
224
+ allowed_origins: ['https://web.example.com'])
225
+ )
226
+
227
+ status, headers, body = middleware.call(
228
+ 'REQUEST_METHOD' => 'OPTIONS',
229
+ 'HTTP_HOST' => 'api.example.com',
230
+ 'HTTP_ORIGIN' => 'https://web.example.com',
231
+ 'HTTP_ACCESS_CONTROL_REQUEST_METHOD' => 'POST',
232
+ 'REMOTE_ADDR' => '127.0.0.1',
233
+ 'rack.input' => StringIO.new('')
234
+ )
235
+
236
+ expect(status).to eq(204)
237
+ expect(headers).to include('Access-Control-Allow-Origin' => 'https://web.example.com')
238
+ expect(body.fetch(0)).to eq('')
239
+ end
178
240
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: debugbundle
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - DebugBundle
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-05-26 00:00:00.000000000 Z
10
+ date: 2026-05-29 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: base64