rack_jwt_aegis 1.0.1 → 1.0.2

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: 85f1445f011d0e42c075e3777164d61b215aa04b6f78e535db987c3f0e239c6f
4
+ data.tar.gz: 94fa6e5dd9041709d394e1c42dc5cc938aeb4fb9325218a94cab6352a4a7cf47
5
5
  SHA512:
6
- metadata.gz: 7d279d25565397a64e45be0b157a01e63ca7c8e688fd1bb3592649cef8a2037380f5c9c1ddad645ad46f2326d5cd8cc279661207371825d26fe0c6cc036dd272
7
- data.tar.gz: fe7e42dc37a5dca3a4fe443fc14e0a64178994b20406471ab6139be4e0b8917a663bcc45d29a819749875a9ef4cecd7d85cd8e13ef9cf5b6054ace0be090d540
6
+ metadata.gz: 0e3e83285e30c8b86efb2977aba6cfeccf728539b689fb80fb658e29ac91e61c455ab48c5793b689293154d3fe985ad6fee71d3b2381261f34b481370c89e8a1
7
+ data.tar.gz: f42e6cdb32d72f06ef14d7c016cbb7970ad5b04f9626b16cc1bf7a4062cdb6b68a899548e80259bac934622b2adc3080121c2b7e82daa84ab93f2df30ad34e63
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,16 @@ 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.0.2] - 2025-08-13
9
+
10
+ ### 🔧 Fixed
11
+
12
+ - Add :validate_tenant_id configuration and incorporate this to the MultiTenantValidator#validate_tenant_id_header
13
+
14
+ #### Code Quality & Maintenance
15
+
16
+ - Refactor the Configuration and MultiTenantValidator
17
+
8
18
  ## [1.0.1] - 2025-08-13
9
19
 
10
20
  ### 🔧 Fixed
@@ -23,7 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
23
33
  #### Developer Experience
24
34
 
25
35
  - **Consistent Logging**: Unified debug log format across all components with automatic timestamp formatting
26
- - **Component Identification**: Automatic component name inference for better log traceability
36
+ - **Component Identification**: Automatic component name inference for better log traceability
27
37
  - **Configurable Log Levels**: Support for info, warn, and error log levels with appropriate output streams
28
38
 
29
39
  ### 🏗️ Technical Details
@@ -37,7 +47,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
37
47
  #### Testing & Quality
38
48
 
39
49
  - **Test Suite**: All 340 tests pass with 975 assertions
40
- - **Coverage Maintained**: 98.17% line coverage, 92.83% branch coverage
50
+ - **Coverage Maintained**: 98.17% line coverage, 92.83% branch coverage
41
51
  - **RBAC Integration**: Verified all role-based authorization tests pass after refactoring
42
52
  - **Zero Regression**: No functional changes, only structural improvements
43
53
 
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
 
@@ -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
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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RackJwtAegis
4
- VERSION = '1.0.1'
4
+ VERSION = '1.0.2'
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.0.2
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