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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -0
- data/lib/tina4/cli.rb +7 -2
- data/lib/tina4/crud.rb +56 -13
- data/lib/tina4/csp.rb +119 -0
- data/lib/tina4/error_overlay.rb +75 -37
- data/lib/tina4/frond.rb +7 -0
- data/lib/tina4/gallery/auth/src/routes/api/gallery_auth.rb +25 -11
- data/lib/tina4/gallery/queue/src/routes/api/gallery_queue.rb +21 -8
- data/lib/tina4/gallery/templates/src/templates/gallery_page.twig +34 -15
- data/lib/tina4/graphql.rb +9 -3
- data/lib/tina4/middleware.rb +16 -6
- data/lib/tina4/rack_app.rb +70 -23
- data/lib/tina4/response.rb +6 -1
- data/lib/tina4/session.rb +145 -10
- data/lib/tina4/template.rb +11 -3
- data/lib/tina4/templates/errors/302.twig +1 -1
- data/lib/tina4/templates/errors/401.twig +2 -2
- data/lib/tina4/templates/errors/403.twig +1 -1
- data/lib/tina4/templates/errors/404.twig +1 -1
- data/lib/tina4/templates/errors/500.twig +1 -1
- data/lib/tina4/templates/errors/502.twig +2 -2
- data/lib/tina4/templates/errors/503.twig +2 -2
- data/lib/tina4/templates/errors/base.twig +4 -1
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +30 -14
- data/lib/tina4.rb +1 -0
- metadata +3 -2
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
|
|
185
|
-
#
|
|
186
|
-
#
|
|
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.
|
|
190
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
data/lib/tina4/template.rb
CHANGED
|
@@ -56,7 +56,11 @@ module Tina4
|
|
|
56
56
|
end
|
|
57
57
|
|
|
58
58
|
def globals
|
|
59
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
data/lib/tina4/webserver.rb
CHANGED
|
@@ -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
|
-
|
|
131
|
-
|
|
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.
|
|
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-
|
|
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
|