tina4ruby 3.13.159 → 3.13.160

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c8eaa3629bc154d2dfc5419385857a0f9c3a8ede768af9363664c80448be9e19
4
- data.tar.gz: aefe567a6fb3cf128750add20e11dc7683325a14b978708c57010ac5d4062bbe
3
+ metadata.gz: 04d0f0c9549b131ac6cc66c25af963c658b14a34c5f1690c17c46b2df16b0ab5
4
+ data.tar.gz: 8ae3b7fb96142e89e7db778c790f45b53b763e1d2531870bbda84a5cf5422c83
5
5
  SHA512:
6
- metadata.gz: 2041d862f935b2a6611323856b8cfc7ed376009f1a0d7a52583f9ada47eeb4da1298e01c8ef6b48f17bbc71bd2d2c13bf4ae712d1004ba80636c772e18ab358f
7
- data.tar.gz: 1d4d7726c126abab28b2f6261b1f5961249e8a2a8ca77e579941379c7943705d91094b0e2caf7701193a1f633ce7ccdd59a5287b1e1bc111ee438c70d3301cda
6
+ metadata.gz: 5f50bfc2ae1a344b6398760b720caf2fc50cf9b18858ce64c2cd2ea1be4422537952a74028c34aea84244bd84e42e05f387f3c1e7830be48b7eaca7c0d8d58f7
7
+ data.tar.gz: 3a8666bf0297807ddaee244b20d64e187dd76af5d8004555f10b19c79e6015d5620e42f86fab423b16ba6ac85d7d0347028491659798967cc36626e756e80547
data/CHANGELOG.md CHANGED
@@ -6,6 +6,24 @@ number means the same thing everywhere.
6
6
  **The authoritative release notes for every shipped version live in the documentation:**
7
7
  https://tina4.com/ruby/36-releases
8
8
 
9
+ ## 3.13.160 - 2026-11-07
10
+
11
+ ### Fixed
12
+ - The secure-by-default auth gate answered a tokenless request to a protected route with
13
+ a raw `401 {"error": "Unauthorized"}` JSON tuple whatever the client's `Accept`, and a
14
+ role/permission-guarded (RBAC) route answered a bare `403` the same way — so a browser
15
+ that posted a form after its session expired saw raw JSON instead of the error page. Both
16
+ now route through a shared negotiated renderer (`RackApp.negotiated_error_tuple`), the same
17
+ negotiation every other framework error already used (ERR-DEC-02): a browser gets
18
+ `errors/401.twig` / `errors/403.twig`, an API client the
19
+ `{error, code, message, status, request_id}` envelope (#342).
20
+ - `Tina4::Middleware.middleware_500` answered a raw `500 {"error": "Internal Server Error",
21
+ "status": 500}` body regardless of `Accept` and with no request id. It now negotiates the
22
+ same way — `errors/500.twig` for a browser, the JSON envelope with the request id for an
23
+ API client — while keeping the exception out of the body (CWE-209) (#342).
24
+ - `401` gained its `UNAUTHORIZED` entry in the error-code-name map, so the negotiated 401
25
+ JSON envelope carries a real code rather than `HTTP_401`.
26
+
9
27
  ## 3.13.159 - 2026-11-06
10
28
 
11
29
  ### Fixed
@@ -114,7 +114,7 @@ module Tina4
114
114
  begin
115
115
  result = entry[:handler].call(request, response)
116
116
  rescue StandardError, ScriptError => error
117
- middleware_500(response, "before handler", error)
117
+ middleware_500(response, "before handler", error, request)
118
118
  return false
119
119
  end
120
120
  return false if result == false
@@ -126,7 +126,7 @@ module Tina4
126
126
  begin
127
127
  result = klass.send(method_name, request, response)
128
128
  rescue StandardError, ScriptError => error
129
- middleware_500(response, "#{class_label(klass)}.#{method_name}", error)
129
+ middleware_500(response, "#{class_label(klass)}.#{method_name}", error, request)
130
130
  return false
131
131
  end
132
132
 
@@ -174,7 +174,7 @@ module Tina4
174
174
  begin
175
175
  entry[:handler].call(request, response)
176
176
  rescue StandardError, ScriptError => error
177
- middleware_500(response, "after handler", error)
177
+ middleware_500(response, "after handler", error, request)
178
178
  end
179
179
  end
180
180
 
@@ -184,7 +184,7 @@ module Tina4
184
184
  begin
185
185
  result = klass.send(method_name, request, response)
186
186
  rescue StandardError, ScriptError => error
187
- middleware_500(response, "#{class_label(klass)}.#{method_name}", error)
187
+ middleware_500(response, "#{class_label(klass)}.#{method_name}", error, request)
188
188
  next
189
189
  end
190
190
  if result.is_a?(Tina4::Response)
@@ -203,7 +203,7 @@ module Tina4
203
203
  # (NEVER silent) then sets the response to the canonical error shape —
204
204
  # byte-identical to the Python master ({"error":"Internal Server Error",
205
205
  # "status":500} + status 500). Returns the response for chaining.
206
- def middleware_500(response, label, error)
206
+ def middleware_500(response, label, error, request = nil)
207
207
  begin
208
208
  Tina4::Log.error(
209
209
  "Middleware #{label} raised #{error.class.name}: #{error.message}"
@@ -216,7 +216,30 @@ module Tina4
216
216
  # never let logging break the worker
217
217
  end
218
218
  end
219
- response.json({ error: "Internal Server Error", status: 500 }, 500)
219
+ # ERR-DEC-02 (#342): answer through the SAME negotiation every other
220
+ # framework error uses — errors/500.twig for a browser, the JSON envelope
221
+ # {error, code, message, status, request_id} for an API client. The real
222
+ # exception stays in the log, never in the body (CWE-209). It used to
223
+ # return an unconditional {"error":"Internal Server Error","status":500}.
224
+ # `request` is optional only for backward compatibility; the dispatcher
225
+ # always passes it, and without it the JSON envelope is used.
226
+ request_id = Tina4::Log.get_request_id || ""
227
+ json_body = { error: true, code: "INTERNAL_SERVER_ERROR", message: "Internal Server Error", status: 500, request_id: request_id }
228
+ accept = request.respond_to?(:headers) ? (request.headers["accept"] || "") : ""
229
+ return response.json(json_body, 500) if Tina4::Template.wants_json?(accept)
230
+
231
+ path = request.respond_to?(:path) ? request.path.to_s : ""
232
+ html = begin
233
+ Tina4::Template.render_error(500, { "path" => CGI.escapeHTML(path), "request_id" => request_id, "error_message" => "" })
234
+ rescue StandardError
235
+ nil
236
+ end
237
+ if html && response.respond_to?(:html)
238
+ response.html(html, 500)
239
+ else
240
+ response.json(json_body, 500)
241
+ end
242
+ response
220
243
  end
221
244
 
222
245
  # The `false` row of the return-value table, on its own.
@@ -422,7 +422,9 @@ module Tina4
422
422
  # (error: true, code, message, status) already shared by app-level
423
423
  # response.error() calls, plus request_id for correlation (feature 43,
424
424
  # ERR-404-REQUESTID) - the SAME shape Python/PHP/Node build.
425
- ERROR_CODE_NAMES = { 403 => "FORBIDDEN", 404 => "NOT_FOUND", 500 => "INTERNAL_SERVER_ERROR" }.freeze
425
+ ERROR_CODE_NAMES = {
426
+ 401 => "UNAUTHORIZED", 403 => "FORBIDDEN", 404 => "NOT_FOUND", 500 => "INTERNAL_SERVER_ERROR"
427
+ }.freeze
426
428
 
427
429
  def error_json_body(status, message, request_id)
428
430
  Tina4::Response.error_response(
@@ -1236,10 +1238,34 @@ module Tina4
1236
1238
  # in-process TestClient call it, so the test surface enforces the identical
1237
1239
  # gate as production (Python #PY2 parity). Instantiating a RackApp just to
1238
1240
  # reach the check would run full boot/route-discovery — this needs none of it.
1241
+ # Build a negotiated error Rack tuple (ERR-DEC-02, #342): a browser gets
1242
+ # errors/{code}.twig, an API client the JSON envelope, both carrying
1243
+ # request_id. A CLASS method so the class-method auth / RBAC gates
1244
+ # (enforce_route_auth, rbac_forbidden) answer like every other framework
1245
+ # error; the instance handle_403/handle_404/handle_500 paths keep their own
1246
+ # copies (they carry extra template-serving / debug-overlay logic).
1247
+ def self.negotiated_error_tuple(code, message, path = "", accept = "")
1248
+ request_id = Tina4::Log.get_request_id || "-"
1249
+ if Tina4::Template.wants_json?(accept)
1250
+ body = Tina4::Response.error_response(ERROR_CODE_NAMES[code] || "HTTP_#{code}", message, code)
1251
+ .merge(request_id: request_id)
1252
+ return [code, { "content-type" => "application/json" }, [JSON.generate(body)]]
1253
+ end
1254
+ html = begin
1255
+ Tina4::Template.render_error(code, { "path" => CGI.escapeHTML(path.to_s), "request_id" => request_id, "error_message" => "" })
1256
+ rescue StandardError
1257
+ "#{code} #{message}"
1258
+ end
1259
+ [code, { "content-type" => "text/html" }, [html]]
1260
+ end
1261
+
1239
1262
  def self.enforce_route_auth(env, route)
1240
1263
  return nil unless route.auth_required
1241
1264
 
1242
- unauthorized = [401, { "content-type" => "application/json" }, [JSON.generate({ error: "Unauthorized" })]]
1265
+ # ERR-DEC-02 (#342): a browser gets errors/401.twig, an API client the JSON
1266
+ # envelope — the same negotiation every other framework error uses. Was an
1267
+ # unconditional JSON tuple.
1268
+ unauthorized = negotiated_error_tuple(401, "Unauthorized", env["PATH_INFO"] || "", env["HTTP_ACCEPT"] || "")
1243
1269
 
1244
1270
  # Token slots in priority order: Authorization Bearer header, then the
1245
1271
  # body formToken (frond.js puts the auth token it received as a
@@ -1283,7 +1309,7 @@ module Tina4
1283
1309
  # ── RBAC guards (Feature 138): authorization AFTER authentication ──
1284
1310
  # Auth has passed (401 ruled out above). If the route carries role/
1285
1311
  # permission guards, the verified payload must satisfy them, else 403.
1286
- deny = rbac_forbidden(route, env["tina4.auth_payload"])
1312
+ deny = rbac_forbidden(route, env["tina4.auth_payload"], env)
1287
1313
  return deny if deny
1288
1314
 
1289
1315
  nil
@@ -1317,7 +1343,7 @@ module Tina4
1317
1343
  # verified payload, else nil. AND across guard groups, OR within a group.
1318
1344
  # Roles read the `roles` claim (legacy singular `role` coerced); permissions
1319
1345
  # read `permissions` with granted-side wildcards. Feature 138 / ADR-0058.
1320
- def self.rbac_forbidden(route, payload)
1346
+ def self.rbac_forbidden(route, payload, env = nil)
1321
1347
  required_roles = route.respond_to?(:roles) ? Array(route.roles) : []
1322
1348
  required_perms = route.respond_to?(:perms) ? Array(route.perms) : []
1323
1349
  return nil if required_roles.empty? && required_perms.empty?
@@ -1325,17 +1351,19 @@ module Tina4
1325
1351
  subject = payload.is_a?(Hash) ? payload : {}
1326
1352
  roles = _rbac_claim_list(subject, "roles", "role")
1327
1353
  required_roles.each do |group|
1328
- return _rbac_forbidden_tuple unless group.any? { |r| roles.include?(r) }
1354
+ return _rbac_forbidden_tuple(env) unless group.any? { |r| roles.include?(r) }
1329
1355
  end
1330
1356
  perms = _rbac_claim_list(subject, "permissions", nil)
1331
1357
  required_perms.each do |group|
1332
- return _rbac_forbidden_tuple unless group.any? { |req| _rbac_perm_granted(perms, req) }
1358
+ return _rbac_forbidden_tuple(env) unless group.any? { |req| _rbac_perm_granted(perms, req) }
1333
1359
  end
1334
1360
  nil
1335
1361
  end
1336
1362
 
1337
- def self._rbac_forbidden_tuple
1338
- [403, { "content-type" => "application/json" }, [JSON.generate({ error: "Forbidden" })]]
1363
+ # ERR-DEC-02 negotiation (#342): HTML page for a browser, JSON envelope for
1364
+ # an API client, both with request_id — instead of the old unconditional JSON.
1365
+ def self._rbac_forbidden_tuple(env = nil)
1366
+ negotiated_error_tuple(403, "Forbidden", env && env["PATH_INFO"] || "", env && env["HTTP_ACCEPT"] || "")
1339
1367
  end
1340
1368
 
1341
1369
  # Read a claim as a list of strings; coerce a legacy singular string.
data/lib/tina4/version.rb CHANGED
@@ -7,5 +7,5 @@
7
7
 
8
8
 
9
9
  module Tina4
10
- VERSION = "3.13.159"
10
+ VERSION = "3.13.160"
11
11
  end
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.159
4
+ version: 3.13.160
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-06 00:00:00.000000000 Z
11
+ date: 2026-11-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: sqlite3