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.
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.
@@ -56,7 +56,11 @@ module Tina4
56
56
  end
57
57
 
58
58
  def globals
59
- @globals ||= {}
59
+ # csp_nonce() is a built-in template global (ADR-0088): a template can
60
+ # stamp the current response's CSP nonce on an inline <style>/<script>
61
+ # — <style nonce="{{ csp_nonce() }}">. Matches the Frond engine global
62
+ # and the value the security middleware names in the CSP header.
63
+ @globals ||= { "csp_nonce" => -> { Tina4::Csp.current_nonce } }
60
64
  end
61
65
 
62
66
  def add_global(key, value)
@@ -87,7 +91,11 @@ module Tina4
87
91
  error_dirs = TEMPLATE_DIRS.map { |d| File.join(Dir.pwd, d, "errors") }
88
92
  error_dirs << File.join(File.dirname(__FILE__), "templates", "errors")
89
93
 
90
- context = { "code" => code }.merge(data.transform_keys(&:to_s))
94
+ # csp_nonce() (ADR-0088) so the error twigs can carry a nonce on their
95
+ # inline <style>; render_error builds its own context and does not merge
96
+ # globals, so inject it here explicitly.
97
+ context = { "code" => code, "csp_nonce" => -> { Tina4::Csp.current_nonce } }
98
+ .merge(data.transform_keys(&:to_s))
91
99
 
92
100
  error_dirs.each do |dir|
93
101
  %w[.twig .html .erb].each do |ext|
@@ -152,7 +160,7 @@ module Tina4
152
160
 
153
161
  def error_overlay_css(color)
154
162
  <<~CSS
155
- <style>
163
+ <style nonce="#{Tina4::Csp.current_nonce}">
156
164
  * { box-sizing: border-box; margin: 0; padding: 0; }
157
165
  body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
