rack_jwt_aegis 1.0.1 → 1.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6bc9e89c08947641810cb3c0a5aef91c56554dcf44e593abc10cb7b75ebe6478
4
- data.tar.gz: bcbd0711caf2bd74d65ede714cf4cf24eb016146fc69e20bfdf76a165d8a009c
3
+ metadata.gz: 0e776985ff922d83b9ac75821698125a565dce342be61bd4bf6012fc97dfd82c
4
+ data.tar.gz: 7bbb4006689d39ea9ec47ad869b01468bbfbddfb0ecbaa430d28ac96f1fd33b7
5
5
  SHA512:
6
- metadata.gz: 7d279d25565397a64e45be0b157a01e63ca7c8e688fd1bb3592649cef8a2037380f5c9c1ddad645ad46f2326d5cd8cc279661207371825d26fe0c6cc036dd272
7
- data.tar.gz: fe7e42dc37a5dca3a4fe443fc14e0a64178994b20406471ab6139be4e0b8917a663bcc45d29a819749875a9ef4cecd7d85cd8e13ef9cf5b6054ace0be090d540
6
+ metadata.gz: 64eb785ccd161bf7f7a7e0ce2b9840007885778b47d35a5528fe586ebcc160aa2f3d356e92868f8931c0789603a78fbc1feef56d6141dbeb322611330404b77a
7
+ data.tar.gz: 84f45c9c58aed4eadbcfd8a1d78d1a3e0cdd47c9f602ae9047e847738c671c0513dacfc9298207456fa969a44045ec6b8b8ff29fea3e0c29f76357aa4933ddaf
data/.yardopts CHANGED
@@ -1,16 +1,14 @@
1
1
  --output-dir doc
2
2
  --readme README.md
3
- --markup-provider=kramdown
4
- --markup=markdown
5
3
  --main README.md
4
+ --markup=markdown
6
5
  --protected
7
6
  --private
8
7
  --no-private
9
- --title "RackJwtAegis API Documentation"
8
+ --title "RackJwtAegis Rack Middleware API Documentation"
10
9
  --charset utf-8
11
10
  lib/**/*.rb
12
11
  -
13
12
  README.md
14
13
  LICENSE.txt
15
14
  CODE_OF_CONDUCT.md
16
- adrs/architecture.md
data/CHANGELOG.md CHANGED
@@ -5,6 +5,136 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.1.0] - 2025-08-14
9
+
10
+ ### 🚀 Added
11
+
12
+ #### Enhanced RBAC Cache Format
13
+
14
+ - **Improved Performance**: Changed RBAC cache format from array-based to flat object structure for O(1) role lookup
15
+ - **Before**: `"permissions": [{ "role-id": ["resource:method"] }]` (O(n) array iteration)
16
+ - **After**: `"permissions": { "role-id": ["resource:method"] }` (O(1) direct access)
17
+ - **Developer Experience**: Enhanced error detection with descriptive ConfigurationError exceptions
18
+ - Catches common migration mistakes when upgrading from array to object format
19
+ - Provides clear error messages with expected format examples
20
+ - Helps developers quickly identify and fix RBAC configuration issues
21
+
22
+ #### Documentation Improvements
23
+
24
+ - **Clean Documentation**: Fixed YARD documentation rendering issues with improved Markdown processing
25
+ - Added Redcarpet gem for better Markdown parsing in YARD
26
+ - Removed complex Jekyll integration that was causing rendering issues
27
+ - Maintained YARD for comprehensive API documentation with cleaner output
28
+ - **API Documentation**: Enhanced code comments for better YARD rendering
29
+ - Updated key method documentation with clearer parameter descriptions
30
+ - Improved examples and usage patterns in documentation
31
+ - Better integration with YARD's HTML generation
32
+
33
+ ### 🔧 Fixed
34
+
35
+ #### RBAC System Improvements
36
+
37
+ - **Cache Format Validation**: Added explicit validation for RBAC permissions format
38
+ - Raises `ConfigurationError` when permissions is not a Hash
39
+ - Provides helpful error messages for developers
40
+ - Maintains backward compatibility for other validation scenarios
41
+ - **Role Lookup Logic**: Optimized role permission checking with direct hash access
42
+ - Eliminated unnecessary array iteration in permission validation
43
+ - Improved performance for applications with many roles
44
+ - Maintains support for both string and integer role keys
45
+
46
+ #### Bug Fixes
47
+
48
+ - **Test Coverage**: Fixed `test_check_rbac_format?` test that was using old array format
49
+ - **Key Resolution**: Fixed JWT payload key resolution bug in `rbac_last_update_timestamp` method
50
+ - **Validation Logic**: Updated all validation tests to use new flat object format
51
+
52
+ ### 🏗️ Technical Details
53
+
54
+ #### Architecture Changes
55
+
56
+ - **RBAC Manager**: Updated `validate_rbac_cache_format` and `check_rbac_format?` methods
57
+ - Direct hash lookup: `permissions_data[role_id]` instead of array iteration
58
+ - Improved error handling with specific ConfigurationError exceptions
59
+ - Enhanced validation with clear developer feedback
60
+ - **Exception Handling**: Re-raises ConfigurationError while preserving other error handling
61
+ - Developer errors bubble up for immediate attention
62
+ - Runtime errors (cache issues) are still handled gracefully
63
+ - Maintains existing debug logging for troubleshooting
64
+
65
+ #### Performance Improvements
66
+
67
+ - **O(1) Role Lookup**: Direct hash access for role permissions
68
+ - **Reduced Memory**: Eliminated nested array structures
69
+ - **Faster Validation**: Simplified permission checking logic
70
+
71
+ #### Developer Experience
72
+
73
+ - **Better Error Messages**: Clear configuration error descriptions
74
+ ```ruby
75
+ # Example error message:
76
+ "RBAC permissions must be a Hash with role-id keys, not Array.
77
+ Expected format: {\"role-id\": [\"resource:method\", ...]}, but got: Array"
78
+ ```
79
+ - **Migration Support**: Catches common mistakes when upgrading cache format
80
+ - **Improved Documentation**: Cleaner YARD output with better Markdown rendering
81
+
82
+ ### 📚 Documentation Updates
83
+
84
+ - **README.md**: Updated RBAC cache format examples and specifications
85
+ - **YARD Integration**: Fixed documentation rendering with Redcarpet Markdown processor
86
+ - **Code Examples**: Updated all examples to use new flat object format
87
+
88
+ ### 🧪 Testing
89
+
90
+ - **Test Coverage Maintained**: All tests updated to use new cache format
91
+ - **Enhanced Validation**: Added tests for new configuration error scenarios
92
+ - **Comprehensive Coverage**: Validated migration scenarios and edge cases
93
+
94
+ ### ⚠️ Migration Guide
95
+
96
+ #### Updating RBAC Cache Format
97
+
98
+ **Before (v1.0.x):**
99
+ ```ruby
100
+ Rails.cache.write("permissions", {
101
+ 'last_update' => Time.now.to_i,
102
+ 'permissions' => [
103
+ { '123' => ['sales/invoices:get', 'sales/invoices:post'] },
104
+ { '456' => ['admin/*:*'] }
105
+ ]
106
+ })
107
+ ```
108
+
109
+ **After (v1.1.0+):**
110
+ ```ruby
111
+ Rails.cache.write("permissions", {
112
+ 'last_update' => Time.now.to_i,
113
+ 'permissions' => {
114
+ '123' => ['sales/invoices:get', 'sales/invoices:post'],
115
+ '456' => ['admin/*:*']
116
+ }
117
+ })
118
+ ```
119
+
120
+ #### Benefits of Migration
121
+
122
+ - **🚀 Better Performance**: O(1) role lookup instead of O(n) iteration
123
+ - **🛠️ Easier Management**: Direct role access for permission updates
124
+ - **📝 Cleaner Code**: Simpler data structure that's easier to understand and maintain
125
+
126
+ ---
127
+
128
+ ## [1.0.2] - 2025-08-13
129
+
130
+ ### 🔧 Fixed
131
+
132
+ - Add :validate_tenant_id configuration and incorporate this to the MultiTenantValidator#validate_tenant_id_header
133
+
134
+ #### Code Quality & Maintenance
135
+
136
+ - Refactor the Configuration and MultiTenantValidator
137
+
8
138
  ## [1.0.1] - 2025-08-13
