tina4-python 3.13.94__tar.gz → 3.13.95__tar.gz

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 (183) hide show
  1. {tina4_python-3.13.94 → tina4_python-3.13.95}/.gitignore +12 -0
  2. {tina4_python-3.13.94 → tina4_python-3.13.95}/PKG-INFO +20 -2
  3. {tina4_python-3.13.94 → tina4_python-3.13.95}/README.md +1 -1
  4. tina4_python-3.13.95/pyproject.toml +121 -0
  5. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/CLAUDE.md +14 -5
  6. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/__init__.py +1 -1
  7. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/auth/__init__.py +239 -35
  8. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/cache/__init__.py +630 -163
  9. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/cli/__init__.py +5 -1
  10. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/core/middleware.py +269 -31
  11. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/core/rate_limiter.py +28 -2
  12. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/core/request.py +108 -6
  13. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/core/response.py +92 -3
  14. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/core/router.py +55 -11
  15. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/core/server.py +858 -293
  16. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/database/adapter.py +404 -135
  17. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/database/connection.py +180 -23
  18. tina4_python-3.13.95/tina4_python/database/database_url.py +365 -0
  19. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/database/firebird.py +163 -48
  20. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/database/mongodb.py +15 -3
  21. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/database/mssql.py +58 -49
  22. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/database/mysql.py +47 -49
  23. tina4_python-3.13.95/tina4_python/database/odbc.py +224 -0
  24. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/database/postgres.py +39 -48
  25. tina4_python-3.13.95/tina4_python/database/sql_translator.py +272 -0
  26. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/database/sqlite.py +19 -38
  27. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/debug/__init__.py +262 -45
  28. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/dev_admin/__init__.py +96 -49
  29. tina4_python-3.13.95/tina4_python/dev_admin/metrics.py +375 -0
  30. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/docstore/__init__.py +240 -49
  31. tina4_python-3.13.95/tina4_python/dotenv/__init__.py +288 -0
  32. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/mcp/tools.py +12 -2
  33. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/mqtt/__init__.py +5 -1
  34. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/orm/model.py +91 -18
  35. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/queue/__init__.py +35 -28
  36. tina4_python-3.13.95/tina4_python/queue/amqp_url.py +73 -0
  37. tina4_python-3.13.95/tina4_python/queue/kafka_backend.py +210 -0
  38. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/queue/lite_backend.py +23 -1
  39. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/queue/mongo_backend.py +65 -6
  40. tina4_python-3.13.95/tina4_python/queue/rabbitmq_backend.py +221 -0
  41. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/queue_backends/kafka_backend.py +13 -7
  42. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/queue_backends/mongo_backend.py +20 -7
  43. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/queue_backends/rabbitmq_backend.py +31 -17
  44. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/session/__init__.py +190 -16
  45. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/session_handlers/__init__.py +8 -2
  46. tina4_python-3.13.95/tina4_python/session_handlers/memcached_handler.py +155 -0
  47. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/session_handlers/mongodb_handler.py +81 -29
  48. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/session_handlers/redis_handler.py +4 -1
  49. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/session_handlers/valkey_handler.py +4 -1
  50. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/websocket/__init__.py +9 -3
  51. tina4_python-3.13.94/pyproject.toml +0 -73
  52. tina4_python-3.13.94/tina4_python/database/odbc.py +0 -177
  53. tina4_python-3.13.94/tina4_python/dev_admin/metrics.py +0 -867
  54. tina4_python-3.13.94/tina4_python/dev_admin/metrics_engine.py +0 -108
  55. tina4_python-3.13.94/tina4_python/dotenv/__init__.py +0 -163
  56. tina4_python-3.13.94/tina4_python/queue/kafka_backend.py +0 -151
  57. tina4_python-3.13.94/tina4_python/queue/rabbitmq_backend.py +0 -186
  58. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/HtmlElement.py +0 -0
  59. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/Testing.py +0 -0
  60. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/ai/__init__.py +0 -0
  61. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/api/__init__.py +0 -0
  62. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/container/__init__.py +0 -0
  63. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/context/__init__.py +0 -0
  64. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/context/chunker.py +0 -0
  65. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/core/__init__.py +0 -0
  66. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/core/cache.py +0 -0
  67. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/core/constants.py +0 -0
  68. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/core/events.py +0 -0
  69. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/crud/__init__.py +0 -0
  70. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/database/__init__.py +0 -0
  71. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/debug/error_overlay.py +0 -0
  72. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/dev_admin/plan.py +0 -0
  73. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/dev_admin/project_index.py +0 -0
  74. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/docs.py +0 -0
  75. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/env.py +0 -0
  76. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/frond/FROND.md +0 -0
  77. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/frond/__init__.py +0 -0
  78. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/frond/compiler.py +0 -0
  79. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/frond/engine.py +0 -0
  80. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/frond/parser.py +0 -0
  81. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/gallery/auth/meta.json +0 -0
  82. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  83. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/gallery/database/meta.json +0 -0
  84. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  85. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/gallery/error-overlay/meta.json +0 -0
  86. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  87. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/gallery/orm/meta.json +0 -0
  88. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  89. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  90. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/gallery/queue/meta.json +0 -0
  91. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  92. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/gallery/rest-api/meta.json +0 -0
  93. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  94. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/gallery/templates/meta.json +0 -0
  95. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  96. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  97. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/graphql/__init__.py +0 -0
  98. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/i18n/__init__.py +0 -0
  99. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/mcp/__init__.py +0 -0
  100. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/mcp/protocol.py +0 -0
  101. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/messenger/__init__.py +0 -0
  102. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/migration/__init__.py +0 -0
  103. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/migration/runner.py +0 -0
  104. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/mqtt/message.py +0 -0
  105. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/orm/__init__.py +0 -0
  106. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/orm/fields.py +0 -0
  107. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/public/__feedback/widget.js +0 -0
  108. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/public/css/tina4.css +0 -0
  109. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/public/css/tina4.min.css +0 -0
  110. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/public/favicon.ico +0 -0
  111. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/public/images/logo.svg +0 -0
  112. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  113. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/public/js/frond.js +0 -0
  114. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/public/js/frond.min.js +0 -0
  115. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  116. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/public/js/tina4.min.js +0 -0
  117. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/public/js/tina4js.min.js +0 -0
  118. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/public/swagger/index.html +0 -0
  119. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  120. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/query_builder/__init__.py +0 -0
  121. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/queue/job.py +0 -0
  122. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/queue_backends/__init__.py +0 -0
  123. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/realtime/__init__.py +0 -0
  124. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/realtime/models/Attachment.py +0 -0
  125. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/realtime/models/Channel.py +0 -0
  126. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/realtime/models/ChannelMember.py +0 -0
  127. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/realtime/models/Message.py +0 -0
  128. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/realtime/models/Workspace.py +0 -0
  129. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/realtime/models/__init__.py +0 -0
  130. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/realtime/storage.py +0 -0
  131. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/scss/__init__.py +0 -0
  132. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  133. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/scss/tina4css/_badges.scss +0 -0
  134. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  135. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/scss/tina4css/_cards.scss +0 -0
  136. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/scss/tina4css/_forms.scss +0 -0
  137. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/scss/tina4css/_grid.scss +0 -0
  138. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/scss/tina4css/_modals.scss +0 -0
  139. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/scss/tina4css/_nav.scss +0 -0
  140. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/scss/tina4css/_pagination.scss +0 -0
  141. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/scss/tina4css/_reset.scss +0 -0
  142. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/scss/tina4css/_tables.scss +0 -0
  143. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/scss/tina4css/_typography.scss +0 -0
  144. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  145. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/scss/tina4css/_variables.scss +0 -0
  146. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/scss/tina4css/base.scss +0 -0
  147. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/scss/tina4css/colors.scss +0 -0
  148. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/scss/tina4css/tina4.scss +0 -0
  149. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/seeder/__init__.py +0 -0
  150. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/service/__init__.py +0 -0
  151. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/swagger/__init__.py +0 -0
  152. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/templates/components/crud.twig +0 -0
  153. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  154. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  155. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/templates/docker/python/Dockerfile +0 -0
  156. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  157. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/templates/errors/302.twig +0 -0
  158. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/templates/errors/401.twig +0 -0
  159. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/templates/errors/403.twig +0 -0
  160. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/templates/errors/404.twig +0 -0
  161. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/templates/errors/500.twig +0 -0
  162. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/templates/errors/502.twig +0 -0
  163. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/templates/errors/503.twig +0 -0
  164. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/templates/errors/base.twig +0 -0
  165. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/templates/frontend/README.md +0 -0
  166. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/templates/readme.md +0 -0
  167. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/test/__init__.py +0 -0
  168. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/test_client/__init__.py +0 -0
  169. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  170. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  171. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  172. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  173. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  174. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  175. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  176. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  177. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  178. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  179. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  180. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  181. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/validator/__init__.py +0 -0
  182. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/websocket/backplane.py +0 -0
  183. {tina4_python-3.13.94 → tina4_python-3.13.95}/tina4_python/wsdl/__init__.py +0 -0
