tina4-python 3.13.106__tar.gz → 3.13.108__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 (163) hide show
  1. {tina4_python-3.13.106 → tina4_python-3.13.108}/PKG-INFO +3 -4
  2. {tina4_python-3.13.106 → tina4_python-3.13.108}/README.md +2 -3
  3. {tina4_python-3.13.106 → tina4_python-3.13.108}/pyproject.toml +1 -1
  4. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/__init__.py +2 -2
  5. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/cache/__init__.py +35 -0
  6. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/core/router.py +144 -0
  7. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/core/server.py +16 -0
  8. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/database/adapter.py +58 -13
  9. tina4_python-3.13.108/tina4_python/public/js/tina4js.min.js +48 -0
  10. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/service/__init__.py +10 -0
  11. tina4_python-3.13.106/tina4_python/public/js/tina4js.min.js +0 -48
  12. {tina4_python-3.13.106 → tina4_python-3.13.108}/.gitignore +0 -0
  13. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/CLAUDE.md +0 -0
  14. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/HtmlElement.py +0 -0
  15. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/Testing.py +0 -0
  16. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/ai/__init__.py +0 -0
  17. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/ai/client.py +0 -0
  18. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/api/__init__.py +0 -0
  19. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/auth/__init__.py +0 -0
  20. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/cli/__init__.py +0 -0
  21. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/container/__init__.py +0 -0
  22. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/context/__init__.py +0 -0
  23. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/context/chunker.py +0 -0
  24. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/core/__init__.py +0 -0
  25. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/core/cache.py +0 -0
  26. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/core/constants.py +0 -0
  27. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/core/events.py +0 -0
  28. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/core/middleware.py +0 -0
  29. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/core/port_takeover.py +0 -0
  30. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/core/rate_limiter.py +0 -0
  31. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/core/request.py +0 -0
  32. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/core/response.py +0 -0
  33. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/crud/__init__.py +0 -0
  34. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/database/__init__.py +0 -0
  35. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/database/connection.py +0 -0
  36. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/database/database_url.py +0 -0
  37. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/database/firebird.py +0 -0
  38. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/database/mongodb.py +0 -0
  39. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/database/mssql.py +0 -0
  40. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/database/mysql.py +0 -0
  41. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/database/odbc.py +0 -0
  42. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/database/postgres.py +0 -0
  43. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/database/sql_translator.py +0 -0
  44. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/database/sqlite.py +0 -0
  45. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/debug/__init__.py +0 -0
  46. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/debug/error_overlay.py +0 -0
  47. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/dev_admin/__init__.py +0 -0
  48. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/dev_admin/metrics.py +0 -0
  49. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/dev_admin/plan.py +0 -0
  50. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/dev_admin/project_index.py +0 -0
  51. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/docs.py +0 -0
  52. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/docstore/__init__.py +0 -0
  53. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/dotenv/__init__.py +0 -0
  54. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/env.py +0 -0
  55. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/frond/FROND.md +0 -0
  56. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/frond/__init__.py +0 -0
  57. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/frond/compiler.py +0 -0
  58. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/frond/engine.py +0 -0
  59. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/frond/parser.py +0 -0
  60. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/gallery/auth/meta.json +0 -0
  61. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  62. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/gallery/database/meta.json +0 -0
  63. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  64. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/gallery/error-overlay/meta.json +0 -0
  65. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  66. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/gallery/orm/meta.json +0 -0
  67. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  68. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  69. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/gallery/queue/meta.json +0 -0
  70. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  71. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/gallery/rest-api/meta.json +0 -0
  72. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  73. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/gallery/templates/meta.json +0 -0
  74. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  75. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  76. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/graphql/__init__.py +0 -0
  77. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/i18n/__init__.py +0 -0
  78. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/mcp/__init__.py +0 -0
  79. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/mcp/protocol.py +0 -0
  80. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/mcp/tools.py +0 -0
  81. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/messenger/__init__.py +0 -0
  82. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/migration/__init__.py +0 -0
  83. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/migration/runner.py +0 -0
  84. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/mqtt/__init__.py +0 -0
  85. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/mqtt/message.py +0 -0
  86. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/orm/__init__.py +0 -0
  87. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/orm/fields.py +0 -0
  88. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/orm/model.py +0 -0
  89. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/orm/point.py +0 -0
  90. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/public/__feedback/widget.js +0 -0
  91. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/public/css/tina4.css +0 -0
  92. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/public/css/tina4.min.css +0 -0
  93. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/public/favicon.ico +0 -0
  94. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/public/images/logo.svg +0 -0
  95. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  96. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/public/js/frond.js +0 -0
  97. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/public/js/frond.min.js +0 -0
  98. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  99. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/public/js/tina4.min.js +0 -0
  100. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/public/swagger/index.html +0 -0
  101. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  102. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/query_builder/__init__.py +0 -0
  103. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/queue/__init__.py +0 -0
  104. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/queue/amqp_url.py +0 -0
  105. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/queue/job.py +0 -0
  106. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/queue/kafka_backend.py +0 -0
  107. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/queue/lite_backend.py +0 -0
  108. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/queue/mongo_backend.py +0 -0
  109. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/queue/rabbitmq_backend.py +0 -0
  110. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/queue_backends/__init__.py +0 -0
  111. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/queue_backends/kafka_backend.py +0 -0
  112. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/queue_backends/mongo_backend.py +0 -0
  113. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  114. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/realtime/__init__.py +0 -0
  115. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/realtime/models/Attachment.py +0 -0
  116. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/realtime/models/Channel.py +0 -0
  117. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/realtime/models/ChannelMember.py +0 -0
  118. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/realtime/models/Message.py +0 -0
  119. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/realtime/models/Workspace.py +0 -0
  120. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/realtime/models/__init__.py +0 -0
  121. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/realtime/storage.py +0 -0
  122. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/seeder/__init__.py +0 -0
  123. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/session/__init__.py +0 -0
  124. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/session_handlers/__init__.py +0 -0
  125. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/session_handlers/memcached_handler.py +0 -0
  126. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  127. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/session_handlers/redis_handler.py +0 -0
  128. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/session_handlers/valkey_handler.py +0 -0
  129. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/sso.py +0 -0
  130. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/swagger/__init__.py +0 -0
  131. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/templates/components/crud.twig +0 -0
  132. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  133. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  134. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/templates/docker/python/Dockerfile +0 -0
  135. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  136. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/templates/errors/302.twig +0 -0
  137. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/templates/errors/401.twig +0 -0
  138. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/templates/errors/403.twig +0 -0
  139. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/templates/errors/404.twig +0 -0
  140. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/templates/errors/500.twig +0 -0
  141. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/templates/errors/502.twig +0 -0
  142. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/templates/errors/503.twig +0 -0
  143. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/templates/errors/base.twig +0 -0
  144. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/templates/frontend/README.md +0 -0
  145. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/templates/readme.md +0 -0
  146. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/test/__init__.py +0 -0
  147. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/test_client/__init__.py +0 -0
  148. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  149. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  150. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  151. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  152. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  153. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  154. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  155. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  156. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  157. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  158. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  159. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  160. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/validator/__init__.py +0 -0
  161. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/websocket/__init__.py +0 -0
  162. {tina4_python-3.13.106 → tina4_python-3.13.108}/tina4_python/websocket/backplane.py +0 -0
  163. {tina4_python-3.13.106 → tina4_python-3.13.108}/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.106
