rhales 0.4.0 → 0.5.3

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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/.github/renovate.json5 +52 -0
  3. data/.github/workflows/ci.yml +123 -0
  4. data/.github/workflows/claude-code-review.yml +69 -0
  5. data/.github/workflows/claude.yml +49 -0
  6. data/.github/workflows/code-smells.yml +146 -0
  7. data/.github/workflows/ruby-lint.yml +78 -0
  8. data/.github/workflows/yardoc.yml +126 -0
  9. data/.gitignore +55 -0
  10. data/.pr_agent.toml +63 -0
  11. data/.pre-commit-config.yaml +89 -0
  12. data/.prettierignore +8 -0
  13. data/.prettierrc +38 -0
  14. data/.reek.yml +98 -0
  15. data/.rubocop.yml +428 -0
  16. data/.serena/.gitignore +3 -0
  17. data/.yardopts +56 -0
  18. data/CHANGELOG.md +44 -0
  19. data/CLAUDE.md +1 -1
  20. data/Gemfile +29 -0
  21. data/Gemfile.lock +189 -0
  22. data/README.md +686 -868
  23. data/Rakefile +46 -0
  24. data/debug_context.rb +25 -0
  25. data/demo/rhales-roda-demo/.gitignore +7 -0
  26. data/demo/rhales-roda-demo/Gemfile +32 -0
  27. data/demo/rhales-roda-demo/Gemfile.lock +151 -0
  28. data/demo/rhales-roda-demo/MAIL.md +405 -0
  29. data/demo/rhales-roda-demo/README.md +376 -0
  30. data/demo/rhales-roda-demo/RODA-TEMPLATE-ENGINES.md +192 -0
  31. data/demo/rhales-roda-demo/Rakefile +49 -0
  32. data/demo/rhales-roda-demo/app.rb +325 -0
  33. data/demo/rhales-roda-demo/bin/rackup +26 -0
  34. data/demo/rhales-roda-demo/config.ru +13 -0
  35. data/demo/rhales-roda-demo/db/migrate/001_create_rodauth_tables.rb +266 -0
  36. data/demo/rhales-roda-demo/db/migrate/002_create_rodauth_password_tables.rb +79 -0
  37. data/demo/rhales-roda-demo/db/migrate/003_add_admin_account.rb +68 -0
  38. data/demo/rhales-roda-demo/templates/change_login.rue +31 -0
  39. data/demo/rhales-roda-demo/templates/change_password.rue +36 -0
  40. data/demo/rhales-roda-demo/templates/close_account.rue +31 -0
  41. data/demo/rhales-roda-demo/templates/create_account.rue +40 -0
  42. data/demo/rhales-roda-demo/templates/dashboard.rue +150 -0
  43. data/demo/rhales-roda-demo/templates/home.rue +78 -0
  44. data/demo/rhales-roda-demo/templates/layouts/main.rue +168 -0
  45. data/demo/rhales-roda-demo/templates/login.rue +65 -0
  46. data/demo/rhales-roda-demo/templates/logout.rue +25 -0
  47. data/demo/rhales-roda-demo/templates/reset_password.rue +26 -0
  48. data/demo/rhales-roda-demo/templates/verify_account.rue +27 -0
  49. data/demo/rhales-roda-demo/test_full_output.rb +27 -0
  50. data/demo/rhales-roda-demo/test_simple.rb +24 -0
  51. data/docs/.gitignore +9 -0
  52. data/docs/architecture/data-flow.md +499 -0
  53. data/examples/dashboard-with-charts.rue +271 -0
  54. data/examples/form-with-validation.rue +180 -0
  55. data/examples/simple-page.rue +61 -0
  56. data/examples/vue.rue +136 -0
  57. data/generate-json-schemas.ts +158 -0
  58. data/json_schemer_migration_summary.md +172 -0
  59. data/lib/rhales/adapters/base_auth.rb +2 -0
  60. data/lib/rhales/adapters/base_request.rb +2 -0
  61. data/lib/rhales/adapters/base_session.rb +2 -0
  62. data/lib/rhales/adapters.rb +7 -0
  63. data/lib/rhales/configuration.rb +47 -0
  64. data/lib/rhales/core/context.rb +354 -0
  65. data/lib/rhales/{rue_document.rb → core/rue_document.rb} +56 -38
  66. data/lib/rhales/{template_engine.rb → core/template_engine.rb} +66 -59
  67. data/lib/rhales/{view.rb → core/view.rb} +112 -135
  68. data/lib/rhales/{view_composition.rb → core/view_composition.rb} +78 -8
  69. data/lib/rhales/core.rb +9 -0
  70. data/lib/rhales/errors/hydration_collision_error.rb +2 -0
  71. data/lib/rhales/errors.rb +2 -0
  72. data/lib/rhales/{earliest_injection_detector.rb → hydration/earliest_injection_detector.rb} +4 -0
  73. data/lib/rhales/hydration/hydration_data_aggregator.rb +487 -0
  74. data/lib/rhales/{hydration_endpoint.rb → hydration/hydration_endpoint.rb} +16 -12
  75. data/lib/rhales/{hydration_injector.rb → hydration/hydration_injector.rb} +4 -0
  76. data/lib/rhales/{hydration_registry.rb → hydration/hydration_registry.rb} +2 -0
  77. data/lib/rhales/hydration/hydrator.rb +102 -0
  78. data/lib/rhales/{link_based_injection_detector.rb → hydration/link_based_injection_detector.rb} +4 -0
  79. data/lib/rhales/{mount_point_detector.rb → hydration/mount_point_detector.rb} +4 -0
  80. data/lib/rhales/{safe_injection_validator.rb → hydration/safe_injection_validator.rb} +4 -0
  81. data/lib/rhales/hydration.rb +13 -0
  82. data/lib/rhales/{refinements → integrations/refinements}/require_refinements.rb +3 -1
  83. data/lib/rhales/{tilt.rb → integrations/tilt.rb} +22 -15
  84. data/lib/rhales/integrations.rb +6 -0
  85. data/lib/rhales/middleware/json_responder.rb +191 -0
  86. data/lib/rhales/middleware/schema_validator.rb +300 -0
  87. data/lib/rhales/middleware.rb +6 -0
  88. data/lib/rhales/parsers/handlebars_parser.rb +2 -0
  89. data/lib/rhales/parsers/rue_format_parser.rb +9 -7
  90. data/lib/rhales/parsers.rb +9 -0
  91. data/lib/rhales/{csp.rb → security/csp.rb} +27 -3
  92. data/lib/rhales/utils/json_serializer.rb +114 -0
  93. data/lib/rhales/utils/logging_helpers.rb +75 -0
  94. data/lib/rhales/utils/schema_extractor.rb +132 -0
  95. data/lib/rhales/utils/schema_generator.rb +194 -0
  96. data/lib/rhales/utils.rb +40 -0
  97. data/lib/rhales/version.rb +3 -1
  98. data/lib/rhales.rb +41 -24
  99. data/lib/tasks/rhales_schema.rake +197 -0
  100. data/package.json +10 -0
  101. data/pnpm-lock.yaml +345 -0
  102. data/pnpm-workspace.yaml +2 -0
  103. data/proofs/error_handling.rb +79 -0
  104. data/proofs/expanded_object_inheritance.rb +82 -0
  105. data/proofs/partial_context_scoping_fix.rb +168 -0
  106. data/proofs/ui_context_partial_inheritance.rb +236 -0
  107. data/rhales.gemspec +14 -6
  108. data/schema_vs_data_comparison.md +254 -0
  109. data/test_direct_access.rb +36 -0
  110. metadata +141 -23
  111. data/CLAUDE.locale.txt +0 -7
  112. data/lib/rhales/context.rb +0 -239
  113. data/lib/rhales/hydration_data_aggregator.rb +0 -221
  114. data/lib/rhales/hydrator.rb +0 -141
  115. data/lib/rhales/parsers/handlebars-grammar-review.txt +0 -39