@@ -10,6 +10,13 @@
10
10
  .env.local
11
11
  /public/css/test.css
12
12
  /sessions/
13
+ # Runtime session files written by the suite. This was the ONE gap of the four:
14
+ # php ignores /data/, node ignores data/, ruby ignores sessions/ (and defaults to
15
+ # pwd/sessions anyway) - only python left data/sessions/ visible, so a plain test
16
+ # run dirtied the tree and `git add -A` committed session payloads. It did: five
17
+ # of them are tracked on feature/audit-auth. FileSessionHandler already does
18
+ # mkdir(parents=True, exist_ok=True), so a fresh clone needs nothing here.
19
+ /data/sessions/
13
20
  /tests/secrets/domain.cert
14
21
  /tests/secrets/private.key
15
22
  /tests/secrets/public.key
@@ -98,3 +105,8 @@ example/src/**/__pycache__/
98
105
  # relative to cwd would create these under the repo. Never commit them.
99
106
  /private/
100
107
  /var/folders/
108
+ /data/sessions/
109
+
110
+ # Local file-backend queue store - runtime artefacts, never committed.
111
+ # Four probe .queue-data files were swept in by `git add -A` on 2026-08-03.
112
+ data/queue/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tina4-python
3
- Version: 3.13.94
3
+ Version: 3.13.95
4
4
  Summary: Tina4 Python v3 — Zero-dependency, lightweight web framework
5
5
  Author-email: Andre van Zuydam <andrevanzuydam@gmail.com>
6
6
  License: MIT
@@ -14,6 +14,8 @@ Requires-Dist: pymssql>=2.3; extra == 'all-db'
14
14
  Requires-Dist: pyodbc>=5.0; extra == 'all-db'
15
15
  Provides-Extra: firebird
16
16
  Requires-Dist: firebird-driver>=1.10; extra == 'firebird'
17
+ Provides-Extra: kafka
18
+ Requires-Dist: confluent-kafka>=2.0; extra == 'kafka'
17
19
  Provides-Extra: memcache
18
20
  Requires-Dist: pymemcache>=4.0; extra == 'memcache'
19
21
  Provides-Extra: mongo
@@ -26,8 +28,24 @@ Provides-Extra: odbc
26
28
  Requires-Dist: pyodbc>=5.0; extra == 'odbc'
27
29
  Provides-Extra: postgres
28
30
  Requires-Dist: psycopg2-binary>=2.9; extra == 'postgres'
31
+ Provides-Extra: rabbitmq
32
+ Requires-Dist: pika>=1.3; extra == 'rabbitmq'
29
33
  Provides-Extra: redis
