rhales 0.3.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.
@@ -0,0 +1,106 @@
1
+ # lib/rhales/adapters/base_auth.rb
2
+
3
+ module Rhales
4
+ module Adapters
5
+ # Base authentication adapter interface
6
+ #
7
+ # Defines the contract that authentication adapters must implement
8
+ # to work with Rhales. This allows the library to work with any
9
+ # authentication system by implementing this interface.
10
+ class BaseAuth
11
+ # Check if user is anonymous
12
+ def anonymous?
13
+ raise NotImplementedError, 'Subclasses must implement #anonymous?'
14
+ end
15
+
16
+ # Get user's theme preference
17
+ def theme_preference
18
+ raise NotImplementedError, 'Subclasses must implement #theme_preference'
19
+ end
20
+
21
+ # Get user identifier (optional)
22
+ def user_id
23
+ nil
24
+ end
25
+
26
+ # Get user display name (optional)
27
+ def display_name
28
+ nil
29
+ end
30
+
31
+ # Check if user has specific role/permission (optional)
32
+ def role?(*)
33
+ raise NotImplementedError, 'Subclasses must implement #role?'
34
+ end
35
+
36
+ # Get user attributes as hash (optional)
37
+ def attributes
38
+ {}
39
+ end
40
+
41
+ class << self
42
+ # Create anonymous user instance
43
+ def anonymous
44
+ new
45
+ end
46
+ end
47
+ end
48
+
49
+ # Default implementation for anonymous users
50
+ class AnonymousAuth < BaseAuth
51
+ def anonymous?
52
+ true
53
+ end
54
+
55
+ def theme_preference
56
+ 'light'
57
+ end
58
+
59
+ def user_id
60
+ nil
61
+ end
62
+
63
+ def role?(*)
64
+ false
65
+ end
66
+
67
+ def display_name
68
+ 'Anonymous'
69
+ end
70
+ end
71
+
72
+ # Example authenticated user implementation
73
+ class AuthenticatedAuth < BaseAuth
74
+ attr_reader :user_data
75
+
76
+ def initialize(user_data = {})
77
+ @user_data = user_data
78
+ end
79
+
80
+ def anonymous?
81
+ false
82
+ end
83
+
84
+ def theme_preference
85
+ @user_data[:theme] || @user_data['theme'] || 'light'
86
+ end
87
+
88
+ def user_id
89
+ @user_data[:id] || @user_data['id']
90
+ end
91
+
92
+ def display_name
93
+ @user_data[:name] || @user_data['name'] || 'User'
94
+ end
95
+
96
+ def role?(role)
97
+ roles = @user_data[:roles] || @user_data['roles'] || []
98
+ roles.include?(role) || roles.include?(role.to_s)
99
+ end
100
+
101
+ def attributes
102
+ @user_data
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,97 @@
1
+ # lib/rhales/adapters/base_request.rb
2
+
3
+ module Rhales
4
+ module Adapters
5
+ # Base request adapter interface
6
+ #
7
+ # Defines the contract that request adapters must implement
8
+ # to work with Rhales. This allows the library to work with any
9
+ # web framework by implementing this interface.
10
+ class BaseRequest
11
+ # Get request path
12
+ def path
13
+ raise NotImplementedError, 'Subclasses must implement #path'
14
+ end
15
+
16
+ # Get request method
17
+ def method
18
+ raise NotImplementedError, 'Subclasses must implement #method'
19
+ end
20
+
21
+ # Get client IP
22
+ def ip
23
+ raise NotImplementedError, 'Subclasses must implement #ip'
24
+ end
25
+
26
+ # Get request parameters
27
+ def params
28
+ raise NotImplementedError, 'Subclasses must implement #params'
29
+ end
30
+
31
+ # Get request environment
32
+ def env
33
+ raise NotImplementedError, 'Subclasses must implement #env'
34
+ end
35
+ end
36
+
37
+ # Simple request adapter for framework integration
38
+ class SimpleRequest < BaseRequest
39
+ attr_reader :request_path, :request_method, :client_ip, :request_params, :request_env
40
+
41
+ def initialize(path: '/', method: 'GET', ip: '127.0.0.1', params: {}, env: {})
42
+ @request_path = path
43
+ @request_method = method
44
+ @client_ip = ip
45
+ @request_params = params
46
+ @request_env = env
47
+ end
48
+
49
+ def path
50
+ @request_path
51
+ end
52
+
53
+ def method
54
+ @request_method
55
+ end
56
+
57
+ def ip
58
+ @client_ip
59
+ end
60
+
61
+ def params
62
+ @request_params
63
+ end
64
+
65
+ def env
66
+ @request_env
67
+ end
68
+ end
69
+
70
+ # Framework request adapter wrapper
71
+ class FrameworkRequest < BaseRequest
72
+ def initialize(framework_request)
73
+ @framework_request = framework_request
74
+ end
75
+
76
+ def path
77
+ @framework_request.path
78
+ end
79
+
80
+ def method
81
+ @framework_request.request_method
82
+ end
83
+
84
+ def ip
85
+ @framework_request.ip
86
+ end
87
+
88
+ def params
89
+ @framework_request.params
90
+ end
91
+
92
+ def env
93
+ @framework_request.env
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,93 @@
1
+ # lib/rhales/adapters/base_session.rb
2
+
3
+ module Rhales
4
+ module Adapters
5
+ # Base session adapter interface
6
+ #
7
+ # Defines the contract that session adapters must implement
8
+ # to work with Rhales. This allows the library to work with any
9
+ # session management system.
10
+ class BaseSession
11
+ # Check if session is authenticated
12
+ def authenticated?
13
+ raise NotImplementedError, 'Subclasses must implement #authenticated?'
14
+ end
15
+
16
+ # Get session identifier
17
+ def session_id
18
+ nil
19
+ end
20
+
21
+ # Get session data
22
+ def data
23
+ {}
24
+ end
25
+
26
+ # Check if session is valid/active
27
+ def valid?
28
+ true
29
+ end
30
+
31
+ # Get session creation time
32
+ def created_at
33
+ nil
34
+ end
35
+
36
+ # Get last access time
37
+ def last_accessed_at
38
+ nil
39
+ end
40
+ end
41
+
42
+ # Default implementation for anonymous sessions
43
+ class AnonymousSession < BaseSession
44
+ def authenticated?
45
+ false
46
+ end
47
+
48
+ def session_id
49
+ 'anonymous'
50
+ end
51
+
52
+ def valid?
53
+ true
54
+ end
55
+ end
56
+
57
+ # Example authenticated session implementation
58
+ class AuthenticatedSession < BaseSession
59
+ attr_reader :session_data
60
+
61
+ def initialize(session_data = {})
62
+ @session_data = session_data
63
+ end
64
+
65
+ def authenticated?
66
+ !@session_data.empty? && valid?
67
+ end
68
+
69
+ def session_id
70
+ @session_data[:id] || @session_data['id']
71
+ end
72
+
73
+ def data
74
+ @session_data
75
+ end
76
+
77
+ def valid?
78
+ return false unless @session_data[:created_at] || @session_data['created_at']
79
+
80
+ # Add session validation logic here (expiry, etc.)
81
+ true
82
+ end
83
+
84
+ def created_at
85
+ @session_data[:created_at] || @session_data['created_at']
86
+ end
87
+
88
+ def last_accessed_at
89
+ @session_data[:last_accessed_at] || @session_data['last_accessed_at']
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,156 @@
1
+ # lib/rhales/configuration.rb
2
+
3
+ module Rhales
4
+ # Configuration management for Rhales library
5
+ #
6
+ # Provides a clean, testable alternative to global configuration access.
7
+ # Supports block-based configuration typical of Ruby gems and dependency injection.
8
+ #
9
+ # Usage:
10
+ # Rhales.configure do |config|
11
+ # config.default_locale = 'en'
12
+ # config.template_paths = ['app/templates', 'lib/templates']
13
+ # config.features = { account_creation: true }
14
+ # end
15
+ class Configuration
16
+ # Core application settings
17
+ attr_accessor :default_locale, :app_environment, :development_enabled
18
+
19
+ # Template settings
20
+ attr_accessor :template_paths, :template_root, :cache_templates
21
+
22
+ # Security settings
23
+ attr_accessor :csrf_token_name, :nonce_header_name, :csp_enabled, :csp_policy, :auto_nonce
24
+
25
+ # Feature flags
26
+ attr_accessor :features
27
+
28
+ # Site configuration
29
+ attr_accessor :site_host, :site_ssl_enabled, :api_base_url
30
+
31
+ # Performance settings
32
+ attr_accessor :cache_parsed_templates, :cache_ttl
33
+
34
+ def initialize
35
+ # Set sensible defaults
36
+ @default_locale = 'en'
37
+ @app_environment = 'development'
38
+ @development_enabled = false
39
+ @template_paths = []
40
+ @cache_templates = true
41
+ @csrf_token_name = 'csrf_token'
42
+ @nonce_header_name = 'nonce'
43
+ @csp_enabled = true
44
+ @auto_nonce = true
45
+ @csp_policy = default_csp_policy
46
+ @features = {}
47
+ @site_ssl_enabled = false
48
+ @cache_parsed_templates = true
49
+ @cache_ttl = 3600 # 1 hour
50
+ end
51
+
52
+ # Build API base URL from site configuration
53
+ def api_base_url
54
+ return @api_base_url if @api_base_url
55
+
56
+ return nil unless @site_host
57
+
58
+ protocol = @site_ssl_enabled ? 'https' : 'http'
59
+ "#{protocol}://#{@site_host}/api"
60
+ end
61
+
62
+ # Check if development mode is enabled
63
+ def development?
64
+ @development_enabled || @app_environment == 'development'
65
+ end
66
+
67
+ # Check if production mode
68
+ def production?
69
+ @app_environment == 'production'
70
+ end
71
+
72
+ # Default CSP policy with secure defaults
73
+ def default_csp_policy
74
+ {
75
+ 'default-src' => ["'self'"],
76
+ 'script-src' => ["'self'", "'nonce-{{nonce}}'"],
77
+ 'style-src' => ["'self'", "'nonce-{{nonce}}'", "'unsafe-hashes'"],
78
+ 'img-src' => ["'self'", 'data:'],
79
+ 'font-src' => ["'self'"],
80
+ 'connect-src' => ["'self'"],
81
+ 'base-uri' => ["'self'"],
82
+ 'form-action' => ["'self'"],
83
+ 'frame-ancestors' => ["'none'"],
84
+ 'object-src' => ["'none'"],
85
+ 'media-src' => ["'self'"],
86
+ 'worker-src' => ["'self'"],
87
+ 'manifest-src' => ["'self'"],
88
+ 'prefetch-src' => ["'self'"],
89
+ 'upgrade-insecure-requests' => []
90
+ }.freeze
91
+ end
92
+
93
+ # Get feature flag value
94
+ def feature_enabled?(feature_name)
95
+ @features[feature_name] || @features[feature_name.to_s] || false
96
+ end
97
+
98
+ # Validate configuration
99
+ def validate!
100
+ errors = []
101
+
102
+ # Validate locale
103
+ if @default_locale.nil? || @default_locale.empty?
104
+ errors << 'default_locale cannot be empty'
105
+ end
106
+
107
+ # Validate template paths exist if specified
108
+ @template_paths.each do |path|
109
+ unless Dir.exist?(path)
110
+ errors << "Template path does not exist: #{path}"
111
+ end
112
+ end
113
+
114
+ # Validate cache TTL
115
+ if @cache_ttl && @cache_ttl <= 0
116
+ errors << 'cache_ttl must be positive'
117
+ end
118
+
119
+ raise ConfigurationError, "Configuration errors: #{errors.join(', ')}" unless errors.empty?
120
+ end
121
+
122
+ # Deep freeze configuration to prevent modification after setup
123
+ def freeze!
124
+ @features.freeze
125
+ @template_paths.freeze
126
+ freeze
127
+ end
128
+
129
+ class ConfigurationError < StandardError; end
130
+ end
131
+
132
+ class << self
133
+ # Global configuration instance
134
+ def configuration
135
+ @configuration ||= Configuration.new
136
+ end
137
+
138
+ # Configure Rhales with block
139
+ def configure
140
+ yield(configuration) if block_given?
141
+ configuration.validate!
142
+ configuration.freeze!
143
+ configuration
144
+ end
145
+
146
+ # Reset configuration (useful for testing)
147
+ def reset_configuration!
148
+ @configuration = nil
149
+ end
150
+
151
+ # Shorthand access to configuration
152
+ def config
153
+ configuration
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,240 @@
1
+ # lib/rhales/context.rb
2
+
3
+ require_relative 'configuration'
4
+ require_relative 'adapters/base_auth'
5
+ require_relative 'adapters/base_session'
6
+ require_relative 'adapters/base_request'
7
+ require_relative 'csp'
8
+
9
+ module Rhales
10
+ # RSFCContext provides a clean interface for RSFC templates to access
11
+ # server-side data. Follows the established pattern from InitScriptContext
12
+ # and EnvironmentContext for focused, single-responsibility context objects.
13
+ #
14
+ # The context provides two layers of data:
15
+ # 1. App: Framework-provided data (CSRF tokens, authentication, config)
16
+ # 2. Props: Application data passed to the view (user, content, features)
17
+ #
18
+ # App data is accessible as both direct variables and through the app.* namespace.
19
+ # Props take precedence over app data for variable resolution.
20
+ #
21
+ # One RSFCContext instance is created per page render and shared across
22
+ # the main template and all partials to maintain security boundaries.
23
+ class Context
24
+ attr_reader :req, :sess, :cust, :locale, :props, :config, :app_data
25
+
26
+ def initialize(req, sess = nil, cust = nil, locale_override = nil, props: {}, config: nil)
27
+ @req = req
28
+ @sess = sess || default_session
29
+ @cust = cust || default_customer
30
+ @config = config || Rhales.configuration
31
+ @locale = locale_override || @config.default_locale
32
+
33
+ # Normalize props keys to strings for consistent access
34
+ @props = normalize_keys(props).freeze
35
+
36
+ # Build context layers (two-layer model: app + props)
37
+ @app_data = build_app_data.freeze
38
+
39
+ # Pre-compute all_data before freezing
40
+ # Props take precedence over app data, and add app namespace
41
+ @all_data = @app_data.merge(@props).merge({ 'app' => @app_data }).freeze
42
+
43
+ # Make context immutable after creation
44
+ freeze
45
+ end
46
+
47
+ # Get variable value with dot notation support (e.g., "user.id", "features.account_creation")
48
+ def get(variable_path)
49
+ path_parts = variable_path.split('.')
50
+ current_value = all_data
51
+
52
+ path_parts.each do |part|
53
+ case current_value
54
+ when Hash
55
+ if current_value.key?(part)
56
+ current_value = current_value[part]
57
+ elsif current_value.key?(part.to_sym)
58
+ current_value = current_value[part.to_sym]
59
+ else
60
+ return nil
61
+ end
62
+ when Object
63
+ if current_value.respond_to?(part)
64
+ current_value = current_value.public_send(part)
65
+ elsif current_value.respond_to?("#{part}?")
66
+ current_value = current_value.public_send("#{part}?")
67
+ else
68
+ return nil
69
+ end
70
+ else
71
+ return nil
72
+ end
73
+
74
+ return nil if current_value.nil?
75
+ end
76
+
77
+ current_value
78
+ end
79
+
80
+ # Get all available data (runtime + business + computed)
81
+ attr_reader :all_data
82
+
83
+ # Check if variable exists
84
+ def variable?(variable_path)
85
+ !get(variable_path).nil?
86
+ end
87
+
88
+ # Get list of all available variable paths (for validation)
89
+ def available_variables
90
+ collect_variable_paths(all_data)
91
+ end
92
+
93
+ # Resolve variable (alias for get method for hydrator compatibility)
94
+ def resolve_variable(variable_path)
95
+ get(variable_path)
96
+ end
97
+
98
+ private
99
+
100
+
101
+ # Build consolidated app data (replaces runtime_data + computed_data)
102
+ def build_app_data
103
+ app = {}
104
+
105
+ # Request context (from current runtime_data)
106
+ if req && req.respond_to?(:env) && req.env
107
+ app['csrf_token'] = req.env.fetch(@config.csrf_token_name, nil)
108
+ app['nonce'] = get_or_generate_nonce
109
+ app['request_id'] = req.env.fetch('request_id', nil)
110
+ app['domain_strategy'] = req.env.fetch('domain_strategy', :default)
111
+ app['display_domain'] = req.env.fetch('display_domain', nil)
112
+ else
113
+ # Generate nonce even without request if CSP is enabled
114
+ app['nonce'] = get_or_generate_nonce
115
+ end
116
+
117
+ # Configuration (from both layers)
118
+ app['environment'] = @config.app_environment
119
+ app['api_base_url'] = @config.api_base_url
120
+ app['features'] = @config.features
121
+ app['development'] = @config.development?
122
+
123
+ # Authentication & UI (from current computed_data)
124
+ app['authenticated'] = authenticated?
125
+ app['theme_class'] = determine_theme_class
126
+
127
+ app
128
+ end
129
+
130
+ # Build API base URL from configuration (deprecated - moved to config)
131
+ def build_api_base_url
132
+ @config.api_base_url
133
+ end
134
+
135
+ # Determine theme class for CSS
136
+ def determine_theme_class
137
+ # Default theme logic - can be overridden by business data
138
+ if props['theme']
139
+ "theme-#{props['theme']}"
140
+ elsif cust && cust.respond_to?(:theme_preference)
141
+ "theme-#{cust.theme_preference}"
142
+ else
143
+ 'theme-light'
144
+ end
145
+ end
146
+
147
+ # Check if user is authenticated
148
+ def authenticated?
149
+ sess && sess.authenticated? && cust && !cust.anonymous?
150
+ end
151
+
152
+ # Get default session instance
153
+ def default_session
154
+ Rhales::Adapters::AnonymousSession.new
155
+ end
156
+
157
+ # Get default customer instance
158
+ def default_customer
159
+ Rhales::Adapters::AnonymousAuth.new
160
+ end
161
+
162
+ # Normalize hash keys to strings recursively
163
+ def normalize_keys(data)
164
+ case data
165
+ when Hash
166
+ data.each_with_object({}) do |(key, value), result|
167
+ result[key.to_s] = normalize_keys(value)
168
+ end
169
+ when Array
170
+ data.map { |item| normalize_keys(item) }
171
+ else
172
+ data
173
+ end
174
+ end
175
+
176
+ # Recursively collect all variable paths from nested data
177
+ def collect_variable_paths(data, prefix = '')
178
+ paths = []
179
+
180
+ case data
181
+ when Hash
182
+ data.each do |key, value|
183
+ current_path = prefix.empty? ? key.to_s : "#{prefix}.#{key}"
184
+ paths << current_path
185
+
186
+ if value.is_a?(Hash) || value.is_a?(Object)
187
+ paths.concat(collect_variable_paths(value, current_path))
188
+ end
189
+ end
190
+ when Object
191
+ # For objects, collect method names that look like attributes
192
+ data.public_methods(false).each do |method|
193
+ method_name = method.to_s
194
+ next if method_name.end_with?('=') # Skip setters
195
+ next if method_name.start_with?('_') # Skip private-ish methods
196
+
197
+ current_path = prefix.empty? ? method_name : "#{prefix}.#{method_name}"
198
+ paths << current_path
199
+ end
200
+ end
201
+
202
+ paths
203
+ end
204
+
205
+ # Get or generate nonce for CSP
206
+ def get_or_generate_nonce
207
+ # Try to get existing nonce from request env
208
+ if req && req.respond_to?(:env) && req.env
209
+ existing_nonce = req.env.fetch(@config.nonce_header_name, nil)
210
+ return existing_nonce if existing_nonce
211
+ end
212
+
213
+ # Generate new nonce if auto_nonce is enabled or CSP is enabled
214
+ return CSP.generate_nonce if @config.auto_nonce || (@config.csp_enabled && csp_nonce_required?)
215
+
216
+ # Return nil if nonce is not needed
217
+ nil
218
+ end
219
+
220
+ # Check if CSP policy requires nonce
221
+ def csp_nonce_required?
222
+ return false unless @config.csp_enabled
223
+
224
+ csp = CSP.new(@config)
225
+ csp.nonce_required?
226
+ end
227
+
228
+ class << self
229
+ # Create context with business data for a specific view
230
+ def for_view(req, sess, cust, locale, config: nil, **props)
231
+ new(req, sess, cust, locale, props: props, config: config)
232
+ end
233
+
234
+ # Create minimal context for testing
235
+ def minimal(props: {}, config: nil)
236
+ new(nil, nil, nil, 'en', props: props, config: config)
237
+ end
238
+ end
239
+ end
240
+ end