3
+ Version: 3.13.108
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
@@ -56,13 +56,12 @@ Description-Content-Type: text/markdown
56
56
  <h1 align="center">Tina4 Python</h1>
57
57
 
58
58
  <p align="center">
59
- 98 built-in features. Zero dependencies. One import, everything works.
59
+ Zero dependencies. One import, everything works.
60
60
  </p>
61
61
 
62
62
  <p align="center">
63
63
  <a href="https://pypi.org/project/tina4-python/"><img src="https://img.shields.io/pypi/v/tina4-python?color=7b1fa2&label=PyPI" alt="PyPI"></a>
64
- <img src="https://img.shields.io/badge/tests-2%2C281%20passing-brightgreen" alt="Tests">
65
- <img src="https://img.shields.io/badge/features-98-blue" alt="Features">
64
+ <a href="https://github.com/tina4stack/tina4-python/actions/workflows/test.yml"><img src="https://img.shields.io/github/actions/workflow/status/tina4stack/tina4-python/test.yml?label=tests" alt="Tests"></a>
66
65
  <img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="Zero Deps">
67
66
  <a href="https://tina4.com"><img src="https://img.shields.io/badge/docs-tina4.com-7b1fa2" alt="Docs"></a>
68
67
  </p>
@@ -5,13 +5,12 @@
5
5
  <h1 align="center">Tina4 Python</h1>
6
6
 
7
7
  <p align="center">
8
- 98 built-in features. Zero dependencies. One import, everything works.
8
+ Zero dependencies. One import, everything works.
9
9
  </p>
10
10
 
11
11
  <p align="center">
12
12
  <a href="https://pypi.org/project/tina4-python/"><img src="https://img.shields.io/pypi/v/tina4-python?color=7b1fa2&label=PyPI" alt="PyPI"></a>
13
- <img src="https://img.shields.io/badge/tests-2%2C281%20passing-brightgreen" alt="Tests">
14
- <img src="https://img.shields.io/badge/features-98-blue" alt="Features">
13
+ <a href="https://github.com/tina4stack/tina4-python/actions/workflows/test.yml"><img src="https://img.shields.io/github/actions/workflow/status/tina4stack/tina4-python/test.yml?label=tests" alt="Tests"></a>
15
14
  <img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="Zero Deps">
16
15
  <a href="https://tina4.com"><img src="https://img.shields.io/badge/docs-tina4.com-7b1fa2" alt="Docs"></a>
17
16
  </p>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tina4-python"
3
- version = "3.13.106"
3
+ version = "3.13.108"
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.106"
54
+ return "3.13.108"
55
55
 
56
56
 
57
57
  __version__ = _resolve_version()
@@ -71,7 +71,7 @@ __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
77
  # websocket is not in that tuple. The tina4_python.websocket *subpackage* is
@@ -1190,6 +1190,17 @@ def _vary_fields(response) -> list:
1190
1190
  return [f.strip().lower() for f in raw.split(",") if f.strip()]
1191
1191
 
1192
1192
 
1193
+ def _cache_control_tokens(carrier) -> set:
1194
+ """The Cache-Control directive names on a request or response, lowercased.
1195
+
1196
+ Parsed as comma-separated tokens with any ``=value`` stripped, rather than by
1197
+ substring search, so ``no-cache="Set-Cookie"`` is recognised as ``no-cache``
1198
+ and a directive name never matches as a fragment of a longer one.
1199
+ """
1200
+ raw = _header_value(carrier, "Cache-Control") or ""
1201
+ return {token.split("=", 1)[0].strip().lower() for token in raw.split(",") if token.strip()}
1202
+
1203
+
1193
1204
  def _shared_cache_allowed(response) -> bool:
1194
1205
  """Does the response carry a directive that lets a shared cache store it?"""
1195
1206
  cc = (_header_value(response, "Cache-Control") or "").lower()
@@ -1373,11 +1384,35 @@ class ResponseCache:
1373
1384
  URL, because the key is method + URL only.
1374
1385
  * s4.1 — "A stored response with a Vary header field value containing a
1375
1386
  member '*' always fails to match", so storing one is pointless.
1387
+
1388
+ The same s3 reasoning applies to two cases the Authorization check alone
1389
+ does not cover, and both were storable before 3.13.108:
1390
+
1391
+ * ``no-store`` forbids storage in any cache and ``private`` forbids it in
1392
+ a shared one. Neither was honoured, so a handler had no way at all to
1393
+ keep a response out of this cache — setting the correct standard header
1394
+ did nothing.
1395
+ * A caller identified by a session cookie is as specific as one carrying
1396
+ Authorization, and Tina4's own session mechanism IS a cookie. Because
1397
+ the key is method + URL, storing such a response replays one signed-in
1398
+ user's page to whoever asks for that URL next. A response that installs
1399
+ a session is per-user by construction for the same reason.
1400
+
1401
+ Both cookie cases stay cacheable when the response opts in explicitly with
1402
+ a shared-cache directive, which is how a genuinely public page served to
1403
+ cookie-bearing browsers keeps its hit rate.
1376
1404
  """
1377
1405
  if "*" in _vary_fields(response):
1378
1406
  return False
1407
+ directives = _cache_control_tokens(response)
1408
+ if directives & {"no-store", "private", "no-cache"}:
1409
+ return False
1379
1410
  if _header_value(request, "Authorization") is not None:
1380
1411
  return _shared_cache_allowed(response)
1412
+ if _header_value(request, "Cookie") is not None:
1413
+ return _shared_cache_allowed(response)
1414
+ if _header_value(response, "Set-Cookie") is not None:
1415
+ return _shared_cache_allowed(response)
1381
1416
  return True
1382
1417
 
1383
1418
  @staticmethod
@@ -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
 
@@ -62,16 +62,41 @@ def resolve_connect_timeout() -> float | None:
62
62
 
63
63
 
64
64
  def driver_connect_timeout_seconds(seconds: float | None) -> int | None:
65
- """A driver's own connect-timeout option value, rounded UP to whole seconds.
65
+ """A driver's own connect-timeout option, in whole seconds, STRICTLY longer
66
+ than the bound it protects.
66
67
 
67
68
  Every driver option here (libpq ``connect_timeout``, mysql-connector
68
69
  ``connection_timeout``, pymssql ``login_timeout``, pyodbc ``timeout``) is
69
- whole seconds. Rounding UP matters: a driver that fired EARLY — at 2s for a
70
- configured 2.5s — would raise its own raw error before our bound was
71
- reached, and the caller would see a bare driver message instead of one
72
- naming the variable that caused it.
70
+ whole seconds, so the bound has to become an integer — and which integer
71
+ decides whether the driver's deadline lands after ours or on top of it.
72
+ ``ceil`` put it on top: for a whole-second bound ``ceil(N) == N``, and the
73
+ shipped default of 10 is whole. ``floor(s) + 1`` is strictly greater for
74
+ every input, fractional or whole, which is what leaves the driver's own
75
+ timer the room to fire first — the thing this wrapper is built on. The
76
+ cost is at most one extra second, on a path that has already failed.
73
77
  """