30
34
  Requires-Dist: redis>=5.0; extra == 'redis'
35
+ Provides-Extra: s3
36
+ Requires-Dist: boto3>=1.34; extra == 's3'
37
+ Provides-Extra: test
38
+ Requires-Dist: boto3>=1.34; extra == 'test'
39
+ Requires-Dist: confluent-kafka>=2.0; extra == 'test'
40
+ Requires-Dist: firebird-driver>=1.10; extra == 'test'
41
+ Requires-Dist: mysql-connector-python>=9.0; extra == 'test'
42
+ Requires-Dist: pika>=1.3; extra == 'test'
43
+ Requires-Dist: psycopg2-binary>=2.9; extra == 'test'
44
+ Requires-Dist: pymemcache>=4.0; extra == 'test'
45
+ Requires-Dist: pymongo>=4.0; extra == 'test'
46
+ Requires-Dist: pymssql>=2.3; extra == 'test'
47
+ Requires-Dist: pyodbc>=5.0; extra == 'test'
48
+ Requires-Dist: redis>=5.0; extra == 'test'
31
49
  Description-Content-Type: text/markdown
32
50
 
33
51
  <p align="center">
@@ -116,7 +134,7 @@ Every feature is built from scratch -- no pip install, no node_modules, no third
116
134
  | **Core HTTP** (7) | Router with path params (`{id:int}`, `{p:path}`), Server, Request/Response, Middleware pipeline, Static file serving, CORS |
117
135
  | **Database** (6) | SQLite, PostgreSQL, MySQL, MSSQL, Firebird: unified adapter, connection pooling, query cache, transactions, race-safe ID generation, SQL dialect translation |
118
136
  | **ORM** (7) | Active Record with typed fields, relationships (`has_one`/`has_many`/`belongs_to`), soft delete, QueryBuilder + MongoDB support, Auto-CRUD generator, migrations with rollback |
119
- | **Auth & Security** (5) | JWT (HS256/RS256), password hashing (PBKDF2-SHA256), API key validation, rate limiting, CSRF form tokens |
137
+ | **Auth & Security** (5) | JWT (HS256/HS384/HS512, zero-dependency; RS256 opt-in, needs `cryptography`), password hashing (PBKDF2-SHA256), API key validation, rate limiting, CSRF form tokens |
120
138
  | **Templating** (3) | Frond engine (Twig/Jinja2-compatible, pre-compiled 2.8x faster), SCSS auto-compilation, built-in CSS (~24 KB) |
121
139
  | **API & Integration** (5) | HTTP client (zero-dep), GraphQL with ORM auto-schema + GraphiQL IDE, WSDL/SOAP with auto WSDL, WebSocket (RFC 6455) + Redis backplane, MCP server (24 dev tools) |
122
140
  | **Background** (3) | Job queue (File/RabbitMQ/Kafka/MongoDB) with priority, delay, retry, dead letters; service runner; event system (on/emit/once/off) |
@@ -84,7 +84,7 @@ Every feature is built from scratch -- no pip install, no node_modules, no third
84
84
  | **Core HTTP** (7) | Router with path params (`{id:int}`, `{p:path}`), Server, Request/Response, Middleware pipeline, Static file serving, CORS |
85
85
  | **Database** (6) | SQLite, PostgreSQL, MySQL, MSSQL, Firebird: unified adapter, connection pooling, query cache, transactions, race-safe ID generation, SQL dialect translation |
86
86
  | **ORM** (7) | Active Record with typed fields, relationships (`has_one`/`has_many`/`belongs_to`), soft delete, QueryBuilder + MongoDB support, Auto-CRUD generator, migrations with rollback |
87
- | **Auth & Security** (5) | JWT (HS256/RS256), password hashing (PBKDF2-SHA256), API key validation, rate limiting, CSRF form tokens |
87
+ | **Auth & Security** (5) | JWT (HS256/HS384/HS512, zero-dependency; RS256 opt-in, needs `cryptography`), password hashing (PBKDF2-SHA256), API key validation, rate limiting, CSRF form tokens |
88
88
  | **Templating** (3) | Frond engine (Twig/Jinja2-compatible, pre-compiled 2.8x faster), SCSS auto-compilation, built-in CSS (~24 KB) |
89
89
  | **API & Integration** (5) | HTTP client (zero-dep), GraphQL with ORM auto-schema + GraphiQL IDE, WSDL/SOAP with auto WSDL, WebSocket (RFC 6455) + Redis backplane, MCP server (24 dev tools) |
90
90
  | **Background** (3) | Job queue (File/RabbitMQ/Kafka/MongoDB) with priority, delay, retry, dead letters; service runner; event system (on/emit/once/off) |
