tina4ruby 3.13.94 → 3.13.96

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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +883 -0
  3. data/README.md +1 -1
  4. data/lib/tina4/auth.rb +166 -87
  5. data/lib/tina4/auto_crud.rb +29 -32
  6. data/lib/tina4/cache_backends/base_backend.rb +19 -0
  7. data/lib/tina4/cache_backends/database_backend.rb +29 -0
  8. data/lib/tina4/cache_backends/memcached_backend.rb +124 -13
  9. data/lib/tina4/cache_backends/memory_backend.rb +15 -0
  10. data/lib/tina4/cache_backends/redis_backend.rb +173 -52
  11. data/lib/tina4/cache_backends.rb +10 -1
  12. data/lib/tina4/cli.rb +23 -39
  13. data/lib/tina4/cors.rb +186 -30
  14. data/lib/tina4/database/sqlite3_adapter.rb +4 -1
  15. data/lib/tina4/database.rb +322 -22
  16. data/lib/tina4/database_adapter.rb +178 -0
  17. data/lib/tina4/database_result.rb +63 -17
  18. data/lib/tina4/database_url.rb +363 -0
  19. data/lib/tina4/dev.rb +0 -1
  20. data/lib/tina4/dev_admin.rb +118 -20
  21. data/lib/tina4/dispatch_pipeline.rb +605 -0
  22. data/lib/tina4/docstore.rb +274 -60
  23. data/lib/tina4/drivers/firebird_driver.rb +118 -4
  24. data/lib/tina4/drivers/mongodb_driver.rb +19 -4
  25. data/lib/tina4/drivers/mssql_driver.rb +73 -10
  26. data/lib/tina4/drivers/mysql_driver.rb +71 -4
  27. data/lib/tina4/drivers/odbc_driver.rb +40 -4
  28. data/lib/tina4/drivers/postgres_driver.rb +97 -10
  29. data/lib/tina4/drivers/sqlite_driver.rb +21 -2
  30. data/lib/tina4/env.rb +176 -34
  31. data/lib/tina4/field_types.rb +12 -0
  32. data/lib/tina4/health.rb +30 -14
  33. data/lib/tina4/job.rb +15 -5
  34. data/lib/tina4/log.rb +236 -32
  35. data/lib/tina4/mcp.rb +11 -5
  36. data/lib/tina4/messenger.rb +248 -36
  37. data/lib/tina4/metrics.rb +179 -891
  38. data/lib/tina4/middleware.rb +191 -56
  39. data/lib/tina4/migration.rb +17 -1
  40. data/lib/tina4/orm.rb +114 -17
  41. data/lib/tina4/public/css/tina4.min.css +1 -1
  42. data/lib/tina4/queue.rb +154 -9
  43. data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
  44. data/lib/tina4/queue_backends/lite_backend.rb +121 -25
  45. data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
  46. data/lib/tina4/queue_backends/rabbitmq_backend.rb +194 -1
  47. data/lib/tina4/rack_app.rb +94 -316
  48. data/lib/tina4/request.rb +48 -8
  49. data/lib/tina4/response.rb +42 -1
  50. data/lib/tina4/response_cache.rb +142 -24
  51. data/lib/tina4/router.rb +141 -12
  52. data/lib/tina4/session.rb +243 -29
  53. data/lib/tina4/session_handlers/database_handler.rb +185 -20
  54. data/lib/tina4/session_handlers/file_handler.rb +113 -21
  55. data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
  56. data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
  57. data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
  58. data/lib/tina4/session_handlers/redis_handler.rb +20 -6
  59. data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
  60. data/lib/tina4/shutdown.rb +180 -30
  61. data/lib/tina4/sql_translator.rb +110 -0
  62. data/lib/tina4/swagger.rb +50 -18
  63. data/lib/tina4/version.rb +1 -1
  64. data/lib/tina4/webserver.rb +28 -6
  65. data/lib/tina4.rb +289 -37
  66. metadata +35 -17
  67. data/lib/tina4/scss_compiler.rb +0 -349
