tina4ruby 3.13.141 → 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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -0
  3. data/lib/tina4/cli.rb +7 -2
  4. data/lib/tina4/crud.rb +61 -20
  5. data/lib/tina4/csp.rb +119 -0
  6. data/lib/tina4/error_overlay.rb +79 -43
  7. data/lib/tina4/frond.rb +7 -0
  8. data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +25 -11
  9. data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +30 -10
  10. data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +34 -15
  11. data/lib/tina4/graphql.rb +9 -3
  12. data/lib/tina4/job.rb +17 -0
  13. data/lib/tina4/middleware.rb +16 -6
  14. data/lib/tina4/migration.rb +49 -41
  15. data/lib/tina4/queue_backends/kafka_backend.rb +12 -31
  16. data/lib/tina4/queue_backends/rabbitmq_backend.rb +15 -37
  17. data/lib/tina4/queue_backends/republish_retry_policy.rb +35 -0
  18. data/lib/tina4/rack_app.rb +70 -23
  19. data/lib/tina4/response.rb +6 -1
  20. data/lib/tina4/session.rb +145 -10
  21. data/lib/tina4/session_handlers/redis_handler.rb +7 -51
  22. data/lib/tina4/session_handlers/resp_session_handler.rb +75 -0
  23. data/lib/tina4/session_handlers/valkey_handler.rb +6 -51
  24. data/lib/tina4/template.rb +11 -3
  25. data/lib/tina4/templates/errors/302.twig +1 -1
  26. data/lib/tina4/templates/errors/401.twig +2 -2
  27. data/lib/tina4/templates/errors/403.twig +1 -1
  28. data/lib/tina4/templates/errors/404.twig +1 -1
  29. data/lib/tina4/templates/errors/500.twig +1 -1
  30. data/lib/tina4/templates/errors/502.twig +2 -2
  31. data/lib/tina4/templates/errors/503.twig +2 -2
  32. data/lib/tina4/templates/errors/base.twig +4 -1
  33. data/lib/tina4/version.rb +1 -1
  34. data/lib/tina4/webserver.rb +30 -14
  35. data/lib/tina4/websocket.rb +26 -2
  36. data/lib/tina4/websocket_backplane.rb +16 -13
  37. data/lib/tina4.rb +1 -0
  38. metadata +5 -2
@@ -0,0 +1,35 @@
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
+ module Tina4
9
+ module QueueBackends
10
+ # The at-least-once fail policy (ADR-0022) shared by the broker backends
11
+ # whose protocol carries no delivery counter (kafka, rabbitmq). A Kafka
12
+ # record and an AMQP body are both immutable, so recording a failed attempt
13
+ # means RE-PUBLISHING a body that carries the incremented count rather than
14
+ # mutating the message in place.
15
+ #
16
+ # Increment attempts, record the error, dead-letter at the retry limit else
17
+ # re-enqueue, then ack LAST -- so a crash between the re-publish and the ack
18
+ # redelivers the job rather than losing it, which is the contract.
19
+ #
20
+ # The host backend must define +enqueue+, +dead_letter+, +complete+ (its
21
+ # terminal ack) and an +@max_retries+.
22
+ module RepublishRetryPolicy
23
+ def fail(job, error = "")
24
+ job.attempts += 1
25
+ job.error = error
26
+ if job.attempts >= @max_retries
27
+ dead_letter(job)
28
+ else
29
+ enqueue(job)
30
+ end
31
+ complete(job)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -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
data/lib/tina4/session.rb CHANGED
@@ -175,23 +175,72 @@ module Tina4
175
175
  def clear
176
176
  @data = {}
177
177
  @modified = true
178
+ @cleared = true
178
179
  end
179
180
 
180
181
  def to_hash
181
182
  @data.dup
182
183
  end
183
184
 
184
- # Persist the session if dirty. On a backend write failure the error is
185
- # logged and false is returned — the @modified (dirty) flag is RETAINED so
186
- # a later save can retry. Returns true on a successful (or no-op) write.
185
+ # Persist this request's session. On a backend failure the error is logged
186
+ # and false is returned — the @modified (dirty) flag is RETAINED so a later
187
+ # save can retry. Returns true on a successful (or no-op) write.
187
188
  #
188
189
  # A cleared id (@id nil, e.g. after #destroy) is a no-op: there is nothing
