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,65 @@
1
+ <!-- demo/rhales-roda-demo/templates/login.rue -->
2
+
3
+ <schema lang="js-zod" version="2" envelope="SuccessEnvelope" layout="layouts/main">
4
+ const schema = z.object({
5
+ title: z.string(),
6
+ zodauth: z.any()
7
+ });
8
+ </schema>
9
+
10
+ <template>
11
+ <div class="card" style="max-width: 400px; margin: 0 auto;">
12
+ <h2>{{title}}</h2>
13
+
14
+ <form method="post" action="/login">
15
+ {{{rodauth.csrf_tag}}}
16
+
17
+ <div class="form-group">
18
+ <label for="login">Email</label>
19
+ <input type="email" name="login" id="login" required autofocus>
20
+ </div>
21
+
22
+ <div class="form-group">
23
+ <label for="password">Password</label>
24
+ <input type="password" name="password" id="password" required>
25
+ </div>
26
+
27
+ <button type="submit" class="btn">Login</button>
28
+ <a href="/register" style="margin-left: 1rem;">Need an account?</a>
29
+ </form>
30
+
31
+ <div style="margin-top: 2rem; padding-top: 2rem; border-top: 1px solid #eee;">
32
+ <h3 style="font-size: 1rem; margin-bottom: 1rem;">Demo Accounts</h3>
33
+ {{#each demo_accounts}}
34
+ <div style="margin-bottom: 0.5rem; font-size: 0.9rem;">
35
+ <code>{{email}} / {{password}}</code> <em>({{role}})</em>
36
+ </div>
37
+ {{/each}}
38
+ </div>
39
+ </div>
40
+ </template>
41
+
42
+ <logic>
43
+ # LOGIN VIEW TEMPLATE
44
+ #
45
+ # AVAILABLE VARIABLES:
46
+ # Global (all Rodauth views):
47
+ # - rodauth.* : Full Rodauth object (csrf_tag, logged_in?, login_error_flash, etc.)
48
+ # - flash_notice : Success message from flash[:notice]
49
+ # - flash_error : Error message from flash[:error]
50
+ # - current_path : Current URL path
51
+ # - request_method : HTTP method
52
+ # - demo_accounts : Demo credentials array
53
+ #
54
+ # Login-specific:
55
+ # - rodauth.login : Previously submitted login value (on error)
56
+ # - rodauth.login_error_flash : Login-specific error message
57
+ # - rodauth.login_param : Form field name for login ('login')
58
+ # - rodauth.password_param : Form field name for password ('password')
59
+ #
60
+ # Key Rodauth methods for login:
61
+ # - rodauth.csrf_tag : Complete CSRF input tag (use {{{...}}})
62
+ # - rodauth.login_path : Login form action URL
63
+ # - rodauth.create_account_path : Registration URL
64
+ # - rodauth.reset_password_path : Password reset URL
65
+ </logic>
@@ -0,0 +1,25 @@
1
+ <!-- demo/rhales-roda-demo/templates/logout.rue -->
2
+
3
+ <schema lang="js-zod" version="2" envelope="SuccessEnvelope" layout="layouts/main">
4
+ const schema = z.object({
5
+ title: z.string(),
6
+ zodauth: z.any()
7
+ });
8
+ </schema>
9
+
10
+ <template>
11
+ <div style="max-width: 400px; margin: 0 auto; padding: 2rem;">
12
+ <h2>Logout</h2>
13
+
14
+ <form method="post" action="/logout">
15
+ {{{rodauth.csrf_tag}}}
16
+ <p>Are you sure you want to logout?</p>
17
+ <button type="submit" class="btn">Logout</button>
18
+ <a href="/" style="margin-left: 1rem;">Cancel</a>
19
+ </form>
20
+ </div>
21
+ </template>
22
+
23
+ <logic>
24
+ # Logout confirmation page
25
+ </logic>
@@ -0,0 +1,26 @@
1
+ <!-- demo/rhales-roda-demo/templates/reset_password.rue -->
2
+
3
+ <schema lang="js-zod" version="2" envelope="SuccessEnvelope" layout="layouts/main">
4
+ const schema = z.object({
5
+ page_title: z.string(),
6
+ submit_text: z.string(),
7
+ zodauth: z.any()
8
+ });
9
+ </schema>
10
+
11
+ <template>
12
+ <div class="auth-form">
13
+ <h2>{{page_title}}</h2>
14
+
15
+ <form method="post">
16
+ <div class="form-group">
17
+ <label for="login">Email:</label>
18
+ <input type="email" id="login" name="login" required />
19
+ </div>
20
+
21
+ {{{rodauth.csrf_tag}}}
22
+
23
+ <button type="submit" class="btn btn-primary">{{submit_text}}</button>
24
+ </form>
25
+ </div>
26
+ </template>
@@ -0,0 +1,27 @@
1
+ <!-- demo/rhales-roda-demo/templates/verify_account.rue -->
2
+
3
+ <schema lang="js-zod" version="2" envelope="SuccessEnvelope" layout="layouts/main">
4
+ const schema = z.object({
5
+ page_title: z.string(),
6
+ submit_text: z.string(),
7
+ zodauth: z.any()
8
+ });
9
+ </schema>
10
+
11
+ <template>
12
+ <div class="auth-form">
13
+ <h2>{{page_title}}</h2>
14
+
15
+ <form method="post">
16
+ {{rodauth.inspect}}
17
+ {{#if rodauth.verify_account_key_value}}
18
+ <input type="hidden" name="key" value="{{rodauth.verify_account_key_value}}" />
19
+ {{/if}}
20
+
21
+ <!-- plop -->
22
+ {{{rodauth.csrf_tag}}}
23
+
24
+ <button type="submit" class="btn btn-primary">{{submit_text}}</button>
25
+ </form>
26
+ </div>
27
+ </template>
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative 'app'
4
+ require 'rack/mock'
5
+
6
+ # Create a mock rack env for the home page
7
+ env = Rack::MockRequest.env_for('/', method: 'GET')
8
+
9
+ begin
10
+ app = RhalesDemo.app
11
+ _status, _headers, body = app.call(env)
12
+
13
+ puts '=== FULL OUTPUT ==='
14
+ puts body.first
15
+ puts '=== END OUTPUT ==='
16
+
17
+ # Check for demo accounts
18
+ if body.first.include?('demo@example.com')
19
+ puts "\n✅ Demo accounts are rendering correctly!"
20
+ else
21
+ puts "\n❌ Demo accounts are NOT rendering!"
22
+ puts "Looking for 'demo_accounts' in template locals..."
23
+ end
24
+ rescue StandardError => ex
25
+ puts "❌ ERROR: #{ex.class}: #{ex.message}"
26
+ puts ex.backtrace[0..5].join("\n")
27
+ end
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative 'app'
4
+ require 'rack/mock'
5
+
6
+ # Create a mock rack env for the home page
7
+ env = Rack::MockRequest.env_for('/', method: 'GET')
8
+
9
+ begin
10
+ app = RhalesDemo.app
11
+ status, headers, body = app.call(env)
12
+ puts "Status: #{status}"
13
+ puts "Headers: #{headers}"
14
+ puts "Body preview: #{body.first[0..200]}..."
15
+
16
+ if status == 200
17
+ puts "\n✅ SUCCESS: Tilt integration is working! Rhales templates are rendering."
18
+ else
19
+ puts "\n❌ ERROR: Got status #{status}"
20
+ end
21
+ rescue StandardError => ex
22
+ puts "\n❌ ERROR: #{ex.class}: #{ex.message}"
23
+ puts ex.backtrace[0..5].join("\n")
24
+ end
data/docs/.gitignore ADDED
@@ -0,0 +1,9 @@
1
+ *
2
+ !.gitignore
3
+
4
+ # Ignore WIP or temp dev files with uppercase names
5
+ [A-Z]*.rb
6
+ [A-Z]*.md
7
+
8
+ !architecture/
9
+ !architecture/README.md
@@ -0,0 +1,499 @@
1
+ # Rhales Data Flow Architecture
2
+
3
+ This document explains how data flows from your Ruby code through Rhales templates to the browser, with critical security considerations.
4
+
5
+ ## Table of Contents
6
+
7
+ 1. [Overview](#overview)
8
+ 2. [Three-Layer Context Model](#three-layer-context-model)
9
+ 3. [Complete Data Flow](#complete-data-flow)
10
+ 4. [Critical Security Model](#critical-security-model)
11
+ 5. [Tilt Integration](#tilt-integration)
12
+ 6. [Schema Role: Validator vs Filter](#schema-role-validator-vs-filter)
13
+ 7. [Layout Data Inheritance](#layout-data-inheritance)
14
+ 8. [Best Practices](#best-practices)
15
+
16
+ ## Overview
17
+
18
+ Rhales implements a **three-layer context system** that separates data concerns:
19
+
20
+ - **Request Layer**: Framework-provided data (CSRF tokens, nonces, auth status)
21
+ - **Server Layer**: Template-only data (never sent to browser)
22
+ - **Client Layer**: Data serialized to browser (explicitly controlled by developer)
23
+
24
+ **Key Principle**: The schema validates data contracts but does NOT filter which data gets serialized. YOU choose what goes in `client:`.
25
+
26
+ ## Three-Layer Context Model
27
+
28
+ ### Layer 1: Request (Framework Data)
29
+
30
+ Automatically populated from the request object:
31
+
32
+ ```ruby
33
+ # Automatically available in templates
34
+ {{request.nonce}} # CSP nonce
35
+ {{request.csrf_token}} # CSRF token
36
+ {{request.authenticated?}} # Auth status
37
+ {{request.locale}} # Current locale
38
+ {{request.session}} # Session object
39
+ {{request.user}} # User object
40
+ ```
41
+
42
+ **Source**: `lib/rhales/context.rb:49-74` - `build_app_data` method
43
+
44
+ ### Layer 2: Server (Template-Only)
45
+
46
+ Data that stays on the server, never serialized to browser:
47
+
48
+ ```ruby
49
+ view = Rhales::View.new(request,
50
+ server: {
51
+ page_title: 'Dashboard',
52
+ vite_assets_html: vite_javascript_tag('app'),
53
+ admin_notes: 'Internal only',
54
+ secret_config: ENV['SECRET_KEY']
55
+ }
56
+ )
57
+ ```
58
+
59
+ ```handlebars
60
+ <!-- Available in templates -->
61
+ <title>{{server.page_title}}</title>
62
+ {{{server.vite_assets_html}}}
63
+
64
+ <!-- NOT sent to browser -->
65
+ <!-- Admin notes and secrets stay server-side -->
66
+ ```
67
+
68
+ **Source**: `lib/rhales/context.rb:37` - `@server_data`
69
+
70
+ ### Layer 3: Client (Serialized to Browser)
71
+
72
+ Data explicitly serialized to browser window state:
73
+
74
+ ```ruby
75
+ view = Rhales::View.new(request,
76
+ client: {
77
+ user: current_user.public_data,
78
+ items: Item.all.map(&:to_json_api),
79
+ config: { apiUrl: ENV['PUBLIC_API_URL'] }
80
+ }
81
+ )
82
+ ```
83
+
84
+ ```handlebars
85
+ <!-- Available in templates AND browser -->
86
+ <h1>Welcome {{client.user.name}}</h1>
87
+ ```
88
+
89
+ ```javascript
90
+ // Also available in browser
91
+ window.appData = {
92
+ user: { name: 'Alice', ... },
93
+ items: [...],
94
+ config: { apiUrl: 'https://api.example.com' }
95
+ }
96
+ ```
97
+
98
+ **Source**: `lib/rhales/context.rb:34` - `@client_data`
99
+
100
+ ## Complete Data Flow
101
+
102
+ ### Step 1: Developer Provides Data
103
+
104
+ ```ruby
105
+ # lib/rhales/view.rb:82-93
106
+ View.new(req, locale_override = nil,
107
+ client: { safe_public_data }, # ← Developer chooses
108
+ server: { template_only_data }, # ← Developer chooses
109
+ config: config
110
+ )
111
+ ```
112
+
113
+ ### Step 2: Context Initialization
114
+
115
+ ```ruby
116
+ # lib/rhales/context.rb:28-47
117
+ def initialize(req, locale_override = nil, client: {}, server: {}, config: nil)
118
+ # Normalize and freeze data
119
+ @client_data = normalize_keys(client).freeze
120
+ @server_data = build_app_data.merge(normalize_keys(server)).freeze
121
+
122
+ # Templates get EVERYTHING (merged)
123
+ @all_data = @server_data.merge(@client_data).merge({'app' => @server_data}).freeze
124
+
125
+ # Make context immutable
126
+ freeze
127
+ end
128
+ ```
129
+
130
+ **Result**: Three frozen hashes:
131
+ - `@client_data` → Will be serialized
132
+ - `@server_data` → Template-only
133
+ - `@all_data` → What templates see
134
+
135
+ ### Step 3: Template Rendering
136
+
137
+ Templates access data via `@all_data` with layer fallback:
138
+
139
+ ```handlebars
140
+ <!-- Explicit layer access -->
141
+ {{client.user}}
142
+ {{server.page_title}}
143
+ {{request.nonce}}
144
+
145
+ <!-- Shorthand (checks client → server → request) -->
146
+ {{user}} <!-- Finds in client layer -->
147
+ {{page_title}} <!-- Finds in server layer -->
148
+ {{nonce}} <!-- Finds in request layer -->
149
+ ```
150
+
151
+ **Source**: `lib/rhales/template_engine.rb:157-174` - Variable resolution
152
+
153
+ ### Step 4: Data Serialization
154
+
155
+ ```ruby
156
+ # lib/rhales/hydrator.rb:64-76
157
+ def process_data_section
158
+ if @parser.schema_lang
159
+ # Serialize ENTIRE client data (NO filtering by schema)
160
+ JSONSerializer.dump(@context.client) # ← ALL of @client_data
161
+ else
162
+ '{}'
163
+ end
164
+ end
165
+ ```
166
+
167
+ **Critical**: This serializes the ENTIRE `@client_data` hash. The schema does NOT filter this.
168
+
169
+ ### Step 5: Schema Validation (Optional Middleware)
170
+
171
+ ```ruby
172
+ # lib/rhales/middleware/schema_validator.rb:86-96
173
+ errors = validate_hydration_data(hydration_data, schema, template_name)
174
+
175
+ # Validates AFTER serialization
176
+ # If validation fails:
177
+ # - Development: Raises error
178
+ # - Production: Logs warning
179
+ # But data already in HTML response!
180
+ ```
181
+
182
+ **Critical**: Validation happens AFTER serialization. It cannot prevent data from reaching the browser.
183
+
184
+ ### Step 6: Browser Receives
185
+
186
+ ```html
187
+ <!-- Hydration script automatically injected -->
188
+ <script id="rsfc-data-xyz" type="application/json" data-window="appData">
189
+ {"user":"Alice","password":"secret123","api_key":"xyz"}
190
+ </script>
191
+
192
+ <script nonce="abc">
193
+ window.appData = JSON.parse(document.getElementById('rsfc-data-xyz').textContent);
194
+ </script>
195
+ ```
196
+
197
+ If you passed secrets in `client:`, they're now in the browser. The schema can't stop this.
198
+
199
+ ## Critical Security Model
200
+
201
+ ### ⚠️ Schema is a Validator, NOT a Filter
202
+
203
+ **Common Misconception**: "The schema determines what data gets sent to the browser"
204
+
205
+ **Reality**: The schema validates that serialized data matches a contract. It does NOT filter what gets serialized.
206
+
207
+ #### What Actually Happens
208
+
209
+ ```ruby
210
+ # Step 1: Developer passes data
211
+ view = Rhales::View.new(request,
212
+ client: {
213
+ name: 'Alice',
214
+ password: 'secret123', # ⚠️ Mistake!
215
+ api_key: 'xyz' # ⚠️ Mistake!
216
+ }
217
+ )
218
+
219
+ # Step 2: Hydrator serializes EVERYTHING
220
+ # lib/rhales/hydrator.rb:69
221
+ JSONSerializer.dump(@context.client)
222
+ # Result: {"name":"Alice","password":"secret123","api_key":"xyz"}
223
+
224
+ # Step 3: HTML rendered with data
225
+ html = view.render('dashboard')
226
+ # Result includes: <script>window.data = {"name":"Alice","password":"secret123",...}</script>
227
+
228
+ # Step 4: Middleware validates (OPTIONAL, happens AFTER HTML generated)
229
+ # Schema in template:
230
+ # const schema = z.object({ name: z.string() });
231
+ #
232
+ # Validation FAILS because password/api_key not in schema
233
+ # But data ALREADY in HTML response sent to browser!
234
+ ```
235
+
236
+ #### Developer Responsibility
237
+
238
+ **You MUST ensure** `client:` hash contains ONLY safe data:
239
+
240
+ ```ruby
241
+ # ✅ SAFE - Only public data
242
+ client: {
243
+ user: current_user.name,
244
+ userId: current_user.id,
245
+ theme: current_user.theme_preference
246
+ }
247
+
248
+ # ⚠️ DANGEROUS - Includes sensitive data
249
+ client: {
250
+ user: current_user, # Might include email, phone, etc
251
+ session_token: session[:token],
252
+ admin_secret: ENV['ADMIN_KEY']
253
+ }
254
+ ```
255
+
256
+ **Best Practice**: Create explicit serializer methods:
257
+
258
+ ```ruby
259
+ class User
260
+ def to_client_data
261
+ {
262
+ id: id,
263
+ name: name,
264
+ avatar_url: avatar_url
265
+ # Explicitly exclude: email, password_digest, api_tokens, etc
266
+ }
267
+ end
268
+ end
269
+
270
+ # Usage
271
+ view = Rhales::View.new(request,
272
+ client: {
273
+ user: current_user.to_client_data,
274
+ ...
275
+ }
276
+ )
277
+ ```
278
+
279
+ ## Tilt Integration
280
+
281
+ ### Default Behavior: Everything Serialized
282
+
283
+ When using Tilt (Roda's `view()` helper, Sinatra, etc), the default behavior is **serialize everything**:
284
+
285
+ ```ruby
286
+ # lib/rhales/tilt.rb:179-180
287
+ client_data = props.delete(:client_data) || props.dup # ← Fallback!
288
+ server_data = props.delete(:server_data) || {}
289
+ ```
290
+
291
+ **This means**:
292
+
293
+ ```ruby
294
+ # ⚠️ DANGEROUS - All locals serialized by default!
295
+ view('dashboard', locals: {
296
+ user: current_user, # → Serialized (may include email, etc)
297
+ secret: ENV['SECRET_KEY'], # → Serialized!
298
+ title: 'Dashboard' # → Serialized
299
+ })
300
+
301
+ # Browser gets:
302
+ # window.data = {
303
+ # user: { id: 1, name: 'Alice', email: 'alice@example.com', ... },
304
+ # secret: 'super_secret_key',
305
+ # title: 'Dashboard'
306
+ # }
307
+ ```
308
+
309
+ ### Safe Tilt Usage: Explicit Separation
310
+
311
+ ```ruby
312
+ # ✅ SAFE - Explicit client/server separation
313
+ view('dashboard', locals: {
314
+ client_data: {
315
+ user: current_user.public_data,
316
+ count: Item.count
317
+ },
318
+ server_data: {
319
+ secret: ENV['SECRET_KEY'],
320
+ title: 'Dashboard',
321
+ vite_assets: vite_javascript_tag('app')
322
+ }
323
+ })
324
+ ```
325
+
326
+ ### Recommended Pattern: Helper Method
327
+
328
+ ```ruby
329
+ # app.rb or controller
330
+ def template_locals(page_data = {})
331
+ {
332
+ client_data: {
333
+ authenticated: logged_in?,
334
+ locale: I18n.locale
335
+ }.merge(page_data.fetch(:client, {})),
336
+
337
+ server_data: {
338
+ csrf_token: csrf_token,
339
+ flash_notice: flash[:notice]
340
+ }.merge(page_data.fetch(:server, {}))
341
+ }
342
+ end
343
+
344
+ # Usage
345
+ view('dashboard', locals: template_locals(
346
+ client: { user: current_user.public_data },
347
+ server: { admin_note: 'Internal only' }
348
+ ))
349
+ ```
350
+
351
+ **Source**: `demo/rhales-roda-demo/app.rb:286-320` - Example implementation
352
+
353
+ ## Layout Data Inheritance
354
+
355
+ Layouts receive a **merged context** with rendered content:
356
+
357
+ ```ruby
358
+ # lib/rhales/view.rb:376
359
+ layout_context = context_with_rue_data.merge_client('content' => content_html)
360
+ ```
361
+
362
+ **This means**:
363
+
364
+ 1. Layout sees **all client data** from child template
365
+ 2. Layout sees **all server data** from child template
366
+ 3. Layout gets special `{{content}}` variable with rendered child HTML
367
+ 4. Layout can add its own client/server data (merged, not replaced)
368
+
369
+ **Example**:
370
+
371
+ ```xml
372
+ <!-- Child template: dashboard.rue -->
373
+ <schema lang="js-zod" window="pageData">
374
+ const schema = z.object({
375
+ user: z.string()
376
+ });
377
+ </schema>
378
+
379
+ <template>
380
+ <h1>Dashboard for {{user}}</h1>
381
+ </template>
382
+ ```
383
+
384
+ ```xml
385
+ <!-- Layout: layouts/main.rue -->
386
+ <schema lang="js-zod" window="layoutData">
387
+ const schema = z.object({
388
+ siteName: z.string()
389
+ });
390
+ </schema>
391
+
392
+ <template>
393
+ <!DOCTYPE html>
394
+ <html>
395
+ <head><title>{{siteName}}</title></head>
396
+ <body>
397
+ {{content}} <!-- ← Rendered child -->
398
+ <!-- Layout sees {{user}} from child -->
399
+ <footer>Logged in as: {{user}}</footer>
400
+ </body>
401
+ </html>
402
+ </template>
403
+ ```
404
+
405
+ **Browser receives**:
406
+ ```javascript
407
+ window.pageData = { user: 'Alice' } // From child
408
+ window.layoutData = { siteName: 'My App' } // From layout
409
+ ```
410
+
411
+ ## Best Practices
412
+
413
+ ### 1. Explicit Serializers
414
+
415
+ ```ruby
416
+ # app/serializers/user_client_serializer.rb
417
+ class UserClientSerializer
418
+ def self.call(user)
419
+ {
420
+ id: user.id,
421
+ name: user.name,
422
+ avatar_url: user.avatar_url
423
+ # Explicitly exclude sensitive fields
424
+ }
425
+ end
426
+ end
427
+
428
+ # Usage
429
+ view = Rhales::View.new(request,
430
+ client: {
431
+ user: UserClientSerializer.call(current_user)
432
+ }
433
+ )
434
+ ```
435
+
436
+ ### 2. Schema-First Development
437
+
438
+ Write the schema FIRST, then ensure `client:` hash matches:
439
+
440
+ ```xml
441
+ <!-- 1. Define schema -->
442
+ <schema lang="js-zod" window="data">
443
+ const schema = z.object({
444
+ userId: z.number(),
445
+ userName: z.string(),
446
+ theme: z.enum(['light', 'dark'])
447
+ });
448
+ </schema>
449
+ ```
450
+
451
+ ```ruby
452
+ # 2. Ensure client hash matches
453
+ view = Rhales::View.new(request,
454
+ client: {
455
+ userId: current_user.id,
456
+ userName: current_user.name,
457
+ theme: current_user.theme_preference
458
+ # If you add fields here, update schema!
459
+ }
460
+ )
461
+ ```
462
+
463
+ ### 3. Schema Validation Middleware
464
+
465
+ Enable in development to catch mismatches early:
466
+
467
+ ```ruby
468
+ # config.ru or app initialization
469
+ use Rhales::Middleware::SchemaValidator,
470
+ schemas_dir: './public/schemas',
471
+ fail_on_error: ENV['RACK_ENV'] == 'development'
472
+ ```
473
+
474
+ This catches contract violations but doesn't prevent data leaks.
475
+
476
+ ### 4. Code Review Checklist
477
+
478
+ - [ ] All `client:` data is safe for public exposure
479
+ - [ ] No passwords, API keys, or secrets in `client:`
480
+ - [ ] Sensitive data moved to `server:` layer
481
+ - [ ] Schema matches actual `client:` hash structure
482
+ - [ ] Explicit serializers used for complex objects
483
+ - [ ] Tilt integration uses `:client_data`/`:server_data` keys
484
+
485
+ ## Summary
486
+
487
+ **Data Flow**: `View.new(client:, server:)` → Context → Templates (see all) → Hydrator (serializes ALL client) → Browser
488
+
489
+ **Security Model**: Developer chooses what's in `client:` → Entire `client:` serialized → Schema validates contract
490
+
491
+ **Critical**: Schema does NOT filter data. It validates that what you serialized matches the contract.
492
+
493
+ **Your Responsibility**: Ensure `client:` contains ONLY safe, public data.
494
+
495
+ **References**:
496
+ - `lib/rhales/context.rb` - Three-layer model
497
+ - `lib/rhales/hydrator.rb:69` - Client serialization
498
+ - `lib/rhales/middleware/schema_validator.rb` - Post-serialization validation
499
+ - `lib/rhales/tilt.rb:179-180` - Default fallback behavior