74
- return None if seconds is None else max(1, math.ceil(seconds))
78
+ return None if seconds is None else max(1, math.floor(seconds) + 1)
79
+
80
+
81
+ def bound_was_reached(elapsed_monotonic: float, elapsed_realtime: float,
82
+ seconds: float | None) -> bool:
83
+ """Did a connect that failed take at least the configured bound?
84
+
85
+ Two readings, because the framework and the driver do not share a clock.
86
+ :func:`connect_deadline` times on ``time.monotonic()``; libpq times its own
87
+ ``connect_timeout`` on ``gettimeofday()``. NTP moves the wall clock and
88
+ never touches the monotonic one, so a forward step or slew can make the
89
+ driver abort before a monotonic reading has reached the bound.
90
+
91
+ Taking the LARGER of the two readings covers both directions: the realtime
92
+ reading catches a forward jump, and keeping the monotonic reading means a
93
+ BACKWARD jump cannot hide a timeout that really did happen.
94
+
95
+ Pure, so the decision is testable without faking a clock.
96
+ """
97
+ if seconds is None:
98
+ return False
99
+ return max(elapsed_monotonic, elapsed_realtime) >= seconds
75
100
 
76
101
 
77
102
  @contextlib.contextmanager
@@ -105,19 +130,39 @@ def connect_deadline(host, port):
105
130
  throws away the driver's own diagnosis, and for the watchdog adapters it
106
131
  would abandon a thread the driver could have unwound itself.
107
132
 