9
139
 
10
140
  ### 🔧 Fixed
@@ -23,7 +153,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
23
153
  #### Developer Experience
24
154
 
25
155
  - **Consistent Logging**: Unified debug log format across all components with automatic timestamp formatting
26
- - **Component Identification**: Automatic component name inference for better log traceability
156
+ - **Component Identification**: Automatic component name inference for better log traceability
27
157
  - **Configurable Log Levels**: Support for info, warn, and error log levels with appropriate output streams
28
158
 
29
159
  ### 🏗️ Technical Details
@@ -37,7 +167,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
37
167
  #### Testing & Quality
38
168
 
39
169
  - **Test Suite**: All 340 tests pass with 975 assertions
40
- - **Coverage Maintained**: 98.17% line coverage, 92.83% branch coverage
170
+ - **Coverage Maintained**: 98.17% line coverage, 92.83% branch coverage
41
171
  - **RBAC Integration**: Verified all role-based authorization tests pass after refactoring
42
172
  - **Zero Regression**: No functional changes, only structural improvements
43
173
 
@@ -239,5 +369,7 @@ This 1.0.0 release represents a production-ready JWT authentication middleware w
239
369
 
240
370
  **Deprecations**: None (initial release).
241
371
 
372
+ [1.1.0]: https://github.com/kanutocd/rack_jwt_aegis/releases/tag/v1.1.0
373
+ [1.0.2]: https://github.com/kanutocd/rack_jwt_aegis/releases/tag/v1.0.2
242
374
  [1.0.1]: https://github.com/kanutocd/rack_jwt_aegis/releases/tag/v1.0.1
243
375
  [1.0.0]: https://github.com/kanutocd/rack_jwt_aegis/releases/tag/v1.0.0
