tina4ruby 3.13.142 → 3.13.144

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.
@@ -67,7 +67,7 @@ GALLERY_QUEUE_HTML = <<~'HTML'
67
67
  <div class="card-body">
68
68
  <div class="d-flex gap-2">
69
69
  <input type="text" id="msgInput" class="form-control" placeholder="Enter a task message, e.g. send-email">
70
- <button class="btn btn-primary" onclick="produce()">Produce</button>
70
+ <button class="btn btn-primary" id="produceBtn">Produce</button>
71
71
  </div>
72
72
  </div>
73
73
  </div>
@@ -76,10 +76,10 @@ GALLERY_QUEUE_HTML = <<~'HTML'
76
76
  <div class="card">
77
77
  <div class="card-header">Actions</div>
78
78
  <div class="card-body d-flex gap-2 flex-wrap">
79
- <button class="btn btn-success" onclick="consume()">Consume Next</button>
80
- <button class="btn btn-danger" onclick="failNext()">Fail Next</button>
81
- <button class="btn btn-warning" onclick="retryFailed()">Retry Failed</button>
82
- <button class="btn btn-secondary" onclick="refresh()">Refresh</button>
79
+ <button class="btn btn-success" id="consumeBtn">Consume Next</button>
80
+ <button class="btn btn-danger" id="failBtn">Fail Next</button>
81
+ <button class="btn btn-warning" id="retryBtn">Retry Failed</button>
82
+ <button class="btn btn-secondary" id="refreshBtn">Refresh</button>
83
83
  </div>
84
84
  </div>
85
85
  </div>
@@ -112,7 +112,7 @@ GALLERY_QUEUE_HTML = <<~'HTML'
112
112
  </div>
113
113
  </div>
114
114
 
