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
@@ -7,15 +7,27 @@
7
7
  # Gallery: Auth — JWT login with a visual demo page.
8
8
 
9
9
  Tina4::Router.get("/gallery/auth") do |request, response|
10
+ # ADR-0088: this page's inline <style>/<script> carry the response's CSP nonce
11
+ # so they run under the strict default policy; no inline style=/onclick=.
12
+ nonce = response.csp_nonce
10
13
  html = <<~HTML
11
14
  <!DOCTYPE html>
12
15
  <html lang="en">
13
16
  <head>
14
17
  <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
15
18
  <title>Auth Demo</title><link rel="stylesheet" href="/css/tina4.min.css">
19
+ <style nonce="#{nonce}">
20
+ .auth-demo{max-width:600px}
21
+ .is-hidden{display:none}
22
+ .token-box{word-break:break-all;white-space:pre-wrap;color:#4ade80;background:#1e293b;padding:1rem;border-radius:0.5rem}
23
+ .payload-box{color:#38bdf8;background:#1e293b;padding:1rem;border-radius:0.5rem}
24
+ .howto-card{border:1px solid #334155}
25
+ .howto-heading{color:#e2e8f0}
26
+ .howto-code{background:#0f172a;color:#4ade80;padding:1rem;border-radius:0.5rem;font-size:0.8rem}
27
+ </style>
16
28
  </head>
17
29
  <body class="bg-dark text-light">
18
- <div class="container mt-5" style="max-width:600px;">
30
+ <div class="container mt-5 auth-demo">
19
31
  <h2 class="mb-4">JWT Authentication Demo</h2>
20
32
  <div class="card mb-4">
21
33
  <div class="card-header bg-primary text-white">Login</div>
@@ -28,35 +40,35 @@ Tina4::Router.get("/gallery/auth") do |request, response|
28
40
  <label class="form-label">Password</label>
29
41
  <input type="password" id="password" class="form-control" placeholder="secret" value="secret">
30
42
  </div>
31
- <button class="btn btn-primary" onclick="doLogin()">Login</button>
43
+ <button class="btn btn-primary" id="loginBtn">Login</button>
32
44
  </div>
33
45
  </div>
34
- <div id="result" style="display:none;">
46
+ <div id="result" class="is-hidden">
35
47
  <div class="card mb-3">
36
48
  <div class="card-header bg-success text-white">Token Received</div>
37
49
  <div class="card-body">
38
- <pre id="token" style="word-break:break-all;white-space:pre-wrap;color:#4ade80;background:#1e293b;padding:1rem;border-radius:0.5rem;"></pre>
50
+ <pre id="token" class="token-box"></pre>
39
51
  </div>
40
52
  </div>
41
53
  <div class="card mb-3">
42
54
  <div class="card-header">Token Payload (decoded)</div>
43
55
  <div class="card-body">
44
- <pre id="payload" style="color:#38bdf8;background:#1e293b;padding:1rem;border-radius:0.5rem;"></pre>
56
+ <pre id="payload" class="payload-box"></pre>
45
57
  </div>
46
58
  </div>
47
- <button class="btn btn-outline-info" onclick="verifyToken()">Verify Token</button>
59
+ <button class="btn btn-outline-info" id="verifyBtn">Verify Token</button>
48
60
  <span id="verify-result" class="ms-2"></span>
49
61
  </div>
50
- <div class="card bg-dark mt-4" style="border:1px solid #334155;">
62
+ <div class="card bg-dark mt-4 howto-card">
51
63
  <div class="card-body">
52
- <h6 style="color:#e2e8f0;">How it works</h6>
53
- <pre style="background:#0f172a;color:#4ade80;padding:1rem;border-radius:0.5rem;font-size:0.8rem;"><code>token = Tina4::Auth.create_token({ username: "admin" })
64
+ <h6 class="howto-heading">How it works</h6>
65
+ <pre class="howto-code"><code>token = Tina4::Auth.create_token({ username: "admin" })
54
66
  payload = Tina4::Auth.get_payload(token)
55
67
  result = Tina4::Auth.validate_token(token)</code></pre>
56
68
  </div>
57
69
  </div>
58
70
  </div>
59
- <script>
71
+ <script nonce="#{nonce}">
60
72
  var currentToken = '';
61
73
  function doLogin() {
62
74
  fetch('/api/gallery/auth/login', {
@@ -77,7 +89,7 @@ Tina4::Router.get("/gallery/auth") do |request, response|
77
89
  } catch(e) {
78
90
  document.getElementById('payload').textContent = 'Could not decode';
79
91
  }
80
- document.getElementById('result').style.display = 'block';
92
+ document.getElementById('result').classList.remove('is-hidden');
81
93
  document.getElementById('verify-result').textContent = '';
82
94
  } else {
83
95
  alert(d.error || 'Login failed');
@@ -95,6 +107,8 @@ Tina4::Router.get("/gallery/auth") do |request, response|
95
107
  }
96
108
  });
97
109
  }
110
+ document.getElementById('loginBtn').addEventListener('click', doLogin);
111
+ document.getElementById('verifyBtn').addEventListener('click', verifyToken);
98
112
  </script>
99
113
  </body></html>
100
114
  HTML
@@ -39,6 +39,13 @@ end
39
39
 
40
40
  GALLERY_QUEUE_MAX_RETRIES = 3
41
41
 
42
+ # The next message to consume or fail: the highest-priority pending one whose
43
+ # delay has passed. LIMIT 1 keeps the database from sorting and returning every
44
+ # pending row when the demo only ever wants the first (Carbonah E003).
45
+ GALLERY_QUEUE_NEXT_PENDING_SQL =
46
+ "SELECT * FROM tina4_queue WHERE topic = ? AND status = 'pending' " \
47
+ "AND available_at <= ? ORDER BY priority DESC, id ASC LIMIT 1"
48
+
42
49
  GALLERY_QUEUE_HTML = <<~'HTML'
43
50
  <!DOCTYPE html>
44
51
  <html lang="en">
@@ -60,7 +67,7 @@ GALLERY_QUEUE_HTML = <<~'HTML'
60
67
  <div class="card-body">
61
68
  <div class="d-flex gap-2">
62
69
  <input type="text" id="msgInput" class="form-control" placeholder="Enter a task message, e.g. send-email">
63
- <button class="btn btn-primary" onclick="produce()">Produce</button>
70
+ <button class="btn btn-primary" id="produceBtn">Produce</button>
64
71
  </div>
65
72
  </div>
66
73
  </div>
@@ -69,10 +76,10 @@ GALLERY_QUEUE_HTML = <<~'HTML'
69
76
  <div class="card">
70
77
  <div class="card-header">Actions</div>
71
78
  <div class="card-body d-flex gap-2 flex-wrap">
72
- <button class="btn btn-success" onclick="consume()">Consume Next</button>
73
- <button class="btn btn-danger" onclick="failNext()">Fail Next</button>
74
- <button class="btn btn-warning" onclick="retryFailed()">Retry Failed</button>
75
- <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>
76
83
  </div>
77
84
  </div>
78
85
  </div>
@@ -105,7 +112,7 @@ GALLERY_QUEUE_HTML = <<~'HTML'
105
112
  </div>
106
113
  </div>
107
114
 
108
- <script>
115
+ <script nonce="__CSP_NONCE__">
109
116
  function statusBadge(status) {
110
117
  var colors = {pending:"primary", reserved:"warning", completed:"success", failed:"danger", dead:"secondary"};
111
118
  var color = colors[status] || "secondary";
@@ -115,7 +122,7 @@ function statusBadge(status) {
115
122
  function showAlert(msg, type) {
116
123
  var area = document.getElementById("alertArea");
117
124
  area.innerHTML = '<div class="alert alert-' + type + ' alert-dismissible">' + msg +
118
- '<button type="button" class="btn-close" onclick="this.parentElement.remove()"></button></div>';
125
+ '<button type="button" class="btn-close js-alert-close"></button></div>';
119
126
  setTimeout(function(){ area.innerHTML = ""; }, 3000);
120
127
  }
121
128
 
@@ -189,6 +196,16 @@ async function retryFailed() {
189
196
  refresh();
190
197
  }
191
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
+
192
209
  refresh();
193
210
  setInterval(refresh, 2000);
194
211
  </script>
@@ -199,7 +216,10 @@ HTML
199
216
  # ── Render the interactive HTML page ──────────────────────────
200
217
 
201
218
  Tina4::Router.get("/gallery/queue") do |request, response|
202
- 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))
203
223
  end
204
224
 
205
225
  # ── Produce — add a message to the queue ──────────────────────
@@ -273,7 +293,7 @@ Tina4::Router.post("/api/gallery/queue/consume") do |request, response|
273
293
  now = _gallery_queue_now
274
294
 
275
295
  row = db.fetch_one(
276
- "SELECT * FROM tina4_queue WHERE topic = ? AND status = 'pending' AND available_at <= ? ORDER BY priority DESC, id ASC",
296
+ GALLERY_QUEUE_NEXT_PENDING_SQL,
277
297
  ["gallery-tasks", now]
278
298
  )
279
299
 
@@ -295,7 +315,7 @@ Tina4::Router.post("/api/gallery/queue/fail") do |request, response|
295
315
  now = _gallery_queue_now
296
316
 
297
317
  row = db.fetch_one(
298
- "SELECT * FROM tina4_queue WHERE topic = ? AND status = 'pending' AND available_at <= ? ORDER BY priority DESC, id ASC",
318
+ GALLERY_QUEUE_NEXT_PENDING_SQL,
299
319
  ["gallery-tasks", now]
300
320
  )
301
321
 
@@ -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 }));
data/lib/tina4/job.rb CHANGED
@@ -13,6 +13,23 @@ module Tina4
13
13
  attr_reader :id, :topic, :payload, :created_at, :priority, :available_at