data/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  [![Ruby Version](https://img.shields.io/badge/ruby-%3E%3D%203.1.0-ruby.svg)](https://www.ruby-lang.org/en/)
7
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
8
 
9
- JWT authentication middleware for hierarchical multi-tenant Rack applications with 2-level tenant support.
9
+ JWT authentication and authorization middleware for hierarchical multi-tenant Rack applications with 2-level tenant support.
10
10
 
11
11
  ## Features
12
12
 
@@ -46,25 +46,25 @@ Rack JWT Aegis includes a command-line tool for generating secure JWT secrets:
46
46
 
47
47
  ```bash
48
48
  # Generate a secure JWT secret
49
- rack-jwt-aegis secret
49
+ rack_jwt_aegis secret
50
50
 
51
51
  # Generate base64-encoded secret
52
- rack-jwt-aegis secret --format base64
52
+ rack_jwt_aegis secret --format base64
53
53
 
54
54
  # Generate secret in environment variable format
55
- rack-jwt-aegis secret --env
55
+ rack_jwt_aegis secret --env
56
56
 
57
57
  # Generate multiple secrets
58
- rack-jwt-aegis secret --count 3
58
+ rack_jwt_aegis secret --count 3
59
59
 
60
60
  # Quiet mode (secret only)
61
- rack-jwt-aegis secret --quiet
61
+ rack_jwt_aegis secret --quiet
62
62
 
63
63
  # Custom length (32 bytes)
64
- rack-jwt-aegis secret --length 32
64
+ rack_jwt_aegis secret --length 32
65
65
 
66
66
  # Show help
67
- rack-jwt-aegis --help
67
+ rack_jwt_aegis --help
68
68
  ```
69
69
 
70
70
  ### Security Features
@@ -82,6 +82,7 @@ Rack JWT Aegis includes a command-line tool for generating secure JWT secrets:
82
82
  # config/application.rb
83
83
  config.middleware.insert_before 0, RackJwtAegis::Middleware, {
84
84
  jwt_secret: ENV['JWT_SECRET'],
85
+ validate_tenant_id: true,
85
86
  tenant_id_header_name: 'X-Tenant-Id',
86
87
  skip_paths: ['/api/v1/login', '/health']
87
88
  }
@@ -94,6 +95,7 @@ Rack JWT Aegis includes a command-line tool for generating secure JWT secrets:
94
95
 
95
96
  use RackJwtAegis::Middleware, {
96
97
  jwt_secret: ENV['JWT_SECRET'],
98
+ validate_tenant_id: true,
97
99
  tenant_id_header_name: 'X-Tenant-Id',
98
100
  skip_paths: ['/login', '/health']
99
101
  }
@@ -126,10 +128,20 @@ RackJwtAegis::Middleware.new(app, {
126
128
  jwt_algorithm: 'HS256', # Default: 'HS256'
127
129
 
128
130
  # Multi-Tenant Settings
131
+ validate_tenant_id: true, # Default: false
129
132
  tenant_id_header_name: 'X-Tenant-Id', # Default: 'X-Tenant-Id'
130
133
  validate_subdomain: true, # Default: false
131
134
  validate_pathname_slug: true, # Default: false
132
135
 
136
+ # Default Payload Mapping:
137
+ payload_mapping: {
138
+ user_id: :user_id,
139
+ tenant_id: :tenant_id,
140
+ subdomain: :subdomain,
141
+ pathname_slugs: :pathname_slugs,
142
+ role_ids: :role_ids,
143
+ },
144
+
133
145
  # Path Configuration
134
146
  skip_paths: ['/health', '/api/v1/login'],
135
147
  pathname_slug_pattern: /^\/api\/v1\/([^\/]+)\//, # Default pattern
@@ -172,7 +184,16 @@ RackJwtAegis::Middleware.new(app, {
172
184
  payload['role'] == 'admin' || payload['permissions'].include?('read')
173
185
  },
174
186
 
175
- # Flexible Payload Mapping
187
+ # Payload Mapping defaults:
188
+ # payload_mapping = {
189
+ # user_id: :user_id,
190
+ # tenant_id: :tenant_id,
191
+ # subdomain: :subdomain,
192
+ # pathname_slugs: :pathname_slugs,
193
+ # role_ids: :role_ids,
194
+ # }
195
+
196
+ # Flexible Payload Mapping can be customized into:
176
197
  payload_mapping: {
177
198
  user_id: :sub, # Map 'sub' claim to user_id
178
199
  tenant_id: :company_group_id, # Map 'company_group_id' claim
@@ -190,7 +211,7 @@ Rack JWT Aegis provides multiple strategies for multi-tenant authentication:
190
211
  ### Subdomain Validation
191
212
 
192
213
  ```ruby
193
- # Validates that the JWT's domain matches the request subdomain
214
+ # Validates that the JWT's subdomain claim matches the request's host subdomain
194
215
  config.validate_subdomain = true
195
216
  ```
196
217
 
@@ -206,6 +227,7 @@ config.pathname_slug_pattern = /^\/api\/v1\/([^\/]+)\//
206
227
 
207
228
  ```ruby
208
229
  # Validates the X-Tenant-Id header against JWT payload
230
+ config.validate_tenant_id = true
209
231
  config.tenant_id_header_name = 'X-Tenant-Id'
210
232
  ```
211
233
 
@@ -251,7 +273,7 @@ accessible_companies = RackJwtAegis::RequestContext.pathname_slugs(request.env)
251
273
  # => ["company-a", "company-b"]
252
274
 
253
275
  # Check if user has access to specific company
254
- has_access = RackJwtAegis::RequestContext.has_company_access?(request.env, "company-a")
276
+ has_access = RackJwtAegis::RequestContext.has_pathname_slug_access?(request.env, "company-a")
255
277
  # => true
256
278
 
257
279
  # Helper methods for request objects
@@ -269,7 +291,7 @@ tenant_id = RackJwtAegis::RequestContext.current_tenant_id(request)
269
291
  - `pathname_slugs(env)` - Get array of accessible company slugs
270
292
  - `current_user_id(request)` - Helper for request objects
271
293
  - `current_tenant_id(request)` - Helper for request objects
272
- - `has_company_access?(env, slug)` - Check company access
294
+ - `has_pathname_slug_access?(env, slug)` - Check company access
273
295
 
274
296
  ## JWT Payload Structure
275
297
 
@@ -297,7 +319,7 @@ When RBAC is enabled, the middleware extracts user roles from the JWT payload fo
297
319
  ```ruby
298
320
  payload_mapping: {
299
321
  user_id: :user_id,
300
- tenant_id: :tenant_id,
322
+ tenant_id: :tenant_id,
301
323
  subdomain: :subdomain,
302
324
  pathname_slugs: :pathname_slugs,
303
325
  role_ids: :role_ids # Default field for user roles
@@ -311,7 +333,7 @@ The middleware looks for roles in the following priority order:
311
333
  1. **Configured Field**: Uses the `role_ids` mapping (e.g., if mapped to `:user_roles`, looks for `user_roles` field)
312
334
  2. **Fallback Fields**: If the mapped field is not found, tries these common alternatives:
313
335
  - `roles` - Array of role identifiers
314
- - `role` - Single role identifier
336
+ - `role` - Single role identifier
315
337
  - `user_roles` - Array of user role identifiers
316
338
  - `role_ids` - Array of role IDs (numeric or string)
317
339
 
@@ -341,7 +363,7 @@ The middleware supports flexible role formats:
341
363
  # Array of strings (recommended)
342
364
  "role_ids": ["123", "456", "admin"]
343
365
 
344
- # Array of integers
366
+ # Array of integers
345
367
  "role_ids": [123, 456]
346
368
 
347
369
  # Single string
@@ -417,31 +439,28 @@ When RBAC is enabled, the middleware expects permissions to be stored in the cac
417
439
  ```json
418
440
  {
419
441
  "last_update": 1640995200,
420
- "permissions": [
421
- {
422
- "123": [
423
- "sales/invoices:get",
424
- "sales/invoices:post",
425
- "%r{sales/invoices/\\d+}:get",
426
- "%r{sales/invoices/\\d+}:put",
427
- "users/*:get"
428
- ]
429
- },
430
- {
431
- "456": ["admin/*:*", "reports:get"]
432
- }
433
- ]
442
+ "permissions": {
443
+ "123": [
444
+ "sales/invoices:get",
445
+ "sales/invoices:post",
446
+ "%r{sales/invoices/\\d+}:get",
447
+ "%r{sales/invoices/\\d+}:put",
448
+ "users/*:get"
449
+ ],
450
+ "456": ["admin/*:*", "reports:get"]
451
+ }
434
452
  }
435
453
  ```
436
454
 
437
455
  ### Format Specification
438
456
 
439
457
  - **last_update**: Timestamp for cache invalidation
440
- - **permissions**: Array of role permission objects
441
- - **Role ID**: String or numeric identifier for user roles
442
- - **Permission Format**: `"resource-endpoint:http-method"`
443
- - **resource-endpoint**: API path (literal string or regex pattern)
444
- - **http-method**: `get`, `post`, `put`, `delete`, or `*` (wildcard)
458
+ - **permissions**: Object containing direct role-to-permissions mapping:
459
+ - **Role ID** (key): String or numeric identifier
460
+ - **Permissions** (value): Array of permission strings
461
+ - **Permission Format**: `"resource-endpoint:http-method"`
462
+ - **resource-endpoint**: API path (literal string or regex pattern)
463
+ - **http-method**: `get`, `post`, `put`, `delete`, or `*` (wildcard)
445
464
 
446
465
  ### Permission Examples
447
466
 
data/Rakefile CHANGED
@@ -14,19 +14,11 @@ begin
14
14
  require 'yard'
15
15
  require 'yard/rake/yardoc_task'
16
16
 
17
- # Load custom GFM configuration if available
18
- begin
19
- require_relative '.yard/yard_gfm_config'
20
- rescue LoadError
21
- # GFM config not available, use default markdown processing
22
- end
23
-
24
17
  YARD::Rake::YardocTask.new do |t|
25
18
  t.files = ['lib/**/*.rb']
26
19
  t.options = [
27
20
  '--output-dir', 'doc',
28
21
  '--readme', 'README.md',
29
- '--markup-provider', 'kramdown',
30
22
  '--markup', 'markdown'
31
23
  ]
32
24
  t.stats_options = ['--list-undoc']
@@ -49,6 +49,10 @@ module RackJwtAegis
49
49
  # @return [Boolean] true if pathname slug validation is enabled
50
50
  attr_accessor :validate_pathname_slug
51
51
 
52
+ # Whether to validate tenant id from request header against the tenant id from JWT payload
53
+ # @return [Boolean] true if tenant id validation is enabled
54
+ attr_accessor :validate_tenant_id
55
+
52
56
  # Whether RBAC (Role-Based Access Control) is enabled
53
57
  # @return [Boolean] true if RBAC is enabled
54
58
  attr_accessor :rbac_enabled
@@ -204,6 +208,12 @@ module RackJwtAegis
204
208
  config_boolean?(validate_pathname_slug)
205
209
  end
206
210
 
211
+ # Check if tenant id validation is enabled
212
+ # @return [Boolean] true if tenant id validation is enabled
213
+ def validate_tenant_id?
214
+ config_boolean?(validate_tenant_id)
215
+ end
216
+
207
217
  # Check if debug mode is enabled
208
218
  # @return [Boolean] true if debug mode is enabled
209
219
  def debug_mode?
@@ -261,6 +271,7 @@ module RackJwtAegis
261
271
  @jwt_algorithm = 'HS256'
262
272
  @validate_subdomain = false
263
273
  @validate_pathname_slug = false
274
+ @validate_tenant_id = false
264
275
  @rbac_enabled = false
265
276
  @tenant_id_header_name = 'X-Tenant-Id'
266
277
  @pathname_slug_pattern = %r{^/api/v1/([^/]+)/}
@@ -287,7 +298,7 @@ module RackJwtAegis
287
298
  end
288
299
 
289
300
  def validate_jwt_settings!
290
- raise ConfigurationError, 'jwt_secret is required' if jwt_secret.nil? || jwt_secret.empty?
301
+ raise ConfigurationError, 'jwt_secret is required' if jwt_secret.to_s.strip.empty?
291
302
 
292
303
  valid_algorithms = ['HS256', 'HS384', 'HS512', 'RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512']
293
304
  return if valid_algorithms.include?(jwt_algorithm)
@@ -338,17 +349,23 @@ module RackJwtAegis
338
349
  end
339
350
 
340
351
  def validate_multi_tenant_settings!
341
- if validate_pathname_slug? && pathname_slug_pattern.nil?
342
- raise ConfigurationError, 'pathname_slug_pattern is required when validate_pathname_slug is true'
343
- end
344
-
345
352
  if validate_subdomain? && !payload_mapping.key?(:subdomain)
346
353
  raise ConfigurationError, 'payload_mapping must include :subdomain when validate_subdomain is true'
347
354
  end
348
355
 
349
- return unless validate_pathname_slug? && !payload_mapping.key?(:pathname_slugs)
356
+ if validate_tenant_id?
357
+ error_msg = []
358
+ error_msg << 'payload_mapping must include :tenant_id' unless payload_mapping.key?(:tenant_id)
359
+ error_msg << 'tenant_id_header_name is required' if tenant_id_header_name.to_s.strip.empty?
360
+ raise ConfigurationError, "#{error_msg.join(' and ')} when validate_tenant_id is true" if error_msg.any?
361
+ end
362
+
363
+ return unless validate_pathname_slug?
350
364
 
351
- raise ConfigurationError, 'payload_mapping must include :pathname_slugs when validate_pathname_slug is true'
365
+ error_msg = []
366
+ error_msg << 'payload_mapping must include :pathname_slugs' unless payload_mapping.key?(:pathname_slugs)
367
+ error_msg << 'pathname_slug_pattern is required' if pathname_slug_pattern.to_s.empty?
368
+ raise ConfigurationError, "#{error_msg.join(' and ')} when validate_pathname_slug is true" if error_msg.any?
352
369
  end
353
370
  end
354
371
  end
@@ -20,7 +20,7 @@ module RackJwtAegis
20
20
  timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S.%L')
21
21
 
22
22
  # Determine component name for log prefix
23
- component_name = component || infer_component_name
23
+ component_name = component || self.class.name.split('::').last || 'RackJwtAegis'
24
24
 
25
25
  formatted_message = "[#{timestamp}] #{component_name}: #{message}"
26
26
 
@@ -31,21 +31,5 @@ module RackJwtAegis
31
31
  puts formatted_message
32
32
  end
33
33
  end
34
-
35
- private
36
-
37
- # Infer component name from class name
38
- #
39
- # @return [String] the inferred component name
40
- def infer_component_name
41
- case self.class.name
42
- when /Middleware/
43
- 'RackJwtAegis'
44
- when /RbacManager/
45
- 'RbacManager'
46
- else
47
- self.class.name.split('::').last || 'RackJwtAegis'
48
- end
49
- end
50
34
  end
51
35
  end
@@ -19,6 +19,7 @@ module RackJwtAegis
19
19
  # @example With multi-tenant validation
20
20
  # config = Configuration.new(
21
21
  # jwt_secret: 'your-secret',
22
+ # validate_tenant_id: true,
22
23
  # validate_subdomain: true,
23
24
  # validate_pathname_slug: true
24
25
  # )
@@ -95,21 +96,14 @@ module RackJwtAegis
95
96
  # @param payload [Hash] the JWT payload to validate
96
97
  # @raise [AuthenticationError] if required claims are missing
97
98
  def validate_required_claims(payload)
98
- required_claims = []
99
-
100
99
  # Always require user identification
101
- required_claims << @config.payload_key(:user_id)
102
-
103
- # Multi-tenant validation requirements
104
- if @config.validate_subdomain?
105
- required_claims << @config.payload_key(:tenant_id)
106
- required_claims << @config.payload_key(:subdomain)
107
- end
108
-
100
+ required_claims = [@config.payload_key(:user_id)]
101
+ required_claims << @config.payload_key(:subdomain) if @config.validate_subdomain?
102
+ required_claims << @config.payload_key(:tenant_id) if @config.validate_tenant_id?
109
103
  required_claims << @config.payload_key(:pathname_slugs) if @config.validate_pathname_slug?
104
+ required_claims << @config.payload_key(:role_ids) if @config.rbac_enabled?
110
105
 
111
- missing_claims = required_claims.select { |claim| payload[claim.to_s].nil? }
112
-
106
+ missing_claims = required_claims.select { |claim| payload[claim.to_s].to_s.empty? }
113
107
  return if missing_claims.empty?
114
108
 
115
109
  raise AuthenticationError, "JWT payload missing required claims: #{missing_claims.join(', ')}"
@@ -120,25 +114,24 @@ module RackJwtAegis
120
114
  # @param payload [Hash] the JWT payload to validate
121
115
  # @raise [AuthenticationError] if claim types are invalid
122
116
  def validate_claim_types(payload)
123
- user_id_key = @config.payload_key(:user_id).to_s
124
-
117
+ user_id = payload[@config.payload_key(:user_id).to_s]
125
118
  # User ID should be numeric or string
126
- if payload[user_id_key] && !payload[user_id_key].is_a?(Numeric) && !payload[user_id_key].is_a?(String)
119
+ if user_id.to_s.empty? || (!user_id.is_a?(Numeric) && !user_id.is_a?(String))
127
120
  raise AuthenticationError, 'Invalid user_id format in JWT payload'
128
121
  end
129
122
 
130
- # Company group ID should be numeric or string (if present)
131
- if @config.validate_subdomain?
132
- tenant_id_key = @config.payload_key(:tenant_id).to_s
133
- if payload[tenant_id_key] && !payload[tenant_id_key].is_a?(Numeric) && !payload[tenant_id_key].is_a?(String)
123
+ # Tenant ID should be numeric or string (if present)
124
+ if @config.validate_tenant_id?
125
+ tenant_id = payload[@config.payload_key(:tenant_id).to_s]
126
+ if tenant_id.to_s.empty? || (!tenant_id.is_a?(Numeric) && !tenant_id.is_a?(String))
134
127
  raise AuthenticationError, 'Invalid tenant_id format in JWT payload'
135
128
  end
136
129
  end
137
130
 
138
131
  # Company group domain should be string (if present)
139
132
  if @config.validate_subdomain?
140
- company_domain_key = @config.payload_key(:subdomain).to_s
141
- if payload[company_domain_key] && !payload[company_domain_key].is_a?(String)
133
+ subdomain = payload[@config.payload_key(:subdomain).to_s]
134
+ if subdomain.to_s.empty? || !subdomain.is_a?(String)
142
135
  raise AuthenticationError, 'Invalid subdomain format in JWT payload'
143
136
  end
144
137
  end
@@ -146,8 +139,8 @@ module RackJwtAegis
146
139
  # Company slugs should be array (if present)
147
140
  return unless @config.validate_pathname_slug?
148
141
 
149
- pathname_slugs_key = @config.payload_key(:pathname_slugs).to_s
150
- return unless payload[pathname_slugs_key] && !payload[pathname_slugs_key].is_a?(Array)
142
+ pathname_slugs = payload[@config.payload_key(:pathname_slugs).to_s]
143
+ return unless pathname_slugs && !pathname_slugs.is_a?(Array)
151
144
 
152
145
  raise AuthenticationError, 'Invalid pathname_slugs format in JWT payload - must be array'
153
146
  end
@@ -19,6 +19,8 @@ module RackJwtAegis
19
19
  # @example Advanced usage
20
20
  # use RackJwtAegis::Middleware, {
21
21
  # jwt_secret: ENV['JWT_SECRET'],
22
+ # validate_tenant_id: true,
23
+ # validate_pathname_slug: true,
22
24
  # validate_subdomain: true,
23
25
  # rbac_enabled: true,
24
26
  # cache_store: :redis,
@@ -142,7 +144,7 @@ module RackJwtAegis
142
144
  #
143
145
  # @return [Boolean] true if subdomain or pathname slug validation is enabled
144
146
  def multi_tenant_enabled?
145
- @config.validate_subdomain? || @config.validate_pathname_slug?
147
+ @config.validate_tenant_id? || @config.validate_subdomain? || @config.validate_pathname_slug?
146
148
  end
147
149
 
148
150
  # Generate a string describing enabled features for logging
@@ -150,8 +152,9 @@ module RackJwtAegis
150
152
  # @return [String] comma-separated list of enabled features
151
153
  def enabled_features
152
154
  features = ['JWT']
155
+ features << 'TenantId' if @config.validate_tenant_id?
153
156
  features << 'Subdomain' if @config.validate_subdomain?
154
- features << 'CompanySlug' if @config.validate_pathname_slug?
157
+ features << 'PathnameSlug' if @config.validate_pathname_slug?
155
158
  features << 'RBAC' if @config.rbac_enabled?
156
159
  features.join(', ')
157
160
  end
@@ -33,83 +33,72 @@ module RackJwtAegis
33
33
  # @param payload [Hash] the JWT payload containing tenant information
34
34
  # @raise [AuthorizationError] if tenant validation fails
35
35
  def validate(request, payload)
36
- validate_subdomain(request, payload) if @config.validate_subdomain?
37
- validate_pathname_slug(request, payload) if @config.validate_pathname_slug?
38
- validate_company_header(request, payload) if @config.tenant_id_header_name
36
+ validate_subdomain(request, payload)
37
+ validate_pathname_slug(request, payload)
38
+ validate_tenant_id_header(request, payload)
39
39
  end
40
40
 
41
41
  private
42
42
 
43
43
  # Level 1 Multi-Tenant: Top-level tenant (Company-Group) validation via subdomain
44
44
  def validate_subdomain(request, payload)
45
+ return unless @config.validate_subdomain?
46
+
45
47
  request_host = request.host
46
- return if request_host.nil? || request_host.empty?
48
+ return if request_host.to_s.empty?
47
49
 
48
50
  # Extract subdomain from request host
49
- req_subdomain = extract_subdomain(request_host)
51
+ req_subdomain = extract_subdomain(request_host).to_s.downcase
50
52
 
51
53
  # Get JWT domain claim
52
- jwt_domain_key = @config.payload_key(:subdomain).to_s
53
- jwt_domain = payload[jwt_domain_key]
54
-
55
- if jwt_domain.nil? || jwt_domain.empty?
56
- raise AuthorizationError, 'JWT payload missing subdomain for subdomain validation'
57
- end
58
-
59
- # Extract subdomain from JWT domain
60
- jwt_subdomain = extract_subdomain(jwt_domain)
54
+ jwt_claim = payload[@config.payload_key(:subdomain).to_s].to_s.strip.downcase
55
+ raise AuthorizationError, 'JWT payload missing subdomain for subdomain validation' if jwt_claim.empty?
61
56
 
62
57
  # Compare subdomains
63
- return if subdomains_match?(req_subdomain, jwt_subdomain)
58
+ return if req_subdomain.eql?(jwt_claim)
64
59
 
65
60
  raise AuthorizationError,
66
61
  "Subdomain access denied: request subdomain '#{req_subdomain}' " \
67
- "does not match JWT subdomain '#{jwt_subdomain}'"
62
+ "does not match JWT subdomain '#{jwt_claim}'"
68
63
  end
69
64
 
70
65
  # Level 2 Multi-Tenant: Sub-level tenant (Company) validation via URL path
71
66
  def validate_pathname_slug(request, payload)
67
+ return unless @config.validate_pathname_slug?
68
+
72
69
  # Extract company slug from URL path
73
- company_slug = extract_company_slug_from_path(request.path)
70
+ pathname_slug = extract_slug_from_path(request.path)
74
71
 
75
- return if company_slug.nil? # No company slug in path
72
+ return if pathname_slug.nil? # No company slug in path
76
73
 
77
74
  # Get accessible company slugs from JWT
78
- jwt_slugs_key = @config.payload_key(:pathname_slugs).to_s
79
- accessible_slugs = payload[jwt_slugs_key]
75
+ accessible_slugs = payload[@config.payload_key(:pathname_slugs).to_s]
80
76
 
81
77
  if accessible_slugs.nil? || !accessible_slugs.is_a?(Array) || accessible_slugs.empty?
82
78
  raise AuthorizationError, 'JWT payload missing or invalid pathname_slugs for company access validation'
83
79
  end
84
80
 
85
81
  # Check if requested company slug is in user's accessible list
86
- return if accessible_slugs.include?(company_slug)
82
+ return if accessible_slugs.map(&:downcase).include?(pathname_slug)
87
83
 
88
84
  raise AuthorizationError,
89
- "Company access denied: '#{company_slug}' not in accessible companies #{accessible_slugs}"
85
+ "Company access denied: '#{pathname_slug}' not in accessible companies #{accessible_slugs}"
90
86
  end
91
87
 
92
88
  # Company Group header validation (additional security layer)
93
- def validate_company_header(request, payload)
94
- header_name = "HTTP_#{@config.tenant_id_header_name.upcase.tr('-', '_')}"
95
- header_value = request.get_header(header_name)
96
-
97
- return if header_value.nil? # Header not present, skip validation
98
-
99
- # Get company group ID from JWT
100
- jwt_company_group_key = @config.payload_key(:tenant_id).to_s
101
- jwt_tenant_id = payload[jwt_company_group_key]
89
+ def validate_tenant_id_header(request, payload)
90
+ return unless @config.validate_tenant_id?
102
91
 
103
- raise AuthorizationError, 'JWT payload missing tenant_id for header validation' if jwt_tenant_id.nil?
92
+ # Get tenant id from request header
93
+ header_value = request.get_header("HTTP_#{@config.tenant_id_header_name.upcase.tr('-', '_')}").to_s.downcase
94
+ # Get tenant id from JWT payload
95
+ jwt_claim = payload[@config.payload_key(:tenant_id).to_s].to_s.strip.downcase
96
+ raise AuthorizationError, 'JWT payload missing tenant_id for header validation' if jwt_claim.empty?
104
97
 
105
- # Normalize values for comparison (both as strings)
106
- header_value_str = header_value.to_s
107
- jwt_value_str = jwt_tenant_id.to_s
108
-
109
- return if header_value_str == jwt_value_str
98
+ return if !header_value.empty? && header_value.eql?(jwt_claim)
110
99
 
111
100
  raise AuthorizationError,
112
- "Company group header mismatch: header '#{header_value_str}' does not match JWT '#{jwt_value_str}'"
101
+ "Tenant id header mismatch: header '#{header_value}' does not match JWT '#{jwt_claim}'"
113
102
  end
114
103
 
115
104
  def extract_subdomain(host)
@@ -131,24 +120,9 @@ module RackJwtAegis
131
120
  parts.first
132
121
  end
133
122
 
134
- def extract_company_slug_from_path(path)
135
- return nil if path.nil? || path.empty?
136
-
123
+ def extract_slug_from_path(path)
137
124
  # Use configured pattern to extract company slug
138
- match = @config.pathname_slug_pattern.match(path)
139
- return nil unless match && match[1]
140
-
141
- # Return captured group (company slug)
142
- match[1]
143
- end
144
-
145
- def subdomains_match?(first_subdomain, second_subdomain)
146
- # Handle nil cases
147
- return true if first_subdomain.nil? && second_subdomain.nil?
148
- return false if first_subdomain.nil? || second_subdomain.nil?
149
-
150
- # Case-insensitive comparison
151
- first_subdomain.downcase == second_subdomain.downcase
125
+ @config.pathname_slug_pattern.match(path.to_s.strip.downcase)&.to_a&.last
152
126
  end
153
127
  end
154
128
  end
@@ -51,7 +51,7 @@ module RackJwtAegis
51
51
  has_permission = check_rbac_permission(user_id, request)
52
52
 
53
53
  # Cache the result if middleware has write access
54
- cache_permission_result(permission_key, has_permission) if @permission_cache && @config.cache_write_enabled?
54
+ cache_permission_result(permission_key, has_permission)
55
55
 
56
56
  return if has_permission
57
57
 
@@ -83,23 +83,21 @@ module RackJwtAegis
83
83
  end
84
84
 
85
85
  def build_permission_key(user_id, request)
86
- full_url = "#{request.host}#{request.path}"
87
- "#{user_id}:#{full_url}:#{request.request_method.downcase}"
86
+ "#{user_id}:#{request.host}#{request.path}:#{request.request_method.downcase}"
88
87
  end
89
88
 
90
89
  def check_cached_permission(permission_key)
91
90
  return nil unless @permission_cache
92
91
 
93
92
  begin
94
- # Get the user permissions cache using new format
93
+ # Get the cached user permissions
95
94
  user_permissions = @permission_cache.read('user_permissions')
96
95
  return nil if user_permissions.nil? || !user_permissions.is_a?(Hash)
97
96
 
98
97
  # First check: If RBAC permissions were updated recently, nuke ALL cached permissions
99
98
  rbac_last_update = rbac_last_update_timestamp
100
99
  if rbac_last_update
101
- current_time = Time.now.to_i
102
- rbac_update_age = current_time - rbac_last_update
100
+ rbac_update_age = Time.now.to_i - rbac_last_update
103
101
 
104
102
  # If RBAC was updated within the TTL period, all cached permissions are invalid
105
103
  if rbac_update_age <= @config.user_permissions_ttl
@@ -108,12 +106,11 @@ module RackJwtAegis
108
106
  end
109
107
  end
110
108
 
111
- # Check if permission exists in new format: {"user_id:full_url:method" => timestamp}
109
+ # Check if permission exists in this format: {"user_id:full_url:method" => timestamp}
112
110
  cached_timestamp = user_permissions[permission_key]
113
111
  return nil unless cached_timestamp.is_a?(Integer)
114
112
 
115
- current_time = Time.now.to_i
116
- permission_age = current_time - cached_timestamp
113
+ permission_age = Time.now.to_i - cached_timestamp
117
114
 
118
115
  # Second check: TTL expiration
119
116
  if permission_age > @config.user_permissions_ttl
@@ -181,22 +178,23 @@ module RackJwtAegis
181
178
  return false
182
179
  end
183
180
 
184
- # Check permissions for each user role
185
- rbac_data['permissions'].each do |role_permissions|
186
- user_roles.each do |role_id|
187
- next unless role_permissions.key?(role_id.to_s) || role_permissions.key?(role_id.to_i)
181
+ # Get permissions object for direct lookup
182
+ permissions_data = rbac_data['permissions'] || rbac_data[:permissions]
188
183
 
189
- permissions = role_permissions[role_id.to_s] || role_permissions[role_id.to_i]
190
- matched_permission = find_matching_permission(permissions, request)
184
+ # Check permissions for each user role using direct lookup
185
+ user_roles.each do |role_id|
186
+ # Try both string and integer keys for role lookup
187
+ role_permissions = permissions_data[role_id.to_s] || permissions_data[role_id.to_i]
188
+ next unless role_permissions
191
189
 
192
- next unless matched_permission
190
+ matched_permission = find_matching_permission(role_permissions, request)
191
+ next unless matched_permission
193
192
 
194
- # Cache this specific permission match for faster future lookups
195
- if @permission_cache && @config.cache_write_enabled?
196
- cache_permission_match(user_id, request, role_id, matched_permission)
197
- end
198
- return true
193
+ # Cache this specific permission match for faster future lookups
194
+ if @permission_cache && @config.cache_write_enabled?
195
+ cache_permission_match(user_id, request, role_id, matched_permission)
199
196
  end
197
+ return true
200
198
  end
201
199
 
202
200
  false
@@ -208,7 +206,7 @@ module RackJwtAegis
208
206
 
209
207
  begin
210
208
  rbac_data = @rbac_cache.read('permissions')
211
- if rbac_data.is_a?(Hash) && (rbac_data['last_update'] || rbac_data[:last_update])
209
+ if rbac_data.is_a?(Hash) && (rbac_data.key?('last_update') || rbac_data.key?(:last_update))
212
210
  return rbac_data['last_update'] || rbac_data[:last_update]
213
211
  end
214
212
 
@@ -393,9 +391,9 @@ module RackJwtAegis
393
391
  # Expected format:
394
392
  # {
395
393
  # last_update: timestamp,
396
- # permissions: [
397
- # {role-id: ["{resource-endpoint}:{http-method}"]}
398
- # ]
394
+ # permissions: {
395
+ # "role-id": ["{resource-endpoint}:{http-method}"]
396
+ # }
399
397
  # }
400
398
  def validate_rbac_cache_format(rbac_data)
401
399
  return false unless rbac_data.is_a?(Hash)
@@ -404,31 +402,35 @@ module RackJwtAegis
404
402
  return false unless rbac_data.key?('last_update') || rbac_data.key?(:last_update)
405
403
  return false unless rbac_data.key?('permissions') || rbac_data.key?(:permissions)
406
404
 
407
- # Get permissions array
405
+ # Get permissions object (now expecting a Hash, not Array)
408
406
  permissions = rbac_data['permissions'] || rbac_data[:permissions]
409
- return false unless permissions.is_a?(Array)
410
407
 
411
- # Validate each permission entry
412
- permissions.each do |permission_entry|
413
- return false unless permission_entry.is_a?(Hash)
408
+ # If permissions is present but not a Hash, raise an exception to help developers
409
+ if !permissions.nil? && !permissions.is_a?(Hash)
410
+ raise ConfigurationError, "RBAC permissions must be a Hash with role-id keys, not #{permissions.class}. " \
411
+ "Expected format: {\"role-id\": [\"resource:method\", ...]}, " \
412
+ "but got: #{permissions.class}"
413
+ end
414
414
 
415
- # Each entry should have at least one role-id key
416
- return false if permission_entry.empty?
415
+ # Return false if permissions is nil (should not happen given the key check above, but defensive)
416
+ return false if permissions.nil?
417
417
 
418
- # Validate permission values are arrays of strings
419
- permission_entry.each_value do |role_permissions|
420
- return false unless role_permissions.is_a?(Array)
418
+ # Validate each role's permissions
419
+ permissions.each_value do |role_permissions|
420
+ return false unless role_permissions.is_a?(Array)
421
421
 
422
- # Each permission should be a string in format "endpoint:method"
423
- role_permissions.each do |permission|
424
- return false unless permission.is_a?(String)
425
- # Permission must include ':' (resource:method format)
426
- return false unless permission.include?(':')
427
- end
422
+ # Each permission should be a string in format "endpoint:method"
423
+ role_permissions.each do |permission|
424
+ return false unless permission.is_a?(String)
425
+ # Permission must include ':' (resource:method format) or '*' (wildcard)
426
+ return false unless permission.include?(':') || permission.include?('*')
428
427
  end
429
428
  end
430
429
 
431
430
  true
431
+ rescue ConfigurationError
432
+ # Re-raise configuration errors so developers see them
433
+ raise
432
434
  rescue StandardError => e
433
435
  debug_log("RbacManager: Cache format validation error: #{e.message}", :warn)
434
436
  false
@@ -100,42 +100,23 @@ module RackJwtAegis
100
100
  tenant_id(request.env)
101
101
  end
102
102
 
103
- def self.has_company_access?(env, company_slug)
104
- pathname_slugs(env).include?(company_slug)
103
+ def self.has_pathname_slug_access?(env, pathname_slug)
104
+ pathname_slugs(env).include?(pathname_slug)
105
105
  end
106
106
 
107
107
  private
108
108
 
109
109
  def set_user_context(env, payload)
110
- user_id_key = @config.payload_key(:user_id).to_s
111
- user_id = payload[user_id_key]
112
-
113
- env[USER_ID_KEY] = user_id
110
+ env[USER_ID_KEY] = payload[@config.payload_key(:user_id).to_s]
114
111
  end
115
112
 
116
113
  def set_tenant_context(env, payload)
117
- # Set company group information
118
- if @config.validate_subdomain? || @config.payload_mapping.key?(:tenant_id)
119
- tenant_id_key = @config.payload_key(:tenant_id).to_s
120
- tenant_id = payload[tenant_id_key]
121
- env[TENANT_ID_KEY] = tenant_id
122
- end
123
-
124
- if @config.validate_subdomain?
125
- company_domain_key = @config.payload_key(:subdomain).to_s
126
- company_domain = payload[company_domain_key]
127
- env[SUBDOMAIN_KEY] = company_domain
128
- end
129
-
130
- # Set company slugs for sub-level tenant access
114
+ # Set multi-tenant information
115
+ env[TENANT_ID_KEY] = payload[@config.payload_key(:tenant_id).to_s] if @config.validate_tenant_id?
116
+ env[SUBDOMAIN_KEY] = payload[@config.payload_key(:subdomain).to_s] if @config.validate_subdomain?
131
117
  return unless @config.validate_pathname_slug? || @config.payload_mapping.key?(:pathname_slugs)
132
118
 
133
- pathname_slugs_key = @config.payload_key(:pathname_slugs).to_s
134
- pathname_slugs = payload[pathname_slugs_key]
135
-
136
- # Ensure it's an array
137
- pathname_slugs = Array(pathname_slugs) if pathname_slugs
138
- env[PATHNAME_SLUGS_KEY] = pathname_slugs || []
119
+ env[PATHNAME_SLUGS_KEY] = Array(payload[@config.payload_key(:pathname_slugs).to_s]).flatten
139
120
  end
140
121
  end
141
122
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RackJwtAegis
4
- VERSION = '1.0.1'
4
+ VERSION = '1.1.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rack_jwt_aegis
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ken C. Demanawa
@@ -44,7 +44,7 @@ dependencies:
44
44
  - !ruby/object:Gem::Version
45
45
  version: '3.2'
46
46
  description: |-
47
- JWT authentication midleware with multi-tenant suport,\
47
+ JWT authentication and authorization midleware with multi-tenant suport,\
48
48
  company validation, and subdomain isolation.
49
49
  email:
50
50
  - kenneth.c.demanawa@gmail.com
@@ -54,7 +54,6 @@ extensions: []
54
54
  extra_rdoc_files: []
55
55
  files:
56
56
  - ".rubocop.yml"
57
- - ".yard/yard_gfm_config.rb"
58
57
  - ".yardopts"
59
58
  - CHANGELOG.md
60
59
  - CODE_OF_CONDUCT.md
@@ -101,5 +100,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
101
100
  requirements: []
102
101
  rubygems_version: 3.6.9
103
102
  specification_version: 4
104
- summary: JWT authentication middleware for multi-tenant Rack applications
103
+ summary: JWT authentication and authorization middleware for multi-tenant Rack applications
105
104
  test_files: []
@@ -1,21 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Custom YARD configuration for GitHub Flavored Markdown support
4
- #
5
- # This configuration ensures the kramdown-parser-gfm gem is available for
6
- # proper rendering of fenced code blocks (```language) in YARD documentation.
7
- #
8
- # The actual GFM parsing integration is handled by YARD when kramdown is
9
- # configured with the GFM input parser.
10
-
11
- begin
12
- require 'kramdown'
13
- require 'kramdown-parser-gfm'
14
-
15
- # Ensure GFM parser is available - YARD will use it automatically
16
- # when kramdown processes markdown with input: 'GFM'
17
-
18
- rescue LoadError => e
19
- # Fallback gracefully if GFM parser is not available
20
- puts "Warning: kramdown-parser-gfm not available, fenced code blocks may not render properly: #{e.message}"
21
- end