189
- # to persist, and a write would re-create the just-destroyed record. Mirrors
190
- # the Python master's `if self._session_id and self._dirty`.
190
+ # to persist, and a write would re-create the just-destroyed record.
191
+ #
192
+ # Another request may have changed or ended this session since this one
193
+ # loaded it: a logout, a #regenerate, a set that took a privilege away.
194
+ # Writing back the whole snapshot loaded at the start undid all of those, so
195
+ # a request in flight across a logout logged the user straight back in. The
196
+ # save therefore re-reads the stored record and writes only what THIS
197
+ # request changed onto it, and never re-creates a record another request
198
+ # removed: it ends the session for this request instead, so no cookie goes
199
+ # out for it.
200
+ #
201
+ # SLIDING EXPIRY (ADR-0087): a started session the store already holds is
202
+ # re-written on EVERY save, even when this request changed nothing. That
203
+ # write re-stamps the backend deadline to now + TINA4_SESSION_TTL, so expiry
204
+ # is measured from the last request that TOUCHED the session, not from its
205
+ # last change — a user actively reading pages stays logged in. The re-write
206
+ # is the SAME re-read, merged record the concurrent-save contract computes,
207
+ # so sliding never clobbers a concurrent change. PHP is the reference. The
208
+ # guards above take precedence over the slide: no id → nothing to slide;
209
+ # a record another request ended → forget + end, never re-created; a failed
210
+ # read → deadline untouched, dirty kept for a retry; TINA4_SESSION_TTL=0 →
211
+ # the slide re-stamps a zero (immortal) deadline (ADR-0027).
212
+ #
213
+ # The slide is scoped to @stored — an id the STORE actually holds (adopted,
214
+ # not freshly minted). ADR-0087's own guard: "a request with no session (no
215
+ # id issued, OR THE ID WAS NEVER ADOPTED) writes nothing — there is no
216
+ # deadline to move." A brand-new, unmodified session (minted id, @stored
217
+ # false) therefore still no-ops, exactly as before; a modified session of
218
+ # any kind still writes.
191
219
  def save
192
- return true unless @id && @modified
193
- if safe_write(@id, @data, @ttl)
220
+ return true unless @id && (@modified || @stored)
221
+
222
+ record = @data
223
+ if @stored
224
+ current, failed = current_record
225
+ # Whether the record still exists is unknown, so nothing is written;
226
+ # the dirty flag is kept for a later retry.
227
+ return false if failed
228
+
229
+ if current.nil?
230
+ forget
231
+ @ended = true
232
+ return true
233
+ end
234
+ record = merged(current) unless @cleared
235
+ end
236
+ if safe_write(@id, record, @ttl)
194
237
  @modified = false
238
+ # An empty record is no session (#start never adopts one), so the next
239
+ # save writes whole rather than take this request's own empty write for
240
+ # a logout.
241
+ @stored = !record.empty?
242
+ @cleared = false
243
+ @loaded = fingerprints(@data)
195
244
  true
196
245
  else
197
246
  false # dirty flag retained for retry
@@ -205,9 +254,7 @@ module Tina4
205
254
  # (nulls sessionId). A fresh session needs a new #start, which mints a new id.
206
255
  def destroy
207
256
  safe_destroy(@id) if @id
208
- @id = nil
209
- @data = {}
210
- @modified = false
257
+ forget
211
258
  end
212
259
 
213
260
  # Get a session value with optional default.
@@ -272,11 +319,32 @@ module Tina4
272
319
  # session fixation (a pre-auth session ID must not survive into the
273
320
  # authenticated session). Destroys the old backend record (best-effort)
274
321
  # and persists under the new ID.
322
+ #
323
+ # What is carried is the session as it is stored now with this request's
324
+ # own changes applied, the same merge #save does. If another request ended
325
+ # the session after this one loaded it (a logout, or a regenerate of its
326
+ # own), it stays ended: nothing is carried, no id is minted and nil is
327
+ # returned, so no cookie goes out to replace the one that request sent.
275
328
  def regenerate
329
+ return nil if @ended
330
+
276
331
  old_id = @id
332
+ if old_id && @stored
333
+ current, failed = current_record
334
+ unless failed
335
+ if current.nil?
336
+ forget
337
+ @ended = true
338
+ return nil
339
+ end
340
+ @data.replace(merged(current)) unless @cleared
341
+ end
342
+ end
277
343
  @id = SecureRandom.hex(32)
278
344
  safe_destroy(old_id)
279
345
  @modified = true
346
+ # Nothing is stored under the new id yet, so the save writes it whole.
347
+ @stored = false
280
348
  save
281
349
  @id
282
350
  end
@@ -383,9 +451,76 @@ module Tina4
383
451
  @id = session_id
384
452
  @data = data
385
453
  @modified = false
