tina4ruby 3.13.142 → 3.13.143

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: 7e0e60af31fb00e8d35480634176d6bf978963f0d2e0609ebc9468204d8852d0
4
- data.tar.gz: c34fbf17ba63d2295c13071cdc82cf1eca291bd7fafa4a1e8d6510083bd95ba1
3
+ metadata.gz: 641d95c3ab74eeac0ce2b273ee7c6c44ca8aaab5e7e2a06d8e668eacffc5fb28
4
+ data.tar.gz: f8ad58627e7263bf560b499dc40fbd249d3267516d61b2c31696051f80b11852
5
5
  SHA512:
6
- metadata.gz: 4a290825e4873802ac5311a665c67d19fcb618d77048299601f4bb6d9368a389cb093ce1039eaf7b071f30f1b7f0d7d3d2e297617947198ad9e81912909acb23
7
- data.tar.gz: 287309debcce829752205d6bafedd17e2849b20726c27e151e69503e31a7656bfb420511946512e6f12b11953ec68e4ed01a1628dbf173d1ad9cdee605e1e49e
6
+ metadata.gz: 2354cb021a1136116236d2a94f6c5be38331823eda3e4ea4b37483ac395e3324af69b8a88d55e856039fe1951bb74be6e4918505bcbd6f6b70075f9b2ef0f232
7
+ data.tar.gz: f3c5de4a797f61db477db42c3a46e0f47dd132e7e2522b05d6127ee6c6d84b631215808fcb88953467308291a9754b39811f1ab8604f02fca93921b39b2ea67b
data/CHANGELOG.md CHANGED
@@ -6,6 +6,10 @@ number means the same thing everywhere.
6
6
  **The authoritative release notes for every shipped version live in the documentation:**
7
7
  https://tina4.com/ruby/36-releases
8
8
 