@@ -0,0 +1,300 @@
1
+ # lib/rhales/middleware/schema_validator.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require 'json_schemer'
6
+ require_relative '../utils/json_serializer'
7
+
8
+ module Rhales
9
+ module Middleware
10
+ # Rack middleware that validates hydration data against JSON Schemas
11
+ #
12
+ # This middleware extracts hydration JSON from HTML responses and validates
13
+ # it against the JSON Schema for the template. In development, it fails
14
+ # loudly on mismatches. In production, it logs warnings but continues serving.
15
+ #
16
+ # @example Basic usage with Rack
17
+ # use Rhales::Middleware::SchemaValidator,
18
+ # schemas_dir: './public/schemas',
19
+ # fail_on_error: ENV['RACK_ENV'] == 'development'
20
+ #
21
+ # @example With Roda
22
+ # use Rhales::Middleware::SchemaValidator,
23
+ # schemas_dir: File.expand_path('../public/schemas', __dir__),
24
+ # fail_on_error: ENV['RACK_ENV'] == 'development',
25
+ # enabled: true
26
+ #
27
+ # @example Accessing statistics
28
+ # validator = app.middleware.find { |m| m.is_a?(Rhales::Middleware::SchemaValidator) }
29
+ # puts validator.stats
30
+ class SchemaValidator
31
+ # Raised when schema validation fails in development mode
32
+ class ValidationError < StandardError; end
33
+
34
+ # Initialize the middleware
35
+ #
36
+ # @param app [#call] The Rack application
37
+ # @param options [Hash] Configuration options
38
+ # @option options [String] :schemas_dir Path to JSON schemas directory
39
+ # @option options [Boolean] :fail_on_error Whether to raise on validation errors
40
+ # @option options [Boolean] :enabled Whether validation is enabled
41
+ # @option options [Array<String>] :skip_paths Additional paths to skip validation
42
+ def initialize(app, options = {})
43
+ @app = app
44
+ # Default to public/schemas in implementing project's directory
45
+ @schemas_dir = options.fetch(:schemas_dir, './public/schemas')
46
+ @fail_on_error = options.fetch(:fail_on_error, false)
47
+ @enabled = options.fetch(:enabled, true)
48
+ @skip_paths = options.fetch(:skip_paths, [])
49
+ @schema_cache = {}
50
+ @stats = {
51
+ total_validations: 0,
52
+ total_time_ms: 0,
53
+ failures: 0
54
+ }
55
+ end
56
+
57
+ # Process the Rack request
58
+ #
59
+ # @param env [Hash] The Rack environment
60
+ # @return [Array] Rack response tuple [status, headers, body]
61
+ def call(env)
62
+ return @app.call(env) unless @enabled
63
+ return @app.call(env) if skip_validation?(env)
64
+
65
+ status, headers, body = @app.call(env)
66
+
67
+ # Only validate HTML responses
68
+ content_type = headers['Content-Type']
69
+ return [status, headers, body] unless content_type&.include?('text/html')
70
+
71
+ # Get template name from env (set by View)
72
+ template_name = env['rhales.template_name']
73
+ return [status, headers, body] unless template_name
74
+
75
+ # Get template path if available (for better error messages)
76
+ template_path = env['rhales.template_path']
77
+
78
+ # Load schema for template
79
+ schema = load_schema_cached(template_name)
80
+ return [status, headers, body] unless schema
81
+
82
+ # Extract hydration data from response
83
+ html_body = extract_body(body)
84
+ hydration_data = extract_hydration_data(html_body)
85
+ return [status, headers, body] if hydration_data.empty?
86
+
87
+ # Validate each hydration block
88
+ start_time = Time.now
89
+ errors = validate_hydration_data(hydration_data, schema, template_name)
90
+ elapsed_ms = ((Time.now - start_time) * 1000).round(2)
91
+
92
+ # Update stats
93
+ @stats[:total_validations] += 1
94
+ @stats[:total_time_ms] += elapsed_ms
95
+ @stats[:failures] += 1 if errors.any?
96
+
97
+ # Handle errors
98
+ handle_errors(errors, template_name, template_path, elapsed_ms) if errors.any?
99
+
100
+ [status, headers, body]
101
+ end
102
+
103
+ # Get validation statistics
104
+ #
105
+ # @return [Hash] Statistics including avg_time_ms and success_rate
106
+ def stats
107
+ avg_time = @stats[:total_validations] > 0 ?
108
+ (@stats[:total_time_ms] / @stats[:total_validations]).round(2) : 0
109
+
110
+ @stats.merge(
111
+ avg_time_ms: avg_time,
112
+ success_rate: @stats[:total_validations] > 0 ?
113
+ ((@stats[:total_validations] - @stats[:failures]).to_f / @stats[:total_validations] * 100).round(2) : 0
114
+ )
115
+ end
116
+
117
+ private
118
+
119
+ # Check if validation should be skipped for this request
120
+ def skip_validation?(env)
121
+ path = env['PATH_INFO']
122
+
123
+ # Skip static assets, APIs, public files
124
+ return true if path.start_with?('/assets', '/api', '/public')
125
+
126
+ # Skip configured custom paths
127
+ return true if @skip_paths.any? { |skip_path| path.start_with?(skip_path) }
128
+
129
+ # Skip files with extensions typically not rendered by templates
130
+ return true if path.match?(/\.(css|js|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot)\z/i)
131
+
132
+ false
133
+ end
134
+
135
+ # Load and cache JSON schema for template
136
+ def load_schema_cached(template_name)
137
+ @schema_cache[template_name] ||= begin
138
+ schema_path = File.join(@schemas_dir, "#{template_name}.json")
139
+
140
+ return nil unless File.exist?(schema_path)
141
+
142
+ schema_json = File.read(schema_path)
143
+ schema_hash = JSONSerializer.parse(schema_json)
144
+
145
+ # Create JSONSchemer validator
146
+ # Note: json_schemer handles $schema and $id properly
147
+ JSONSchemer.schema(schema_hash)
148
+ rescue JSON::ParserError => e
149
+ warn "Rhales::SchemaValidator: Failed to parse schema for #{template_name}: #{e.message}"
150
+ nil
151
+ rescue StandardError => e
152
+ warn "Rhales::SchemaValidator: Failed to load schema for #{template_name}: #{e.message}"
153
+ nil
154
+ end
155
+ end
156
+
157
+ # Extract response body as string
158
+ def extract_body(body)
159
+ if body.respond_to?(:each)
160
+ body.each.to_a.join
161
+ elsif body.respond_to?(:read)
162
+ body.read
163
+ else
164
+ body.to_s
165
+ end
166
+ end
167
+
168
+ # Extract hydration JSON blocks from HTML
169
+ #
170
+ # Looks for <script type="application/json" data-window="varName"> tags
171
+ def extract_hydration_data(html)
172
+ hydration_blocks = {}
173
+
174
+ # Match script tags with data-window attribute
175
+ html.scan(/<script[^>]*type=["']application\/json["'][^>]*data-window=["']([^"']+)["'][^>]*>(.*?)<\/script>/m) do |window_var, json_content|
176
+ begin
177
+ hydration_blocks[window_var] = JSONSerializer.parse(json_content.strip)
178
+ rescue JSON::ParserError => e
179
+ warn "Rhales::SchemaValidator: Failed to parse hydration JSON for window.#{window_var}: #{e.message}"
180
+ end
181
+ end
182
+
183
+ hydration_blocks
184
+ end
185
+
186
+ # Validate hydration data against schema
187
+ def validate_hydration_data(hydration_data, schema, template_name)
188
+ errors = []
189
+
190
+ hydration_data.each do |window_var, data|
191
+ # Validate data against schema using json_schemer
192
+ begin
193
+ validation_errors = schema.validate(data).to_a
194
+
195
+ if validation_errors.any?
196
+ errors << {
197
+ window: window_var,
198
+ template: template_name,
199
+ errors: format_errors(validation_errors)
200
+ }
201
+ end
202
+ rescue StandardError => e
203
+ warn "Rhales::SchemaValidator: Schema validation error for #{template_name}: #{e.message}"
204
+ # Don't add to errors array - this is a schema definition problem, not data problem
205
+ end
206
+ end
207
+
208
+ errors
209
+ end
210
+
211
+ # Format json_schemer errors for display
212
+ def format_errors(validation_errors)
213
+ validation_errors.map do |error|
214
+ # json_schemer provides detailed error hash
215
+ # Example: { "data" => value, "data_pointer" => "/user/id", "schema" => {...}, "type" => "required", "error" => "..." }
216
+
217
+ path = error['data_pointer'] || '/'
218
+ type = error['type']
219
+ schema = error['schema'] || {}
220
+ data = error['data']
221
+
222
+ # For type validation errors, format like json-schema did
223
+ # "The property '#/count' of type string did not match the following type: number"
224
+ if schema['type'] && data
225
+ expected = schema['type']
226
+ actual = case data
227
+ when String then 'string'
228
+ when Integer, Float then 'number'
229
+ when TrueClass, FalseClass then 'boolean'
230
+ when Array then 'array'
231
+ when Hash then 'object'
232
+ when NilClass then 'null'
233
+ else data.class.name.downcase
234
+ end
235
+
236
+ "The property '#{path}' of type #{actual} did not match the following type: #{expected}"
237
+ elsif type == 'required'
238
+ details = error['details'] || {}
239
+ missing = details['missing_keys']&.join(', ') || 'unknown'
240
+ "The property '#{path}' is missing required field(s): #{missing}"
241
+ elsif schema['enum']
242
+ expected = schema['enum'].join(', ')
243
+ "The property '#{path}' must be one of: #{expected}"
244
+ elsif schema['minimum']
245
+ min = schema['minimum']
246
+ "The property '#{path}' must be >= #{min}"
247
+ elsif schema['maximum']
248
+ max = schema['maximum']
249
+ "The property '#{path}' must be <= #{max}"
250
+ elsif type == 'additionalProperties'
251
+ "The property '#{path}' is not defined in the schema and the schema does not allow additional properties"
252
+ else
253
+ # Fallback: use json_schemer's built-in error message
254
+ error['error'] || "The property '#{path}' failed '#{type}' validation"
255
+ end
256
+ end
257
+ end
258
+
259
+ # Handle validation errors
260
+ def handle_errors(errors, template_name, template_path, elapsed_ms)
261
+ error_message = build_error_message(errors, template_name, template_path, elapsed_ms)
262
+
263
+ if @fail_on_error
264
+ # Development: Fail loudly
265
+ raise ValidationError, error_message
266
+ else
267
+ # Production: Log warning
268
+ warn error_message
269
+ end
270
+ end
271
+
272
+ # Build detailed error message
273
+ def build_error_message(errors, template_name, template_path, elapsed_ms)
274
+ msg = ["Schema validation failed for template: #{template_name}"]
275
+ msg << "Template path: #{template_path}" if template_path
276
+ msg << "Validation time: #{elapsed_ms}ms"
277
+ msg << ""
278
+
279
+ errors.each do |error|
280
+ msg << "Window variable: #{error[:window]}"
281
+ msg << "Errors:"
282
+ error[:errors].each do |err|
283
+ msg << " - #{err}"
284
+ end
285
+ msg << ""
286
+ end
287
+
288
+ msg << "This means your backend is sending data that doesn't match the contract"
289
+ msg << "defined in the <schema> section of #{template_name}.rue"
290
+ msg << ""
291
+ msg << "To fix:"
292
+ msg << "1. Check the schema definition in #{template_name}.rue"
293
+ msg << "2. Verify the data passed to render('#{template_name}', ...)"
294
+ msg << "3. Ensure types match (string vs number, required fields, etc.)"
295
+
296
+ msg.join("\n")
297
+ end
298
+ end
299
+ end
300
+ end
@@ -0,0 +1,6 @@
1
+ # lib/rhales/middleware.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require_relative 'middleware/json_responder'
6
+ require_relative 'middleware/schema_validator'
@@ -1,4 +1,6 @@
1
1
  # lib/rhales/parsers/handlebars_parser.rb
2
+ #
3
+ # frozen_string_literal: true
2
4
 
3
5
  module Rhales
4
6
  # Hand-rolled recursive descent parser for Handlebars template syntax
@@ -1,4 +1,6 @@
1
1
  # lib/rhales/parsers/rue_format_parser.rb
2
+ #
3
+ # frozen_string_literal: true
2
4
 
3
5
  require 'strscan'
4
6
  require_relative 'handlebars_parser'
@@ -9,7 +11,7 @@ module Rhales
9
11
  # This parser implements .rue file parsing rules in Ruby code and produces
10
12
  # an Abstract Syntax Tree (AST) for .rue file processing. It handles:
11
13
  #
12
- # - Section-based parsing: <data>, <template>, <logic>
14
+ # - Section-based parsing: <schema>, <template>, <logic>
13
15
  # - Attribute extraction from section tags
14
16
  # - Delegation to HandlebarsParser for template content
15
17
  # - Validation of required sections
@@ -21,7 +23,7 @@ module Rhales
21
23
  # File format structure:
22
24
  # rue_file := section+
23
25
  # section := '<' tag_name attributes? '>' content '</' tag_name '>'
24
- # tag_name := 'data' | 'template' | 'logic'
26
+ # tag_name := 'schema' | 'template' | 'logic'
25
27
  # attributes := attribute+
26
28
  # attribute := key '=' quoted_value
27
29
  # content := (text | handlebars_expression)*
@@ -29,9 +31,9 @@ module Rhales
29
31
  class RueFormatParser
30
32
  # At least one of these sections must be present
31
33
  unless defined?(REQUIRES_ONE_OF_SECTIONS)
32
- REQUIRES_ONE_OF_SECTIONS = %w[data template].freeze
34
+ REQUIRES_ONE_OF_SECTIONS = %w[schema template].freeze
33
35
 
34
- KNOWN_SECTIONS = %w[data template logic].freeze
36
+ KNOWN_SECTIONS = %w[schema template logic].freeze
35
37
  ALL_SECTIONS = KNOWN_SECTIONS.freeze
36
38
 
37
39
  # Regular expression to match HTML/XML comments outside of sections
@@ -204,7 +206,7 @@ module Rhales
204
206
  handlebars_parser.parse!
205
207
  handlebars_parser.ast.children
206
208
  else
207
- # For data and logic sections, keep as simple text
209
+ # For schema and logic sections, keep as simple text
208
210
  raw_content.empty? ? [] : [Node.new(:text, current_location, value: raw_content)]
209
211
  end
210
212
  else
@@ -380,10 +382,10 @@ module Rhales
380
382
  when scanner.scan(/<!--.*?-->/m)
381
383
  # Comment token - non-greedy match for complete comments
382
384
  { type: :comment, content: scanner.matched }
383
- when scanner.scan(/<(data|template|logic)(\s[^>]*)?>/m)
385
+ when scanner.scan(/<(schema|template|logic)(\s[^>]*)?>/m)
384
386
  # Section start token - matches opening tags with optional attributes
385
387
  { type: :section_start, content: scanner.matched }
386
- when scanner.scan(%r{</(data|template|logic)>}m)
388
+ when scanner.scan(%r{</(schema|template|logic)>}m)
387
389
  # Section end token - matches closing tags
388
390
  { type: :section_end, content: scanner.matched }
389
391
  when scanner.scan(/[^<]+/)
@@ -0,0 +1,9 @@
1
+ # lib/rhales/parsers.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ # Dependencies
6
+ require_relative 'errors'
7
+
8
+ require_relative 'parsers/handlebars_parser'
9
+ require_relative 'parsers/rue_format_parser'
@@ -1,4 +1,6 @@
1
1
  # lib/rhales/csp.rb
2
+ #
3
+ # frozen_string_literal: true
2
4
 
3
5
  module Rhales
4
6
  # Content Security Policy (CSP) header generation and management
@@ -11,6 +13,8 @@ module Rhales
11
13
  # header = csp.build_header
12
14
  # # => "default-src 'self'; script-src 'self' 'nonce-abc123'; ..."
13
15
  class CSP
16
+ include Rhales::Utils::LoggingHelpers
17
+
14
18
  attr_reader :config, :nonce
15
19
 
16
20
  def initialize(config, nonce: nil)
@@ -23,6 +27,7 @@ module Rhales
23
27
  return nil unless @config.csp_enabled
24
28
 
25
29
  policy_directives = []
30
+ nonce_used = false
26
31
 
27
32
  @config.csp_policy.each do |directive, sources|
28
33
  if sources.empty?
@@ -30,18 +35,37 @@ module Rhales
30
35
  policy_directives << directive
31
36
  else
32
37
  # Process sources and interpolate nonce if present
33
- processed_sources = sources.map { |source| interpolate_nonce(source) }
38
+ processed_sources = sources.map do |source|
39
+ interpolated = interpolate_nonce(source)
40
+ nonce_used = true if interpolated != source
41
+ interpolated
42
+ end
34
43
  directive_string = "#{directive} #{processed_sources.join(' ')}"
35
44
  policy_directives << directive_string
36
45
  end
37
46
  end
38
47
 
39
- policy_directives.join('; ')
48
+ header = policy_directives.join('; ')
49
+
50
+ # Log CSP header generation for security audit
51
+ log_with_metadata(Rhales.logger, :debug, "CSP header generated",
52
+ nonce_used: nonce_used,
53
+ nonce: @nonce,
54
+ directive_count: policy_directives.size,
55
+ header_length: header.length
56
+ )
57
+
58
+ header
40
59
  end
41
60
 
42
61
  # Generate a new nonce value
43
62
  def self.generate_nonce
44
- SecureRandom.hex(16)
63
+ nonce = SecureRandom.hex(16)
64
+
65
+ # Log nonce generation for security audit trail
66
+ Rhales.logger.debug("CSP nonce generated: nonce=#{nonce} length=#{nonce.length} entropy_bits=#{nonce.length * 4}")
67
+
68
+ nonce
45
69
  end
46
70
 
47
71
  # Validate CSP policy configuration
@@ -0,0 +1,114 @@
1
+ # lib/rhales/utils/json_serializer.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ module Rhales
6
+ # Centralized JSON serialization with optional Oj support
7
+ #
8
+ # This module provides a unified interface for JSON operations with automatic
9
+ # Oj optimization when available. Oj is 10-20x faster than stdlib JSON for
10
+ # parsing and 5-10x faster for generation.
11
+ #
12
+ # The serializer backend is determined once at load time for optimal performance.
13
+ # If you want to use Oj, require it before requiring Rhales:
14
+ #
15
+ # @example Ensuring Oj is used
16
+ # require 'oj'
17
+ # require 'rhales'
18
+ #
19
+ # @example Basic usage
20
+ # Rhales::JSONSerializer.dump({ user: 'Alice' })
21
+ # # => "{\"user\":\"Alice\"}"
22
+ #
23
+ # Rhales::JSONSerializer.parse('{"user":"Alice"}')
24
+ # # => {"user"=>"Alice"}
25
+ #
26
+ # @example Pretty printing
27
+ # Rhales::JSONSerializer.pretty_dump({ user: 'Alice', count: 42 })
28
+ # # => "{\n \"user\": \"Alice\",\n \"count\": 42\n}"
29
+ #
30
+ # @example Check backend
31
+ # Rhales::JSONSerializer.backend
32
+ # # => :oj (if available) or :json (stdlib)
33
+ #
34
+ module JSONSerializer
35
+ class << self
36
+ # Serialize Ruby object to JSON string
37
+ #
38
+ # Uses the serializer backend determined at load time (Oj or stdlib JSON).
39
+ #
40
+ # @param obj [Object] Ruby object to serialize
41
+ # @return [String] JSON string (compact format)
42
+ # @raise [TypeError] if object contains non-serializable types
43
+ def dump(obj)
44
+ @json_dumper.call(obj)
45
+ end
46
+
47
+ # Serialize Ruby object to pretty-printed JSON string
48
+ #
49
+ # Uses the serializer backend determined at load time (Oj or stdlib JSON).
50
+ # Output is formatted with indentation for readability.
51
+ #
52
+ # @param obj [Object] Ruby object to serialize
53
+ # @return [String] Pretty-printed JSON string with 2-space indentation
54
+ # @raise [TypeError] if object contains non-serializable types
55
+ def pretty_dump(obj)
56
+ @json_pretty_dumper.call(obj)
57
+ end
58
+
59
+ # Parse JSON string to Ruby object
60
+ #
61
+ # Uses the parser backend determined at load time (Oj or stdlib JSON).
62
+ # Always returns hashes with string keys (not symbols) for consistency.
63
+ #
64
+ # @param json_string [String] JSON string to parse
65
+ # @return [Object] parsed Ruby object (Hash with string keys)
66
+ # @raise [JSON::ParserError, Oj::ParseError] if JSON is malformed
67
+ def parse(json_string)
68
+ @json_loader.call(json_string)
69
+ end
70
+
71
+ # Returns the active JSON backend
72
+ #
73
+ # @return [Symbol] :oj or :json
74
+ def backend
75
+ @backend
76
+ end
77
+
78
+ # Reset backend detection (useful for testing)
79
+ #
80
+ # @api private
81
+ def reset!
82
+ detect_backend!
83
+ end
84
+
85
+ private
86
+
87
+ # Detect and configure JSON backend at load time
88
+ def detect_backend!
89
+ oj_available = begin
90
+ require 'oj'
91
+ true
92
+ rescue LoadError
93
+ false
94
+ end
95
+
96
+ if oj_available
97
+ @backend = :oj
98
+ @json_dumper = ->(obj) { Oj.dump(obj, mode: :strict) }
99
+ @json_pretty_dumper = ->(obj) { Oj.dump(obj, mode: :strict, indent: 2) }
100
+ @json_loader = ->(json_string) { Oj.load(json_string, mode: :strict, symbol_keys: false) }
101
+ else
102
+ require 'json'
103
+ @backend = :json
104
+ @json_dumper = ->(obj) { JSON.generate(obj) }
105
+ @json_pretty_dumper = ->(obj) { JSON.pretty_generate(obj) }
106
+ @json_loader = ->(json_string) { JSON.parse(json_string) }
107
+ end
108
+ end
109
+ end
110
+
111
+ # Initialize backend on load
112
+ detect_backend!
113
+ end
114
+ end
@@ -0,0 +1,75 @@
1
+ # lib/rhales/utils/logging_helpers.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ module Rhales
6
+ module Utils
7
+ # Helper methods for consistent logging and timing instrumentation across Rhales components
8
+ module LoggingHelpers
9
+ include Rhales::Utils
10
+
11
+ # Log with timing for an operation
12
+ #
13
+ # @param logger [Logger] The logger instance to use
14
+ # @param level [Symbol] The log level (:debug, :info, :warn, :error)
15
+ # @param message [String] The log message
16
+ # @param metadata [Hash] Additional metadata to include in the log
17
+ # @yield The block to execute and time
18
+ # @return The result of the block
19
+ #
20
+ # Logs the operation with timing information in microseconds.
21
+ def log_timed_operation(logger, level, message, **metadata)
22
+ start_time = now_in_μs
23
+ result = yield
24
+ duration = now_in_μs - start_time
25
+
26
+ log_with_metadata(logger, level, message, metadata.merge(duration: duration))
27
+
28
+ result
29
+ rescue StandardError => ex
30
+ duration = now_in_μs - start_time
31
+ log_with_metadata(logger, :error, "#{message} failed",
32
+ metadata.merge(
33
+ duration: duration,
34
+ error: ex.message,
35
+ error_class: ex.class.name,
36
+ )
37
+ )
38
+ raise
39
+ end
40
+
41
+ # Log a message with structured metadata
42
+ def log_with_metadata(logger, level, message, metadata = {})
43
+ return logger.public_send(level, message) if metadata.empty?
44
+
45
+ metadata_str = metadata.map { |k, v| "#{k}=#{format_value(v)}" }.join(' ')
46
+ logger.public_send(level, "#{message}: #{metadata_str}")
47
+ end
48
+
49
+ private
50
+
51
+ # Format individual log values
52
+ def format_value(value)
53
+ case value
54
+ when String
55
+ value.include?(' ') ? "\"#{value}\"" : value
56
+ when Symbol, Numeric, true, false, nil
57
+ value.to_s
58
+ when Array
59
+ # For arrays longer than 5 items, show count + first/last items
60
+ if value.size > 5
61
+ first_three = value.first(3).join(', ')
62
+ last_two = value.last(2).join(', ')
63
+ "[#{value.size} items: #{first_three} ... #{last_two}]"
64
+ elsif value.empty?
65
+ '[]'
66
+ else
67
+ "[#{value.join(', ')}]"
68
+ end
69
+ else
70
+ value.to_s
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end