108
- The invariant that makes this work: the driver's option is always >= our
109
- bound (:func:`driver_connect_timeout_seconds` rounds UP), and our clock
110
- starts BEFORE the call, so the driver's own timeout cannot expire before
111
- ``elapsed`` has reached our bound. Break either half and a bare driver
112
- message reaches the caller instead of ours.
133
+ THE INVARIANT, AND WHY IT TAKES TWO CLOCKS
134
+ ------------------------------------------
135
+ What decides the race is not the driver's OPTION against our bound — it
136
+ is the driver's ABORT INSTANT against our reading of a clock. Two things
137
+ have to hold, and each of them was broken:
138
+
139
+ * The driver's option must be STRICTLY greater than our bound, so its
140
+ deadline lands after ours instead of on top of it.
141
+ :func:`driver_connect_timeout_seconds` returns ``floor(s) + 1`` for that
142
+ reason; ``ceil`` left a whole-second bound with no separation at all, and
143
+ the default bound is a whole number.
144
+
145
+ * The comparison has to be made on the clock the DRIVER used. libpq times
146
+ ``connect_timeout`` with ``gettimeofday()`` — CLOCK_REALTIME — while a
147
+ duration in Python belongs on ``time.monotonic()``. NTP slews and steps
148
+ realtime and never touches monotonic, so a monotonic reading can still
149
+ sit below the bound at the instant the driver has already given up, and
150
+ then the driver's own message — which names no tunable — reaches
151
+ the caller. :func:`bound_was_reached` compares both readings.
152
+
153
+ Break either half and a bare driver message reaches the caller instead of
154
+ ours.
113
155
  """
114
156
  seconds = resolve_connect_timeout()
115
157
  started = time.monotonic()
158
+ started_realtime = time.time()
116
159
  try:
117
160
  yield seconds
118
161
  except Exception as failure:
119
- elapsed = time.monotonic() - started
120
- if seconds is not None and elapsed >= seconds:
162
+ elapsed_monotonic = time.monotonic() - started
163
+ elapsed_realtime = time.time() - started_realtime
164
+ elapsed = max(elapsed_monotonic, elapsed_realtime)
165
+ if bound_was_reached(elapsed_monotonic, elapsed_realtime, seconds):
121
166
  raise DatabaseConnectTimeout(
122
167
  f"Database connect to {host}:{port} timed out after {elapsed:.1f}s "
123
168
  f"({CONNECT_TIMEOUT_VARIABLE}={seconds:g} seconds). "
@@ -0,0 +1,48 @@
1
+ "use strict";var Tina4=(()=>{var ee=Object.defineProperty;var Xe=Object.getOwnPropertyDescriptor;var Ye=Object.getOwnPropertyNames;var et=Object.prototype.hasOwnProperty;var tt=(e,n)=>{for(var t in n)ee(e,t,{get:n[t],enumerable:!0})},nt=(e,n,t,r)=>{if(n&&typeof n=="object"||typeof n=="function")for(let o of Ye(n))!et.call(e,o)&&o!==t&&ee(e,o,{get:()=>n[o],enumerable:!(r=Xe(n,o))||r.enumerable});return e};var rt=e=>nt(ee({},"__esModule",{value:!0}),e);var $t={};tt($t,{Tina4Element:()=>N,api:()=>Ae,batch:()=>G,clearPersistedKeys:()=>Ve,computed:()=>ye,createI18n:()=>de,effect:()=>E,html:()=>be,i18n:()=>Je,isSignal:()=>I,navigate:()=>Q,persist:()=>ze,pwa:()=>Ne,route:()=>_e,router:()=>xe,rtc:()=>De,rtcConfig:()=>X,signal:()=>w,sse:()=>Pe,ws:()=>K});var L=null,H=null,U=null,B=null;function O(e){B=e}function J(){return B}var me=null,he=null,ve=[],ot=512;var V=0,te=new Set;function w(e,n){let t=e,r=new Set,o={_t4:!0,get value(){if(L&&(r.add(L),H)){let s=L;H.push(()=>r.delete(s))}return t},set value(s){if(Object.is(s,t))return;let i=t;if(t=s,o._debugInfo&&o._debugInfo.updateCount++,he&&he(o,i,s),V>0)for(let c of r)te.add(c);else{let c;for(let a of[...r])try{a()}catch(l){c===void 0&&(c=l)}if(c!==void 0)throw c}},_subscribe(s){return r.add(s),()=>{r.delete(s)}},peek(){return t}};return me?(o._debugInfo={label:n,createdAt:Date.now(),updateCount:0,subs:r},me(o,n)):ve.length<ot&&ve.push({ref:new WeakRef(o),label:n,createdAt:Date.now(),subs:r}),o}function ye(e){let n=w(void 0);return E(()=>{n.value=e()}),{_t4:!0,get value(){return n.value},set value(t){throw new Error("[tina4] computed signals are read-only")},_subscribe(t){return n._subscribe(t)},peek(){return n.peek()}}}function E(e){let n=!1,t=[],r=[],o=()=>{for(let c of r)c();r=[]},s=()=>{if(n)return;for(let g of t)g();t=[],o();let c=L,a=H,l=U;L=s,H=t,U=r;try{e()}finally{L=c,H=a,U=l}};s();let i=()=>{n=!0;for(let c of t)c();t=[],o()};return U&&U.push(i),B&&B.push(i),i}function G(e){V++;try{e()}finally{if(V--,V===0){let n=[...te];te.clear();let t;for(let r of n)try{r()}catch(o){t===void 0&&(t=o)}if(t!==void 0)throw t}}}function I(e){return e!==null&&typeof e=="object"&&e._t4===!0}var Se=new WeakMap,ne="t4:";function be(e,...n){let t=Se.get(e);if(!t){let i=document.createElement("template"),c=new Map,a="";for(let l=0;l<e.length;l++)if(a+=e[l],l<n.length)if(dt(a)){let m=ut(e[l]);m&&c.set(l,m),a+=`__t4_${l}__`}else a+=`<!--${ne}${l}-->`;i.innerHTML=a,t={template:i,propertyNames:c},Se.set(e,t)}let r=t.template.content.cloneNode(!0),o=st(r);for(let{marker:i,index:c}of o)at(i,n[c]);let s=it(r);for(let i of s)ct(i,n,t.propertyNames);return r}function st(e){let n=[];return se(e,t=>{if(t.nodeType===8){let r=t.data;if(r&&r.startsWith(ne)){let o=parseInt(r.slice(ne.length),10);n.push({marker:t,index:o})}}}),n}function it(e){let n=[];return se(e,t=>{t.nodeType===1&&n.push(t)}),n}function se(e,n){let t=e.childNodes;for(let r=0;r<t.length;r++){let o=t[r];n(o),se(o,n)}}function at(e,n){let t=e.parentNode;if(t)if(I(n)){let r=document.createTextNode("");t.replaceChild(r,e),E(()=>{r.data=String(n.value??"")})}else if(typeof n=="function"){let r=document.createComment("");t.replaceChild(r,e);let o=[],s=[];E(()=>{for(let u of s)u();s=[];let i=[],c=J();O(i);let a=n();O(c),s=i;for(let u of o)u.parentNode?.removeChild(u);o=[];let l=oe(a),g=r.parentNode;if(!g)return;let m=Z(g);for(let u of l){let d=m?D(u,m):u;g.insertBefore(d,r),o.push(d)}})}else if(Te(n)){let r=Z(t);if(r){let o=document.createDocumentFragment();for(let s of Array.from(n.childNodes))o.appendChild(D(s,r));t.replaceChild(o,e)}else t.replaceChild(n,e)}else if(n instanceof Node){let r=Z(t);t.replaceChild(r?D(n,r):n,e)}else if(Array.isArray(n)){let r=Z(t),o=document.createDocumentFragment();for(let s of n){let i=oe(s);for(let c of i)o.appendChild(r?D(c,r):c)}t.replaceChild(o,e)}else{let r=document.createTextNode(String(n??""));t.replaceChild(r,e)}}function ct(e,n,t){let r=[];for(let o of Array.from(e.attributes)){let s=o.name,i=o.value;if(s.startsWith("@")){let a=s.slice(1),l=i.match(/__t4_(\d+)__/);if(l){let g=n[parseInt(l[1],10)];typeof g=="function"&&e.addEventListener(a,m=>G(()=>g(m)))}r.push(s);continue}if(s.startsWith("?")){let a=s.slice(1),l=i.match(/__t4_(\d+)__/);if(l){let g=n[parseInt(l[1],10)];if(I(g)){let m=g;E(()=>{m.value?e.setAttribute(a,""):e.removeAttribute(a)})}else typeof g=="function"?E(()=>{g()?e.setAttribute(a,""):e.removeAttribute(a)}):g&&e.setAttribute(a,"")}r.push(s);continue}if(s.startsWith(".")){let a=i.match(/__t4_(\d+)__/);if(a){let l=parseInt(a[1],10),g=t.get(l)??s.slice(1),m=n[l];I(m)?E(()=>{e[g]=m.value}):typeof m=="function"?E(()=>{e[g]=m()??""}):e[g]=m}r.push(s);continue}let c=i.match(/__t4_(\d+)__/);if(c){let a=n[parseInt(c[1],10)];if(I(a)){let l=a;E(()=>{e.setAttribute(s,String(l.value??""))})}else typeof a=="function"?E(()=>{e.setAttribute(s,String(a()??""))}):e.setAttribute(s,String(a??""))}}for(let o of r)e.removeAttribute(o)}var re="http://www.w3.org/2000/svg",lt="http://www.w3.org/1998/Math/MathML",we="http://www.w3.org/1999/xhtml";function Z(e){let n=e;for(;n&&n.nodeType===1;){let t=n,r=t.namespaceURI;if(r===re&&t.localName==="foreignObject")return null;if(r===re||r===lt)return r;if(r===we)return null;n=n.parentNode}return null}function D(e,n){if(e.nodeType!==1)return e;let t=e;if(t.namespaceURI===n){for(let s of Array.from(t.childNodes)){let i=D(s,n);i!==s&&t.replaceChild(i,s)}return t}let r=document.createElementNS(n,t.localName);for(let s of Array.from(t.attributes))r.setAttribute(s.name,s.value);let o=n===re&&t.localName==="foreignObject"?we:n;for(let s of Array.from(t.childNodes))r.appendChild(D(s,o));return r}function ut(e){return e.match(/\.([^\s"'<>/=]+)\s*=\s*["']?$/)?.[1]}function oe(e){if(e==null||e===!1)return[];if(Te(e))return Array.from(e.childNodes);if(e instanceof Node)return[e];if(Array.isArray(e)){let n=[];for(let t of e)n.push(...oe(t));return n}return[document.createTextNode(String(e))]}function Te(e){return e!=null&&typeof e=="object"&&e.nodeType===11}function dt(e){let n=!1,t=!1,r=!1;for(let o=0;o<e.length;o++){if(!r&&e.startsWith("<!--",o)){let i=e.indexOf("-->",o+4);if(i===-1)return!1;o=i+2;continue}let s=e[o];s==="<"&&!n&&!t&&(r=!0),s===">"&&!n&&!t&&(r=!1),r&&(s==='"'&&!n&&(t=!t),s==="'"&&!t&&(n=!n))}return r}var ke=null,Ce=null;var N=class extends HTMLElement{constructor(){super();this._props={};this._rendered=!1;this._disposeRender=null;this._innerDisposers=[];let t=this.constructor;this._root=t.shadow?this.attachShadow({mode:"open"}):this;for(let[r,o]of Object.entries(t.props))this._props[r]=w(this._coerce(this.getAttribute(r),o))}static get observedAttributes(){return Object.keys(this.props)}connectedCallback(){if(this._rendered)return;this._rendered=!0;let t=this.constructor,r=null;if(t.styles&&t.shadow&&this._root instanceof ShadowRoot){let o=document.createElement("style");o.textContent=t.styles,this._root.appendChild(o),r=o}this._disposeRender=E(()=>{this._innerDisposers.splice(0).forEach(a=>a());let o=[],s=J();O(o);let i=this.render();O(s),this._innerDisposers=o;let c=Array.from(this._root.childNodes);for(let a of c)a!==r&&this._root.removeChild(a);i&&this._root.appendChild(i)}),this.onMount(),ke&&ke(this)}disconnectedCallback(){this._disposeRender&&(this._disposeRender(),this._disposeRender=null),this._innerDisposers.splice(0).forEach(t=>t()),this.onUnmount(),Ce&&Ce(this)}attributeChangedCallback(t,r,o){let i=this.constructor.props[t];i&&this._props[t]&&(this._props[t].value=this._coerce(o,i))}prop(t){if(!this._props[t])throw new Error(`[tina4] Prop '${t}' not declared in static props of <${this.tagName.toLowerCase()}>`);return this._props[t]}emit(t,r){this.dispatchEvent(new CustomEvent(t,{bubbles:!0,composed:!0,...r}))}onMount(){}onUnmount(){}_coerce(t,r){return r===Boolean?t!==null:r===Number?t!==null?Number(t):0:t??""}};N.props={},N.styles="",N.shadow=!0;var ae=[],F=null,j="history",ft=!1,W=[],ie=[],Ee=0;function _e(e,n){let t=[],r;e==="*"?r=".*":r=e.replace(/\{(\w+)\}/g,(s,i)=>(t.push(i),"([^/]+)"));let o=new RegExp(`^${r}$`);typeof n=="function"?ae.push({pattern:e,regex:o,paramNames:t,handler:n}):ae.push({pattern:e,regex:o,paramNames:t,handler:n.handler,guard:n.guard})}function Q(e,n){if(j==="hash")if(n?.replace){let t=new URL(location.href);t.hash="#"+e,history.replaceState(null,"",t.toString()),$()}else{let t=new URL(location.href);t.hash="#"+e,history.pushState(null,"",t.toString()),$()}else n?.replace?history.replaceState(null,"",e):history.pushState(null,"",e),$()}function $(){if(!F)return;let e=performance.now(),n=++Ee,t=j==="hash"?location.hash.slice(1)||"/":location.pathname;for(let r of ae){let o=t.match(r.regex);if(!o)continue;let s={};if(r.paramNames.forEach((a,l)=>{s[a]=decodeURIComponent(o[l+1])}),r.guard){let a=r.guard();if(a===!1)return;if(typeof a=="string"){Q(a,{replace:!0});return}}ie.splice(0).forEach(a=>a()),F.innerHTML="";let i=[];O(i);let c=r.handler(s);if(c instanceof Promise)c.then(a=>{if(O(null),n!==Ee){for(let g of i)g();return}Re(F,a),ie=i;let l=performance.now()-e;for(let g of W)g({path:t,params:s,pattern:r.pattern,durationMs:l})});else{O(null),Re(F,c),ie=i;let a=performance.now()-e;for(let l of W)l({path:t,params:s,pattern:r.pattern,durationMs:a})}return}}function Re(e,n){n instanceof DocumentFragment||n instanceof Node?e.replaceChildren(n):typeof n=="string"?e.innerHTML=n:n!=null&&e.replaceChildren(document.createTextNode(String(n)))}var xe={start(e){if(F=document.querySelector(e.target),!F)throw new Error(`[tina4] Router target '${e.target}' not found in DOM`);j=e.mode??"history",ft=!0,window.addEventListener("popstate",$),j==="hash"&&window.addEventListener("hashchange",$),document.addEventListener("click",n=>{if(n.metaKey||n.ctrlKey||n.shiftKey||n.altKey)return;let t=n.target.closest("a[href]");if(!t||t.origin!==location.origin||t.hasAttribute("target")||t.hasAttribute("download")||t.getAttribute("rel")?.includes("external"))return;n.preventDefault();let r=j==="hash"?t.getAttribute("href"):t.pathname;Q(r)}),$()},on(e,n){return W.push(n),()=>{let t=W.indexOf(n);t>=0&&W.splice(t,1)}}};var _={baseUrl:"",auth:!1,tokenKey:"tina4_token",headers:{}},ce=[],le=[],gt=0;function ue(){try{return localStorage.getItem(_.tokenKey)}catch{return null}}function pt(e){try{localStorage.setItem(_.tokenKey,e)}catch{}}function Me(e,n){let t=Object.entries(n).map(([r,o])=>`${encodeURIComponent(r)}=${encodeURIComponent(String(o))}`).join("&");return e+(e.includes("?")?"&":"?")+t}async function Oe(e,n){e._url=n,e._requestId=++gt;for(let c of ce){let a=c(e);a&&(e=a)}let t=await fetch(n,e),r=t.headers.get("FreshToken");r&&pt(r);let o=t.headers.get("Content-Type")??"",s;o.includes("json")?s=await t.json():s=await t.text();let i={status:t.status,data:s,ok:t.ok,headers:t.headers,_requestId:e._requestId};for(let c of le){let a=c(i);a&&(i=a)}if(!t.ok)throw i;return i.data}async function q(e,n,t,r){let o={method:e,credentials:"same-origin",headers:{"Content-Type":"application/json",..._.headers}};if(_.auth){let s=ue();s&&(o.headers.Authorization=`Bearer ${s}`)}if(t!==void 0&&e!=="GET"){let s=typeof t=="object"&&t!==null?{...t}:t;if(_.auth&&typeof s=="object"&&s!==null){let i=ue();i&&(s.formToken=i)}o.body=JSON.stringify(s)}return r?.headers&&Object.assign(o.headers,r.headers),r?.params&&(n=Me(n,r.params)),Oe(o,_.baseUrl+n)}var Ae={configure(e){Object.assign(_,e)},get(e,n){return q("GET",e,void 0,n)},post(e,n,t){return q("POST",e,n,t)},put(e,n,t){return q("PUT",e,n,t)},patch(e,n,t){return q("PATCH",e,n,t)},delete(e,n){return q("DELETE",e,void 0,n)},async graphql(e,n,t,r){return q("POST",e,{query:n,variables:t||{}},r)},async upload(e,n,t){let r={method:"POST",headers:{..._.headers},body:n};if(delete r.headers["Content-Type"],delete r.headers["content-type"],_.auth){let o=ue();o&&(r.headers.Authorization=`Bearer ${o}`)}return t?.headers&&Object.assign(r.headers,t.headers),t?.params&&(e=Me(e,t.params)),Oe(r,_.baseUrl+e)},intercept(e,n){e==="request"?ce.push(n):le.push(n)},_reset(){_.baseUrl="",_.auth=!1,_.tokenKey="tina4_token",_.headers={},ce.length=0,le.length=0}};function mt(e){let n=e.cacheStrategy??"network-first",t=JSON.stringify(e.precache??[]),r=e.offlineRoute?`'${e.offlineRoute}'`:"null";return`
2
+ const CACHE = 'tina4-v1';
3
+ const PRECACHE = ${t};
4
+ const OFFLINE = ${r};
5
+
6
+ self.addEventListener('install', (e) => {
7
+ e.waitUntil(
8
+ caches.open(CACHE).then((c) => c.addAll(PRECACHE)).then(() => self.skipWaiting())
9
+ );
10
+ });
11
+
12
+ self.addEventListener('activate', (e) => {
13
+ e.waitUntil(self.clients.claim());
14
+ });
15
+
16
+ self.addEventListener('fetch', (e) => {
17
+ const req = e.request;
18
+ if (req.method !== 'GET') return;
19
+
20
+ ${n==="cache-first"?`
21
+ e.respondWith(
22
+ caches.match(req).then((cached) => cached || fetch(req).then((res) => {
23
+ const clone = res.clone();
24
+ caches.open(CACHE).then((c) => c.put(req, clone));
25
+ return res;
26
+ })).catch(() => OFFLINE ? caches.match(OFFLINE) : new Response('Offline', { status: 503 }))
27
+ );`:n==="stale-while-revalidate"?`
28
+ e.respondWith(
29
+ caches.match(req).then((cached) => {
30
+ const fetched = fetch(req).then((res) => {
31
+ caches.open(CACHE).then((c) => c.put(req, res.clone()));
32
+ return res;
33
+ });
34
+ return cached || fetched;
35
+ }).catch(() => OFFLINE ? caches.match(OFFLINE) : new Response('Offline', { status: 503 }))
36
+ );`:`
37
+ e.respondWith(
38
+ fetch(req).then((res) => {
39
+ const clone = res.clone();
40
+ caches.open(CACHE).then((c) => c.put(req, clone));
41
+ return res;
42
+ }).catch(() => caches.match(req).then((cached) =>
43
+ cached || (OFFLINE ? caches.match(OFFLINE) : new Response('Offline', { status: 503 }))
44
+ ))
45
+ );`}
46
+ });
47
+ `.trim()}function Ie(e){let n={name:e.name,short_name:e.shortName??e.name,start_url:"/",display:e.display??"standalone",background_color:e.backgroundColor??"#ffffff",theme_color:e.themeColor??"#000000"};return e.icon&&(n.icons=[{src:e.icon,sizes:"192x192",type:"image/png"},{src:e.icon,sizes:"512x512",type:"image/png"}]),n}var Ne={register(e){let n=Ie(e),t=new Blob([JSON.stringify(n)],{type:"application/json"}),r=document.createElement("link");r.rel="manifest",r.href=URL.createObjectURL(t),document.head.appendChild(r);let o=document.querySelector('meta[name="theme-color"]');o||(o=document.createElement("meta"),o.name="theme-color",document.head.appendChild(o)),o.content=e.themeColor??"#000000","serviceWorker"in navigator&&(e.swUrl?navigator.serviceWorker.register(e.swUrl).catch(s=>{console.warn("[tina4] Service worker registration failed:",s)}):navigator.serviceWorker.register("/sw.js").catch(()=>{console.info("[tina4] No service worker at /sw.js. Use pwa.generateServiceWorker() to create one, or pass swUrl in config.")}))},generateServiceWorker(e){return mt(e)},generateManifest(e){return Ie(e)}};var ht={reconnect:!0,reconnectDelay:1e3,reconnectMaxDelay:3e4,reconnectAttempts:1/0,protocols:[],token:""};function vt(e){let n=Array.isArray(e.protocols)?e.protocols:e.protocols?[e.protocols]:[];return e.token?["bearer",e.token,...n]:e.protocols}function yt(e,n={}){let t={...ht,...n},r=w("connecting"),o=w(!1),s=w(null),i=w(null),c=w(0),a={message:[],open:[],close:[],error:[]},l=null,g=!1,m=t.reconnectDelay,u=null,d=0;function f(v){if(typeof v!="string")return v;try{return JSON.parse(v)}catch{return v}}function h(){r.value=d>0?"reconnecting":"connecting";try{l=new WebSocket(e,vt(t))}catch{r.value="closed",o.value=!1;return}l.onopen=()=>{r.value="open",o.value=!0,i.value=null,d=0,m=t.reconnectDelay,c.value=0;for(let v of a.open)v()},l.onmessage=v=>{let T=f(v.data);s.value=T;for(let R of a.message)R(T)},l.onclose=v=>{r.value="closed",o.value=!1;for(let T of a.close)T(v.code,v.reason);!g&&t.reconnect&&d<t.reconnectAttempts&&x()},l.onerror=v=>{i.value=v;for(let T of a.error)T(v)}}function x(){d++,c.value=d,r.value="reconnecting",u=setTimeout(()=>{u=null,h()},m),m=Math.min(m*2,t.reconnectMaxDelay)}let C={status:r,connected:o,lastMessage:s,error:i,reconnectCount:c,send(v){if(!l||l.readyState!==WebSocket.OPEN)throw new Error("[tina4] WebSocket is not connected");let T=typeof v=="string"?v:JSON.stringify(v);l.send(T)},on(v,T){return a[v].push(T),()=>{let R=a[v],A=R.indexOf(T);A>=0&&R.splice(A,1)}},pipe(v,T){let R=A=>{v.value=T(A,v.value)};return C.on("message",R)},close(v,T){g=!0,u&&(clearTimeout(u),u=null),l&&l.close(v??1e3,T??""),r.value="closed",o.value=!1}};return h(),C}var K={connect:yt};var St={mode:"eventsource",method:"GET",headers:{},body:void 0,reconnect:!0,reconnectDelay:1e3,reconnectMaxDelay:3e4,reconnectAttempts:1/0,events:[],json:!0};function bt(e,n={}){let t={...St,...n},r=w("connecting"),o=w(!1),s=w(null),i=w(null),c=w(null),a=w(0),l={message:[],open:[],close:[],error:[]},g=null,m=null,u=!1,d=t.reconnectDelay,f=null,h=0;function x(p){if(!t.json||typeof p!="string")return p;try{return JSON.parse(p)}catch{return p}}function C(p,S){s.value=p,i.value=S;for(let k of l.message)k(p,S??void 0)}function v(){r.value="open",o.value=!0,c.value=null,h=0,d=t.reconnectDelay,a.value=0;for(let p of l.open)p()}function T(){r.value="closed",o.value=!1;for(let p of l.close)p();!u&&t.reconnect&&h<t.reconnectAttempts&&Y()}function R(p){c.value=p;for(let S of l.error)S(p)}function A(){r.value=h>0?"reconnecting":"connecting";try{g=new EventSource(e)}catch{r.value="closed",o.value=!1;return}g.onopen=()=>v(),g.onmessage=p=>{C(x(p.data),null)};for(let p of t.events)g.addEventListener(p,S=>{C(x(S.data),p)});g.onerror=p=>{R(p),g&&g.readyState===2&&(g=null,T())}}function z(){r.value=h>0?"reconnecting":"connecting",m=new AbortController;let p={method:t.method,headers:t.headers,signal:m.signal};t.body!==void 0&&(p.body=typeof t.body=="string"?t.body:JSON.stringify(t.body)),fetch(e,p).then(async S=>{if(!S.ok){R(new Error(`[tina4] SSE fetch ${S.status}`)),T();return}v();let k=S.body.getReader(),M=new TextDecoder,P="";for(;;){let{done:Ge,value:Ze}=await k.read();if(Ge)break;P+=M.decode(Ze,{stream:!0});let ge=P.split(`
48
+ `);P=ge.pop();for(let Qe of ge){let pe=Qe.trim();pe&&C(x(pe),null)}}let fe=P.trim();fe&&C(x(fe),null),m=null,T()}).catch(S=>{S.name!=="AbortError"&&(m=null,R(S),T())})}function Y(){h++,a.value=h,r.value="reconnecting",f=setTimeout(()=>{f=null,b()},d),d=Math.min(d*2,t.reconnectMaxDelay)}function b(){t.mode==="fetch"?z():A()}let y={status:r,connected:o,lastMessage:s,lastEvent:i,error:c,reconnectCount:a,on(p,S){return l[p].push(S),()=>{let k=l[p],M=k.indexOf(S);M>=0&&k.splice(M,1)}},pipe(p,S){let k=M=>{p.value=S(M,p.value)};return y.on("message",k)},close(){u=!0,f&&(clearTimeout(f),f=null),g&&(g.close(),g=null),m&&(m.abort(),m=null),r.value="closed",o.value=!1}};return b(),y}var Pe={connect:bt};async function X(e="/api/rtc/config"){let n=await fetch(e);if(!n.ok)throw new Error(`[tina4] rtc config fetch failed: ${n.status}`);return n.json()}function wt(){let e=window.location;return`${e.protocol==="https:"?"wss:":"ws:"}//${e.host}`}function Le(e){return/^wss?:\/\//.test(e)?e:wt()+(e.startsWith("/")?e:"/"+e)}function Tt(){let e=globalThis.crypto;if(e&&"randomUUID"in e)return e.randomUUID().slice(0,8);let n="";for(let t=0;t<8;t++)n+=Math.floor(16*(.5+t)).toString(16);return n+Date.now().toString(16).slice(-4)}async function kt(e,n={}){let t=w("connecting"),r=w(null),o=w([]),s=w(!1),i=w(null),c=Tt(),a=n.config??await X(n.configUrl),l=n.iceServers??a.iceServers??[],g=n.signallingUrl??a.signalling??"/ws/rtc",m=Le(g.includes("{room}")?g.replace("{room}",e):`${g}/${e}`),u=null;n.media instanceof MediaStream?u=n.media:n.media!==!1&&(u=await navigator.mediaDevices.getUserMedia(n.media??{audio:!0,video:!0})),r.value=u;let d=u?.getVideoTracks()[0]??null,f=new Map,h=K.connect(m);function x(){o.value=[...f.entries()].map(([b,y])=>({id:b,stream:y.stream}))}function C(b){try{h.send({...b,from:c})}catch{}}function v(b){let y=f.get(b);if(y)return y;let p=new RTCPeerConnection({iceServers:l}),S={pc:p,polite:c<b,makingOffer:!1,ignoreOffer:!1,stream:null};if(f.set(b,S),u)for(let k of u.getTracks())p.addTrack(k,u);return p.onnegotiationneeded=async()=>{try{S.makingOffer=!0,await p.setLocalDescription(),C({type:"desc",to:b,description:p.localDescription})}catch(k){i.value=k}finally{S.makingOffer=!1}},p.onicecandidate=({candidate:k})=>{k&&C({type:"ice",to:b,candidate:k})},p.ontrack=({streams:k})=>{S.stream=k[0]??null,x()},p.onconnectionstatechange=()=>{["failed","closed"].includes(p.connectionState)?T(b):p.connectionState==="connected"&&(t.value="connected")},x(),S}function T(b){let y=f.get(b);if(y){try{y.pc.close()}catch{}f.delete(b),x()}}async function R(b){let y=b,p=y.from;if(!p||p===c||y.to&&y.to!==c)return;if(y.type==="hello"){v(p),C({type:"welcome",to:p});return}if(y.type==="welcome"){v(p);return}if(y.type==="bye"){T(p);return}let S=v(p),k=S.pc;if(y.type==="desc"){let M=y.description,P=M.type==="offer"&&(S.makingOffer||k.signalingState!=="stable");if(S.ignoreOffer=!S.polite&&P,S.ignoreOffer)return;await k.setRemoteDescription(M),M.type==="offer"&&(await k.setLocalDescription(),C({type:"desc",to:p,description:k.localDescription}))}else if(y.type==="ice")try{await k.addIceCandidate(y.candidate)}catch(M){S.ignoreOffer||(i.value=M)}}h.on("message",b=>{R(b)}),h.on("open",()=>{C({type:"hello"})});async function A(b){if(b)for(let{pc:y}of f.values()){let p=y.getSenders().find(S=>S.track?.kind==="video");p&&await p.replaceTrack(b)}}async function z(){await A(d),s.value=!1}async function Y(){let y=(await navigator.mediaDevices.getDisplayMedia({video:!0})).getVideoTracks()[0];await A(y),y.onended=()=>{z()},s.value=!0}return{status:t,localStream:r,peers:o,screenSharing:s,error:i,id:c,shareScreen:Y,stopScreen:z,toggleAudio(b){let y=u?.getAudioTracks()[0];return y?(y.enabled=b??!y.enabled,y.enabled):!1},toggleVideo(b){let y=u?.getVideoTracks()[0];return y?(y.enabled=b??!y.enabled,y.enabled):!1},leave(){C({type:"bye"});for(let b of[...f.keys()])T(b);if(u)for(let b of u.getTracks())b.stop();h.close(),t.value="closed"}}}function Ct(e,n={}){let t=w([]),r=w([]),o=w([]),s=new Map,i=n.typingTimeout??3e3,c=n.url??"/ws/chat",a=Le(c.includes("{channel}")?c.replace("{channel}",String(e)):`${c}/${e}`),l=K.connect(a,{token:n.token});function g(d){o.value.includes(d)||(o.value=[...o.value,d]);let f=s.get(d);f&&clearTimeout(f),s.set(d,setTimeout(()=>{o.value=o.value.filter(h=>h!==d),s.delete(d)},i))}l.on("message",d=>{let f=d;switch(f.type){case"message":t.value=[...t.value,f.message];break;case"presence":f.event==="roster"?r.value=f.users??[]:f.event==="join"&&f.user_id?r.value=[...new Set([...r.value,f.user_id])]:f.event==="leave"&&(r.value=r.value.filter(h=>h!==f.user_id));break;case"typing":f.user_id&&g(f.user_id);break}});let m=n.apiBase??"",u=n.messagesPath??"/api/channels/{id}/messages";return{status:l.status,connected:l.connected,messages:t,presence:r,typing:o,send(d,f){l.send({type:"message",body:d,thread_id:f??null})},sendTyping(){l.send({type:"typing"})},markRead(){l.send({type:"read"})},async history(d,f=50){let h=u.replace("{id}",String(e)),x=new URLSearchParams({limit:String(f)});d&&x.set("before",String(d));let C={};n.token&&(C.Authorization=`Bearer ${n.token}`);let v=await fetch(`${m}${h}?${x}`,{headers:C});if(!v.ok)throw new Error(`[tina4] chat history failed: ${v.status}`);let T=await v.json(),R=[...T].reverse();return t.value=[...R,...t.value],T},close(){for(let d of s.values())clearTimeout(d);s.clear(),l.close()}}}async function Et(e,n,t={}){let r=t.filesPath??"/api/files",o=new FormData;o.append("channel_id",String(e)),o.append("file",n,n.name??"file");let s={};t.token&&(s.Authorization=`Bearer ${t.token}`);let i=await fetch(`${t.apiBase??""}${r}`,{method:"POST",body:o,headers:s});if(!i.ok)throw new Error(`[tina4] file upload failed: ${i.status}`);return i.json()}async function Rt(e,n={}){let t=/^https?:\/\//.test(e)?e:`${n.apiBase??""}${n.filesPath??"/api/files"}/${e}`,r={};n.token&&(r.Authorization=`Bearer ${n.token}`);let o=await fetch(t,{headers:r});if(!o.ok)throw new Error(`[tina4] file fetch failed: ${o.status}`);return URL.createObjectURL(await o.blob())}var De={config:X,call:kt,chat:Ct,upload:Et,fetchBlob:Rt};var Fe={read:e=>JSON.parse(e),write:e=>JSON.stringify(e)},$e=/(token|password|passwd|secret|api[_-]?key|apikey|auth(?!or)|credential|jwt|bearer|otp|seed|private[_-]?key|session[_-]?id)/i,_t=/^[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}$/,xt=/^[A-Za-z0-9+/_=-]{40,}$/,qe=new Set;function Mt(e,n){if($e.test(e))return`key name "${e}" looks like a credential`;if(typeof n=="string"){if(_t.test(n))return"value looks like a JWT";if(n.length>=40&&xt.test(n))return"value looks like a long base64 / token"}if(n&&typeof n=="object"&&!Array.isArray(n)){for(let t of Object.keys(n))if($e.test(t))return`object contains a credential-shape field "${t}"`}return null}function Ot(e,n){qe.has(n)||(qe.add(n),console.warn(`[tina4 persist] ${e} (key: ${JSON.stringify(n)}). localStorage is XSS-readable and never appropriate for credentials, tokens, passwords, personal data, or secrets. See STORAGE.md.`))}function He(e){if(typeof globalThis>"u")return null;try{let n=e==="local"?globalThis.localStorage:globalThis.sessionStorage;return!n||typeof n.getItem!="function"?null:n}catch{return null}}function je(e,n,t){try{let r=JSON.parse(e);return r&&typeof r=="object"&&"value"in r?{version:r.v,payload:r.value}:{version:void 0,payload:r}}catch{return{version:void 0,payload:t?e:n.read(e)}}}function We(e,n,t){return t?e:n.read(typeof e=="string"?e:JSON.stringify(e))}function At(e,n,t,r,o,s,i){try{let c=n.getItem(t);if(c===null)return;let a=je(c,o,s);if(a.version===r||a.version===void 0){e.value=We(a.payload,o,s);return}if(i){try{e.value=i(a.payload,a.version)}catch(l){console.warn(`[tina4 persist] migrate() threw for key "${t}":`,l)}return}console.warn(`[tina4 persist] stored version ${a.version} does not match current ${r} for key "${t}", and no migrate() was provided. Discarding the stored value.`)}catch(c){console.warn(`[tina4 persist] failed to read key "${t}":`,c)}}function Ke(e,n,t){if(t)return;let r=Mt(e,n);r&&Ot(r,e)}function It(e,n,t,r,o,s,i){return E(()=>{let c=e.value;Ke(t,c,i);try{let a=JSON.stringify(s?{v:r,value:c}:{v:r,value:o.write(c)});n.setItem(t,a)}catch(a){console.warn(`[tina4 persist] failed to write key "${t}":`,a)}})}function Nt(e,n,t,r,o,s,i,c){if(!c||typeof globalThis>"u"||!("addEventListener"in globalThis))return null;let a=l=>{let g=l;if(!(g.storageArea!==n||g.key!==t||g.newValue===null))try{let m=je(g.newValue,o,s);m.version!==void 0&&m.version!==r&&i?e.value=i(m.payload,m.version):(m.version===r||m.version===void 0)&&(e.value=We(m.payload,o,s))}catch(m){console.warn(`[tina4 persist] failed to parse storage event for key "${t}":`,m)}};return globalThis.addEventListener?.("storage",a),()=>{globalThis.removeEventListener?.("storage",a)}}function Pt(e,n){try{e.removeItem(n)}catch(t){console.warn(`[tina4 persist] failed to clear key "${n}":`,t)}}function ze(e,n){let{key:t,storage:r="local",serializer:o=Fe,version:s=1,migrate:i,syncTabs:c=!1,silenceCredentialWarning:a=!1}=n;if(!t||typeof t!="string")throw new Error("[tina4 persist] options.key is required and must be a string");let l=o===Fe,g=He(r);if(!g)return Ue(e,()=>{},()=>{});At(e,g,t,s,o,l,i),Ke(t,e.peek(),a);let m=It(e,g,t,s,o,l,a),u=Nt(e,g,t,s,o,l,i,c);return Ue(e,()=>Pt(g,t),()=>{m(),u&&u()})}function Ue(e,n,t){return Object.assign(e,{clear:n,dispose:t})}function Ve(e,n="local"){let t=He(n);if(t)for(let r of e)try{t.removeItem(r)}catch(o){console.warn(`[tina4 persist] failed to clear key "${r}":`,o)}}var Lt=["ar","he","fa","ur","ps","dv","syr","ckb","yi"];function Dt(){return globalThis.navigator?.language||"en"}function Be(e,n="",t={}){for(let[r,o]of Object.entries(e)){let s=n?`${n}.${r}`:r;if(o!==null&&typeof o=="object"&&!Array.isArray(o))Be(o,s,t);else{let i=String(o);t[s]=i,r in t||(t[r]=i)}}return t}function Ft(e,n){return e.replace(/\{(\w+)\}/g,(t,r)=>Object.prototype.hasOwnProperty.call(n,r)?String(n[r]):t)}function de(e={}){let n=e.locale||Dt(),t=e.fallbackLocale||n,r=new Set([...Lt,...e.rtlLocales||[]]),o=w(n,"i18n.locale"),s=new Map,i=new Map;function c(u,d){let f=Be(d),h=s.get(u);s.set(u,h?{...h,...f}:f)}if(e.messages)for(let[u,d]of Object.entries(e.messages))c(u,d);function a(u,d){return s.get(u)?.[d]}function l(u,d){let f=`n|${u}|${JSON.stringify(d||{})}`,h=i.get(f);return h||(h=new Intl.NumberFormat(u,d),i.set(f,h)),h}function g(u,d){let f=`d|${u}|${JSON.stringify(d||{})}`,h=i.get(f);return h||(h=new Intl.DateTimeFormat(u,d),i.set(f,h)),h}function m(u,d){let f=`r|${u}|${JSON.stringify(d||{})}`,h=i.get(f);return h||(h=new Intl.RelativeTimeFormat(u,d),i.set(f,h)),h}return{locale:o,t(u,d){let f=o.value,h=a(f,u);return h===void 0&&t!==f&&(h=a(t,u)),h===void 0&&(h=u),d?Ft(h,d):h},setLocale(u){o.value=u},getLocale(){return o.value},addMessages:c,hasLocale(u){return s.has(u)},availableLocales(){return[...s.keys()].sort()},async loadMessages(u,d){let f=await fetch(d);if(!f.ok)throw new Error(`[tina4 i18n] failed to load "${u}" from ${d}: ${f.status}`);c(u,await f.json())},number(u,d){return l(o.value,d).format(u)},currency(u,d,f){return l(o.value,{style:"currency",currency:d,...f}).format(u)},date(u,d){let f=u instanceof Date?u:new Date(u);return g(o.value,d).format(f)},relativeTime(u,d,f){return m(o.value,f||{numeric:"auto"}).format(u,d)},isRTL(){return r.has(o.value.split("-")[0].toLowerCase())},dir(){return this.isRTL()?"rtl":"ltr"}}}var Je=de();return rt($t);})();
@@ -356,6 +356,16 @@ class ServiceRunner:
356
356
 
357
357
  for svc in self.services:
358
358
  svc["running"] = False
359
+ instance = svc.get("instance")
360
+ if instance is not None and hasattr(instance, "stop") and callable(instance.stop):
361
+ try:
362
+ instance.stop()
363
+ except Exception as exc:
364
+ _get_log().error(
365
+ "Error stopping service instance",
366
+ name=svc.get("name"),
367
+ error=str(exc),
368
+ )
359
369
 
360
370
  # Give threads a moment to finish
361
371
  for t in self._threads: