tina4-python 3.13.105__tar.gz → 3.13.107__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 (162) hide show
  1. {tina4_python-3.13.105 → tina4_python-3.13.107}/PKG-INFO +1 -1
  2. {tina4_python-3.13.105 → tina4_python-3.13.107}/pyproject.toml +1 -1
  3. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/__init__.py +7 -2
  4. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/core/router.py +144 -0
  5. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/core/server.py +16 -0
  6. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/websocket/__init__.py +33 -0
  7. {tina4_python-3.13.105 → tina4_python-3.13.107}/.gitignore +0 -0
  8. {tina4_python-3.13.105 → tina4_python-3.13.107}/README.md +0 -0
  9. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/CLAUDE.md +0 -0
  10. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/HtmlElement.py +0 -0
  11. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/Testing.py +0 -0
  12. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/ai/__init__.py +0 -0
  13. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/ai/client.py +0 -0
  14. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/api/__init__.py +0 -0
  15. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/auth/__init__.py +0 -0
  16. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/cache/__init__.py +0 -0
  17. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/cli/__init__.py +0 -0
  18. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/container/__init__.py +0 -0
  19. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/context/__init__.py +0 -0
  20. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/context/chunker.py +0 -0
  21. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/core/__init__.py +0 -0
  22. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/core/cache.py +0 -0
  23. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/core/constants.py +0 -0
  24. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/core/events.py +0 -0
  25. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/core/middleware.py +0 -0
  26. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/core/port_takeover.py +0 -0
  27. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/core/rate_limiter.py +0 -0
  28. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/core/request.py +0 -0
  29. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/core/response.py +0 -0
  30. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/crud/__init__.py +0 -0
  31. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/database/__init__.py +0 -0
  32. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/database/adapter.py +0 -0
  33. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/database/connection.py +0 -0
  34. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/database/database_url.py +0 -0
  35. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/database/firebird.py +0 -0
  36. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/database/mongodb.py +0 -0
  37. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/database/mssql.py +0 -0
  38. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/database/mysql.py +0 -0
  39. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/database/odbc.py +0 -0
  40. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/database/postgres.py +0 -0
  41. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/database/sql_translator.py +0 -0
  42. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/database/sqlite.py +0 -0
  43. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/debug/__init__.py +0 -0
  44. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/debug/error_overlay.py +0 -0
  45. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/dev_admin/__init__.py +0 -0
  46. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/dev_admin/metrics.py +0 -0
  47. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/dev_admin/plan.py +0 -0
  48. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/dev_admin/project_index.py +0 -0
  49. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/docs.py +0 -0
  50. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/docstore/__init__.py +0 -0
  51. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/dotenv/__init__.py +0 -0
  52. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/env.py +0 -0
  53. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/frond/FROND.md +0 -0
  54. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/frond/__init__.py +0 -0
  55. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/frond/compiler.py +0 -0
  56. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/frond/engine.py +0 -0
  57. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/frond/parser.py +0 -0
  58. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/gallery/auth/meta.json +0 -0
  59. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  60. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/gallery/database/meta.json +0 -0
  61. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  62. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/gallery/error-overlay/meta.json +0 -0
  63. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  64. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/gallery/orm/meta.json +0 -0
  65. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  66. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  67. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/gallery/queue/meta.json +0 -0
  68. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  69. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/gallery/rest-api/meta.json +0 -0
  70. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  71. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/gallery/templates/meta.json +0 -0
  72. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  73. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  74. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/graphql/__init__.py +0 -0
  75. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/i18n/__init__.py +0 -0
  76. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/mcp/__init__.py +0 -0
  77. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/mcp/protocol.py +0 -0
  78. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/mcp/tools.py +0 -0
  79. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/messenger/__init__.py +0 -0
  80. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/migration/__init__.py +0 -0
  81. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/migration/runner.py +0 -0
  82. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/mqtt/__init__.py +0 -0
  83. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/mqtt/message.py +0 -0
  84. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/orm/__init__.py +0 -0
  85. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/orm/fields.py +0 -0
  86. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/orm/model.py +0 -0
  87. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/orm/point.py +0 -0
  88. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/public/__feedback/widget.js +0 -0
  89. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/public/css/tina4.css +0 -0
  90. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/public/css/tina4.min.css +0 -0
  91. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/public/favicon.ico +0 -0
  92. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/public/images/logo.svg +0 -0
  93. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  94. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/public/js/frond.js +0 -0
  95. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/public/js/frond.min.js +0 -0
  96. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  97. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/public/js/tina4.min.js +0 -0
  98. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/public/js/tina4js.min.js +0 -0
  99. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/public/swagger/index.html +0 -0
  100. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  101. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/query_builder/__init__.py +0 -0
  102. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/queue/__init__.py +0 -0
  103. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/queue/amqp_url.py +0 -0
  104. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/queue/job.py +0 -0
  105. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/queue/kafka_backend.py +0 -0
  106. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/queue/lite_backend.py +0 -0
  107. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/queue/mongo_backend.py +0 -0
  108. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/queue/rabbitmq_backend.py +0 -0
  109. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/queue_backends/__init__.py +0 -0
  110. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/queue_backends/kafka_backend.py +0 -0
  111. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/queue_backends/mongo_backend.py +0 -0
  112. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  113. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/realtime/__init__.py +0 -0
  114. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/realtime/models/Attachment.py +0 -0
  115. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/realtime/models/Channel.py +0 -0
  116. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/realtime/models/ChannelMember.py +0 -0
  117. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/realtime/models/Message.py +0 -0
  118. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/realtime/models/Workspace.py +0 -0
  119. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/realtime/models/__init__.py +0 -0
  120. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/realtime/storage.py +0 -0
  121. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/seeder/__init__.py +0 -0
  122. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/service/__init__.py +0 -0
  123. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/session/__init__.py +0 -0
  124. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/session_handlers/__init__.py +0 -0
  125. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/session_handlers/memcached_handler.py +0 -0
  126. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  127. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/session_handlers/redis_handler.py +0 -0
  128. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/session_handlers/valkey_handler.py +0 -0
  129. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/sso.py +0 -0
  130. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/swagger/__init__.py +0 -0
  131. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/templates/components/crud.twig +0 -0
  132. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  133. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  134. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/templates/docker/python/Dockerfile +0 -0
  135. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  136. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/templates/errors/302.twig +0 -0
  137. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/templates/errors/401.twig +0 -0
  138. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/templates/errors/403.twig +0 -0
  139. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/templates/errors/404.twig +0 -0
  140. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/templates/errors/500.twig +0 -0
  141. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/templates/errors/502.twig +0 -0
  142. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/templates/errors/503.twig +0 -0
  143. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/templates/errors/base.twig +0 -0
  144. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/templates/frontend/README.md +0 -0
  145. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/templates/readme.md +0 -0
  146. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/test/__init__.py +0 -0
  147. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/test_client/__init__.py +0 -0
  148. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  149. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  150. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  151. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  152. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  153. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  154. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  155. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  156. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  157. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  158. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  159. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  160. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/validator/__init__.py +0 -0
  161. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/websocket/backplane.py +0 -0
  162. {tina4_python-3.13.105 → tina4_python-3.13.107}/tina4_python/wsdl/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tina4-python
3
- Version: 3.13.105
3
+ Version: 3.13.107
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tina4-python"
3
- version = "3.13.105"
3
+ version = "3.13.107"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -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.105"
54
+ return "3.13.107"
55
55
 
56
56
 
57
57
  __version__ = _resolve_version()
@@ -71,9 +71,14 @@ __version__ = _resolve_version()
71
71
  # ── CORE (always eager) — every app needs these to boot + serve ──
72
72
  from tina4_python.core.router import ( # noqa: E402, F401
73
73
  get, post, put, patch, delete, any_method,
74
- noauth, secured, cached, middleware, template,
74
+ noauth, secured, role, can, cached, middleware, template,
75
75
  Router, RouteGroup,
76
76
  )
77
+ # websocket is not in that tuple. The tina4_python.websocket *subpackage* is
78
+ # itself callable (see tina4_python/websocket/__init__.py) so
79
+ # `from tina4_python import websocket` reaches core.router.websocket without
80
+ # turning `import tina4_python.websocket as ws` into a function. A re-export
81
+ # here would do the latter.
77
82
  from tina4_python.core.constants import ( # noqa: E402, F401
78
83
  HTTP_OK, HTTP_CREATED, HTTP_ACCEPTED, HTTP_NO_CONTENT,
79
84
  HTTP_MOVED, HTTP_REDIRECT, HTTP_NOT_MODIFIED,
@@ -50,6 +50,24 @@ class RouteRef:
50
50
  self._route["auth_required"] = False
51
51
  return self
52
52
 
53
+ def role(self, *names):
54
+ """Require the caller to hold ONE of the named roles (OR). Reads the
55
+ verified JWT ``roles`` claim. Implies auth (a guarded GET still needs a
56
+ token). Stack ``.role()``/``.can()`` for AND. Feature 138 / ADR-0058."""
57
+ if names:
58
+ self._route.setdefault("required_roles", []).append(tuple(names))
59
+ self._route["auth_required"] = True
60
+ return self
61
+
62
+ def can(self, *permissions):
63
+ """Require the caller to hold ONE of the named permissions (OR). Reads
64
+ the verified JWT ``permissions`` claim; granted-side wildcards (``posts.*``,
65
+ ``*``) satisfy a concrete requirement. Implies auth. Feature 138."""
66
+ if permissions:
67
+ self._route.setdefault("required_perms", []).append(tuple(permissions))
68
+ self._route["auth_required"] = True
69
+ return self
70
+
53
71
  def cache(self):
54
72
  """Mark this route as cacheable."""
55
73
  self._route["cached"] = True
@@ -371,6 +389,12 @@ class Router:
371
389
  auth_required = False
372
390
  elif hasattr(handler, "_secured"):
373
391
  auth_required = True
392
+ elif hasattr(handler, "_required_roles") or hasattr(handler, "_required_perms"):
393
+ # A role/permission guard (Feature 138) implies auth — a guarded GET
394
+ # still requires a token. This branch only fires in the unusual
395
+ # innermost-order case (guard BELOW @get); the documented order puts
396
+ # the guard ABOVE, where it flips auth_required via the RouteRef.
397
+ auth_required = True
374
398
  else:
375
399
  # GET, HEAD, OPTIONS, and ANY are public by default. HEAD and
376
400
  # OPTIONS are safe/idempotent introspection methods (RFC 9110
@@ -392,6 +416,11 @@ class Router:
392
416
  "module": getattr(handler, "__module__", ""),
393
417
  "middleware": effective_middleware,
394
418
  "auth_required": auth_required,
419
+ # RBAC guards (Feature 138): lists of OR-groups; AND across groups.
420
+ # Populated here for the innermost-order case, and appended to by the
421
+ # RouteRef.role()/.can() modifiers for the documented guard-above order.
422
+ "required_roles": list(getattr(handler, "_required_roles", []) or []),
423
+ "required_perms": list(getattr(handler, "_required_perms", []) or []),
395
424
  "cached": options.get("cached", False),
396
425
  "cache_max_age": options.get("cache_max_age", 60),
397
426
  "swagger_meta": swagger_meta or options.get("swagger_meta", {}),
@@ -862,6 +891,121 @@ def secured():
862
891
  return decorator
863
892
 
864
893
 
894
+ # ── RBAC guards (Feature 138 / ADR-0058) ──────────────────────
895
+ # Claim-first authorization on top of the JWT auth gate. @role reads the verified
896
+ # `roles` claim, @can reads `permissions`. Multiple args are OR; stack guards for
897
+ # AND. A guard implies @secured. Roles and permissions are independent claims;
898
+ # the core never expands a role into permissions.
899
+
900
+ def role(*names):
901
+ """Require the caller to hold ONE of the named roles (OR). Stack
902
+ ``@role``/``@can`` for AND. Reads the verified JWT ``roles`` claim (a legacy
903
+ singular ``role`` string is coerced). Implies ``@secured``."""
904
+ group = tuple(names)
905
+
906
+ def decorator(fn):
907
+ fn._required_roles = getattr(fn, "_required_roles", []) + [group]
908
+ if hasattr(fn, "_route_ref"):
909
+ route = fn._route_ref._route
910
+ route.setdefault("required_roles", []).append(group)
911
+ was_public = not route.get("auth_required", True)
912
+ route["auth_required"] = True
913
+ if was_public:
914
+ Log.debug(
915
+ f"Route auth updated: {route['method']} {route['path']} "
916
+ f"(auth=required via @role)"
917
+ )
918
+ return fn
919
+ return decorator
920
+
921
+
922
+ def can(*permissions):
923
+ """Require the caller to hold ONE of the named permissions (OR). Stack
924
+ ``@role``/``@can`` for AND. Reads the verified JWT ``permissions`` claim;
925
+ granted-side wildcards (``posts.*``, ``*``) satisfy a concrete requirement.
926
+ Implies ``@secured``."""
927
+ group = tuple(permissions)
928
+
929
+ def decorator(fn):
930
+ fn._required_perms = getattr(fn, "_required_perms", []) + [group]
931
+ if hasattr(fn, "_route_ref"):
932
+ route = fn._route_ref._route
933
+ route.setdefault("required_perms", []).append(group)
934
+ was_public = not route.get("auth_required", True)
935
+ route["auth_required"] = True
936
+ if was_public:
937
+ Log.debug(
938
+ f"Route auth updated: {route['method']} {route['path']} "
939
+ f"(auth=required via @can)"
940
+ )
941
+ return fn
942
+ return decorator
943
+
944
+
945
+ def _rbac_claim_list(subject, key, legacy=None):
946
+ """Read a claim as a list of strings from the VERIFIED payload. Coerces a
947
+ legacy singular string (``role`` -> ``["role"]``). Returns ``[]`` for a
948
+ missing/None subject or claim."""
949
+ if not isinstance(subject, dict):
950
+ return []
951
+ val = subject.get(key)
952
+ out = []
953
+ if isinstance(val, str) and val:
954
+ out = [val]
955
+ elif isinstance(val, (list, tuple)):
956
+ out = [str(x) for x in val if x is not None and str(x) != ""]
957
+ if not out and legacy:
958
+ lv = subject.get(legacy)
959
+ if isinstance(lv, str) and lv:
960
+ out = [lv]
961
+ elif isinstance(lv, (list, tuple)):
962
+ out = [str(x) for x in lv if x is not None and str(x) != ""]
963
+ return out
964
+
965
+
966
+ def _rbac_perm_granted(granted, required):
967
+ """True if any GRANTED permission satisfies the concrete REQUIRED permission.
968
+ Wildcards live only on the granted side: ``*`` grants everything; ``posts.*``
969
+ grants ``posts.<anything...>`` on the dot boundary (never ``users.delete``)."""
970
+ for g in granted:
971
+ if g == "*" or g == required:
972
+ return True
973
+ if g.endswith(".*") and required.startswith(g[:-1]):
974
+ return True
975
+ return False
976
+
977
+
978
+ def rbac_authorized(subject, required_roles, required_perms):
979
+ """Return True if the verified ``subject`` satisfies every guard group.
980
+
981
+ AND across groups, OR within a group. ``required_roles`` / ``required_perms``
982
+ are lists of OR-groups (each ``@role``/``@can`` adds one group). Roles read
983
+ the ``roles`` claim (legacy singular ``role`` coerced); permissions read
984
+ ``permissions`` with granted-side wildcards. A missing subject satisfies no
985
+ group. Feature 138 / ADR-0058."""
986
+ # NOTE: explicit loops, NOT any(...): this module shadows the builtin `any`
987
+ # (a route helper), so `any(...)` here would resolve to the wrong callable.
988
+ roles = _rbac_claim_list(subject, "roles", legacy="role")
989
+ for group in (required_roles or []):
990
+ matched = False
991
+ for r in group:
992
+ if r in roles:
993
+ matched = True
994
+ break
995
+ if not matched:
996
+ return False
997
+ perms = _rbac_claim_list(subject, "permissions")
998
+ for group in (required_perms or []):
999
+ matched = False
1000
+ for req in group:
1001
+ if _rbac_perm_granted(perms, req):
1002
+ matched = True
1003
+ break
1004
+ if not matched:
1005
+ return False
1006
+ return True
1007
+
1008
+
865
1009
  # ── Middleware Decorator ───────────────────────────────────────
866
1010
 
867
1011
  def middleware(*middleware_classes):
@@ -1804,6 +1804,22 @@ def _check_auth(request: Request, response: Response, route: dict) -> bool:
1804
1804
  "status": 401,
1805
1805
  })
1806
1806
  return True
1807
+ # ── RBAC guards (Feature 138): authorization AFTER authentication ──
1808
+ # Auth passed (401 already ruled out above). If the route carries role/
1809
+ # permission guards, the verified payload (request.user) must satisfy them,
1810
+ # else 403 — authenticated but forbidden. An API-key auth leaves request.user
1811
+ # unset, so it carries no roles and is correctly forbidden from a guarded route.
1812
+ _req_roles = route.get("required_roles") or []
1813
+ _req_perms = route.get("required_perms") or []
1814
+ if _req_roles or _req_perms:
1815
+ from tina4_python.core.router import rbac_authorized
1816
+ if not rbac_authorized(getattr(request, "user", None), _req_roles, _req_perms):
1817
+ response.status(403).json({
1818
+ "error": "Forbidden",
1819
+ "message": "You do not have permission to access this resource",
1820
+ "status": 403,
1821
+ })
1822
+ return True
1807
1823
  return False
1808
1824
 
1809
1825
 
@@ -22,6 +22,8 @@ import os
22
22
  import uuid
23
23
  import time
24
24
  from typing import Callable
25
+ import sys as _sys
26
+ from types import ModuleType as _ModuleType
25
27
 
26
28
  MAGIC_STRING = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
27
29
 
@@ -961,3 +963,34 @@ __all__ = [
961
963
  "OP_TEXT", "OP_BINARY", "OP_CLOSE", "OP_PING", "OP_PONG",
962
964
  "CLOSE_NORMAL", "CLOSE_GOING_AWAY", "CLOSE_PROTOCOL_ERROR", "CLOSE_TOO_LARGE",
963
965
  ]
966
+
967
+
968
+ # ── `@websocket("/path")` on the package name ───────────────────────────────
969
+ # `tina4_python.websocket` is two things at once: this RFC 6455 subpackage,
970
+ # and — to every reader of the docs — the route decorator beside @get/@post.
971
+ # Python binds a submodule onto its parent as soon as the submodule loads, so
972
+ #
973
+ # from tina4_python import websocket
974
+ # @websocket("/ws") # TypeError: 'module' object is not callable
975
+ #
976
+ # died at decorate time and auto-discovery dropped every route from that line
977
+ # onward.
978
+ #
979
+ # A re-export in tina4_python/__init__.py AFTER core.server has already loaded
980
+ # this subpackage is stable (importlib setattr happens on first load only).
981
+ # That still fails the other public surface: `import tina4_python.websocket as
982
+ # ws` would then return the function, so `ws.WebSocketServer` breaks. Making
983
+ # this module callable keeps both: the package name decorates, and the
984
+ # subpackage stays a module. Forward *args/**kwargs so this spelling cannot
985
+ # drift from core.router.websocket. Guard sys.modules — a loader that execs
986
+ # this file without registering it must not KeyError.
987
+ class _CallableWebSocketModule(_ModuleType):
988
+ def __call__(self, *args, **kwargs):
989
+ from tina4_python.core.router import websocket as _websocket_route
990
+
991
+ return _websocket_route(*args, **kwargs)
992
+
993
+
994
+ _self = _sys.modules.get(__name__)
995
+ if _self is not None:
996
+ _self.__class__ = _CallableWebSocketModule