14
14
  attr_accessor :status, :attempts, :error, :queue
15
15
 
16
+ # Rebuild a Job from a parsed queue payload (a Hash with string keys, as
17
+ # produced by #to_json and read back off the wire). attempts and error MUST
18
+ # ride along: rebuilding from topic/payload/id alone reset attempts to 0 on
19
+ # every redelivery, so fail()'s attempts >= max_retries check could never
20
+ # trip and a poison job would be retried forever instead of dead-lettering.
21
+ # Shared by the broker backends (kafka, rabbitmq) whose wire format is this
22
+ # JSON.
23
+ def self.from_payload(data)
24
+ new(
25
+ topic: data["topic"],
26
+ payload: data["payload"],
27
+ id: data["id"],
28
+ attempts: data["attempts"] || 0,
29
+ error: data["error"]
30
+ )
31
+ end
32
+
16
33
  def initialize(topic:, payload:, id: nil, priority: 0, available_at: nil,
17
34
  attempts: 0, created_at: nil, error: nil, queue: nil)
18
35
  @id = id || SecureRandom.uuid
@@ -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
@@ -492,56 +492,23 @@ module Tina4
492
492
 
493
493
  # Block comment /* … */ — stripped.
494
494
  if ch == "/" && i + 1 < n && sql[i + 1] == "*"
