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.
- checksums.yaml +4 -4
- data/.github/renovate.json5 +52 -0
- data/.github/workflows/ci.yml +123 -0
- data/.github/workflows/claude-code-review.yml +69 -0
- data/.github/workflows/claude.yml +49 -0
- data/.github/workflows/code-smells.yml +146 -0
- data/.github/workflows/ruby-lint.yml +78 -0
- data/.github/workflows/yardoc.yml +126 -0
- data/.gitignore +55 -0
- data/.pr_agent.toml +63 -0
- data/.pre-commit-config.yaml +89 -0
- data/.prettierignore +8 -0
- data/.prettierrc +38 -0
- data/.reek.yml +98 -0
- data/.rubocop.yml +428 -0
- data/.serena/.gitignore +3 -0
- data/.yardopts +56 -0
- data/CHANGELOG.md +44 -0
- data/CLAUDE.md +1 -1
- data/Gemfile +29 -0
- data/Gemfile.lock +189 -0
- data/README.md +686 -868
- data/Rakefile +46 -0
- data/debug_context.rb +25 -0
- data/demo/rhales-roda-demo/.gitignore +7 -0
- data/demo/rhales-roda-demo/Gemfile +32 -0
- data/demo/rhales-roda-demo/Gemfile.lock +151 -0
- data/demo/rhales-roda-demo/MAIL.md +405 -0
- data/demo/rhales-roda-demo/README.md +376 -0
- data/demo/rhales-roda-demo/RODA-TEMPLATE-ENGINES.md +192 -0
- data/demo/rhales-roda-demo/Rakefile +49 -0
- data/demo/rhales-roda-demo/app.rb +325 -0
- data/demo/rhales-roda-demo/bin/rackup +26 -0
- data/demo/rhales-roda-demo/config.ru +13 -0
- data/demo/rhales-roda-demo/db/migrate/001_create_rodauth_tables.rb +266 -0
- data/demo/rhales-roda-demo/db/migrate/002_create_rodauth_password_tables.rb +79 -0
- data/demo/rhales-roda-demo/db/migrate/003_add_admin_account.rb +68 -0
- data/demo/rhales-roda-demo/templates/change_login.rue +31 -0
- data/demo/rhales-roda-demo/templates/change_password.rue +36 -0
- data/demo/rhales-roda-demo/templates/close_account.rue +31 -0
- data/demo/rhales-roda-demo/templates/create_account.rue +40 -0
- data/demo/rhales-roda-demo/templates/dashboard.rue +150 -0
- data/demo/rhales-roda-demo/templates/home.rue +78 -0
- data/demo/rhales-roda-demo/templates/layouts/main.rue +168 -0
- data/demo/rhales-roda-demo/templates/login.rue +65 -0
- data/demo/rhales-roda-demo/templates/logout.rue +25 -0
- data/demo/rhales-roda-demo/templates/reset_password.rue +26 -0
- data/demo/rhales-roda-demo/templates/verify_account.rue +27 -0
- data/demo/rhales-roda-demo/test_full_output.rb +27 -0
- data/demo/rhales-roda-demo/test_simple.rb +24 -0
- data/docs/.gitignore +9 -0
- data/docs/architecture/data-flow.md +499 -0
- data/examples/dashboard-with-charts.rue +271 -0
- data/examples/form-with-validation.rue +180 -0
- data/examples/simple-page.rue +61 -0
- data/examples/vue.rue +136 -0
- data/generate-json-schemas.ts +158 -0
- data/json_schemer_migration_summary.md +172 -0
- data/lib/rhales/adapters/base_auth.rb +2 -0
- data/lib/rhales/adapters/base_request.rb +2 -0
- data/lib/rhales/adapters/base_session.rb +2 -0
- data/lib/rhales/adapters.rb +7 -0
- data/lib/rhales/configuration.rb +47 -0
- data/lib/rhales/core/context.rb +354 -0
- data/lib/rhales/{rue_document.rb → core/rue_document.rb} +56 -38
- data/lib/rhales/{template_engine.rb → core/template_engine.rb} +66 -59
- data/lib/rhales/{view.rb → core/view.rb} +112 -135
- data/lib/rhales/{view_composition.rb → core/view_composition.rb} +78 -8
- data/lib/rhales/core.rb +9 -0
- data/lib/rhales/errors/hydration_collision_error.rb +2 -0
- data/lib/rhales/errors.rb +2 -0
- data/lib/rhales/{earliest_injection_detector.rb → hydration/earliest_injection_detector.rb} +4 -0
- data/lib/rhales/hydration/hydration_data_aggregator.rb +487 -0
- data/lib/rhales/{hydration_endpoint.rb → hydration/hydration_endpoint.rb} +16 -12
- data/lib/rhales/{hydration_injector.rb → hydration/hydration_injector.rb} +4 -0
- data/lib/rhales/{hydration_registry.rb → hydration/hydration_registry.rb} +2 -0
- data/lib/rhales/hydration/hydrator.rb +102 -0
- data/lib/rhales/{link_based_injection_detector.rb → hydration/link_based_injection_detector.rb} +4 -0
- data/lib/rhales/{mount_point_detector.rb → hydration/mount_point_detector.rb} +4 -0
- data/lib/rhales/{safe_injection_validator.rb → hydration/safe_injection_validator.rb} +4 -0
- data/lib/rhales/hydration.rb +13 -0
- data/lib/rhales/{refinements → integrations/refinements}/require_refinements.rb +3 -1
- data/lib/rhales/{tilt.rb → integrations/tilt.rb} +22 -15
- data/lib/rhales/integrations.rb +6 -0
- data/lib/rhales/middleware/json_responder.rb +191 -0
- data/lib/rhales/middleware/schema_validator.rb +300 -0
- data/lib/rhales/middleware.rb +6 -0
- data/lib/rhales/parsers/handlebars_parser.rb +2 -0
- data/lib/rhales/parsers/rue_format_parser.rb +9 -7
- data/lib/rhales/parsers.rb +9 -0
- data/lib/rhales/{csp.rb → security/csp.rb} +27 -3
- data/lib/rhales/utils/json_serializer.rb +114 -0
- data/lib/rhales/utils/logging_helpers.rb +75 -0
- data/lib/rhales/utils/schema_extractor.rb +132 -0
- data/lib/rhales/utils/schema_generator.rb +194 -0
- data/lib/rhales/utils.rb +40 -0
- data/lib/rhales/version.rb +3 -1
- data/lib/rhales.rb +41 -24
- data/lib/tasks/rhales_schema.rake +197 -0
- data/package.json +10 -0
- data/pnpm-lock.yaml +345 -0
- data/pnpm-workspace.yaml +2 -0
- data/proofs/error_handling.rb +79 -0
- data/proofs/expanded_object_inheritance.rb +82 -0
- data/proofs/partial_context_scoping_fix.rb +168 -0
- data/proofs/ui_context_partial_inheritance.rb +236 -0
- data/rhales.gemspec +14 -6
- data/schema_vs_data_comparison.md +254 -0
- data/test_direct_access.rb +36 -0
- metadata +141 -23
- data/CLAUDE.locale.txt +0 -7
- data/lib/rhales/context.rb +0 -239
- data/lib/rhales/hydration_data_aggregator.rb +0 -221
- data/lib/rhales/hydrator.rb +0 -141
- 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,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
|