454
+ # @stored: this request is working on a record the store holds, so a save
455
+ # must never re-create it once it has gone. An empty record does not
456
+ # count: it is no session. A failed read adopts the id with {}
457
+ # (#existing_session_data): not a record this request has seen, so its
458
+ # first save writes whole.
459
+ @stored = !data.empty?
460
+ # @loaded: one fingerprint per key of what this request last saw stored;
461
+ # #save compares the live data against it to find what THIS request
462
+ # changed. @cleared: #clear ran since the last save, so the save replaces
463
+ # the stored record instead of merging into it.
464
+ @loaded = @stored ? fingerprints(@data) : {}
465
+ @cleared = false
466
+ # @ended: this request found its session ended by another request (the
467
+ # record it loaded is gone). It stays ended for the rest of the request:
468
+ # #regenerate mints nothing either.
469
+ @ended = false
386
470
  @id
387
471
  end
388
472
 
473
+ # Forget the session in memory: no data, no id, nothing left to save. What
474
+ # #destroy does after removing the record, and what #save does when it
475
+ # finds another request already removed it.
476
+ def forget
477
+ @id = nil
478
+ @data = {}
479
+ @modified = false
480
+ @stored = false
481
+ @cleared = false
482
+ @loaded = {}
483
+ end
484
+
485
+ # The record as stored right now, as [data, failed]. nil and empty both mean
486
+ # "no session", exactly as in #existing_session_data.
487
+ def current_record
488
+ return [nil, true] if degraded?
489
+
490
+ data = @handler.read(@id)
491
+ [data.nil? || (data.respond_to?(:empty?) && data.empty?) ? nil : data, false]
492
+ rescue StandardError => e
493
+ log_backend_error("read", e)
494
+ raise if @strict
495
+ [nil, true]
496
+ end
497
+
498
+ # The stored record with only this request's own changes applied to it: the
499
+ # keys it set or changed since it last loaded or saved, minus the keys it
500
+ # removed. Every other key keeps the value the store holds now.
501
+ def merged(current)
502
+ record = current.dup
503
+ @data.each do |key, value|
504
+ now = fingerprint(value)
505
+ record[key] = value if now.nil? || @loaded[key] != now
506
+ end
507
+ @loaded.each_key { |key| record.delete(key) unless @data.key?(key) }
508
+ record
509
+ end
510
+
511
+ def fingerprints(data)
512
+ data.each_with_object({}) { |(key, value), prints| prints[key] = fingerprint(value) }
513
+ end
514
+
515
+ # The stored form of a value, for telling whether a request changed it.
516
+ # nil when it cannot be serialised: that always counts as changed, because
517
+ # writing a value again is harmless and missing a change is not.
518
+ def fingerprint(value)
519
+ JSON.generate(value)
520
+ rescue StandardError
521
+ nil
522
+ end
523
+
389
524
  # The stored data for session_id when the backend HOLDS a session under it,
390
525
  # else nil — strict mode's signal to mint a fresh id rather than adopt one
391
526
  # the client chose.
@@ -7,6 +7,7 @@
7
7
 
8
8
  require "json"
9
9
  require_relative "resp_client"
10
+ require_relative "resp_session_handler"
10
11
 
11
12
  module Tina4
12
13
  module SessionHandlers
@@ -16,7 +17,13 @@ module Tina4
16
17
  # backend did the latter and was retired 2026-07-31 as drift); otherwise
17
18
  # speaks raw RESP over a TCP socket via RespClient — zero dependencies, so a
18
19
  # Tina4 app stores sessions in Redis with no extra gem.
20
+ #
21
+ # read/write/destroy/cleanup and the gem-client build come from
22
+ # RespSessionHandler (shared byte-for-byte with ValkeyHandler); only the
23
+ # env-var namespace below differs.
19
24
  class RedisHandler
25
+ include RespSessionHandler
26
+
20
27
  # Connection is configured from TINA4_SESSION_REDIS_* env vars (parity with
21
28
  # Python's RedisSessionHandler and the Ruby ValkeyHandler shape) so
22
29
  # TINA4_SESSION_BACKEND=redis can actually be pointed at a server by env.
@@ -33,57 +40,6 @@ module Tina4
33
40
  @redis = build_gem_client
34
41
  @resp = @redis ? nil : RespClient.new(host: @host, port: @port, password: @password, db: @db)
35
42
  end