495
- endpos = sql.index("*/", i + 2)
496
- i = endpos ? endpos + 2 : n
495
+ i = skip_block_comment(sql, i, n)
497
496
  next
498
497
  end
499
498
 
500
499
  # Line comment -- … — stripped to end of line; the newline is left for the
501
500
  # next iteration so line structure (and NEXT-line boundaries) survive.
502
501
  if ch == "-" && i + 1 < n && sql[i + 1] == "-"
503
- endpos = sql.index("\n", i + 2)
504
- i = endpos || n
502
+ i = skip_line_comment(sql, i, n)
505
503
  next
506
504
  end
507
505
 
508
- # Single-quoted string literal — '' escapes a quote. Copied verbatim.
509
- if ch == "'"
510
- current << "'"
511
- i += 1
512
- while i < n
513
- if sql[i] == "'" && i + 1 < n && sql[i + 1] == "'"
514
- current << "''"
515
- i += 2
516
- elsif sql[i] == "'"
517
- current << "'"
518
- i += 1
519
- break
520
- else
521
- current << sql[i]
522
- i += 1
523
- end
524
- end
525
- next
526
- end
527
-
528
- # Double-quoted identifier — "" escapes a quote. Same verbatim handling.
529
- if ch == '"'
530
- current << '"'
531
- i += 1
532
- while i < n
533
- if sql[i] == '"' && i + 1 < n && sql[i + 1] == '"'
534
- current << '""'
535
- i += 2
536
- elsif sql[i] == '"'
537
- current << '"'
538
- i += 1
539
- break
540
- else
541
- current << sql[i]
542
- i += 1
543
- end
544
- end
506
+ # '…' single-quoted string or "…" double-quoted identifier — copied
507
+ # verbatim, honouring the SQL doubled-quote escape ('' / ""). A ;, -- or
508
+ # /* inside a literal is data, not a delimiter or comment.
509
+ if ch == "'" || ch == '"'
510
+ literal, i = read_quoted_literal(sql, i, ch)
511
+ current << literal
545
512
  next