@@ -0,0 +1,121 @@
1
+ [project]
2
+ name = "tina4-python"
3
+ version = "3.13.95"
4
+ description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
+ authors = [
6
+ {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
7
+ ]
8
+ readme = "README.md"
9
+ requires-python = ">=3.12"
10
+ license = {text = "MIT"}
11
+
12
+ # ZERO core dependencies — this is v3's defining feature
13
+ dependencies = []
14
+
15
+ [project.optional-dependencies]
16
+ # Database drivers — install what you need
17
+ postgres = ["psycopg2-binary>=2.9"]
18
+ mysql = ["mysql-connector-python>=9.0"]
19
+ mssql = ["pymssql>=2.3"]
20
+ firebird = ["firebird-driver>=1.10"]
21
+ mongo = ["pymongo>=4.0"]
22
+ odbc = ["pyodbc>=5.0"]
23
+
24
+ # Session backends — install what you need
25
+ redis = ["redis>=5.0"]
26
+ memcache = ["pymemcache>=4.0"]
27
+
28
+ # File-storage backends — install what you need.
29
+ # The `local` filesystem backend is the zero-dependency default; S3Storage
30
+ # (TINA4_STORAGE_BACKEND=s3) is opt-in and imports boto3 lazily.
31
+ s3 = ["boto3>=1.34"]
32
+
33
+ # Queue backends — install what you need
34
+ kafka = ["confluent-kafka>=2.0"]
35
+ rabbitmq = ["pika>=1.3"]
36
+
37
+ # All database drivers
38
+ all-db = [
39
+ "psycopg2-binary>=2.9",
40
+ "mysql-connector-python>=9.0",
41
+ "pymssql>=2.3",
42
+ "firebird-driver>=1.10",
43
+ "pymongo>=4.0",
44
+ "pyodbc>=5.0",
45
+ ]
46
+
47
+ # Every client the TEST SUITE needs against live services.
48
+ #
49
+ # These were installed ad hoc by CI (`uv pip install pymongo confluent-kafka
50
+ # pika ...`) and declared nowhere, so a fresh host had no way to know what the
51
+ # suite requires. confluent-kafka and pika in particular were in NO extra: the
52
+ # lab host's first full-suite run failed with three "confluent-kafka not
53
+ # installed" errors, correctly raised by the TINA4_REQUIRE_SERVICES gate, and
54
+ # `uv sync --all-extras` could not have fixed it because the dependency was not
55
+ # expressed anywhere. One declared extra means `uv sync --extra test` is
56
+ # reproducible on any machine.
57
+ #
58
+ # Includes firebird-driver even though the Firebird CI job installs it on its
59
+ # own: `uv sync --extra X` PRUNES anything outside the named extras, so a test
60
+ # extra that omitted it would silently uninstall it on a host that had it.
61
+ #
62
+ # boto3 is here for exactly that reason, MEASURED on the lab 2026-08-06
63
+ # (Ubuntu 24.04.4 LTS x86_64, Python 3.13.3): it was declared in NO extra, but
64
+ # happened to be installed ad hoc, so the two REAL-MinIO tests in
65
+ # tests/test_realtime_files.py were passing. Running `uv sync --extra test`
66
+ # pruned boto3 (plus s3transfer and jmespath) and those tests went straight from
67
+ # "2 passed" to "2 skipped" -- with the suite still GREEN under
68
+ # TINA4_REQUIRE_SERVICES=1, because the skip reason matched neither a service
69
+ # keyword nor an unavailable hint in the conftest gate. Live S3 coverage
70
+ # evaporated silently. Declaring it here is the fix; tests/conftest.py now also
71
+ # recognises a missing boto3 so the gate can never miss it again.
72
+ test = [
73
+ "psycopg2-binary>=2.9",
74
+ "mysql-connector-python>=9.0",
75
+ "pymssql>=2.3",
76
+ "firebird-driver>=1.10",
77
+ "pymongo>=4.0",
78
+ "pyodbc>=5.0",
79
+ "redis>=5.0",
80
+ "pymemcache>=4.0",
81
+ "confluent-kafka>=2.0",
82
+ "pika>=1.3",
83
+ "boto3>=1.34",
84
+ ]
85
+
86
+ [dependency-groups]
87
+ dev = [
88
+ "bottle>=0.13.4",
89
+ "cython>=3.2.4",
90
+ "django>=6.0.3",
91
+ "fastapi>=0.135.1",
92
+ "flask>=3.1.3",
93
+ "pytest>=8.0",
94
+ "pytest-asyncio>=0.23",
95
+ "pytest-cov>=5.0",
96
+ "reportlab>=4.4.10",
97
+ "ruff>=0.4",
98
+ "setuptools>=82.0.1",
99
+ "uvicorn>=0.42.0",
100
+ ]
101
+
102
+ [build-system]
103
+ requires = ["hatchling"]
104
+ build-backend = "hatchling.build"
105
+
106
+ [tool.hatch.build]
107
+ include = ["tina4_python/**/*"]
108
+
109
+ [project.scripts]
110
+ tina4python = "tina4_python.cli:main"
111
+
112
+ [tool.pytest.ini_options]
113
+ asyncio_mode = "auto"
114
+ testpaths = ["tests"]
115
+ markers = [
116
+ "slow: a test that needs real wall-clock time to reproduce a timing bug (deselect with -m 'not slow')",
117
+ ]
118
+
119
+ [tool.ruff]
120
+ target-version = "py312"
121
+ line-length = 120
@@ -514,8 +514,11 @@ TINA4_TOKEN_LIMIT is used to set the session time, default 60 minutes
514
514
 
515
515
  Set `TINA4_SESSION_BACKEND` to choose a backend:
516
516
 
517
- The value is the backend NAME, not a class name. An unknown value falls back to
518
- the file backend without raising.
517
+ The value is the backend NAME, not a class name, and it is normalised (trimmed and
518
+ lowercased), so " Redis " resolves. An UNRECOGNISED value raises `ValueError` at
519
+ startup, naming the bad value and the valid ones -- it used to fall back to the
520
+ file backend silently, which turned a typo into sessions quietly written to local
521
+ disk. Unset or blank still means file.
519
522
 
520
523
  | Value | Backend | Required package |
521
524
  |---------|---------|-----------------|
@@ -1641,17 +1644,23 @@ TINA4_TOKEN_LIMIT=60 # Token lifetime in minutes (default: 60)
1641
1644
  TINA4_DATABASE_URL=sqlite:///app.db # Connection URL (driver://host:port/database)
1642
1645
  TINA4_DATABASE_USERNAME= # DB username (for PostgreSQL, MySQL, etc.)
1643
1646
  TINA4_DATABASE_PASSWORD= # DB password
1647
+ TINA4_DATABASE_CONNECT_TIMEOUT=10 # Seconds a database connect may block (default 10). Set <= 0 to wait
1648
+ # indefinitely (the old behaviour); a non-numeric value warns and uses 10.
1649
+ # On expiry the error names the host, port, elapsed seconds and this variable.
1650
+ # Applies to postgres/mysql/mssql/odbc/firebird, and to mongodb's deferred
1651
+ # connect. SQLite is exempt: it opens a local file, there is no peer to hang on
1644
1652
 
1645
1653
  # Framework
1646
1654
  TINA4_DEBUG=true # Enable dev mode (toolbar, live reload, error overlay)
1647
1655
  TINA4_LOG_LEVEL=INFO # Log verbosity: ALL, DEBUG, INFO, WARNING, ERROR, CRITICAL (default: INFO)
1648
1656
  TINA4_LOCALE=en # Language for framework messages (en, fr, af, zh, ja, es)
1649
- TINA4_DEFAULT_WEBSERVER=FALSE # Set to TRUE to use Tina4's built-in webserver instead of ASGI
1657
+ TINA4_DEFAULT_WEBSERVER=FALSE # TRUE pins Tina4's built-in asyncio webserver even when a production ASGI server (uvicorn/hypercorn/granian) is installed. Unset/FALSE: a production server is used when one is importable and TINA4_DEBUG is off
1658
+ TINA4_SHUTDOWN_TIMEOUT=30 # Seconds to drain in-flight requests on SIGTERM/SIGINT (default 30). Honoured on the built-in server, and mapped onto uvicorn's timeout_graceful_shutdown / hypercorn's graceful_timeout. granian has no drain deadline, so it is not honoured there (logged at startup). A non-positive or unparseable value warns and uses 30
1650
1659
  TINA4_OVERRIDE_CLIENT=false # Set to true to allow running without tina4 CLI (e.g. Docker)
1651
1660
  HOST_NAME=localhost:7145
1652
1661
 
1653
1662
  # Sessions
1654
- TINA4_SESSION_BACKEND=file # file, redis, valkey, mongodb, database (unknown value falls back to file)
1663
+ TINA4_SESSION_BACKEND=file # file, redis, valkey, mongodb, memcached, database (an unknown value RAISES; unset/blank = file)
1655
1664
  TINA4_SESSION_SAMESITE=Lax # SameSite attribute for session cookies (default: Lax)
1656
1665
 
1657
1666
  # Swagger/OpenAPI
@@ -1713,7 +1722,7 @@ anywhere (the production on/off switch, wired for real in 3.13.40).
1713
1722
 
1714
1723
  ## CORS
1715
1724
 
1716
- Built-in — all origins allowed by default. CORS headers and OPTIONS pre-flight are handled automatically.
1725
+ Built-in, and it DENIES by default (ADR-0018). Set `TINA4_CORS_ORIGINS` to the origins you want to allow (comma-separated), or `*` for any. With it unset no `Access-Control-Allow-Origin` is sent and the browser blocks the cross-origin request. `TINA4_CORS_CREDENTIALS=true` is never sent alongside a `*` origin (the Fetch Standard forbids the pair). OPTIONS pre-flight is handled automatically.
1717
1726
 
1718
1727
  ## SCSS Workflow
1719
1728
 
@@ -51,7 +51,7 @@ def _resolve_version() -> str:
51
51
  #
52
52
  # test_version_constant.py now asserts this literal equals the pyproject
53
53
  # version, so the release bump cannot leave it behind again.
54
- return "3.13.94"
54
+ return "3.13.95"
55
55
 
56
56
 
57
57
  __version__ = _resolve_version()
@@ -1,7 +1,19 @@
1
1
  # Tina4 Auth — Zero-dependency JWT, password hashing, and token validation.
2
2
  """
3
- JWT implementation using Python stdlib only (hmac + hashlib).
4
- No PyJWT, no cryptography package.
3
+ JWT implementation using Python stdlib only (hmac + hashlib). No PyJWT.
4
+
5
+ HMAC — HS256 (default), HS384, HS512 — is Tina4's JWT algorithm family, and it is
6
+ zero-dependency in all four frameworks. RS256 is OPT-IN: Python's standard library
7
+ has no asymmetric crypto, so it needs the ``cryptography`` package, which Tina4
8
+ never declares as a dependency. Nothing about RS256 is loaded, probed or warned
9
+ about unless a caller actually asks for it; when they do without a backend
10
+ installed, they get ``RS256UnavailableError`` naming the exact install command.
11
+
12
+ SECURITY — HMAC IS SYMMETRIC. Every service that VERIFIES a token holds the SAME
13
+ secret that signs it, so any verifier can also MINT tokens. That is fine for one
14
+ app or a trusted fleet, and wrong for handing tokens to a third party you do not
15
+ control. RS256 exists so a verifier can hold only a public key; reach for it (and
16
+ install ``cryptography``) when the verifier is not someone you trust to sign.
5
17
 
6
18
  from tina4_python.auth import Auth
7
19
 
@@ -196,8 +208,9 @@ def _resolve_secret(secret: str = None) -> str:
196
208
  return env_secret
197
209
 
198
210
 
199
- # Supported JWT algorithms. HMAC only — the whole family is in hashlib, so this
200
- # stays zero-dependency. The header's "alg" is now always the one we actually sign
211
+ # The STANDARD JWT algorithm family for Tina4: HMAC, in every framework, with zero
212
+ # dependencies (python: hmac + hashlib, php: hash_hmac, ruby: OpenSSL::HMAC, node:
213
+ # node:crypto createHmac). The header's "alg" is always the one we actually sign
201
214
  # with (python#105): the digest is looked up here rather than hardcoded.
202
215
  _HMAC_ALGORITHMS = {
203
216
  "HS256": hashlib.sha256,
@@ -205,24 +218,170 @@ _HMAC_ALGORITHMS = {
205
218
  "HS512": hashlib.sha512,
206
219
  }
207
220
 
221
+ # RS256 is OPT-IN. It is a recognised algorithm NAME everywhere, but it only works
222
+ # where the runtime provides asymmetric crypto. Python's standard library provides
223
+ # NONE, so RS256 in Python needs a library the app already installed - and Tina4
224
+ # still declares no dependency on one. See _require_rs256() for the contract.
225
+ _RSA_ALGORITHMS = ("RS256",)
226
+
227
+ # Every algorithm name Tina4 recognises. RS256's PRESENCE here is deliberate: an app
228
+ # may configure it, and whether it WORKS is decided later, at the point of use.
229
+ _SUPPORTED_ALGORITHMS = tuple(_HMAC_ALGORITHMS) + _RSA_ALGORITHMS
230
+
231
+
232
+ class RS256UnavailableError(RuntimeError):
233
+ """RS256 was asked to sign or verify and no RSA backend is installed.
234
+
235
+ Raised at the POINT OF USE (signing or verifying), never at import, boot, or
236
+ construction - an app that never touches RS256 never sees this type at all.
237
+
238
+ It is deliberately loud. Returning ``None`` here would be indistinguishable
239
+ from "that token is invalid", so a missing deployment dependency would read
240
+ as an authentication failure and be debugged as the wrong problem.
241
+
242
+ Cross-framework contract: the BEHAVIOUR is identical in all four - loud, at
243
+ the point of use, naming the remedy - while the raised TYPE is each runtime's
244
+ own idiom, because that is what a developer's existing handlers already
245
+ catch:
246
+
247
+ python RS256UnavailableError(RuntimeError) pip install cryptography
248
+ php \\RuntimeException install ext-openssl
249
+ ruby NotImplementedError rebuild Ruby with OpenSSL
250
+ node Error (node:crypto is builtin)
251
+
252
+ Only Python and PHP can realistically raise: Ruby's OpenSSL is a stdlib
253
+ default gem and Node's crypto is builtin, so their branches are unreachable
254
+ on any normal build. A single shared class name in four languages would be
255
+ four new public types for a state two of them cannot reach, so the contract
256
+ is pinned on the message and the timing instead - see
257
+ tests/test_auth_rs256_optin.py and the equivalents in the other three.
258
+ """
259
+
260
+
261
+ # The message a developer actually sees. It names the package, the exact install
262
+ # command, and the zero-dependency alternative, because "RS256 unavailable" with no
263
+ # remedy is the mysterious failure this ruling exists to remove.
264
+ #
265
+ # ONE library is named on purpose. Supporting cryptography AND PyJWT AND
266
+ # python-jose would double this code to buy nothing: they all end up calling
267
+ # cryptography for RSA, and the base64url header.payload.signature envelope is
268
+ # something Auth already builds for HMAC. cryptography supplies the missing
269
+ # PRIMITIVE and nothing else.
270
+ _RS256_UNAVAILABLE_MESSAGE = (
271
+ "JWT algorithm RS256 was requested, but no RSA backend is installed. "
272
+ "Python's standard library has no asymmetric crypto, so Tina4 cannot sign or "
273
+ "verify RS256 on its own - and Tina4 will not add a dependency for you. "
274
+ "Install it:\n"
275
+ " uv add cryptography (or: pip install cryptography)\n"
276
+ "Or stay zero-dependency: the HMAC family (HS256/HS384/HS512) is Tina4's "
277
+ "default and works in all four frameworks with no install at all - set "
278
+ "TINA4_JWT_ALGORITHM=HS256."
279
+ )
280
+
281
+
282
+ def _require_rs256():
283
+ """Return the RSA primitives RS256 needs, or raise ``RS256UnavailableError``.
284
+
285
+ LAZY CAPABILITY DETECTION. This function is called from the RS256 signing and
286
+ verifying paths and NOWHERE else: there is no import-time probe, no boot-time
287
+ probe, no startup warning and no log line. An app on the default HMAC path
288
+ never executes a line of this function, so it pays nothing - not an import,
289
+ not a warning - for a feature it does not use.
290
+
291
+ The import is the detection: if ``cryptography`` is installed we use it, and
292
+ if it is not, the ImportError becomes the actionable error above.
293
+ """
294
+ try:
295
+ from cryptography.hazmat.primitives import hashes, serialization
296
+ from cryptography.hazmat.primitives.asymmetric import padding
297
+ except ImportError:
298
+ raise RS256UnavailableError(_RS256_UNAVAILABLE_MESSAGE) from None
299
+ return hashes, serialization, padding
300
+
301
+
302
+ def _pem_bytes(key: str) -> bytes:
303
+ """Coerce PEM key material to bytes.
304
+
305
+ The key travels in the same ``secret`` slot the HMAC family uses, which is the
306
+ contract PHP and Node already implement (PHP hands ``$secret`` straight to
307
+ openssl_pkey_get_private/openssl_pkey_get_public). One slot, one env var
308
+ (TINA4_SECRET), no new API surface for the other three to mirror.
309
+ """
310
+ return key.encode() if isinstance(key, str) else bytes(key or b"")
311
+
312
+
313
+ def _rs256_sign(signing_input: bytes, private_key_pem: str) -> bytes:
314
+ """RSASSA-PKCS1-v1_5 over SHA-256 - the signature RFC 7518 calls RS256."""
315
+ hashes, serialization, padding = _require_rs256()
316
+ private_key = serialization.load_pem_private_key(
317
+ _pem_bytes(private_key_pem), password=None
318
+ )
319
+ return private_key.sign(signing_input, padding.PKCS1v15(), hashes.SHA256())
320
+
321
+
322
+ def _rs256_verify(signing_input: bytes, signature: bytes, key_pem: str) -> bool:
323
+ """Verify an RS256 signature with a public (or private) PEM key.
324
+
325
+ A verifier normally holds ONLY the public key - that is the entire reason
326
+ RS256 exists. A private PEM is accepted too so one configured key round-trips
327
+ in a single-service app.
328
+ """
329
+ hashes, serialization, padding = _require_rs256()
330
+ pem = _pem_bytes(key_pem)
331
+ try:
332
+ public_key = serialization.load_pem_public_key(pem)
333
+ except Exception:
334
+ try:
335
+ public_key = serialization.load_pem_private_key(pem, password=None).public_key()
336
+ except Exception:
337
+ return False # unusable key material is a failed verification
338
+ try:
339
+ public_key.verify(signature, signing_input, padding.PKCS1v15(), hashes.SHA256())
340
+ return True
341
+ except Exception:
342
+ return False
343
+
208
344
  # Seconds of clock skew tolerated on the "nbf" (not-before) claim. Without this a
209
345
  # token minted on one host and validated on another a second behind is rejected
210
346
  # for no real reason; RFC 7519 explicitly allows "a small leeway".
211
347
  _JWT_LEEWAY_SECONDS = 60
212
348
 
213
349
 
350
+ def _numeric_date(value) -> "int | None":
351
+ """Coerce an RFC 7519 NumericDate claim to int seconds, else ``None``.
352
+
353
+ RFC 7519 s2 defines ``exp``/``nbf``/``iat`` as a NumericDate — a JSON
354
+ numeric value. A claim that is PRESENT but not a number is malformed, and a
355
+ malformed constraint must never read as "no constraint": treating a
356
+ non-numeric ``exp`` as absent turns a broken token into one that never
357
+ expires. ``bool`` is excluded deliberately — it is an ``int`` subclass in
358
+ Python, so ``exp: true`` would otherwise compare as the year 1970.
359
+ """
360
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
361
+ return None
362
+ return int(value)
363
+
364
+
214
365
  def _resolve_algorithm(algorithm: str = None) -> str:
215
366
  """Pick the JWT algorithm: explicit arg, else TINA4_JWT_ALGORITHM, else HS256.
216
367
 
217
- Raises ValueError naming the supported set when asked for one we cannot sign
218
- (python#106 — the env var was registered in the CLI's known_vars and then
219
- silently ignored, so a user could set HS512 and still get HS256 tokens).
368
+ Raises ValueError naming the supported set when asked for one we do not
369
+ recognise at all (python#106 — the env var was registered in the CLI's
370
+ known_vars and then silently ignored, so a user could set HS512 and still get
371
+ HS256 tokens).
372
+
373
+ RS256 RESOLVES HERE WITHOUT ANY PROBE. Recognising the name is not the same as
374
+ being able to sign with it: whether an RSA backend exists is decided lazily, in
375
+ _require_rs256(), the first time a token is actually signed or verified. That
376
+ split is what keeps the HMAC majority free of any RS256 detection cost.
220
377
  """
221
378
  chosen = (algorithm or os.environ.get("TINA4_JWT_ALGORITHM") or "HS256").strip()
222
- if chosen not in _HMAC_ALGORITHMS:
379
+ if chosen not in _SUPPORTED_ALGORITHMS:
223
380
  raise ValueError(
224
381
  f"Unsupported JWT algorithm {chosen!r}. Tina4 signs with "
225
- f"{', '.join(sorted(_HMAC_ALGORITHMS))} (HMAC only, zero-dependency). "
382
+ f"{', '.join(sorted(_HMAC_ALGORITHMS))} (HMAC, zero-dependency, the "
383
+ f"default and the cross-framework standard) or RS256 (opt-in, needs an "
384
+ f"RSA backend installed - see RS256UnavailableError). "
226
385
  f"Set TINA4_JWT_ALGORITHM to one of those."
227
386
  )
228
387
  return chosen
@@ -310,29 +469,51 @@ class Auth:
310
469
 
311
470
  # Pin the algorithm to OUR configured one instead of trusting the
312
471
  # token's header. A token asking to be verified as anything else --
313
- # "none", a weaker HMAC, or an RSA alg we do not implement -- is
472
+ # "none", a weaker HMAC, or RS256 when we are configured for HMAC -- is
314
473
  # rejected before any signature work. Matches the Ruby check.
474
+ #
475
+ # This matters MORE now that RS256 is opt-in, not less: pinning is what
476
+ # stops an attacker re-labelling a token's header to pick an algorithm
477
+ # we did not choose, and it also means an RS256-labelled token can never
478
+ # drive an HMAC-configured app into the RS256 capability check.
315
479
  header = json.loads(_b64url_decode(h))
316
480
  if header.get("alg") != self.algorithm:
317
481
  return None
318
482
 
319
- expected = self._sign(f"{h}.{p}")
320
- if not hmac.compare_digest(sig, expected):
483
+ if not self._verify(f"{h}.{p}", sig):
321
484
  return None
322
485
 
323
486
  payload = json.loads(_b64url_decode(p))
324
487
 
325
- now = time.time()
326
- if "exp" in payload and now > payload["exp"]:
327
- return None
328
-
329
- # "nbf" (not-before): a post-dated token is not valid yet. Was
330
- # honoured only by Ruby, so Python/PHP/Node accepted tokens their
331
- # issuer had explicitly marked as not-yet-usable (python#107).
332
- if "nbf" in payload and now + _JWT_LEEWAY_SECONDS < payload["nbf"]:
333
- return None
488
+ # Integer seconds, so all four frameworks compare the SAME number.
489
+ # PHP/Ruby read an integer clock and Python/Node a float one; with a
490
+ # float clock "now > exp" and "now >= exp" differ only on an exactly
491
+ # integral instant, so truncating here is what makes the boundary
492
+ # identical everywhere rather than merely close.
493
+ now = int(time.time())
494
+
495
+ # RFC 7519 s4.1.4: "The processing of the 'exp' claim requires that
496
+ # the current date/time MUST be before the expiration date/time".
497
+ # now == exp is therefore ALREADY expired, so the test is >=.
498
+ if "exp" in payload:
499
+ exp = _numeric_date(payload["exp"])
500
+ if exp is None or now >= exp:
501
+ return None
502
+
503
+ # "nbf" (not-before): a post-dated token is not valid yet. Tolerates
504
+ # _JWT_LEEWAY_SECONDS of clock skew, which RFC 7519 s4.1.5 permits.
505
+ if "nbf" in payload:
506
+ nbf = _numeric_date(payload["nbf"])
507
+ if nbf is None or now + _JWT_LEEWAY_SECONDS < nbf:
508
+ return None
334
509
 
335
510
  return payload
511
+ except RS256UnavailableError:
512
+ # A missing RSA backend is a DEPLOYMENT fault, not an invalid token.
513
+ # Swallowing it into the None below would report "authentication
514
+ # failed" for every request and hide the one-line fix, so it is the
515
+ # single exception this catch-all deliberately lets through.
516
+ raise
336
517
  except Exception:
337
518
  return None
338
519
 
@@ -362,6 +543,11 @@ class Auth:
362
543
  return self.get_token(payload, expires_in=expires_in)
363
544
 
364
545
  def _sign(self, message: str) -> str:
546
+ # RS256 is the opt-in branch: the capability check happens HERE, at the
547
+ # point of use, and raises RS256UnavailableError naming the fix if no RSA
548
+ # backend is installed. self.secret carries the PEM private key.
549
+ if self.algorithm in _RSA_ALGORITHMS:
550
+ return _b64url_encode(_rs256_sign(message.encode(), self.secret))
365
551
  # Digest comes from the configured algorithm, so the "alg" we advertise in
366
552
  # the header is the one that actually produced this signature.
367
553
  sig = hmac.new(
@@ -369,6 +555,19 @@ class Auth:
369
555
  ).digest()
370
556
  return _b64url_encode(sig)
371
557
 
558
+ def _verify(self, message: str, signature: str) -> bool:
559
+ """Check a signature against the CONFIGURED algorithm.
560
+
561
+ Symmetric and asymmetric verification are genuinely different operations:
562
+ HMAC recomputes the signature and compares it in constant time, while RS256
563
+ verifies against a public key it cannot use to sign. Recomputing an RSA
564
+ signature would demand the private key on every verifier, which is exactly
565
+ the property RS256 exists to avoid.
566
+ """
567
+ if self.algorithm in _RSA_ALGORITHMS:
568
+ return _rs256_verify(message.encode(), _b64url_decode(signature), self.secret)
569
+ return hmac.compare_digest(signature, self._sign(message))
570
+
372
571
  # ── Legacy aliases ─────────────────────────────────────────────
373
572
 
374
573
  # get_token and valid_token are the primary names
@@ -410,7 +609,8 @@ class Auth:
410
609
  def authenticate_request_static(cls, headers: dict) -> dict | None:
411
610
  """Extract and validate auth from request headers without instantiating Auth.
412
611
 
413
- Reads TINA4_SECRET from env. Checks: Bearer JWT, Bearer API key, Basic auth.
612
+ Reads TINA4_SECRET from env. Checks a Bearer JWT, then a Bearer API key.
613
+ There is no Basic branch - see authenticate_request.
414
614
  Returns payload dict on success, None on failure.
415
615
  """
416
616
  secret = _resolve_secret()
@@ -481,8 +681,17 @@ class Auth:
481
681
  secret: Override signing secret (default: self.secret / TINA4_SECRET env var).
482
682
  algorithm: JWT algorithm override (default: self.algorithm / TINA4_JWT_ALGORITHM).
483
683
 
484
- Checks: Bearer JWT, Bearer API key, Basic auth.
485
- Returns payload dict on success, None on failure.
684
+ Checks a Bearer JWT, then falls back to a Bearer API key. Returns the
685
+ verified payload dict on success, ``None`` on failure.
686
+
687
+ There is deliberately NO Basic branch. This method used to decode
688
+ ``Authorization: Basic`` and return a truthy
689
+ ``{"auth_type": "basic", "username": ..., "password": ...}`` — but it
690
+ never checked those credentials against anything, so every caller
691
+ following the documented ``if auth is None: return 401`` idiom
692
+ authenticated any client that sent a base64 string. PHP, Ruby and Node
693
+ all returned None there. An app that wants Basic auth must verify the
694
+ credentials itself against its own user store.
486
695
  """
487
696
  # Both overrides used to be accepted and then dropped on the floor: the
488
697
  # body called self.valid_token(), so a caller passing secret= or
@@ -498,18 +707,13 @@ class Auth:
498
707
  token = auth_header[7:]
499
708
  if self.valid_token(token):
500
709
  return self.get_payload(token)
710
+ # "_auth" is the cross-framework key for a non-JWT auth result; PHP
711
+ # and Node already used it, Python used "auth_type" and Ruby
712
+ # "api_key", so the same successful auth read three different ways.
501
713
  if self.validate_api_key(token):
502
- return {"auth_type": "api_key"}
714
+ return {"_auth": "api_key"}
503
715
  return None
504
716
 
505
- if auth_header.startswith("Basic "):
506
- try:
507
- decoded = base64.b64decode(auth_header[6:]).decode()
508
- username, password = decoded.split(":", 1)
509
- return {"auth_type": "basic", "username": username, "password": password}
510
- except Exception:
511
- return None
512
-
513
717
  return None
514
718
 
515
719
 
@@ -584,7 +788,7 @@ class AuthMiddleware:
584
788
 
585
789
 
586
790
  __all__ = [
587
- "Auth", "AuthMiddleware", "get_token", "valid_token", "get_payload",
588
- "refresh_token", "authenticate_request", "validate_api_key",
791
+ "Auth", "AuthMiddleware", "RS256UnavailableError", "get_token", "valid_token",
792
+ "get_payload", "refresh_token", "authenticate_request", "validate_api_key",
589
793
  "ensure_dev_secret",
590
794
  ]