9
+ ## 3.13.143 — 2026-10-01
10
+
11
+ A fresh project's own styling now loads under the strict default Content-Security-Policy. The framework serves `default-src 'self'`, which a browser enforces by refusing every inline `<style>` and `<script>` -- so a new project rendered unstyled with its scripts blocked. The framework now mints one nonce per response, injects it into the `style-src` and `script-src` directives, and stamps it on every inline block it emits, de-inlining the `style=` and `on*=` attributes it used to rely on; a template that needs an inline block stamps `csp_nonce()` on it (ADR-0088, #96). A session's expiry now slides on activity: a read-only request that touched a stored session re-writes the merged record to move the deadline forward, so a request in flight next to a logout no longer loses the wrong state (ADR-0087, #94). Internally, the serve-debug readiness probe is identity-guarded so a foreign server on a reused ephemeral port can no longer be mistaken for the child under test -- a continuous-integration flake fix with no runtime change (#95). The framework still has no required runtime dependencies.
12
+
9
13
  ## 3.13.142 — 2026-09-30
10
14
 
11
15
  A release led by real fixes for anyone on WebSockets, sessions, or the gallery queue, then a round of internal tidying and a new metrics gate that holds the line on complexity. The framework still carries no required runtime dependencies.
data/lib/tina4/cli.rb CHANGED
@@ -2749,7 +2749,11 @@ module Tina4
2749
2749
  {% extends "base.twig" %}
2750
2750
  {% block title %}Login{% endblock %}
2751
2751
  {% block content %}
2752
- <div class="container mt-4" style="max-width:400px">
2752
+ {# No inline style attribute: a nonce lets this style block run under the
2753
+ strict default Content-Security-Policy. csp_nonce() is a built-in
2754
+ Frond global that returns this response's nonce. #}
2755
+ <style nonce="{{ csp_nonce() }}">.auth-form{max-width:400px;margin:0 auto}</style>
2756
+ <div class="container mt-4 auth-form">
2753
2757
  <h1>Login</h1>
2754
2758
  <form method="post" action="/api/auth/login">
2755
2759
  {{ form_token() }}
@@ -2777,7 +2781,8 @@ module Tina4
2777
2781
  {% extends "base.twig" %}
2778
2782
  {% block title %}Register{% endblock %}
2779
2783
  {% block content %}
2780
- <div class="container mt-4" style="max-width:400px">
2784
+ <style nonce="{{ csp_nonce() }}">.auth-form{max-width:400px;margin:0 auto}</style>
2785
+ <div class="container mt-4 auth-form">
2781
2786
  <h1>Register</h1>
2782
2787
  <form method="post" action="/api/auth/register">
2783
2788
  {{ form_token() }}
data/lib/tina4/crud.rb CHANGED
@@ -126,8 +126,8 @@ module Tina4
126
126
  end
127
127
  if editable
128
128
  html += "<td>"
129
- html += "<button class=\"btn btn-sm btn-primary me-1\" onclick=\"crudSave('#{table_name}', '#{pk_value}')\">Save</button>"
130
- html += "<button class=\"btn btn-sm btn-danger\" onclick=\"crudDelete('#{table_name}', '#{pk_value}')\">Delete</button>"
129
+ html += "<button class=\"btn btn-sm btn-primary me-1\" data-crud-inline=\"save\" data-table=\"#{h(table_name)}\" data-id=\"#{h(pk_value)}\">Save</button>"
130
+ html += "<button class=\"btn btn-sm btn-danger\" data-crud-inline=\"delete\" data-table=\"#{h(table_name)}\" data-id=\"#{h(pk_value)}\">Delete</button>"
131
131
  html += "</td>"
132
132
  end
133
133
  html += "</tr>"
@@ -457,7 +457,7 @@ module Tina4
457
457
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
458
458
  <title>#{h(title)}</title>
459
459
  <link rel="stylesheet" href="/css/tina4.min.css">
460
- <style>
460
+ <style nonce="#{Tina4::Csp.current_nonce}">
461
461
  .crud-container { max-width: 1200px; margin: 2rem auto; padding: 0 1rem; }
462
462
  .crud-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
463
463
  .crud-search { max-width: 300px; }
@@ -491,7 +491,7 @@ module Tina4
491
491
  class="form-control crud-search">
492
492
  <button type="submit" class="btn btn-secondary">Search</button>
493
493
  </form>
494
- <button class="btn btn-primary" onclick="crudShowCreate()">+ New</button>
494
+ <button class="btn btn-primary" data-crud-action="create">+ New</button>
495
495
  </div>
496
496
  </div>
497
497
  <div class="crud-info">
@@ -526,8 +526,8 @@ module Tina4
526
526
  html += "<td>#{h(value)}</td>"
527
527
  end
528
528
  html += "<td>"
529
- html += "<button class=\"btn btn-sm btn-primary me-1\" onclick=\"crudShowEdit(#{pk_value.is_a?(String) ? "'#{h(pk_value)}'" : pk_value})\">Edit</button>"
530
- html += "<button class=\"btn btn-sm btn-danger\" onclick=\"crudShowDelete(#{pk_value.is_a?(String) ? "'#{h(pk_value)}'" : pk_value})\">Delete</button>"
529
+ html += "<button class=\"btn btn-sm btn-primary me-1\" data-crud-action=\"edit\" data-id=\"#{h(pk_value)}\">Edit</button>"
530
+ html += "<button class=\"btn btn-sm btn-danger\" data-crud-action=\"delete\" data-id=\"#{h(pk_value)}\">Delete</button>"
531
531
  html += "</td></tr>"
532
532
  end
533
533
  end
@@ -567,8 +567,8 @@ module Tina4
567
567
  <p>Are you sure you want to delete this record? This action cannot be undone.</p>
568
568
  <input type="hidden" id="delete-pk-value">
569
569
  <div class="modal-footer">
570
- <button class="btn btn-secondary" onclick="crudCloseModal('delete')">Cancel</button>
571
- <button class="btn btn-danger" onclick="crudConfirmDelete()">Delete</button>
570
+ <button class="btn btn-secondary" data-crud-action="close" data-crud-modal="delete">Cancel</button>
571
+ <button class="btn btn-danger" data-crud-action="confirm-delete">Delete</button>
572
572
  </div>
573
573
  </div>
574
574
  </div>
@@ -586,7 +586,7 @@ module Tina4
586
586
  html = "<div class=\"modal-overlay\" id=\"modal-#{id}\">"
587
587
  html += "<div class=\"modal-box\">"
588
588
  html += "<h3>#{h(title)}</h3>"
589
- html += "<form id=\"form-#{id}\" onsubmit=\"return false;\">"
589
+ html += "<form id=\"form-#{id}\" data-crud-form=\"1\">"
590
590
  html += "<input type=\"hidden\" id=\"#{id}-pk-value\" name=\"#{pk}\">" if edit
591
591
 
592
592
  columns.each do |col|
@@ -599,8 +599,8 @@ module Tina4
599
599
  end
600
600
 
601
601
  html += "<div class=\"modal-footer\">"
602
- html += "<button type=\"button\" class=\"btn btn-secondary\" onclick=\"crudCloseModal('#{id}')\">Cancel</button>"
603
- html += "<button type=\"button\" class=\"btn btn-primary\" onclick=\"crudSave#{edit ? 'Edit' : 'Create'}()\">Save</button>"
602
+ html += "<button type=\"button\" class=\"btn btn-secondary\" data-crud-action=\"close\" data-crud-modal=\"#{h(id)}\">Cancel</button>"
603
+ html += "<button type=\"button\" class=\"btn btn-primary\" data-crud-action=\"save\" data-crud-mode=\"#{edit ? 'edit' : 'create'}\">Save</button>"
604
604
  html += "</div></form></div></div>"
605
605
  html
606
606
  end
@@ -609,7 +609,7 @@ module Tina4
609
609
  def build_crud_javascript(api_path, pk, columns, request_path)
610
610
  columns_json = JSON.generate(columns.map(&:to_s))
611
611
  <<~HTML
612
- <script>
612
+ <script nonce="#{Tina4::Csp.current_nonce}">
613
613
  var CRUD_API = '#{api_path}';
614
614
  var CRUD_PK = '#{pk}';
615
615
  var CRUD_COLUMNS = #{columns_json};
@@ -708,6 +708,37 @@ module Tina4
708
708
  .catch(function(e) { crudShowAlert('Failed to delete: ' + e, 'danger'); });
709
709
  }
710
710
 
711
+ // Replaces the modal forms' inline submit handler (a CSP-blocked on*=
712
+ // attribute): they post over fetch(), so stop the native submit here.
713
+ document.addEventListener('submit', function (event) {
714
+ if (event.target.closest('form[data-crud-form]')) {
715
+ event.preventDefault();
716
+ }
717
+ });
718
+
719
+ // CSP-clean action wiring: a nonce authorises this <script> element
720
+ // but never an inline on*= attribute, so every button carries
721
+ // data-crud-action and binds through one delegated listener.
722
+ document.addEventListener('click', function (event) {
723
+ var button = event.target.closest('[data-crud-action]');
724
+ if (!button) return;
725
+ var action = button.dataset.crudAction;
726
+ if (action === 'create') {
727
+ crudShowCreate();
728
+ } else if (action === 'edit') {
729
+ crudShowEdit(button.dataset.id);
730
+ } else if (action === 'delete') {
731
+ crudShowDelete(button.dataset.id);
732
+ } else if (action === 'close') {
733
+ crudCloseModal(button.dataset.crudModal);
734
+ } else if (action === 'confirm-delete') {
735
+ crudConfirmDelete();
736
+ } else if (action === 'save') {
737
+ if (button.dataset.crudMode === 'edit') { crudSaveEdit(); }
738
+ else { crudSaveCreate(); }
739
+ }
740
+ });
741
+
711
742
  // Close modal on overlay click
712
743
  document.querySelectorAll('.modal-overlay').forEach(function(overlay) {
713
744
  overlay.addEventListener('click', function(e) {
@@ -729,7 +760,7 @@ module Tina4
729
760
 
730
761
  def inline_crud_javascript(table_name)
731
762
  <<~JS
732
- <script>
763
+ <script nonce="#{Tina4::Csp.current_nonce}">
733
764
  function crudSave(table, id) {
734
765
  const row = document.querySelector(`tr[data-id="${id}"]`);
735
766
  const cells = row.querySelectorAll('td[data-field]');
@@ -748,6 +779,18 @@ module Tina4
748
779
  .then(d => { document.querySelector(`tr[data-id="${id}"]`).remove(); })
749
780
  .catch(e => alert('Error: ' + e));
750
781
  }
782
+ // CSP-clean wiring: a nonce authorises this <script> element but never
783
+ // an inline on*= attribute, so the Save/Delete buttons carry
784
+ // data-crud-inline + data-* and bind through one delegated listener.
785
+ document.addEventListener('click', function (event) {
786
+ var button = event.target.closest('[data-crud-inline]');
787
+ if (!button) return;
788
+ if (button.dataset.crudInline === 'save') {
789
+ crudSave(button.dataset.table, button.dataset.id);
790
+ } else if (button.dataset.crudInline === 'delete') {
791
+ crudDelete(button.dataset.table, button.dataset.id);
792
+ }
793
+ });
751
794
  </script>
752
795
  JS
753
796
  end
data/lib/tina4/csp.rb ADDED
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+ # Copyright (c) 2026 Code Infinity
3
+ # SPDX-License-Identifier: MPL-2.0
4
+ # This Source Code Form is subject to the terms of the Mozilla Public
5
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ # file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+
8
+ require "securerandom"
9
+
10
+ module Tina4
11
+ # Per-response Content-Security-Policy nonce (ADR-0088).
12
+ #
13
+ # The framework serves a strict default CSP (`default-src 'self'`). A browser
14
+ # refuses every inline `<style>` and `<script>` under that policy unless the
15
+ # element carries a nonce that the `Content-Security-Policy` header also names.
16
+ # So the framework mints one cryptographically-random nonce per response, stamps
17
+ # it on every inline `<style>`/`<script>` it emits, and injects the matching
18
+ # `'nonce-<X>'` into the `style-src` and `script-src` directives of the CSP
19
+ # header. The same value reaches user templates through the Frond global
20
+ # `csp_nonce()`.
21
+ #
22
+ # The nonce lives in a thread-local, so each request (one Puma worker thread)
23
+ # gets its own value and concurrent requests never see each other's.
24
+ # `RackApp#call` sets a fresh nonce at the start of every request and clears it
25
+ # in `ensure`, exactly as it does the request id. Whoever touches the nonce
26
+ # first in a request — the body emitter rendering inline content, or the
27
+ # security middleware building the header — gets the same value, because
28
+ # {current_nonce} generates one on first access and caches it on the thread for
29
+ # the rest of the request.
30
+ module Csp
31
+ THREAD_KEY = :tina4_csp_nonce
32
+
33
+ module_function
34
+
35
+ # Return a fresh cryptographically-random nonce (128 bits, base64).
36
+ # SecureRandom.base64 is stdlib (no `base64` gem), encodes 16 random bytes.
37
+ def generate_nonce
38
+ SecureRandom.base64(16)
39
+ end
40
+
41
+ # Set the nonce for the current request (thread) context.
42
+ def set_current_nonce(value)
43
+ Thread.current[THREAD_KEY] = value
44
+ end
45
+
46
+ # Clear the nonce at the end of a request (mirrors Log.clear_request_id).
47
+ def clear_current_nonce
48
+ Thread.current[THREAD_KEY] = nil
49
+ end
50
+
51
+ # Return the current request's nonce, minting one on first access.
52
+ #
53
+ # Generate-on-first-access makes the value independent of ordering: whether
54
+ # the inline body or the CSP header is built first, both read the same nonce.
55
+ def current_nonce
56
+ value = Thread.current[THREAD_KEY]
57
+ if value.nil? || value.empty?
58
+ value = generate_nonce
59
+ Thread.current[THREAD_KEY] = value
60
+ end
61
+ value
62
+ end
63
+
64
+ # Frond / template global: the current response's CSP nonce.
65
+ #
66
+ # <style nonce="{{ csp_nonce() }}"> ... </style>
67
+ # <script nonce="{{ csp_nonce() }}"> ... </script>
68
+ def csp_nonce
69
+ current_nonce
70
+ end
71
+
72
+ # Return +csp+ with `'nonce-<nonce>'` present in style-src AND script-src.
73
+ #
74
+ # For the default `default-src 'self'` this yields
75
+ # `default-src 'self'; style-src 'self' 'nonce-X'; script-src 'self' 'nonce-X'`.
76
+ # When a directive is absent it is derived from `default-src` (falling back to
77
+ # `'self'`) so the framework's own nonce'd content always works; when present
78
+ # the nonce is appended (idempotently). Every other directive is kept, in order.
79
+ def inject_nonce_into_csp(csp, nonce)
80
+ token = "'nonce-#{nonce}'"
81
+ directives = []
82
+ csp.to_s.split(";").each do |part|
83
+ part = part.strip
84
+ next if part.empty?
85
+
86
+ bits = part.split(/\s+/, 2)
87
+ name = bits[0].downcase
88
+ value = bits.length > 1 ? bits[1].strip : ""
89
+ directives << [name, value]
90
+ end
91
+
92
+ default_pair = directives.find { |name, _| name == "default-src" }
93
+ default_value = default_pair ? default_pair[1] : "'self'"
94
+
95
+ %w[style-src script-src].each do |directive|
96
+ existing = directives.find { |name, _| name == directive }
97
+ if existing
98
+ existing[1] = "#{existing[1]} #{token}".strip unless existing[1].split(/\s+/).include?(token)
99
+ else
100
+ base = default_value.empty? ? "'self'" : default_value
101
+ directives << [directive, "#{base} #{token}".strip]
102
+ end
103
+ end
104
+
105
+ directives.map { |name, value| value.empty? ? name : "#{name} #{value}" }.join("; ")
106
+ end
107
+
108
+ # Build the CSP header value for +nonce+ from the environment.
109
+ #
110
+ # Honours TINA4_CSP (default `default-src 'self'`) and always injects the
111
+ # nonce into style-src and script-src. A blank TINA4_CSP is treated as the
112
+ # default policy (the empty-string / unset behaviour is unchanged elsewhere).
113
+ def resolve_csp_header(nonce)
114
+ csp = ENV["TINA4_CSP"]
115
+ csp = "default-src 'self'" if csp.nil? || csp.empty?
116
+ inject_nonce_into_csp(csp, nonce)
117
+ end
118
+ end
119
+ end
@@ -86,7 +86,7 @@ module Tina4
86
86
  end
87
87
  hidden = backtrace.length - [backtrace.length, MAX_FRAMES].min
88
88
  if hidden.positive?
89
- frames_html << "<div style=\"color:#{SUBTEXT};padding:8px 0;font-size:13px;\">" \
89
+ frames_html << "<div class=\"eo-hidden-frames\">" \
90
90
  "&#8230; #{hidden} more stack frames hidden (truncated at #{MAX_FRAMES})</div>"
91
91
  end
92
92
 
@@ -121,6 +121,10 @@ module Tina4
121
121
  env_section = collapsible("Environment", table(env_pairs))
122
122
  stack_section = collapsible("Stack Trace", frames_html, open_by_default: true)
123
123
 
124
+ # ADR-0088: one nonce'd <style> holds the whole overlay stylesheet; no
125
+ # element carries a style= attribute, because a nonce covers a <style>
126
+ # ELEMENT but never a style attribute. The value matches the CSP header.
127
+ nonce = Tina4::Csp.current_nonce
124
128
  <<~HTML
125
129
  <!DOCTYPE html>
126
130
  <html lang="en">
@@ -128,25 +132,24 @@ module Tina4
128
132
  <meta charset="utf-8">
129
133
  <meta name="viewport" content="width=device-width,initial-scale=1">
130
134
  <title>Tina4 Error — #{esc(exc_type)}</title>
131
- <style>
132
- *{margin:0;padding:0;box-sizing:border-box;}
133
- body{background:#{BG};color:#{TEXT_COLOR};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:24px;line-height:1.5;}
135
+ <style nonce="#{nonce}">
136
+ #{overlay_stylesheet}
134
137
  </style>
135
138
  </head>
136
139
  <body>
137
- <div style="max-width:960px;margin:0 auto;">
138
- <div style="margin-bottom:24px;">
139
- <div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;">
140
- <span style="background:#{RED};color:#{BG};padding:4px 12px;border-radius:4px;font-weight:700;font-size:13px;text-transform:uppercase;">Error</span>
141
- <span style="color:#{SUBTEXT};font-size:14px;">Tina4 Debug Overlay</span>
140
+ <div class="eo-wrap">
141
+ <div class="eo-header">
142
+ <div class="eo-badge-row">
143
+ <span class="eo-badge">Error</span>
144
+ <span class="eo-sub">Tina4 Debug Overlay</span>
142
145
  </div>
143
- <h1 style="color:#{RED};font-size:28px;font-weight:700;margin-bottom:8px;">#{esc(exc_type)}</h1>
144
- <p style="color:#{TEXT_COLOR};font-size:18px;font-family:'SF Mono','Fira Code','Consolas',monospace;background:#{SURFACE};padding:12px 16px;border-radius:6px;border-left:4px solid #{RED};">#{esc(exc_msg)}</p>
146
+ <h1 class="eo-type">#{esc(exc_type)}</h1>
147
+ <p class="eo-msg">#{esc(exc_msg)}</p>
145
148
  </div>
146
149
  #{stack_section}
147
150
  #{request_section}
148
151
  #{env_section}
149
- <div style="margin-top:32px;padding-top:16px;border-top:1px solid #{OVERLAY_COLOR};color:#{SUBTEXT};font-size:12px;">
152
+ <div class="eo-footer">
150
153
  Tina4 Debug Overlay &mdash; This page is only shown in debug mode. Set TINA4_DEBUG=false in production.
151
154
  </div>
152
155
  </div>
@@ -155,6 +158,45 @@ module Tina4
155
158
  HTML
156
159
  end
157
160
 
161
+ # The overlay's full stylesheet, served inside one nonce'd <style> block.
162
+ # Keeping the rules here (not on the elements) is what makes the overlay
163
+ # CSP-clean: no framework page emits a style="..." attribute.
164
+ def overlay_stylesheet
165
+ <<~CSS
166
+ *{margin:0;padding:0;box-sizing:border-box;}
167
+ body{background:#{BG};color:#{TEXT_COLOR};font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:24px;line-height:1.5;}
168
+ .eo-wrap{max-width:960px;margin:0 auto;}
169
+ .eo-header{margin-bottom:24px;}
170
+ .eo-badge-row{display:flex;align-items:center;gap:12px;margin-bottom:12px;}
171
+ .eo-badge{background:#{RED};color:#{BG};padding:4px 12px;border-radius:4px;font-weight:700;font-size:13px;text-transform:uppercase;}
172
+ .eo-sub{color:#{SUBTEXT};font-size:14px;}
173
+ .eo-type{color:#{RED};font-size:28px;font-weight:700;margin-bottom:8px;}
174
+ .eo-msg{color:#{TEXT_COLOR};font-size:18px;font-family:'SF Mono','Fira Code','Consolas',monospace;background:#{SURFACE};padding:12px 16px;border-radius:6px;border-left:4px solid #{RED};}
175
+ .eo-footer{margin-top:32px;padding-top:16px;border-top:1px solid #{OVERLAY_COLOR};color:#{SUBTEXT};font-size:12px;}
176
+ .eo-source{background:#{SURFACE};border-radius:6px;padding:12px;overflow-x:auto;font-family:'SF Mono','Fira Code','Consolas',monospace;font-size:13px;line-height:1.6;}
177
+ .eo-line{display:flex;padding:1px 0;}
178
+ .eo-line-err{background:#{ERROR_LINE_BG};}
179
+ .eo-ln{color:#{YELLOW};min-width:3.5em;text-align:right;padding-right:1em;user-select:none;}
180
+ .eo-marker{color:#{RED};width:1.2em;user-select:none;}
181
+ .eo-code{color:#{TEXT_COLOR};white-space:pre-wrap;tab-size:4;}
182
+ .eo-frame{margin-bottom:16px;}
183
+ .eo-frame-head{margin-bottom:4px;}
184
+ .eo-file{color:#{BLUE};}
185
+ .eo-sep{color:#{SUBTEXT};}
186
+ .eo-lineno{color:#{YELLOW};}
187
+ .eo-fn{color:#{GREEN};}
188
+ .eo-stale{background:#{PEACH};color:#{BG};padding:1px 8px;border-radius:3px;font-size:11px;font-weight:700;margin-left:6px;}
189
+ .eo-details{margin-top:16px;}
190
+ .eo-summary{cursor:pointer;color:#{LAVENDER};font-weight:600;font-size:15px;padding:8px 0;user-select:none;}
191
+ .eo-details-body{padding:8px 0;}
192
+ .eo-table{border-collapse:collapse;width:100%;}
193
+ .eo-key{color:#{PEACH};padding:4px 16px 4px 0;vertical-align:top;white-space:nowrap;}
194
+ .eo-val{color:#{TEXT_COLOR};padding:4px 0;word-break:break-all;}
195
+ .eo-none{color:#{SUBTEXT};}
196
+ .eo-hidden-frames{color:#{SUBTEXT};padding:8px 0;font-size:13px;}
197
+ CSS
198
+ end
199
+
158
200
  # Return true if TINA4_DEBUG is enabled.
159
201
  def is_debug_mode
160
202
  Tina4::Env.is_truthy(ENV.fetch("TINA4_DEBUG", ""))
@@ -205,30 +247,28 @@ module Tina4
205
247
  return "" if lines.empty?
206
248
 
207
249
  rows = lines.map do |num, text, is_error|
208
- bg = is_error ? "background:#{ERROR_LINE_BG};" : ""
250
+ row_class = is_error ? "eo-line eo-line-err" : "eo-line"
209
251
  marker = is_error ? "&#x25b6;" : " "
210
- "<div style=\"#{bg}display:flex;padding:1px 0;\">" \
211
- "<span style=\"color:#{YELLOW};min-width:3.5em;text-align:right;padding-right:1em;user-select:none;\">#{num}</span>" \
212
- "<span style=\"color:#{RED};width:1.2em;user-select:none;\">#{marker}</span>" \
213
- "<span style=\"color:#{TEXT_COLOR};white-space:pre-wrap;tab-size:4;\">#{esc(text)}</span>" \
252
+ "<div class=\"#{row_class}\">" \
253
+ "<span class=\"eo-ln\">#{num}</span>" \
254
+ "<span class=\"eo-marker\">#{marker}</span>" \
255
+ "<span class=\"eo-code\">#{esc(text)}</span>" \
214
256
  "</div>"
215
257
  end.join("\n")
216
258
 
217
- "<div style=\"background:#{SURFACE};border-radius:6px;padding:12px;overflow-x:auto;" \
218
- "font-family:'SF Mono','Fira Code','Consolas',monospace;font-size:13px;line-height:1.6;\">" \
219
- "#{rows}</div>"
259
+ "<div class=\"eo-source\">#{rows}</div>"
220
260
  end
221
261
 
222
262
  def format_frame(filename, lineno, func_name, captured_at: 0.0)
223
263
  source = (filename && lineno.positive?) ? format_source_block(filename, lineno) : ""
224
264
  stale_badge = stale_file_badge(filename, captured_at)
225
- "<div style=\"margin-bottom:16px;\">" \
226
- "<div style=\"margin-bottom:4px;\">" \
227
- "<span style=\"color:#{BLUE};\">#{esc(filename.to_s)}</span>" \
228
- "<span style=\"color:#{SUBTEXT};\"> : </span>" \
229
- "<span style=\"color:#{YELLOW};\">#{lineno}</span>" \
230
- "<span style=\"color:#{SUBTEXT};\"> in </span>" \
231
- "<span style=\"color:#{GREEN};\">#{esc(func_name.to_s)}</span>" \
265
+ "<div class=\"eo-frame\">" \
266
+ "<div class=\"eo-frame-head\">" \
267
+ "<span class=\"eo-file\">#{esc(filename.to_s)}</span>" \
268
+ "<span class=\"eo-sep\"> : </span>" \
269
+ "<span class=\"eo-lineno\">#{lineno}</span>" \
270
+ "<span class=\"eo-sep\"> in </span>" \
271
+ "<span class=\"eo-fn\">#{esc(func_name.to_s)}</span>" \
232
272
  "#{stale_badge}" \
233
273
  "</div>" \
234
274
  "#{source}" \
@@ -247,8 +287,7 @@ module Tina4
247
287
  return "" if mtime <= captured_at + 0.5
248
288
 
249
289
  mtime_str = Time.at(mtime).utc.strftime("%H:%M:%S")
250
- "<span style=\"background:#{PEACH};color:#{BG};padding:1px 8px;" \
251
- "border-radius:3px;font-size:11px;font-weight:700;margin-left:6px;\">" \
290
+ "<span class=\"eo-stale\">" \
252
291
  "FILE MODIFIED @ #{mtime_str} UTC &mdash; source may not match what failed" \
253
292
  "</span>"
254
293
  rescue StandardError
@@ -257,23 +296,22 @@ module Tina4
257
296
 
258
297
  def collapsible(title, content, open_by_default: false)
259
298
  open_attr = open_by_default ? " open" : ""
260
- "<details style=\"margin-top:16px;\"#{open_attr}>" \
261
- "<summary style=\"cursor:pointer;color:#{LAVENDER};font-weight:600;font-size:15px;" \
262
- "padding:8px 0;user-select:none;\">#{esc(title)}</summary>" \
263
- "<div style=\"padding:8px 0;\">#{content}</div>" \
299
+ "<details class=\"eo-details\"#{open_attr}>" \
300
+ "<summary class=\"eo-summary\">#{esc(title)}</summary>" \
301
+ "<div class=\"eo-details-body\">#{content}</div>" \
264
302
  "</details>"
265
303
  end
266
304
 
267
305
  def table(pairs)
268
- return "<span style=\"color:#{SUBTEXT};\">None</span>" if pairs.empty?
306
+ return "<span class=\"eo-none\">None</span>" if pairs.empty?
269
307
 
270
308
  rows = pairs.map do |key, val|
271
309
  "<tr>" \
272
- "<td style=\"color:#{PEACH};padding:4px 16px 4px 0;vertical-align:top;white-space:nowrap;\">#{esc(key)}</td>" \
273
- "<td style=\"color:#{TEXT_COLOR};padding:4px 0;word-break:break-all;\">#{esc(val)}</td>" \
310
+ "<td class=\"eo-key\">#{esc(key)}</td>" \
311
+ "<td class=\"eo-val\">#{esc(val)}</td>" \
274
312
  "</tr>"
275
313
  end.join
276
- "<table style=\"border-collapse:collapse;width:100%;\">#{rows}</table>"
314
+ "<table class=\"eo-table\">#{rows}</table>"
277
315
  end
278
316
  end
279
317
  end
data/lib/tina4/frond.rb CHANGED
@@ -3184,6 +3184,13 @@ module Tina4
3184
3184
  # Both this global and the |dump filter call Frond.render_dump which
3185
3185
  # returns an empty SafeString in production so dump never leaks state.
3186
3186
  @globals["dump"] = ->(value = nil) { Frond.render_dump(value) }
3187
+
3188
+ # CSP nonce: {{ csp_nonce() }} returns the current response's nonce so a
3189
+ # template can serve an inline <style>/<script> under the strict default
3190
+ # Content-Security-Policy — <style nonce="{{ csp_nonce() }}">. The value
3191
+ # matches the 'nonce-X' the security middleware puts in the CSP header
3192
+ # (ADR-0088).
3193
+ @globals["csp_nonce"] = -> { Tina4::Csp.current_nonce }
3187
3194
  end
3188
3195
 
3189
3196
  # Render a value as a pre-formatted inspect() wrapped in <pre> tags.
@@ -7,15 +7,27 @@
7
7
  # Gallery: Auth — JWT login with a visual demo page.
8
8
 
9
9
  Tina4::Router.get("/gallery/auth") do |request, response|
10
+ # ADR-0088: this page's inline <style>/<script> carry the response's CSP nonce
11
+ # so they run under the strict default policy; no inline style=/onclick=.
12
+ nonce = response.csp_nonce
10
13
  html = <<~HTML
11
14
  <!DOCTYPE html>
12
15
  <html lang="en">
13
16
  <head>
14
17
  <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
15
18
  <title>Auth Demo</title><link rel="stylesheet" href="/css/tina4.min.css">
19
+ <style nonce="#{nonce}">
20
+ .auth-demo{max-width:600px}
21
+ .is-hidden{display:none}
22
+ .token-box{word-break:break-all;white-space:pre-wrap;color:#4ade80;background:#1e293b;padding:1rem;border-radius:0.5rem}
23
+ .payload-box{color:#38bdf8;background:#1e293b;padding:1rem;border-radius:0.5rem}
24
+ .howto-card{border:1px solid #334155}
25
+ .howto-heading{color:#e2e8f0}
26
+ .howto-code{background:#0f172a;color:#4ade80;padding:1rem;border-radius:0.5rem;font-size:0.8rem}
27
+ </style>
16
28
  </head>
17
29
  <body class="bg-dark text-light">
18
- <div class="container mt-5" style="max-width:600px;">
30
+ <div class="container mt-5 auth-demo">
19
31
  <h2 class="mb-4">JWT Authentication Demo</h2>
20
32
  <div class="card mb-4">
21
33
  <div class="card-header bg-primary text-white">Login</div>
@@ -28,35 +40,35 @@ Tina4::Router.get("/gallery/auth") do |request, response|
28
40
  <label class="form-label">Password</label>
29
41
  <input type="password" id="password" class="form-control" placeholder="secret" value="secret">
30
42
  </div>
31
- <button class="btn btn-primary" onclick="doLogin()">Login</button>
43
+ <button class="btn btn-primary" id="loginBtn">Login</button>
32
44
  </div>
33
45
  </div>
34
- <div id="result" style="display:none;">
46
+ <div id="result" class="is-hidden">
35
47
  <div class="card mb-3">
36
48
  <div class="card-header bg-success text-white">Token Received</div>
37
49
  <div class="card-body">
38
- <pre id="token" style="word-break:break-all;white-space:pre-wrap;color:#4ade80;background:#1e293b;padding:1rem;border-radius:0.5rem;"></pre>
50
+ <pre id="token" class="token-box"></pre>
39
51
  </div>
40
52
  </div>
41
53
  <div class="card mb-3">
42
54
  <div class="card-header">Token Payload (decoded)</div>
43
55
  <div class="card-body">
44
- <pre id="payload" style="color:#38bdf8;background:#1e293b;padding:1rem;border-radius:0.5rem;"></pre>
56
+ <pre id="payload" class="payload-box"></pre>
45
57
  </div>
46
58
  </div>
47
- <button class="btn btn-outline-info" onclick="verifyToken()">Verify Token</button>
59
+ <button class="btn btn-outline-info" id="verifyBtn">Verify Token</button>
48
60
  <span id="verify-result" class="ms-2"></span>
49
61
  </div>
50
- <div class="card bg-dark mt-4" style="border:1px solid #334155;">
62
+ <div class="card bg-dark mt-4 howto-card">
51
63
  <div class="card-body">
52
- <h6 style="color:#e2e8f0;">How it works</h6>
53
- <pre style="background:#0f172a;color:#4ade80;padding:1rem;border-radius:0.5rem;font-size:0.8rem;"><code>token = Tina4::Auth.create_token({ username: "admin" })
64
+ <h6 class="howto-heading">How it works</h6>
65
+ <pre class="howto-code"><code>token = Tina4::Auth.create_token({ username: "admin" })
54
66
  payload = Tina4::Auth.get_payload(token)
55
67
  result = Tina4::Auth.validate_token(token)</code></pre>
56
68
  </div>
57
69
  </div>
58
70
  </div>
59
- <script>
71
+ <script nonce="#{nonce}">
60
72
  var currentToken = '';
61
73
  function doLogin() {
62
74
  fetch('/api/gallery/auth/login', {
@@ -77,7 +89,7 @@ Tina4::Router.get("/gallery/auth") do |request, response|
77
89
  } catch(e) {
78
90
  document.getElementById('payload').textContent = 'Could not decode';
79
91
  }
80
- document.getElementById('result').style.display = 'block';
92
+ document.getElementById('result').classList.remove('is-hidden');
81
93
  document.getElementById('verify-result').textContent = '';
82
94
  } else {
83
95
  alert(d.error || 'Login failed');
@@ -95,6 +107,8 @@ Tina4::Router.get("/gallery/auth") do |request, response|
95
107
  }
96
108
  });
97
109
  }
110
+ document.getElementById('loginBtn').addEventListener('click', doLogin);
111
+ document.getElementById('verifyBtn').addEventListener('click', verifyToken);
98
112
  </script>
99
113
  </body></html>
100
114
  HTML