115
- <script>
115
+ <script nonce="__CSP_NONCE__">
116
116
  function statusBadge(status) {
117
117
  var colors = {pending:"primary", reserved:"warning", completed:"success", failed:"danger", dead:"secondary"};
118
118
  var color = colors[status] || "secondary";
@@ -122,7 +122,7 @@ function statusBadge(status) {
122
122
  function showAlert(msg, type) {
123
123
  var area = document.getElementById("alertArea");
124
124
  area.innerHTML = '<div class="alert alert-' + type + ' alert-dismissible">' + msg +
125
- '<button type="button" class="btn-close" onclick="this.parentElement.remove()"></button></div>';
125
+ '<button type="button" class="btn-close js-alert-close"></button></div>';
126
126
  setTimeout(function(){ area.innerHTML = ""; }, 3000);
127
127
  }
128
128
 
@@ -196,6 +196,16 @@ async function retryFailed() {
196
196
  refresh();
197
197
  }
198
198
 
199
+ // CSP-clean wiring: no inline onclick — bind every control via addEventListener.
200
+ document.getElementById("produceBtn").addEventListener("click", produce);
201
+ document.getElementById("consumeBtn").addEventListener("click", consume);
202
+ document.getElementById("failBtn").addEventListener("click", failNext);
203
+ document.getElementById("retryBtn").addEventListener("click", retryFailed);
204
+ document.getElementById("refreshBtn").addEventListener("click", refresh);
205
+ document.getElementById("alertArea").addEventListener("click", function(e){
206
+ if (e.target.classList.contains("js-alert-close")) e.target.parentElement.remove();
207
+ });
208
+
199
209
  refresh();
200
210
  setInterval(refresh, 2000);
201
211
  </script>
@@ -206,7 +216,10 @@ HTML
206
216
  # ── Render the interactive HTML page ──────────────────────────
207
217
 
208
218
  Tina4::Router.get("/gallery/queue") do |request, response|
209
- response.html(GALLERY_QUEUE_HTML)
219
+ # ADR-0088: stamp this response's CSP nonce on the inline <script> so it runs
220
+ # under the strict default policy (the HTML is a frozen, non-interpolated
221
+ # constant, so the nonce is substituted at serve time).
222
+ response.html(GALLERY_QUEUE_HTML.gsub("__CSP_NONCE__", response.csp_nonce))
210
223
  end
211
224
 
212
225
  # ── Produce — add a message to the queue ──────────────────────
@@ -5,10 +5,23 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>{{ title }}</title>
7
7
  <link rel="stylesheet" href="/css/tina4.min.css">
8
- <style>
8
+ <style nonce="{{ csp_nonce() }}">
9
9
  .component-section { margin-bottom: 2.5rem; }
10
10
  .component-section h3 { border-bottom: 2px solid var(--bs-primary, #0d6efd); padding-bottom: 0.5rem; margin-bottom: 1rem; }
11
11
  .code-preview { background: #1e293b; color: #4ade80; padding: 1rem; border-radius: 0.5rem; font-family: monospace; font-size: 0.85rem; overflow-x: auto; margin-top: 0.75rem; }
12
+ .progress-25 { width: 25%; }
13
+ .progress-50 { width: 50%; }
14
+ .progress-75 { width: 75%; }
15
+ .progress-100 { width: 100%; }
16
+ .build-card { background: #0f172a; border: 1px solid #334155; }
17
+ .build-heading { color: #e2e8f0; }
18
+ .build-code { background: #1e293b; border: 1px solid #334155; border-radius: 0.5rem; padding: 1.25rem; margin-top: 0.75rem; overflow-x: auto; font-family: 'SF Mono', SFMono-Regular, Consolas, monospace; font-size: 0.9rem; line-height: 1.7; }
19
+ .build-note { color: #94a3b8; margin-top: 0.75rem; margin-bottom: 0; }
20
+ .tok-kw { color: #38bdf8; }
21
+ .tok-str { color: #4ade80; }
22
+ .tok-fn { color: #fbbf24; }
23
+ .tok-dec { color: #c084fc; }
24
+ .tok-comment { color: #94a3b8; }
12
25
  </style>
13
26
  </head>
14
27
  <body>
@@ -29,7 +42,7 @@
29
42
 
30
43
  <div class="alert alert-info alert-dismissible mb-4">
31
44
  <strong>tina4css</strong> — Zero-dependency CSS framework (~24KB). Bootstrap-compatible class names, dark mode ready. No CDN needed — ships with every Tina4 project.
32
- <button type="button" class="btn-close" onclick="this.parentElement.remove()"></button>
45
+ <button type="button" class="btn-close js-dismiss"></button>
33
46
  </div>
34
47
 
35
48
  <!-- Buttons -->
@@ -217,10 +230,10 @@
217
230
  <!-- Progress -->
218
231
  <div class="component-section">
219
232
  <h3>Progress Bars</h3>
220
- <div class="progress mb-2"><div class="progress-bar bg-primary" style="width: 25%">25%</div></div>
221
- <div class="progress mb-2"><div class="progress-bar bg-success" style="width: 50%">50%</div></div>
222
- <div class="progress mb-2"><div class="progress-bar bg-warning" style="width: 75%">75%</div></div>
223
- <div class="progress"><div class="progress-bar bg-danger" style="width: 100%">100%</div></div>
233
+ <div class="progress mb-2"><div class="progress-bar bg-primary progress-25">25%</div></div>
234
+ <div class="progress mb-2"><div class="progress-bar bg-success progress-50">50%</div></div>
235
+ <div class="progress mb-2"><div class="progress-bar bg-warning progress-75">75%</div></div>
236
+ <div class="progress"><div class="progress-bar bg-danger progress-100">100%</div></div>
224
237
  </div>
225
238
 
226
239
  <!-- List Group -->
@@ -235,23 +248,29 @@
235
248
  </div>
236
249
 
237
250
  <!-- How this page was built -->
238
- <div class="card mt-4 mb-5" style="background:#0f172a;border:1px solid #334155;">
251
+ <div class="card mt-4 mb-5 build-card">
239
252
  <div class="card-body">
240
- <h5 style="color:#e2e8f0;">How this page was built</h5>
241
- <pre style="background:#1e293b;border:1px solid #334155;border-radius:0.5rem;padding:1.25rem;margin-top:0.75rem;overflow-x:auto;font-family:'SF Mono',SFMono-Regular,Consolas,monospace;font-size:0.9rem;line-height:1.7;"><code><span style="color:#c084fc;">Tina4::Router.get</span> <span style="color:#4ade80;">"/gallery/page"</span>, <span style="color:#38bdf8;">template:</span> <span style="color:#4ade80;">"gallery_page.twig"</span> <span style="color:#c084fc;">do</span> |request, response|
253
+ <h5 class="build-heading">How this page was built</h5>
254
+ <pre class="build-code"><code><span class="tok-dec">Tina4::Router.get</span> <span class="tok-str">"/gallery/page"</span>, <span class="tok-fn">template:</span> <span class="tok-str">"gallery_page.twig"</span> <span class="tok-dec">do</span> |request, response|
242
255
  response.call({
243
- <span style="color:#fbbf24;">title:</span> <span style="color:#4ade80;">"tina4css Component Showcase"</span>,
244
- <span style="color:#fbbf24;">items:</span> [...]
256
+ <span class="tok-fn">title:</span> <span class="tok-str">"tina4css Component Showcase"</span>,
257
+ <span class="tok-fn">items:</span> [...]
245
258
  }, Tina4::HTTP_OK)
246
- <span style="color:#c084fc;">end</span></code></pre>
247
- <p style="color:#94a3b8;margin-top:0.75rem;margin-bottom:0;">
248
- Rendered via <code style="color:#c084fc;">template:</code> keyword. Styled with <strong style="color:#e2e8f0;">tina4css</strong> — zero external CDN.
249
- All components above use only <code style="color:#4ade80;">&lt;link href="/css/tina4.min.css"&gt;</code>.
259
+ <span class="tok-dec">end</span></code></pre>
260
+ <p class="build-note">
261
+ Rendered via <code class="tok-dec">template:</code> keyword. Styled with <strong class="build-heading">tina4css</strong> — zero external CDN.
262
+ All components above use only <code class="tok-str">&lt;link href="/css/tina4.min.css"&gt;</code>.
250
263
  </p>
251
264
  </div>
252
265
  </div>
253
266
 
254
267
  </div>
255
268
 
269
+ <script nonce="{{ csp_nonce() }}">
270
+ document.querySelectorAll('.js-dismiss').forEach(function(b){
271
+ b.addEventListener('click', function(){ this.parentElement.remove(); });
272
+ });
273
+ </script>
274
+
256
275
  </body>
257
276
  </html>
data/lib/tina4/graphql.rb CHANGED
@@ -1205,19 +1205,25 @@ module Tina4
1205
1205
  private
1206
1206
 
1207
1207
  def graphiql_html(endpoint)
1208
+ # ADR-0088: no inline style= attribute, and the inline bootstrap <script>
1209
+ # carries this response's CSP nonce. The cross-origin GraphiQL CDN assets
1210
+ # still need TINA4_CSP to list unpkg.com (the default 'self' blocks them),
1211
+ # same as the swagger UI — this is a dev-only playground.
1212
+ nonce = Tina4::Csp.current_nonce
1208
1213
  <<~HTML
1209
1214
  <!DOCTYPE html>
1210
1215
  <html>
1211
1216
  <head>
1212
1217
  <title>GraphiQL — Tina4 Ruby</title>
1213
1218
  <link rel="stylesheet" href="https://unpkg.com/graphiql@3/graphiql.min.css" />
1219
+ <style nonce="#{nonce}">body{margin:0;height:100vh}#graphiql{height:100vh}</style>
1214
1220
  </head>
1215
- <body style="margin:0;height:100vh;">
1216
- <div id="graphiql" style="height:100vh;"></div>
1221
+ <body>
1222
+ <div id="graphiql"></div>
1217
1223
  <script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
1218
1224
  <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
1219
1225
  <script crossorigin src="https://unpkg.com/graphiql@3/graphiql.min.js"></script>
1220
- <script>
1226
+ <script nonce="#{nonce}">
1221
1227
  const fetcher = GraphiQL.createFetcher({ url: '#{endpoint}' });
1222
1228
  ReactDOM.createRoot(document.getElementById('graphiql'))
1223
1229
  .render(React.createElement(GraphiQL, { fetcher }));
@@ -805,7 +805,14 @@ module Tina4
805
805
  [
806
806
  ["X-Frame-Options", ENV["TINA4_FRAME_OPTIONS"] || "SAMEORIGIN"],
807
807
  ["X-Content-Type-Options", "nosniff"],
808
- ["Content-Security-Policy", ENV["TINA4_CSP"] || "default-src 'self'"],
808
+ # ADR-0088: always name a per-response nonce in style-src AND script-src
809
+ # so the framework's own inline <style>/<script> (and app templates using
810
+ # the Frond csp_nonce() global) run under the strict policy without ever
811
+ # adding 'unsafe-inline'. Derives style-src/script-src from default-src /
812
+ # 'self' when TINA4_CSP omits them. The value comes from the request
813
+ # thread-local so it matches the nonce the HTML body stamps on its inline
814
+ # elements.
815
+ ["Content-Security-Policy", Tina4::Csp.resolve_csp_header(Tina4::Csp.current_nonce)],
809
816
  ["Referrer-Policy", ENV["TINA4_REFERRER_POLICY"] || "strict-origin-when-cross-origin"],
810
817
  ["X-XSS-Protection", "0"],
811
818
  ["Permissions-Policy", ENV["TINA4_PERMISSIONS_POLICY"] || "camera=(), microphone=(), geolocation=()"]
@@ -847,11 +854,14 @@ module Tina4
847
854
 
848
855
  @csp_default_warned = true
849
856
  message = "TINA4_CSP is not set, so Tina4 is serving the default Content-Security-Policy " \
850
- "\"default-src 'self'\" on every response. That default blocks runtime-injected " \
851
- "inline styles, cross-origin fonts/scripts/CDNs, data: URIs, and cross-origin " \
852
- "WebSocket/XHR (e.g. a separate API or LiveKit host). If your app uses any of " \
853
- "these, set TINA4_CSP to a policy that allows them (see https://tina4.com); to " \
854
- "silence this notice without changing behaviour, set TINA4_CSP=\"default-src 'self'\"."
857
+ "\"default-src 'self'\" on every response. The framework injects a per-response " \
858
+ "nonce into style-src and script-src, so its own inline <style>/<script> (and " \
859
+ "your templates using the csp_nonce() Frond global) work under this policy. The " \
860
+ "default still blocks cross-origin fonts/scripts/CDNs, data: URIs, and " \
861
+ "cross-origin WebSocket/XHR (e.g. a separate API or LiveKit host). If your app " \
862
+ "uses any of these, set TINA4_CSP to a policy that allows them (see " \
863
+ "https://tina4.com); to silence this notice without changing behaviour, set " \
864
+ "TINA4_CSP=\"default-src 'self'\"."
855
865
  begin
856
866
  Tina4::Log.warning(message)
857
867
  rescue StandardError
@@ -103,6 +103,11 @@ module Tina4
103
103
  # on the response whatever outcome the pipeline produced (200/404/500/413).
104
104
  request_id = Tina4::Log.sanitize_request_id(env["HTTP_X_REQUEST_ID"]) || SecureRandom.hex(4)
105
105
  Tina4::Log.set_request_id(request_id)
106
+ # One CSP nonce per request, published on the thread-local (ADR-0088) so the
107
+ # inline HTML body and the security middleware's CSP header name the SAME
108
+ # value. Cleared in `ensure`, exactly like the request id (an overlapping
109
+ # request on another thread never shares it).
110
+ Tina4::Csp.set_current_nonce(Tina4::Csp.generate_nonce)
106
111
 
107
112
  begin
108
113
  result = dispatch_pipeline(env)
@@ -112,8 +117,10 @@ module Tina4
112
117
  # The request pipeline installs the id before its first log and
113
118
  # clears it in `finally`/`ensure` after its last (Decision 12 /
114
119
  # LOG-Q03), so an overlapping request on another thread can never
115
- # observe a stale id from a request that already finished.
120
+ # observe a stale id from a request that already finished. The CSP
121
+ # nonce has the same lifetime.
116
122
  Tina4::Log.clear_request_id
123
+ Tina4::Csp.clear_current_nonce
117
124
  end
118
125
  end
119
126
 
@@ -374,6 +381,10 @@ module Tina4
374
381
  # zero-dependency — no vendored ~1.4MB swagger-ui-dist). Air-gapped
375
382
  # deployments point TINA4_SWAGGER_UI_CDN at a self-hosted mirror.
376
383
  cdn = (ENV["TINA4_SWAGGER_UI_CDN"] || "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5").sub(%r{/+\z}, "")
384
+ # ADR-0088: nonce the inline bootstrap <script> so it runs under the strict
385
+ # default CSP. The external <script src>/<link> need none ('self' allows
386
+ # same-origin; a CDN still needs TINA4_CSP to list its host).
387
+ nonce = Tina4::Csp.current_nonce
377
388
  html = <<~HTML
378
389
  <!DOCTYPE html>
379
390
  <html lang="en">
@@ -385,7 +396,7 @@ module Tina4
385
396
  <body>
386
397
  <div id="swagger-ui"></div>
387
398
  <script src="#{cdn}/swagger-ui-bundle.js"></script>
388
- <script>
399
+ <script nonce="#{nonce}">
389
400
  SwaggerUIBundle({ url: '/swagger/openapi.json', dom_id: '#swagger-ui' });
390
401
  </script>
391
402
  </body>
@@ -554,6 +565,12 @@ module Tina4
554
565
 
555
566
  def render_landing_page
556
567
  port = ENV["PORT"] || "7145"
568
+ # One CSP nonce for this response (ADR-0088): the <style>/<script> below
569
+ # carry it, and the security middleware names the SAME value in the CSP
570
+ # header. No inline style=/onclick= — a nonce covers a <style>/<script>
571
+ # ELEMENT but never a style or event-handler ATTRIBUTE, so every style
572
+ # lives in a class and every handler is bound with addEventListener.
573
+ nonce = Tina4::Csp.current_nonce
557
574
 
558
575
  # Check deployed state for each gallery item
559
576
  project_src = File.join(@root_dir, "src")
@@ -569,13 +586,13 @@ module Tina4
569
586
 
570
587
  gallery_cards = gallery_items.map do |item|
571
588
  deployed = File.file?(File.join(project_src, item[:file_check]))
572
- deployed_badge = deployed ? '<span style="position:absolute;top:0.75rem;right:0.75rem;background:#22c55e;color:#fff;font-size:0.65rem;padding:0.15rem 0.5rem;border-radius:0.25rem;font-weight:600;">DEPLOYED</span>' : ''
589
+ deployed_badge = deployed ? '<span class="deployed-badge">DEPLOYED</span>' : ''
573
590
  try_btn = if deployed
574
591
  %(<a href="#{item[:try_url]}" class="gbtn gbtn-try" target="_blank">Try It</a>)
575
592
  else
576
- %(<button class="gbtn gbtn-deploy" onclick="deployGallery('#{item[:id]}','#{item[:try_url]}')">Deploy &amp; Try</button>)
593
+ %(<button class="gbtn gbtn-deploy" data-gallery-deploy data-id="#{item[:id]}" data-try-url="#{item[:try_url]}">Deploy &amp; Try</button>)
577
594
  end
578
- view_btn = %(<button class="gbtn gbtn-view" onclick="viewGallery('#{item[:id]}')">View</button>)
595
+ view_btn = %(<button class="gbtn gbtn-view" data-gallery-view data-id="#{item[:id]}">View</button>)
579
596
 
580
597
  <<~CARD
581
598
  <div class="gallery-card">
@@ -584,7 +601,7 @@ module Tina4
584
601
  <div class="icon">#{item[:icon]}</div>
585
602
  <h3>#{item[:name]}</h3>
586
603
  <p>#{item[:desc]}</p>
587
- <div style="display:flex;gap:0.5rem;margin-top:0.75rem;">#{try_btn}#{view_btn}</div>
604
+ <div class="gallery-card-actions">#{try_btn}#{view_btn}</div>
588
605
  </div>
589
606
  CARD
590
607
  end.join
@@ -596,7 +613,7 @@ module Tina4
596
613
  <meta charset="utf-8">
597
614
  <meta name="viewport" content="width=device-width, initial-scale=1">
598
615
  <title>Tina4Ruby</title>
599
- <style>
616
+ <style nonce="#{nonce}">
600
617
  *{margin:0;padding:0;box-sizing:border-box}
601
618
  body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0f172a;color:#e2e8f0;min-height:100vh;display:flex;flex-direction:column;align-items:center;position:relative}
602
619
  .bg-watermark{position:fixed;bottom:-5%;right:-5%;width:45%;opacity:0.04;pointer-events:none;z-index:0}
@@ -638,6 +655,21 @@ module Tina4
638
655
  .view-modal-close:hover{color:#e2e8f0}
639
656
  @keyframes wiggle{0%{transform:rotate(0deg)}15%{transform:rotate(14deg)}30%{transform:rotate(-10deg)}45%{transform:rotate(8deg)}60%{transform:rotate(-4deg)}75%{transform:rotate(2deg)}100%{transform:rotate(0deg)}}
640
657
  .star-wiggle{display:inline-block;transform-origin:center}
658
+ .tagline-sub{font-size:0.95rem;margin-top:-1rem}
659
+ .deployed-badge{position:absolute;top:0.75rem;right:0.75rem;background:#22c55e;color:#fff;font-size:0.65rem;padding:0.15rem 0.5rem;border-radius:0.25rem;font-weight:600}
660
+ .gallery-card-actions{display:flex;gap:0.5rem;margin-top:0.75rem}
661
+ .gallery-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1rem}
662
+ .view-modal-title{margin-bottom:1rem;color:#e2e8f0}
663
+ .vm-intro{color:#94a3b8;margin-bottom:1rem}
664
+ .vm-file-list{list-style:none;padding:0}
665
+ .vm-file{padding:0.25rem 0;color:#4ade80;font-family:monospace;font-size:0.85rem}
666
+ .vm-tryurl{color:#94a3b8;margin-top:1rem}
667
+ .vm-code{color:#38bdf8}
668
+ .tok-comment{color:#64748b}
669
+ .tok-kw{color:#c084fc}
670
+ .tok-str{color:#4ade80}
671
+ .tok-fn{color:#38bdf8}
672
+ .tok-dec{color:#fbbf24}
641
673
  </style>
642
674
  </head>
643
675
  <body>
@@ -646,7 +678,7 @@ module Tina4
646
678
  <img src="/images/tina4-logo-icon.webp" class="logo" alt="Tina4">
647
679
  <h1>Tina4Ruby</h1>
648
680
  <p class="tagline">The Intelligent Native Application 4ramework</p>
649
- <p class="tagline" style="font-size:0.95rem;margin-top:-1rem">Simple. Fast. Human. &nbsp;|&nbsp; Built for AI. Built for you.</p>
681
+ <p class="tagline tagline-sub">Simple. Fast. Human. &nbsp;|&nbsp; Built for AI. Built for you.</p>
650
682
  <div class="actions">
651
683
  <a href="https://tina4.com/ruby" class="btn" target="_blank">Website</a>
652
684
  <a href="/__dev" class="btn">Dev Admin</a>
@@ -664,33 +696,32 @@ module Tina4
664
696
  <div class="section">
665
697
  <div class="card">
666
698
  <h2>Getting Started</h2>
667
- <pre class="code-block"><code><span style="color:#64748b"># app.rb</span>
668
- <span style="color:#c084fc">require</span> <span style="color:#4ade80">"tina4"</span>
699
+ <pre class="code-block"><code><span class="tok-comment"># app.rb</span>
700
+ <span class="tok-kw">require</span> <span class="tok-str">"tina4"</span>
669
701
 
670
- Tina4::Router.<span style="color:#38bdf8">get</span>(<span style="color:#4ade80">"/hello"</span>) <span style="color:#c084fc">do</span> |request, response|
671
- response.<span style="color:#38bdf8">json</span>({ <span style="color:#fbbf24">message:</span> <span style="color:#4ade80">"Hello World!"</span> })
672
- <span style="color:#c084fc">end</span>
702
+ Tina4::Router.<span class="tok-fn">get</span>(<span class="tok-str">"/hello"</span>) <span class="tok-kw">do</span> |request, response|
703
+ response.<span class="tok-fn">json</span>({ <span class="tok-dec">message:</span> <span class="tok-str">"Hello World!"</span> })
704
+ <span class="tok-kw">end</span>
673
705
 
674
- Tina4::WebServer.new(<span style="color:#fbbf24">port:</span> <span style="color:#38bdf8">7145</span>).start <span style="color:#64748b"># starts on port 7145</span></code></pre>
706
+ Tina4::WebServer.new(<span class="tok-dec">port:</span> <span class="tok-fn">7145</span>).start <span class="tok-comment"># starts on port 7145</span></code></pre>
675
707
  </div>
676
708
  </div>
677
709
  <div class="gallery">
678
710
  <h2 id="gallery">Gallery</h2>
679
- <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:1rem;">
711
+ <div class="gallery-grid">
680
712
  #{gallery_cards}
681
713
  </div>
682
714
  </div>
683
715
  <div class="view-modal" id="viewModal">
684
716
  <div class="view-modal-content">
685
- <button class="view-modal-close" onclick="document.getElementById('viewModal').classList.remove('active')">&times;</button>
686
- <h3 id="viewModalTitle" style="margin-bottom:1rem;color:#e2e8f0;"></h3>
717
+ <button class="view-modal-close" data-modal-close>&times;</button>
718
+ <h3 id="viewModalTitle" class="view-modal-title"></h3>
687
719
  <div id="viewModalBody"></div>
688
720
  </div>
689
721
  </div>
690
- <script>
691
- function deployGallery(name, tryUrl) {
722
+ <script nonce="#{nonce}">
723
+ function deployGallery(btn, name, tryUrl) {
692
724
  if (!confirm('Deploy the "' + name + '" gallery example into your project?')) return;
693
- var btn = event.target;
694
725
  btn.disabled = true;
695
726
  btn.textContent = 'Deploying...';
696
727
  fetch('/__dev/api/gallery/deploy', {
@@ -733,18 +764,34 @@ module Tina4
733
764
  var title = document.getElementById('viewModalTitle');
734
765
  var body = document.getElementById('viewModalBody');
735
766
  title.textContent = item.name + ' — ' + item.description;
736
- var html = '<p style="color:#94a3b8;margin-bottom:1rem;">Files that will be deployed:</p><ul style="list-style:none;padding:0;">';
767
+ var html = '<p class="vm-intro">Files that will be deployed:</p><ul class="vm-file-list">';
737
768
  (item.files || []).forEach(function(f) {
738
- html += '<li style="padding:0.25rem 0;color:#4ade80;font-family:monospace;font-size:0.85rem;">src/' + f + '</li>';
769
+ html += '<li class="vm-file">src/' + f + '</li>';
739
770
  });
740
771
  html += '</ul>';
741
772
  if (item.try_url) {
742
- html += '<p style="color:#94a3b8;margin-top:1rem;">Try URL: <code style="color:#38bdf8;">' + item.try_url + '</code></p>';
773
+ html += '<p class="vm-tryurl">Try URL: <code class="vm-code">' + item.try_url + '</code></p>';
743
774
  }
744
775
  body.innerHTML = html;
745
776
  document.getElementById('viewModal').classList.add('active');
746
777
  });
747
778
  }
779
+ // CSP-clean wiring: no inline onclick handlers — bind every gallery
780
+ // button from its data-* attributes (a nonce covers script elements, not
781
+ // event-handler attributes).
782
+ document.querySelectorAll('[data-gallery-deploy]').forEach(function(btn){
783
+ btn.addEventListener('click', function(){
784
+ deployGallery(btn, btn.dataset.id, btn.dataset.tryUrl);
785
+ });
786
+ });
787
+ document.querySelectorAll('[data-gallery-view]').forEach(function(btn){
788
+ btn.addEventListener('click', function(){ viewGallery(btn.dataset.id); });
789
+ });
790
+ document.querySelectorAll('[data-modal-close]').forEach(function(btn){
791
+ btn.addEventListener('click', function(){
792
+ document.getElementById('viewModal').classList.remove('active');
793
+ });
794
+ });
748
795
  document.getElementById('viewModal').addEventListener('click', function(e) {
749
796
  if (e.target === this) this.classList.remove('active');
750
797
  });
@@ -63,7 +63,7 @@ module Tina4
63
63
  TEXT_CONTENT_TYPE = "text/plain; charset=utf-8"
64
64
  XML_CONTENT_TYPE = "application/xml; charset=utf-8"
65
65
 
66
- attr_accessor :status_code, :headers, :body, :cookies
66
+ attr_accessor :status_code, :headers, :body, :cookies, :csp_nonce
67
67
 
68
68
  # ADR-0068: a header name must be an RFC 9110 token, and a header value,
69
69
  # redirect location or cookie attribute may never carry CR, LF or NUL (a
@@ -89,6 +89,11 @@ module Tina4
89
89
  @body = ""
90
90
  @cookies = nil # Lazy -- most responses have no cookies
91
91
  @content_type_from_header = false
92
+ # Per-response CSP nonce (ADR-0088). RackApp#call mints one per request on
93
+ # the thread-local, so every Response built during that request reads the
94
+ # SAME value the security middleware names in the Content-Security-Policy
95
+ # header and the framework's inline <style>/<script> stamp on themselves.
96
+ @csp_nonce = Tina4::Csp.current_nonce
92
97
  end
93
98
 
94
99
  # Chainable status setter