36
-
37
- def read(session_id)
38
- key = "#{@prefix}#{session_id}"
39
- data = @redis ? @redis.get(key) : @resp.get(key)
40
- return nil unless data
41
- JSON.parse(data)
42
- rescue JSON::ParserError
43
- nil
44
- end
45
-
46
- # Write session data. A per-call +ttl+ WINS over the handler default, so
47
- # asking for a 60s session really gets 60s; 0 uses the default. Every
48
- # handler in every Tina4 framework takes this third argument -- Session#write
49
- # passes it, and a handler that did not accept it raised ArgumentError,
50
- # which safe_write swallowed into a silent STALE write.
51
- #
52
- # @param session_id [String] the session id
53
- # @param data [Hash] the payload to store
54
- # @param ttl [Integer] per-call lifetime in seconds; 0 uses the handler default
55
- def write(session_id, data, ttl = 0)
56
- key = "#{@prefix}#{session_id}"
57
- payload = JSON.generate(data)
58
- effective_ttl = ttl.to_i.positive? ? ttl.to_i : @ttl
59
- if @redis
60
- @redis.setex(key, effective_ttl, payload)
61
- else
62
- @resp.setex(key, effective_ttl, payload)
63
- end
64
- end
65
-
66
- def destroy(session_id)
67
- key = "#{@prefix}#{session_id}"
68
- @redis ? @redis.del(key) : @resp.del(key)
69
- end
70
-
71
- def cleanup
72
- # Redis handles TTL automatically
73
- end
74
-
75
- private
76
-
77
- # Use the official `redis` gem only when the REAL gem is loadable (guard
78
- # against an in-test fake `Redis` constant that has no VERSION). Returns nil
79
- # when the gem is absent so the caller falls back to the raw RESP client.
80
- def build_gem_client
81
- require "redis"
82
- return nil unless defined?(::Redis::VERSION)
83
- Redis.new(host: @host, port: @port, db: @db, password: @password)
84
- rescue LoadError
85
- nil
86
- end
87
43
  end
88
44
  end
89
45
  end
@@ -0,0 +1,75 @@
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 "json"
9
+
10
+ module Tina4
11
+ module SessionHandlers
12
+ # Shared RESP-protocol session storage. Redis and Valkey speak the same RESP
13
+ # wire protocol, so reading, writing, destroying and cleaning up a session --
14
+ # and building the optional `redis`-gem client -- are byte-identical between
15
+ # them. Only the env-var namespace differs (TINA4_SESSION_REDIS_* vs
16
+ # TINA4_SESSION_VALKEY_*), which each handler sets in its own initialize.
17
+ #
18
+ # A handler includes this and, in initialize, sets @prefix, @ttl, @host,
19
+ # @port, @db, @password, @redis (the gem client or nil) and @resp (the raw
20
+ # RespClient fallback, used when @redis is nil).
21
+ module RespSessionHandler
22
+ def read(session_id)
23
+ key = "#{@prefix}#{session_id}"
24
+ data = @redis ? @redis.get(key) : @resp.get(key)
25
+ return nil unless data
26
+ JSON.parse(data)
27
+ rescue JSON::ParserError
28
+ nil
29
+ end
30
+
31
+ # Write session data. A per-call +ttl+ WINS over the handler default, so
32
+ # asking for a 60s session really gets 60s; 0 uses the default. Every
33
+ # handler in every Tina4 framework takes this third argument -- Session#write
34
+ # passes it, and a handler that did not accept it raised ArgumentError,
35
+ # which safe_write swallowed into a silent STALE write.
36
+ #
37
+ # @param session_id [String] the session id
38
+ # @param data [Hash] the payload to store
39
+ # @param ttl [Integer] per-call lifetime in seconds; 0 uses the handler default
40
+ def write(session_id, data, ttl = 0)
41
+ key = "#{@prefix}#{session_id}"
42
+ payload = JSON.generate(data)
43
+ effective_ttl = ttl.to_i.positive? ? ttl.to_i : @ttl
44
+ if @redis
45
+ @redis.setex(key, effective_ttl, payload)
46
+ else
47
+ @resp.setex(key, effective_ttl, payload)
48
+ end
49
+ end
50
+
51
+ def destroy(session_id)
52
+ key = "#{@prefix}#{session_id}"
53
+ @redis ? @redis.del(key) : @resp.del(key)
54
+ end
55
+
56
+ def cleanup
57
+ # Redis/Valkey expire keys by TTL automatically -- nothing to sweep.
58
+ end
59
+
60
+ private
61
+
62
+ # Use the official `redis` gem (RESP-compatible with both Redis and
63
+ # Valkey) only when the REAL gem is loadable (guard against an in-test fake
64
+ # `Redis` constant that has no VERSION). Returns nil when the gem is absent
65
+ # so the caller falls back to the raw RESP client.
66
+ def build_gem_client
67
+ require "redis"
68
+ return nil unless defined?(::Redis::VERSION)
69
+ Redis.new(host: @host, port: @port, db: @db, password: @password)
70
+ rescue LoadError
71
+ nil
72
+ end
73
+ end
74
+ end
75
+ end