@@ -0,0 +1,605 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tina4
4
+ # The dispatch pipeline: the concerns of RackApp#call, named and ordered.
5
+ #
6
+ # #call was one 260-line function at cyclomatic complexity 53 against a
7
+ # ceiling of 10, on the path of every single request. Splitting it into a
8
+ # module is not only about that number: rack_app.rb also holds static file
9
+ # serving, the swagger UI, WebSocket upgrades and the error pages, so the
10
+ # pipeline was interleaved with four unrelated subsystems in one 999-line
11
+ # file. This is the pipeline, on its own, readable end to end.
12
+ #
13
+ # Mixed into RackApp, so a stage can still call the app's helpers
14
+ # (#handle_route, #try_static, #dev_mode?) directly.
15
+ module DispatchPipeline
16
+ # ── The stages ────────────────────────────────────────
17
+ #
18
+ # #call was one 260-line function at cyclomatic complexity 53 against a
19
+ # ceiling of 10, and it is on the path of every single request. The stages
20
+ # below are that function's concerns, named and ordered as DATA so the
21
+ # pipeline can be read, tested and compared across the four frameworks
22
+ # without reading an implementation.
23
+ #
24
+ # Two phases, because the function genuinely has two:
25
+ #
26
+ # REQUEST_STAGES run in order until one RETURNS a Rack triple. That
27
+ # triple is the response; later request stages do not
28
+ # run. `match_route` is terminal - it always produces
29
+ # one.
30
+ # ALWAYS_STAGES run over the triple no matter how it was produced -
31
+ # including the swagger and static branches, which
32
+ # return early and skip everything else.
33
+ # RESPONSE_STAGES run in order over the triple, each returning a new
34
+ # triple or nil to leave it unchanged. These are the
35
+ # post-processing steps (logging, injection, session
36
+ # save) that only apply to a dispatched response.
37
+ #
38
+ # Contract each stage obeys, asserted by spec/dispatch_pipeline_spec.rb:
39
+ # * it takes (ctx) or (ctx, response) and nothing else - no stage reads a
40
+ # local of #call, because there are none left to read
41
+ # * it never calls another stage directly; ordering lives in these lists.
42
+ # `method_not_allowed` and `not_found` are therefore STAGES, not
43
+ # helpers called by `match_route` - the fallback chain is expressed as
44
+ # list order like everything else
45
+ # * a request stage returning nil means "not mine, keep going"
46
+ #
47
+ # Ordering is BEHAVIOUR here, not taste: dev routes must beat route
48
+ # matching, the pre-match middleware must run before the match so its
49
+ # headers survive a 401 (ADR-0012), and static resolution happens inside
50
+ # `match_route`'s not-found fallback because routes beat files (ADR-0010).
51
+ REQUEST_STAGES = %i[
52
+ reset_request_caches
53
+ cors_preflight
54
+ websocket_upgrade
55
+ dev_routes
56
+ feedback_routes
57
+ global_middleware_pre
58
+ match_route
59
+ method_not_allowed
60
+ not_found
61
+ ].freeze
62
+
63
+ # Runs on EVERY response, including the ones that bypass the rest.
64
+ #
65
+ # RFC 9110 s9.3.2 is not conditional: a HEAD response MUST NOT carry
66
+ # content, whatever produced it. This used to live in RESPONSE_STAGES, so
67
+ # the swagger and static branches - which return early - skipped it, and
68
+ # `HEAD /style.css` shipped the whole file body. Measured 2026-07-31: Ruby
69
+ # returned 15 bytes where PHP, Python and Node all returned 0.
70
+ ALWAYS_STAGES = %i[
71
+ head_strip
72
+ apply_cors
73
+ ].freeze
74
+
75
+ RESPONSE_STAGES = %i[
76
+ dev_inspector_capture
77
+ request_log
78
+ dev_toolbar_inject
79
+ feedback_inject
80
+ session_save
81
+ ].freeze
82
+
83
+ # ── The route pipeline ───────────────────────────────────────────
84
+ #
85
+ # #handle_route was cyclomatic complexity 24 in one 118-line function, with
86
+ # the same two-phase shape as #call. These stages run in order until one
87
+ # returns a Rack triple (a 401/403 or a short-circuiting middleware); if
88
+ # none does, the handler is invoked and the result finalised.
89
+ #
90
+ # Ordering is BEHAVIOUR, decided and written down (ADR-0012): the post-match
91
+ # global middleware runs BEFORE the auth gate so a rate limiter can throttle
92
+ # a brute-force login and an access log records the 401, while the route's
93
+ # OWN middleware runs AFTER it, so middleware attached to a secured route
94
+ # never processes an unauthenticated request.
95
+ ROUTE_STAGES = %i[
96
+ prepare_route_request
97
+ global_middleware_post
98
+ route_auth_handler
99
+ route_auth_gate
100
+ route_middleware
101
+ ].freeze
102
+
103
+ # Per-route state shared between route stages.
104
+ RouteContext = Struct.new(
105
+ :env, :route, :path_params, :pre_request, :pre_response,
106
+ :request, :response,
107
+ keyword_init: true
108
+ )
109
+
110
+ # Per-request state shared between stages.
111
+ #
112
+ # This exists so a stage can be called on its own with nothing but a
113
+ # context - the alternative is stages reading each other's locals, which is
114
+ # the coupling the extraction is removing. `bypass_response_stages` records
115
+ # an EXISTING quirk rather than introducing one: the swagger and static
116
+ # branches used to `return` straight out of #call, skipping every
117
+ # post-processing step. See the comment on #match_route.
118
+ DispatchContext = Struct.new(
119
+ :env, :method, :path, :started_at,
120
+ :pre_request, :pre_response, :matched_pattern, :matched,
121
+ :bypass_response_stages,
122
+ keyword_init: true
123
+ )
124
+
125
+ private
126
+
127
+ # ── REQUEST STAGES ───────────────────────────────────────────────
128
+ # Each returns a Rack triple to answer the request, or nil to pass.
129
+
130
+ # Request-scoped query cache boundary (v3.13.23). Tina4 Ruby runs a
131
+ # long-running Rack server, so the request-scoped DB cache (default-on)
132
+ # would otherwise serve rows from a previous request. Clear it on every
133
+ # live connection at the very start of each request, before any routing.
134
+ # No-op for persistent-mode (TINA4_DB_CACHE=true) connections.
135
+ def reset_request_caches(_ctx)
136
+ Tina4::Database.reset_request_caches if defined?(Tina4::Database)
137
+ nil
138
+ end
139
+
140
+ # Fast-path: CORS preflight. Real CORS preflight requests carry an Origin
141
+ # header AND an Access-Control-Request-Method header - the browser is
142
+ # asking "may I send this method?" before the actual request. If neither is
143
+ # present, the OPTIONS is a plain protocol-introspection request (link
144
+ # checker, monitoring probe, RFC 9110 s9.3.7 OPTIONS) and must fall through
145
+ # to the router's generic Allow-header response. Otherwise we would shadow
146
+ # the framework's own OPTIONS support and force every operator to
147
+ # hand-register CORS exceptions for every introspection client.
148
+ def cors_preflight(ctx)
149
+ return nil unless ctx.method == "OPTIONS"
150
+ return nil unless ctx.env["HTTP_ORIGIN"] || ctx.env["HTTP_ACCESS_CONTROL_REQUEST_METHOD"]
151
+
152
+ # Carry the resource's REAL method set as Allow (RFC 9110 s9.3.7) so a
153
+ # preflight answers the same question a bare OPTIONS does, on top of the
154
+ # CORS policy headers. See ADR-0013.
155
+ Tina4::CorsMiddleware.preflight_response(
156
+ ctx.env, allow: Tina4::Router.methods_allowed_for_path(ctx.path)
157
+ )
158
+ end
159
+
160
+ # WebSocket upgrade - match against registered ws_routes.
161
+ def websocket_upgrade(ctx)
162
+ return nil unless websocket_upgrade?(ctx.env)
163
+
164
+ ws_result = Tina4::Router.find_ws_route(ctx.path)
165
+ return nil unless ws_result
166
+
167
+ ws_route, ws_params = ws_result
168
+ handle_websocket_upgrade(ctx.env, ws_route, ws_params)
169
+ end
170
+
171
+ # Dev dashboard routes (handled before anything else).
172
+ def dev_routes(ctx)
173
+ return nil unless ctx.path.start_with?("/__dev")
174
+
175
+ # Block live-reload endpoint on the AI port - AI tools must get stable
176
+ # responses.
177
+ if ctx.path == "/__dev_reload" && ctx.env["tina4.ai_port"]
178
+ return [404, { "content-type" => "text/plain" }, ["Not available on AI port"]]
179
+ end
180
+
181
+ Tina4::DevAdmin.handle_request(ctx.env)
182
+ end
183
+
184
+ # Customer feedback widget routes (parity with Python's /__feedback/*
185
+ # surface - see tina4/feedback.rb). Always available - the master switch
186
+ # (TINA4_ENABLE_FEEDBACK) is enforced INSIDE handle_request so route shape
187
+ # stays stable across environments.
188
+ def feedback_routes(ctx)
189
+ return nil unless ctx.path.start_with?("/__feedback")
190
+
191
+ Tina4::Feedback.handle_request(ctx.env)
192
+ end
193
+
194
+ # PRE-MATCH global middleware. The request/response pair is built HERE,
195
+ # before matching, so CORS and anything else that must survive a
196
+ # short-circuit can set headers that outlive a 401/403 - a browser shown a
197
+ # 401 with no CORS headers reports a CORS error and hides the real status.
198
+ #
199
+ # The SAME pair is threaded into handle_route; building a second one would
200
+ # silently discard whatever the pre-match pass set, which is the entire
201
+ # point of running it. So this stage ALWAYS runs and always populates the
202
+ # context, even when no pre-match middleware is registered.
203
+ def global_middleware_pre(ctx)
204
+ ctx.pre_request = Tina4::Request.new(ctx.env)
205
+ ctx.pre_request.user = ctx.env["tina4.auth_payload"] if ctx.env["tina4.auth_payload"]
206
+ ctx.pre_response = Tina4::Response.new
207
+
208
+ middleware = Tina4::Middleware.pre_match_middleware
209
+ return nil if middleware.empty?
210
+ return nil if Tina4::Middleware.run_before(middleware, ctx.pre_request, ctx.pre_response)
211
+
212
+ Tina4::Middleware.run_after(middleware, ctx.pre_request, ctx.pre_response)
213
+ ctx.pre_response.to_rack
214
+ end
215
+
216
+ # Match a route and run it. Returns nil when nothing matched, so the
217
+ # next stages (#method_not_allowed, then #not_found) get their turn.
218
+ #
219
+ # ROUTES BEAT FILES (ADR-0010): static assets and the swagger UI are
220
+ # resolved in the not-found fallback, only once no route has claimed the
221
+ # path. A file in public/ can arrive from a build step or a careless
222
+ # deploy, and it must never silently shadow a reviewed route. This also
223
+ # retired the `unless path.start_with?("/api/")` guard that used to wrap
224
+ # the static check - a partial patch for exactly that hazard.
225
+ def match_route(ctx)
226
+ result = Tina4::Router.match(ctx.method, ctx.path)
227
+ ctx.matched = result
228
+
229
+ return nil unless result
230
+
231
+ route, path_params = result
232
+ ctx.matched_pattern = route.path
233
+ handle_route(ctx.env, route, path_params, ctx.pre_request, ctx.pre_response)
234
+ end
235
+
236
+ # RFC 9110 conformance - before falling through to 404, check whether the
237
+ # PATH is known to the router under any OTHER method.
238
+ # - OPTIONS -> 204 with Allow (s9.3.7). A bare OPTIONS on an unknown
239
+ # path also returns 204 (empty Allow): OPTIONS is a discovery method,
240
+ # and rejecting unknown probes with 404 confuses link checkers and
241
+ # monitoring tools.
242
+ # - Any other method (PUT on GET-only, TRACE, CONNECT) -> 405 with Allow
243
+ # (s15.5.6 + s10.2.1) when the path exists.
244
+ # Returns nil when nothing about the path is known, so #not_found runs.
245
+ def method_not_allowed(ctx)
246
+ allowed = Tina4::Router.methods_allowed_for_path(ctx.path)
247
+
248
+ if ctx.method.to_s.upcase == "OPTIONS"
249
+ allow_header = allowed.empty? ? "" : allowed.join(", ")
250
+ return [204, { "allow" => allow_header, "content-length" => "0" }, [""]]
251
+ end
252
+
253
+ return nil if allowed.empty?
254
+
255
+ allow_header = allowed.join(", ")
256
+ body = %({"error":"Method Not Allowed","path":"#{ctx.path}","method":"#{ctx.method}","allow":[#{allowed.map { |m| %("#{m}") }.join(",")}],"status":405})
257
+ [405, {
258
+ "allow" => allow_header,
259
+ "content-type" => "application/json",
260
+ "content-length" => body.bytesize.to_s
261
+ }, [body]]
262
+ end
263
+
264
+ # No route claimed the path. NOW try the swagger UI and the filesystem -
265
+ # after matching, per ADR-0010.
266
+ #
267
+ # The swagger and static branches set `bypass_response_stages`, which
268
+ # PRESERVES an existing quirk rather than introducing one: they used to
269
+ # `return` straight out of #call, so they skipped HEAD stripping, the dev
270
+ # toolbar, feedback injection and the session save. That is why a HEAD
271
+ # request for a static file currently returns a body, in violation of RFC
272
+ # 9110 s9.3.2. Recorded as a finding and fixed separately with its own test
273
+ # pair - NOT silently inside this extraction.
274
+ def not_found(ctx)
275
+ if ctx.path == "/swagger" || ctx.path == "/swagger/"
276
+ ctx.bypass_response_stages = true
277
+ return serve_swagger_ui
278
+ elsif ctx.path == "/swagger/openapi.json"
279
+ ctx.bypass_response_stages = true
280
+ return serve_openapi_json
281
+ end
282
+
283
+ static_response = try_static(ctx.path, ctx.env)
284
+ if static_response
285
+ ctx.bypass_response_stages = true
286
+ return static_response
287
+ end
288
+
289
+ handle_404(ctx.path)
290
+ end
291
+
292
+ # ── RESPONSE STAGES ──────────────────────────────────────────────
293
+ # Each returns a replacement Rack triple, or nil to leave it unchanged.
294
+
295
+ # RFC 9110 s9.3.2: a HEAD response MUST NOT include content. Strip the body
296
+ # unconditionally and record what Content-Length the GET would have sent -
297
+ # cache validators, link checkers and monitoring probes use that header to
298
+ # estimate sizes.
299
+ def head_strip(ctx, response)
300
+ return nil unless ctx.method.to_s.upcase == "HEAD"
301
+
302
+ status, headers, body_parts = response
303
+ joined = body_parts.respond_to?(:join) ? body_parts.join : body_parts.to_s
304
+ return nil if joined.empty?
305
+
306
+ new_headers = headers.dup
307
+ new_headers["content-length"] = joined.bytesize.to_s
308
+ [status, new_headers, [""]]
309
+ end
310
+
311
+ # Stamp the CORS policy headers on every response.
312
+ #
313
+ # This was the gap that made Ruby's CORS unusable: CorsMiddleware only ever
314
+ # answered the PREFLIGHT (see #cors_preflight), and `apply_headers` - the
315
+ # method that handles the ACTUAL response - was never called from anywhere
316
+ # in the dispatch path. Measured 2026-07-31 through the real Rack app: a
317
+ # preflight came back 204 with Access-Control-Allow-Origin, then the real
318
+ # GET came back 200 with no CORS headers at all, so the browser blocked it.
319
+ # The preflight said "yes you may" and the response did not follow through.
320
+ #
321
+ # It lives in ALWAYS_STAGES, not RESPONSE_STAGES, so the headers survive a
322
+ # short-circuited 401/403 and the swagger/static early-return branches. A
323
+ # browser shown a 401 without CORS headers reports a CORS error and the
324
+ # real status never reaches the developer debugging it (ADR-0012).
325
+ #
326
+ # A preflight already carries them from #cors_preflight, so re-applying
327
+ # would be harmless but wasteful - policy_headers is idempotent either way.
328
+ def apply_cors(ctx, response)
329
+ status, headers, body = response
330
+ new_headers = headers.dup
331
+ Tina4::CorsMiddleware.apply_headers(new_headers, ctx.env)
332
+ return nil if new_headers == headers
333
+
334
+ [status, new_headers, body]
335
+ end
336
+
337
+ # Capture the request for the dev inspector.
338
+ def dev_inspector_capture(ctx, response)
339
+ return nil unless dev_mode? && !ctx.path.start_with?("/__dev")
340
+
341
+ Tina4::DevAdmin.request_inspector.capture(
342
+ method: ctx.method,
343
+ path: ctx.path,
344
+ status: response[0],
345
+ duration: elapsed_ms(ctx)
346
+ )
347
+ nil
348
+ end
349
+
350
+ # Request log line (v3.13.14). The dev inspector only feeds the /__dev UI -
351
+ # it never reached stdout, so `tina4ruby serve` printed the banner then went
352
+ # silent. Emit a per-request line through Tina4::Log so it lands on stdout
353
+ # (docker logs / k8s). On by default in dev, opt-in in production via
354
+ # TINA4_LOG_REQUESTS. Same format across all four frameworks.
355
+ def request_log(ctx, response)
356
+ return nil unless request_logging_enabled? && !ctx.path.start_with?("/__dev")
357
+
358
+ Tina4::Log.info("#{ctx.method} #{ctx.path} -> #{response[0]} (#{elapsed_ms(ctx)}ms)")
359
+ nil
360
+ end
361
+
362
+ # Inject the dev overlay button for HTML responses in dev mode.
363
+ def dev_toolbar_inject(ctx, response)
364
+ return nil unless dev_mode? && !ctx.path.start_with?("/__dev")
365
+
366
+ status, headers, body_parts = response
367
+ content_type = headers["content-type"] || ""
368
+ return nil unless content_type.include?("text/html")
369
+
370
+ request_info = {
371
+ method: ctx.method,
372
+ path: ctx.path,
373
+ matched_pattern: ctx.matched_pattern || "(no match)"
374
+ }
375
+ overlay = inject_dev_overlay(body_parts.join, request_info, ai_port: ctx.env["tina4.ai_port"])
376
+ [status, headers, [overlay]]
377
+ end
378
+
379
+ # Customer feedback widget injection - runs LAST of the injectors so its
380
+ # <script> tag survives any earlier post-processing. No-op if disabled
381
+ # (TINA4_ENABLE_FEEDBACK off), the user is not whitelisted, the path is
382
+ # /__dev or /__feedback, or the body is not text/html with a closing
383
+ # </body>. Mirrors Python's server.py call site.
384
+ def feedback_inject(ctx, response)
385
+ status, headers, body_parts = response
386
+ content_type = headers["content-type"] || ""
387
+ return nil unless content_type.include?("text/html") && body_parts.respond_to?(:join)
388
+
389
+ joined = body_parts.join
390
+ return nil unless joined.include?("</body>")
391
+
392
+ injected = Tina4::Feedback.inject_feedback_widget(
393
+ Struct.new(:path, :env).new(ctx.path, ctx.env), joined
394
+ )
395
+ return nil if injected == joined
396
+
397
+ new_headers = headers.dup
398
+ new_headers["content-length"] = injected.bytesize.to_s if new_headers["content-length"]
399
+ [status, new_headers, [injected]]
400
+ rescue StandardError
401
+ # Injection is best-effort - never break the response.
402
+ nil
403
+ end
404
+
405
+ # Save the session and set the cookie if a session was used.
406
+ def session_save(ctx, response)
407
+ return nil unless ctx.matched
408
+
409
+ request_obj = ctx.env["tina4.request"]
410
+ return nil unless request_obj&.instance_variable_get(:@session)
411
+
412
+ status, headers, body_parts = response
413
+ sess = request_obj.session
414
+ sess.save
415
+
416
+ # Probabilistic garbage collection (~1% of requests).
417
+ if rand(1..100) == 1
418
+ begin
419
+ sess.gc
420
+ rescue StandardError
421
+ # GC failure is non-critical - silently ignore
422
+ end
423
+ end
424
+
425
+ # Read the INCOMING session cookie by the SAME configured name the write
426
+ # side emits - via the one shared resolver (Session.cookie_name), exact
427
+ # `name=` prefix. A hardcoded "tina4_session=" here never matches a cookie
428
+ # renamed through TINA4_SESSION_NAME, so the auto-Set-Cookie would be
429
+ # needlessly re-emitted on every request that already carries the renamed
430
+ # cookie. Parity with Python's core/server._init_session cookie_prefix.
431
+ sid = sess.id
432
+ cookie_prefix = "#{Tina4::Session.cookie_name}="
433
+ cookie_val = (ctx.env["HTTP_COOKIE"] || "").split(";").map(&:strip)
434
+ .find { |part| part.start_with?(cookie_prefix) }
435
+ &.slice(cookie_prefix.length..)
436
+ if sid && sid != cookie_val
437
+ # Route through Session#cookie_header rather than hand-writing the
438
+ # header, so TINA4_SESSION_SECURE / _SAMESITE / _HTTPONLY / _NAME / _TTL
439
+ # are all honoured and Secure reflects the request scheme. The old
440
+ # hand-written literal ignored every attribute except TTL and hardcoded
441
+ # SameSite=Lax + HttpOnly, making the security env vars silent no-ops
442
+ # (issue #31).
443
+ headers["set-cookie"] = sess.cookie_header
444
+ end
445
+ [status, headers, body_parts]
446
+ end
447
+
448
+ # Milliseconds since the request entered the pipeline.
449
+ def elapsed_ms(ctx)
450
+ ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - ctx.started_at) * 1000).round(3)
451
+ end
452
+
453
+ # ── ROUTE STAGES ─────────────────────────────────────────────────
454
+ # Each returns a Rack triple to answer the request, or nil to pass.
455
+
456
+ # Reuse the pre-match pair when one was built, so anything the pre-match
457
+ # middleware set (headers, request.user) reaches the handler. Path params
458
+ # are only known now, so they are attached here.
459
+ def prepare_route_request(ctx)
460
+ ctx.request = ctx.pre_request || Tina4::Request.new(ctx.env)
461
+ ctx.request.path_params = ctx.path_params
462
+ ctx.env["tina4.request"] = ctx.request # Store for session save after response
463
+ ctx.response = ctx.pre_response || Tina4::Response.new
464
+ nil
465
+ end
466
+
467
+ # POST-match global middleware (block-based + class-based before_* methods).
468
+ # It runs after the route matched, because middleware like CSRF reads the
469
+ # matched route's metadata to honour no_auth. Pre-match middleware already
470
+ # ran in #call.
471
+ #
472
+ # M2 - AFTER-ON-4xx RULE: when a before_* short-circuits (4xx/skip) or
473
+ # throws (clean 500), the after-pass STILL runs so after_* can add
474
+ # headers/logging - consistent across all 4 frameworks.
475
+ def global_middleware_post(ctx)
476
+ middleware = Tina4::Middleware.post_match_middleware
477
+ return nil if Tina4::Middleware.run_before(middleware, ctx.request, ctx.response)
478
+
479
+ Tina4::Middleware.run_after(middleware, ctx.request, ctx.response)
480
+ ctx.response.to_rack
481
+ end
482
+
483
+ # Legacy per-route auth_handler.
484
+ def route_auth_handler(ctx)
485
+ return nil unless ctx.route.auth_handler
486
+ return nil if ctx.route.auth_handler.call(ctx.env)
487
+
488
+ handle_403(ctx.env["PATH_INFO"] || "/")
489
+ end
490
+
491
+ # Secure-by-default: enforce bearer-token auth on write routes.
492
+ #
493
+ # Extracted onto the class so the in-process TestClient enforces the EXACT
494
+ # same gate (parity with Python #PY2 - a tokenless write must 401 in tests
495
+ # too, or a green test hides a live 401 and the verification lies).
496
+ def route_auth_gate(ctx)
497
+ unauthorized = self.class.enforce_route_auth(ctx.env, ctx.route)
498
+
499
+ if unauthorized
500
+ # Carry the middleware headers onto the 401. This is the case the
501
+ # pre/post split exists for: a browser shown a 401 with no CORS headers
502
+ # reports a CORS error, so the real status never reaches the developer.
503
+ # enforce_route_auth builds a bare Rack tuple (a class method shared
504
+ # with TestClient, with no Response object), so the merge happens here.
505
+ # ctx.response carries BOTH middleware passes, not just pre-match.
506
+ if ctx.response.respond_to?(:headers) && ctx.response.headers.is_a?(Hash)
507
+ status, headers, body = unauthorized
508
+ # The auth tuple wins on a genuine clash - it owns content-type.
509
+ unauthorized = [status, ctx.response.headers.merge(headers), body]
510
+ end
511
+ return unauthorized
512
+ end
513
+
514
+ # The verified JWT payload is only on env once the gate has run.
515
+ ctx.request.user = ctx.env["tina4.auth_payload"] if ctx.env["tina4.auth_payload"]
516
+ nil
517
+ end
518
+
519
+ # Per-route class-based middleware — its before_* pass.
520
+ def route_middleware(ctx)
521
+ return nil unless ctx.route.respond_to?(:run_middleware)
522
+ return nil if ctx.route.run_middleware(ctx.request, ctx.response)
523
+
524
+ # AFTER-ON-HALT: the route's own after_* hooks still run when one of its
525
+ # before_* hooks short-circuited, exactly as the global ones do.
526
+ ctx.route.run_after_middleware(ctx.request, ctx.response) if ctx.route.respond_to?(:run_after_middleware)
527
+
528
+ # Send the response the middleware SET. This used to be a hardcoded
529
+ # [403, {"content-type" => "text/html"}, ["403 Forbidden"]], which threw
530
+ # away the middleware's own answer: a 401 with a WWW-Authenticate header,
531
+ # a 302 to /login and a JSON error body all arrived as the same bare 403.
532
+ # A middleware that halts having set nothing still gets a 403 — that is
533
+ # applied by the return-value table (Tina4::Middleware.refuse), not here.
534
+ ctx.response.to_rack
535
+ end
536
+
537
+ # Invoke the route handler, wrapped in any function-style middleware.
538
+ #
539
+ # The call is built as a lambda so function-style middleware can wrap it.
540
+ # Path params are still bound by name - the continuation just forwards the
541
+ # (possibly-mutated) request/response pair the outer middleware passed in.
542
+ #
543
+ # Function middleware folds into a Russian-doll chain around the handler:
544
+ # first declared is the OUTERMOST layer, receiving the request first,
545
+ # calling next_handler to descend, and running its "after" code on the way
546
+ # out. Class-based middleware (before_*/after_*) never comes through here:
547
+ # its before_* pass is #route_middleware above and its after_* pass is
548
+ # #finalise_route_response below, both via Tina4::Middleware.
549
+ # tina4-book#141 PY-10-01 (cross-framework parity).
550
+ def invoke_route_handler(ctx)
551
+ handler_params = ctx.route.handler.parameters.map(&:last)
552
+ route_params = ctx.path_params || {}
553
+
554
+ invoke = lambda do |req, resp|
555
+ args = handler_params.map do |name|
556
+ if route_params.key?(name)
557
+ route_params[name]
558
+ elsif name == :request || name == :req
559
+ req
560
+ else
561
+ resp
562
+ end
563
+ end
564
+ args.empty? ? ctx.route.handler.call : ctx.route.handler.call(*args)
565
+ end
566
+
567
+ fn_mws = ctx.route.respond_to?(:function_middleware) ? ctx.route.function_middleware : []
568
+ return invoke.call(ctx.request, ctx.response) if fn_mws.empty?
569
+
570
+ chain = invoke
571
+ fn_mws.reverse_each do |mw|
572
+ inner = chain
573
+ chain = ->(req, resp) { mw.call(req, resp, inner) }
574
+ end
575
+ chain.call(ctx.request, ctx.response)
576
+ end
577
+
578
+ # Turn the handler's return value into a Rack triple.
579
+ def finalise_route_response(ctx, result)
580
+ # Template rendering: when a template is set and the handler returned a
581
+ # Hash, render the template with the hash as data and return the HTML.
582
+ if ctx.route.template && result.is_a?(Hash)
583
+ ctx.response.html(Tina4::Template.render(ctx.route.template, result))
584
+ return ctx.response.to_rack
585
+ end
586
+
587
+ # Skip auto_detect if the handler already returned the response object.
588
+ final = result.equal?(ctx.response) ? result : Tina4::Response.auto_detect(result, ctx.response)
589
+
590
+ # Global after middleware (block-based + class-based after_* methods).
591
+ Tina4::Middleware.run_after(Tina4::Middleware.global_middleware, ctx.request, final)
592
+
593
+ # The route's OWN class middleware after_* hooks. Same orchestrator and
594
+ # same discovery as the global pass. Scope order matches the before pass
595
+ # (global, then route) — after hooks are deliberately NOT unwound in
596
+ # reverse; that decision is being taken separately.
597
+ ctx.route.run_after_middleware(ctx.request, final) if ctx.route.respond_to?(:run_after_middleware)
598
+
599
+ # Inject FreshToken when a body formToken was used for auth.
600
+ final.add_header("FreshToken", ctx.env["tina4.fresh_token"]) if ctx.env["tina4.fresh_token"]
601
+
602
+ final.to_rack
603
+ end
604
+ end
605
+ end