158
166
  .error-card { background: #1e293b; border: 1px solid #334155; border-radius: 1rem; padding: 3rem; text-align: center; max-width: 520px; width: 90%; }
@@ -9,6 +9,6 @@
9
9
  <div class="spinner"></div>
10
10
  <div class="error-title">Redirecting…</div>
11
11
  <div class="error-msg">You are being redirected to a new location.</div>
12
- <div class="error-path" style="color:var(--primary)">{{ redirect_url }}</div>
12
+ <div class="error-path c-primary">{{ redirect_url }}</div>
13
13
  <a href="{{ redirect_url }}" class="error-home">Click here if not redirected</a>
14
14
  {% endblock %}
@@ -1,9 +1,9 @@
1
1
  {% extends "errors/base.twig" %}
2
2
  {% block title %}401 — Unauthorized{% endblock %}
3
3
  {% block content %}
4
- <div class="error-code" style="color:var(--danger)">401</div>
4
+ <div class="error-code c-danger">401</div>
5
5
  <div class="error-title">Unauthorized</div>
6
6
  <div class="error-msg">You need to sign in to access this resource.</div>
7
- <div class="error-path" style="color:var(--danger)">{{ path }}</div>
7
+ <div class="error-path c-danger">{{ path }}</div>
8
8
  <a href="/" class="error-home">Go Home</a>
9
9
  {% endblock %}
@@ -4,7 +4,7 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>403 — Forbidden</title>
7
- <style>
7
+ <style nonce="{{ csp_nonce() }}">
8
8
  * { box-sizing: border-box; margin: 0; padding: 0; }
9
9
  body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
10
10
  .error-card { background: #1e293b; border: 1px solid #334155; border-radius: 1rem; padding: 3rem; text-align: center; max-width: 520px; width: 90%; }
@@ -4,7 +4,7 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>404 - Not Found</title>
7
- <style>
7
+ <style nonce="{{ csp_nonce() }}">
8
8
  * { box-sizing: border-box; margin: 0; padding: 0; }
9
9
  body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
10
10
  .error-card { background: #1e293b; border: 1px solid #334155; border-radius: 1rem; padding: 3rem; text-align: center; max-width: 520px; width: 90%; }
@@ -4,7 +4,7 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>500 - Server Error</title>
7
- <style>
7
+ <style nonce="{{ csp_nonce() }}">
8
8
  * { box-sizing: border-box; margin: 0; padding: 0; }
9
9
  body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
10
10
  .error-card { background: #1e293b; border: 1px solid #334155; border-radius: 1rem; padding: 3rem; text-align: left; max-width: 700px; width: 90%; }
@@ -1,9 +1,9 @@
1
1
  {% extends "errors/base.twig" %}
2
2
  {% block title %}502 — Bad Gateway{% endblock %}
3
3
  {% block content %}
4
- <div class="error-code" style="color:var(--danger)">502</div>
4
+ <div class="error-code c-danger">502</div>
5
5
  <div class="error-title">Bad Gateway</div>
6
6
  <div class="error-msg">The upstream server returned an invalid response.</div>
7
- <div class="error-path" style="color:var(--danger)">{{ path }}</div>
7
+ <div class="error-path c-danger">{{ path }}</div>
8
8
  <a href="/" class="error-home">Go Home</a>
9
9
  {% endblock %}
@@ -4,9 +4,9 @@
4
4
  .error-code { color: var(--warn); }
5
5
  {% endblock %}
6
6
  {% block content %}
7
- <div class="error-code" style="color:var(--warn)">503</div>
7
+ <div class="error-code c-warn">503</div>
8
8
  <div class="error-title">Service Unavailable</div>
9
9
  <div class="error-msg">The server is temporarily unavailable. Please try again shortly.</div>
10
- <div class="error-path" style="color:var(--warn)">{{ path }}</div>
10
+ <div class="error-path c-warn">{{ path }}</div>
11
11
  <a href="/" class="error-home">Go Home</a>
12
12
  {% endblock %}
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  {% block meta %}{% endblock %}
7
7
  <title>{% block title %}Error{% endblock %}</title>
8
- <style>
8
+ <style nonce="{{ csp_nonce() }}">
9
9
  :root {
10
10
  --bg: #0f172a; --surface: #1e293b; --border: #334155;
11
11
  --text: #e2e8f0; --muted: #94a3b8; --primary: #3b82f6;
@@ -23,6 +23,9 @@ body { font-family: var(--font); background: var(--bg); color: var(--text); min-
23
23
  .error-home { display: inline-block; margin-top: 1.5rem; padding: 0.5rem 1.5rem; background: var(--primary); color: #fff; text-decoration: none; border-radius: 0.5rem; font-size: 0.85rem; }
24
24
  .error-home:hover { opacity: 0.9; }
25
25
  .logo { width: 3rem; height: 3rem; margin-bottom: 1rem; opacity: 0.5; }
26
+ .c-danger { color: var(--danger); }
27
+ .c-warn { color: var(--warn); }
28
+ .c-primary { color: var(--primary); }
26
29
  {% block extra_styles %}{% endblock %}
27
30
  </style>
28
31
  </head>
data/lib/tina4/version.rb CHANGED
@@ -7,5 +7,5 @@
7
7
 
8
8
 
9
9
  module Tina4
10
- VERSION = "3.13.142"
10
+ VERSION = "3.13.144"
11
11
  end
@@ -126,20 +126,9 @@ module Tina4
126
126
  # drains in-flight requests (bounded by TINA4_SHUTDOWN_TIMEOUT).
127
127
  Tina4::Shutdown.setup(server: @server)
128
128
 
129
- # Test port (port + 1000) — stable, no-browser
130
- no_ai_port = %w[true 1 yes].include?(ENV.fetch("TINA4_NO_AI_PORT", "").downcase)
131
- is_debug = %w[true 1 yes].include?(ENV.fetch("TINA4_DEBUG", "").downcase)
132
-
133
- if is_debug && !no_ai_port
134
- ai_port = @port + 1000
135
- begin
136
- # Tagged so the pipeline suppresses live reload on this port.
137
- @server.listen(@host, ai_port, Tina4::AiPortRackApp.new(@app))
138
- puts " Test Port: http://localhost:#{ai_port} (stable — no hot-reload)"
139
- rescue Errno::EADDRINUSE
140
- puts " Test Port: SKIPPED (port #{ai_port} in use)"
141
- end
142
- end
129
+ # Test port (port + 1000) — stable, no-browser. Extracted so its bind
130
+ # rescue stays out of #start's cyclomatic complexity.
131
+ bind_auxiliary_ai_port
143
132
 
144
133
  @server.start
145
134
 
@@ -156,6 +145,33 @@ module Tina4
156
145
  Tina4::PortTakeover.remove_pidfile(@port)
157
146
  end
158
147
 
148
+ # Bind the auxiliary AI/test port (main port + 1000), a debug-only, stable,
149
+ # no-hot-reload sibling of the main port. It is a CONVENIENCE, never
150
+ # load-bearing: any bind failure here must DEGRADE to no-aux-port and leave
151
+ # the main server the operator asked for untouched. The rescue used to catch
152
+ # only Errno::EADDRINUSE, so any OTHER bind failure propagated out of #start
153
+ # and crashed the whole boot: a transient getaddrinfo/Socket::ResolutionError
154
+ # on a fresh host under process churn (a measured macOS artifact),
155
+ # Errno::EADDRNOTAVAIL, Errno::EACCES. Catch the broad bind-failure set
156
+ # (SocketError covers Socket::ResolutionError; SystemCallError covers every
157
+ # Errno::*), log LOUD so it is never silent, and carry on main-port only.
158
+ # Parity with Python (`except OSError`) and PHP (`\Throwable`).
159
+ def bind_auxiliary_ai_port
160
+ no_ai_port = %w[true 1 yes].include?(ENV.fetch("TINA4_NO_AI_PORT", "").downcase)
161
+ is_debug = %w[true 1 yes].include?(ENV.fetch("TINA4_DEBUG", "").downcase)
162
+ return unless is_debug && !no_ai_port
163
+
164
+ ai_port = @port + 1000
165
+ # Tagged so the pipeline suppresses live reload on this port.
166
+ @server.listen(@host, ai_port, Tina4::AiPortRackApp.new(@app))
167
+ puts " Test Port: http://localhost:#{ai_port} (stable — no hot-reload)"
168
+ rescue Errno::EADDRINUSE
169
+ puts " Test Port: SKIPPED (port #{ai_port} in use)"
170
+ rescue SocketError, SystemCallError => e
171
+ Tina4::Log.error("Test Port: SKIPPED — auxiliary port #{ai_port} bind failed (#{e.class}: #{e.message})")
172
+ puts " Test Port: SKIPPED (#{e.class})"
173
+ end
174
+
159
175
  # Dispatch a Rack-style env through the Tina4 app and return [status, headers, body].
160
176
  #
161
177
  # Useful for testing and embedding — does not require a running server.
data/lib/tina4.rb CHANGED
@@ -12,6 +12,7 @@ require_relative "tina4/constants"
12
12
  require_relative "tina4/log"
13
13
  require_relative "tina4/debug" # backward compat alias
14
14
  require_relative "tina4/env"
15
+ require_relative "tina4/csp" # per-response CSP nonce (ADR-0088) — before response
15
16
  require_relative "tina4/sqlite_path_resolver"
16
17
  require_relative "tina4/row_symbolizer"
17
18
  require_relative "tina4/router"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tina4ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.13.142
4
+ version: 3.13.144
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tina4 Team
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-30 00:00:00.000000000 Z
11
+ date: 2026-10-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: sqlite3
@@ -212,6 +212,7 @@ files:
212
212
  - lib/tina4/context/chunker.rb
213
213
  - lib/tina4/cors.rb
214
214
  - lib/tina4/crud.rb
215
+ - lib/tina4/csp.rb
215
216
  - lib/tina4/database.rb
216
217
  - lib/tina4/database/sqlite3_adapter.rb
217
218
  - lib/tina4/database_adapter.rb