546
513
  end
547
514
 
@@ -592,6 +559,47 @@ module Tina4
592
559
  m && m[1]
593
560
  end
594
561
 
562
+ # Skip a /* … */ block comment that opens at +start_index+. Returns the index
563
+ # just past the closing "*/", or +length+ when the comment is unterminated.
564
+ def skip_block_comment(sql, start_index, length)
565
+ endpos = sql.index("*/", start_index + 2)
566
+ endpos ? endpos + 2 : length
567
+ end
568
+
569
+ # Skip a -- … line comment that opens at +start_index+. Returns the index of
570
+ # the terminating newline (left for the next iteration so line structure
571
+ # survives), or +length+ when the comment runs to the end of the script.
572
+ def skip_line_comment(sql, start_index, length)
573
+ sql.index("\n", start_index + 2) || length
574
+ end
575
+
576
+ # Read a '…' string literal or "…" quoted identifier that opens at the
577
+ # +quote+ at +start_index+. A doubled quote ('' or "") is an escaped quote,
578
+ # not a terminator, so it is copied and scanning continues. An unterminated
579
+ # literal is read to the end of the script (matching the raw scan it replaces).
580
+ #
581
+ # @return [Array(String, Integer)] the verbatim literal (both quotes included)
582
+ # and the index just past the closing quote.
583
+ def read_quoted_literal(sql, start_index, quote)
584
+ n = sql.length
585
+ literal = quote.dup
586
+ i = start_index + 1
587
+ while i < n
588
+ if sql[i] == quote && i + 1 < n && sql[i + 1] == quote
589
+ literal << quote << quote
590
+ i += 2
591
+ elsif sql[i] == quote
592
+ literal << quote
593
+ i += 1
594
+ break
595
+ else
596
+ literal << sql[i]
597
+ i += 1
598
+ end
599
+ end
600
+ [literal, i]
601
+ end
602
+
595
603
  def execute_sql_file(file)
596
604
  sql = File.read(file)
597
605
  statements = split_sql_statements(sql)
@@ -7,10 +7,15 @@
7
7
 
8
8
 
9
9
  require_relative "../parse_json"
10
+ require_relative "republish_retry_policy"
10
11
 
11
12
  module Tina4
12
13
  module QueueBackends
13
14
  class KafkaBackend
15
+ # Shared at-least-once fail() (ADR-0022): re-publish carrying the new
16
+ # attempt count, dead-letter at the limit, ack last. See RabbitmqBackend.
17
+ include RepublishRetryPolicy
18
+
14
19
  def initialize(options = {})
15
20
  require "rdkafka"
16
21
  @brokers = options[:brokers] || "localhost:9092"
@@ -131,17 +136,10 @@ module Tina4
131
136
  data = Tina4.parse_json(msg.payload)
132
137
  @last_message = msg
133
138
 
134
- # attempts and error MUST be carried back. Rebuilding from
135
- # topic/payload/id alone reset attempts to 0 on every redelivery, so
136
- # fail()'s attempts >= max_retries check could never trip and a poison
137
- # record would be re-produced forever instead of dead-lettering.
138
- Tina4::Job.new(
139
- topic: data["topic"],
140
- payload: data["payload"],
141
- id: data["id"],
142
- attempts: data["attempts"] || 0,
143
- error: data["error"]
144
- )
139
+ # attempts and error MUST be carried back (Job.from_payload does this);
140
+ # rebuilding from topic/payload/id alone reset attempts to 0 on every
141
+ # redelivery, so a poison record would be re-produced forever.
142
+ Tina4::Job.from_payload(data)
145
143
  rescue Rdkafka::RdkafkaError
146
144
  nil
147
145
  end
@@ -186,26 +184,9 @@ module Tina4
186
184
  acknowledge(message)
187
185
  end
188
186
 
189
- # Record a failed attempt, then retry it or dead-letter it.
190
- #
191
- # This did not exist. Job#fail guarded on respond_to?(:fail) and silently
192
- # degraded to in-memory bookkeeping, so job.fail() NEVER reached Kafka:
193
- # the offset was never committed and no dead letter was produced.
194
- #
195
- # A retry RE-PRODUCES a record carrying the new count, because a Kafka
196
- # record is immutable and carries no delivery counter. The offset is
197
- # committed LAST so a crash in between redelivers rather than loses -
198
- # at-least-once, which is the contract.
199
- def fail(job, error = "")
200
- job.attempts += 1
201
- job.error = error
202
- if job.attempts >= @max_retries
203
- dead_letter(job)
204
- else
205
- enqueue(job)
206
- end
207
- acknowledge(job)
208
- end
187
+ # fail() comes from RepublishRetryPolicy (re-produce carrying the new
188
+ # count, dead-letter at the limit, commit the offset LAST). Kafka's
189
+ # +complete+ is +acknowledge+, so the terminal ack commits the offset.
209
190
 
210
191
  def retry(job, delay_seconds: 0)
211
192
  if delay_seconds.to_f > 0
@@ -7,10 +7,15 @@
7
7
 
8
8
 
9
9
  require_relative "../parse_json"
10
+ require_relative "republish_retry_policy"
10
11
 
11
12
  module Tina4
12
13
  module QueueBackends
13
14
  class RabbitmqBackend
15
+ # Shared at-least-once fail() (ADR-0022): re-publish carrying the new
16
+ # attempt count, dead-letter at the limit, ack last. See KafkaBackend.
17
+ include RepublishRetryPolicy
18
+
14
19
  def initialize(options = {})
15
20
  require "bunny"
16
21
  @connection = Bunny.new(
@@ -71,17 +76,10 @@ module Tina4
71
76
  return nil unless payload
72
77
 
73
78
  data = Tina4.parse_json(payload)
74
- # attempts and error MUST be carried back. Rebuilding the Job from
75
- # topic/payload/id alone reset attempts to 0 on every redelivery, so
76
- # fail()'s attempts >= max_retries check could never trip and a poison
77
- # job would be retried forever instead of dead-lettering.
78
- msg = Tina4::Job.new(
79
- topic: data["topic"],
80
- payload: data["payload"],
81
- id: data["id"],
82
- attempts: data["attempts"] || 0,
83
- error: data["error"]
84
- )
79
+ # attempts and error MUST be carried back (Job.from_payload does this);
80
+ # rebuilding from topic/payload/id alone reset attempts to 0 on every
81
+ # redelivery, so a poison job would be retried forever.
82
+ msg = Tina4::Job.from_payload(data)
85
83
  @last_delivery_tag = delivery_info.delivery_tag
86
84
  msg
87
85
  end
@@ -108,32 +106,12 @@ module Tina4
108
106
  dlq.publish(message.to_json, persistent: true)
109
107
  end
110
108
 
111
- # Record a failed attempt, then retry it or dead-letter it.
112
- #
113
- # This did not exist. Job#fail guarded on respond_to?(:fail) and silently
114
- # degraded to in-memory bookkeeping, so job.fail() NEVER reached the
115
- # broker: the delivery stayed unacked, no dead letter was written, and
116
- # both failed() and dead_letters() reported nothing. The job was lost as
117
- # far as the application could see.
118
- #
119
- # AMQP basic.nack(requeue=true) returns the ORIGINAL body unmodified --
120
- # the protocol carries no delivery counter -- so a retry RE-PUBLISHES a
121
- # body carrying the new count instead. That is what every AMQP client
122
- # that counts attempts does (Celery, Spring AMQP's
123
- # RepublishMessageRecoverer, laravel-queue-rabbitmq).
124
- def fail(job, error = "")
125
- job.attempts += 1
126
- job.error = error
127
- if job.attempts >= @max_retries
128
- dead_letter(job)
129
- else
130
- enqueue(job)
131
- end
132
- # Ack LAST: the re-publish (or dead-letter) is durable before the
133
- # original leaves the queue, so a crash in between redelivers rather
134
- # than loses. That is at-least-once, which is the contract.
135
- complete(job)
136
- end
109
+ # fail() comes from RepublishRetryPolicy. AMQP basic.nack(requeue=true)
110
+ # returns the ORIGINAL body unmodified -- the protocol carries no delivery
111
+ # counter -- so the shared policy RE-PUBLISHES a body carrying the new
112
+ # count, then complete() acks LAST. That is what every AMQP client that
113
+ # counts attempts does (Celery, Spring AMQP's RepublishMessageRecoverer,
114
+ # laravel-queue-rabbitmq).
137
115
 
138
116
  # Explicit re-queue requested by the caller (job.retry). Always
139
117
  # re-enqueues regardless of the retry limit -- a manual override,