stkit 17.2.7__tar.gz → 17.3.1__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 (245) hide show
  1. {stkit-17.2.7 → stkit-17.3.1}/CHANGELOG.md +18 -0
  2. {stkit-17.2.7 → stkit-17.3.1}/PKG-INFO +2 -2
  3. {stkit-17.2.7 → stkit-17.3.1}/pyproject.toml +2 -2
  4. {stkit-17.2.7 → stkit-17.3.1}/stk/__init__.py +1 -1
  5. {stkit-17.2.7 → stkit-17.3.1}/stk/app.py +31 -8
  6. {stkit-17.2.7 → stkit-17.3.1}/stk/cli/copy_rules.py +2 -1
  7. stkit-17.3.1/stk/migrations/versions/stk_0007_confirm_existing_users.py +28 -0
  8. {stkit-17.2.7 → stkit-17.3.1}/stk/security.py +0 -25
  9. {stkit-17.2.7 → stkit-17.3.1}/stk/settings.py +11 -2
  10. stkit-17.3.1/stk/templates/security/confirm_email.html +30 -0
  11. stkit-17.3.1/stk/templates/security/forgot_password.html +44 -0
  12. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/security/login_user.html +5 -0
  13. stkit-17.3.1/stk/templates/security/reset_password.html +45 -0
  14. stkit-17.3.1/stk/templates/security/send_confirmation.html +44 -0
  15. {stkit-17.2.7 → stkit-17.3.1}/stk/user/models.py +7 -0
  16. stkit-17.3.1/tests/test_confirm_migration.py +28 -0
  17. {stkit-17.2.7 → stkit-17.3.1}/tests/test_copy_rules.py +6 -0
  18. {stkit-17.2.7 → stkit-17.3.1}/tests/test_security_compatibility.py +94 -21
  19. {stkit-17.2.7 → stkit-17.3.1}/tests/test_static_versioning.py +15 -0
  20. stkit-17.2.7/stk/templates/security/confirm_email.html +0 -44
  21. stkit-17.2.7/stk/templates/security/forgot_password.html +0 -63
  22. stkit-17.2.7/stk/templates/security/reset_password.html +0 -53
  23. stkit-17.2.7/stk/templates/security/send_confirmation.html +0 -48
  24. {stkit-17.2.7 → stkit-17.3.1}/.gitignore +0 -0
  25. {stkit-17.2.7 → stkit-17.3.1}/LICENSE +0 -0
  26. {stkit-17.2.7 → stkit-17.3.1}/README.md +0 -0
  27. {stkit-17.2.7 → stkit-17.3.1}/SECURITY.md +0 -0
  28. {stkit-17.2.7 → stkit-17.3.1}/examples/minimal/.env-sample +0 -0
  29. {stkit-17.2.7 → stkit-17.3.1}/examples/minimal/.gitignore +0 -0
  30. {stkit-17.2.7 → stkit-17.3.1}/examples/minimal/README.md +0 -0
  31. {stkit-17.2.7 → stkit-17.3.1}/examples/minimal/minimal/__init__.py +0 -0
  32. {stkit-17.2.7 → stkit-17.3.1}/examples/minimal/minimal/app.py +0 -0
  33. {stkit-17.2.7 → stkit-17.3.1}/examples/minimal/minimal/migrations/364232ae77d5_add_note.py +0 -0
  34. {stkit-17.2.7 → stkit-17.3.1}/examples/minimal/minimal/note/__init__.py +0 -0
  35. {stkit-17.2.7 → stkit-17.3.1}/examples/minimal/minimal/note/models.py +0 -0
  36. {stkit-17.2.7 → stkit-17.3.1}/examples/minimal/minimal/note/templates/note/index.html +0 -0
  37. {stkit-17.2.7 → stkit-17.3.1}/examples/minimal/minimal/note/views.py +0 -0
  38. {stkit-17.2.7 → stkit-17.3.1}/examples/minimal/minimal/settings.py +0 -0
  39. {stkit-17.2.7 → stkit-17.3.1}/examples/minimal/minimal/templates/layout.html +0 -0
  40. {stkit-17.2.7 → stkit-17.3.1}/examples/minimal/pyproject.toml +0 -0
  41. {stkit-17.2.7 → stkit-17.3.1}/examples/minimal/tests/__init__.py +0 -0
  42. {stkit-17.2.7 → stkit-17.3.1}/examples/minimal/tests/test_app.py +0 -0
  43. {stkit-17.2.7 → stkit-17.3.1}/examples/minimal/uv.lock +0 -0
  44. {stkit-17.2.7 → stkit-17.3.1}/stk/__main__.py +0 -0
  45. {stkit-17.2.7 → stkit-17.3.1}/stk/agent_login.py +0 -0
  46. {stkit-17.2.7 → stkit-17.3.1}/stk/checks.py +0 -0
  47. {stkit-17.2.7 → stkit-17.3.1}/stk/cli/__init__.py +0 -0
  48. {stkit-17.2.7 → stkit-17.3.1}/stk/cli/agent.py +0 -0
  49. {stkit-17.2.7 → stkit-17.3.1}/stk/cli/base.py +0 -0
  50. {stkit-17.2.7 → stkit-17.3.1}/stk/cli/database.py +0 -0
  51. {stkit-17.2.7 → stkit-17.3.1}/stk/cli/develop.py +0 -0
  52. {stkit-17.2.7 → stkit-17.3.1}/stk/cli/doctor.py +0 -0
  53. {stkit-17.2.7 → stkit-17.3.1}/stk/cli/main.py +0 -0
  54. {stkit-17.2.7 → stkit-17.3.1}/stk/cli/reports.py +0 -0
  55. {stkit-17.2.7 → stkit-17.3.1}/stk/cli/smoke.py +0 -0
  56. {stkit-17.2.7 → stkit-17.3.1}/stk/cli/users.py +0 -0
  57. {stkit-17.2.7 → stkit-17.3.1}/stk/cli/watch.py +0 -0
  58. {stkit-17.2.7 → stkit-17.3.1}/stk/extensions.py +0 -0
  59. {stkit-17.2.7 → stkit-17.3.1}/stk/files/__init__.py +0 -0
  60. {stkit-17.2.7 → stkit-17.3.1}/stk/files/models.py +0 -0
  61. {stkit-17.2.7 → stkit-17.3.1}/stk/files/store.py +0 -0
  62. {stkit-17.2.7 → stkit-17.3.1}/stk/files/views.py +0 -0
  63. {stkit-17.2.7 → stkit-17.3.1}/stk/mcp/__init__.py +0 -0
  64. {stkit-17.2.7 → stkit-17.3.1}/stk/mcp/models.py +0 -0
  65. {stkit-17.2.7 → stkit-17.3.1}/stk/mcp/server.py +0 -0
  66. {stkit-17.2.7 → stkit-17.3.1}/stk/mcp/views.py +0 -0
  67. {stkit-17.2.7 → stkit-17.3.1}/stk/migrations/__init__.py +0 -0
  68. {stkit-17.2.7 → stkit-17.3.1}/stk/migrations/env.py +0 -0
  69. {stkit-17.2.7 → stkit-17.3.1}/stk/migrations/script.py.mako +0 -0
  70. {stkit-17.2.7 → stkit-17.3.1}/stk/migrations/versions/stk_0001_initial_schema.py +0 -0
  71. {stkit-17.2.7 → stkit-17.3.1}/stk/migrations/versions/stk_0002_jobs.py +0 -0
  72. {stkit-17.2.7 → stkit-17.3.1}/stk/migrations/versions/stk_0003_files.py +0 -0
  73. {stkit-17.2.7 → stkit-17.3.1}/stk/migrations/versions/stk_0004_notifications.py +0 -0
  74. {stkit-17.2.7 → stkit-17.3.1}/stk/migrations/versions/stk_0005_mcp_tokens.py +0 -0
  75. {stkit-17.2.7 → stkit-17.3.1}/stk/migrations/versions/stk_0006_stk_mcp_token.py +0 -0
  76. {stkit-17.2.7 → stkit-17.3.1}/stk/notification/__init__.py +0 -0
  77. {stkit-17.2.7 → stkit-17.3.1}/stk/notification/models.py +0 -0
  78. {stkit-17.2.7 → stkit-17.3.1}/stk/notification/views.py +0 -0
  79. {stkit-17.2.7 → stkit-17.3.1}/stk/portal/views.py +0 -0
  80. {stkit-17.2.7 → stkit-17.3.1}/stk/public/__init__.py +0 -0
  81. {stkit-17.2.7 → stkit-17.3.1}/stk/public/models.py +0 -0
  82. {stkit-17.2.7 → stkit-17.3.1}/stk/public/views.py +0 -0
  83. {stkit-17.2.7 → stkit-17.3.1}/stk/scaffold/__init__.py +0 -0
  84. {stkit-17.2.7 → stkit-17.3.1}/stk/scaffold/app/CLAUDE.md +0 -0
  85. {stkit-17.2.7 → stkit-17.3.1}/stk/scaffold/app/Dockerfile +0 -0
  86. {stkit-17.2.7 → stkit-17.3.1}/stk/scaffold/app/README.md +0 -0
  87. {stkit-17.2.7 → stkit-17.3.1}/stk/scaffold/app/__app__/__init__.py +0 -0
  88. {stkit-17.2.7 → stkit-17.3.1}/stk/scaffold/app/__app__/app.py +0 -0
  89. {stkit-17.2.7 → stkit-17.3.1}/stk/scaffold/app/__app__/settings.py +0 -0
  90. {stkit-17.2.7 → stkit-17.3.1}/stk/scaffold/app/claude/skills/stk-api/SKILL.md +0 -0
  91. {stkit-17.2.7 → stkit-17.3.1}/stk/scaffold/app/claude/skills/stk-blueprint/SKILL.md +0 -0
  92. {stkit-17.2.7 → stkit-17.3.1}/stk/scaffold/app/claude/skills/stk-deploy/SKILL.md +0 -0
  93. {stkit-17.2.7 → stkit-17.3.1}/stk/scaffold/app/claude/skills/stk-lessons/SKILL.md +0 -0
  94. {stkit-17.2.7 → stkit-17.3.1}/stk/scaffold/app/claude/skills/stk-migrate/SKILL.md +0 -0
  95. {stkit-17.2.7 → stkit-17.3.1}/stk/scaffold/app/docker-compose.yml +0 -0
  96. {stkit-17.2.7 → stkit-17.3.1}/stk/scaffold/app/dockerignore +0 -0
  97. {stkit-17.2.7 → stkit-17.3.1}/stk/scaffold/app/env-sample +0 -0
  98. {stkit-17.2.7 → stkit-17.3.1}/stk/scaffold/app/github/workflows/checks.yml +0 -0
  99. {stkit-17.2.7 → stkit-17.3.1}/stk/scaffold/app/gitignore +0 -0
  100. {stkit-17.2.7 → stkit-17.3.1}/stk/scaffold/app/nginx/nginx.conf +0 -0
  101. {stkit-17.2.7 → stkit-17.3.1}/stk/scaffold/app/nginx/stk.conf +0 -0
  102. {stkit-17.2.7 → stkit-17.3.1}/stk/scaffold/app/postgres/init-db.sh +0 -0
  103. {stkit-17.2.7 → stkit-17.3.1}/stk/scaffold/app/pyproject.toml +0 -0
  104. {stkit-17.2.7 → stkit-17.3.1}/stk/scaffold/app/tests/__init__.py +0 -0
  105. {stkit-17.2.7 → stkit-17.3.1}/stk/scaffold/app/tests/test_app.py +0 -0
  106. {stkit-17.2.7 → stkit-17.3.1}/stk/scaffold/generator.py +0 -0
  107. {stkit-17.2.7 → stkit-17.3.1}/stk/scaffold/templates.py +0 -0
  108. {stkit-17.2.7 → stkit-17.3.1}/stk/shell.py +0 -0
  109. {stkit-17.2.7 → stkit-17.3.1}/stk/static/VERSIONS.txt +0 -0
  110. {stkit-17.2.7 → stkit-17.3.1}/stk/static/css/app.css +0 -0
  111. {stkit-17.2.7 → stkit-17.3.1}/stk/static/css/layout.css +0 -0
  112. {stkit-17.2.7 → stkit-17.3.1}/stk/static/css/vuetify.min.css +0 -0
  113. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.animals.woff2 +0 -0
  114. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.arrows-1.woff2 +0 -0
  115. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.arrows-2.woff2 +0 -0
  116. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.badges.woff2 +0 -0
  117. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.brand-1.woff2 +0 -0
  118. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.brand-2.woff2 +0 -0
  119. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.buildings.woff2 +0 -0
  120. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.charts.woff2 +0 -0
  121. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.communication.woff2 +0 -0
  122. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.computers.woff2 +0 -0
  123. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.core.woff2 +0 -0
  124. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.currencies.woff2 +0 -0
  125. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.database.woff2 +0 -0
  126. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.design-1.woff2 +0 -0
  127. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.design-2.woff2 +0 -0
  128. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.development.woff2 +0 -0
  129. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.devices-1.woff2 +0 -0
  130. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.devices-2.woff2 +0 -0
  131. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.document.woff2 +0 -0
  132. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.e-commerce.woff2 +0 -0
  133. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.electrical.woff2 +0 -0
  134. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.extensions.woff2 +0 -0
  135. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.food.woff2 +0 -0
  136. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.games.woff2 +0 -0
  137. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.gender.woff2 +0 -0
  138. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.gestures.woff2 +0 -0
  139. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.health.woff2 +0 -0
  140. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.laundry.woff2 +0 -0
  141. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.letters.woff2 +0 -0
  142. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.logic.woff2 +0 -0
  143. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.map.woff2 +0 -0
  144. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.math.woff2 +0 -0
  145. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.media.woff2 +0 -0
  146. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.mood.woff2 +0 -0
  147. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.nature.woff2 +0 -0
  148. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.numbers.woff2 +0 -0
  149. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.photography.woff2 +0 -0
  150. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.shapes.woff2 +0 -0
  151. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.sport.woff2 +0 -0
  152. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.symbols.woff2 +0 -0
  153. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.system-1.woff2 +0 -0
  154. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.system-2.woff2 +0 -0
  155. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.system-3.woff2 +0 -0
  156. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.text.woff2 +0 -0
  157. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.vehicles.woff2 +0 -0
  158. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.version-control.woff2 +0 -0
  159. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.weather.woff2 +0 -0
  160. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/fonts/tabler-icons.zodiac.woff2 +0 -0
  161. {stkit-17.2.7 → stkit-17.3.1}/stk/static/icons/tabler-icons.min.css +0 -0
  162. {stkit-17.2.7 → stkit-17.3.1}/stk/static/img/auth-bg.webp +0 -0
  163. {stkit-17.2.7 → stkit-17.3.1}/stk/static/img/favicon.ico +0 -0
  164. {stkit-17.2.7 → stkit-17.3.1}/stk/static/img/stk.svg +0 -0
  165. {stkit-17.2.7 → stkit-17.3.1}/stk/static/js/axios.min.js +0 -0
  166. {stkit-17.2.7 → stkit-17.3.1}/stk/static/js/components/NotificationDropdown.js +0 -0
  167. {stkit-17.2.7 → stkit-17.3.1}/stk/static/js/components/ThemeSwitcher.js +0 -0
  168. {stkit-17.2.7 → stkit-17.3.1}/stk/static/js/components/TransitionExpand.js +0 -0
  169. {stkit-17.2.7 → stkit-17.3.1}/stk/static/js/components/VerticalNavGroup.js +0 -0
  170. {stkit-17.2.7 → stkit-17.3.1}/stk/static/js/components/VerticalNavLink.js +0 -0
  171. {stkit-17.2.7 → stkit-17.3.1}/stk/static/js/components/VerticalNavSectionTitle.js +0 -0
  172. {stkit-17.2.7 → stkit-17.3.1}/stk/static/js/components/index.js +0 -0
  173. {stkit-17.2.7 → stkit-17.3.1}/stk/static/js/config.js +0 -0
  174. {stkit-17.2.7 → stkit-17.3.1}/stk/static/js/vue.min.js +0 -0
  175. {stkit-17.2.7 → stkit-17.3.1}/stk/static/js/vuetify.min.js +0 -0
  176. {stkit-17.2.7 → stkit-17.3.1}/stk/static/robots.txt +0 -0
  177. {stkit-17.2.7 → stkit-17.3.1}/stk/tasks/__init__.py +0 -0
  178. {stkit-17.2.7 → stkit-17.3.1}/stk/tasks/models.py +0 -0
  179. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/401.html +0 -0
  180. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/404.html +0 -0
  181. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/500.html +0 -0
  182. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/_brand.html +0 -0
  183. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/auth_layout.html +0 -0
  184. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/cms/activities.html +0 -0
  185. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/cms/jobs.html +0 -0
  186. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/cms/roles.html +0 -0
  187. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/cms/users.html +0 -0
  188. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/dashboard.html +0 -0
  189. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/index.html +0 -0
  190. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/layout.html +0 -0
  191. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/login-layout.html +0 -0
  192. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/mcp/tokens.html +0 -0
  193. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/security/_macros.html +0 -0
  194. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/security/_messages.html +0 -0
  195. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/security/change_password.html +0 -0
  196. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/security/email/change_notice.html +0 -0
  197. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/security/email/change_notice.txt +0 -0
  198. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/security/email/confirmation_instructions.html +0 -0
  199. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/security/email/confirmation_instructions.txt +0 -0
  200. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/security/email/login_instructions.html +0 -0
  201. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/security/email/login_instructions.txt +0 -0
  202. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/security/email/reset_instructions.html +0 -0
  203. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/security/email/reset_instructions.txt +0 -0
  204. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/security/email/reset_notice.html +0 -0
  205. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/security/email/reset_notice.txt +0 -0
  206. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/security/email/welcome.html +0 -0
  207. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/security/email/welcome.txt +0 -0
  208. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/security/mf_recovery.html +0 -0
  209. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/security/mf_recovery_codes.html +0 -0
  210. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/security/register_user.html +0 -0
  211. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/security/send_login.html +0 -0
  212. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/security/two_factor_select.html +0 -0
  213. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/security/two_factor_setup.html +0 -0
  214. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/security/two_factor_verify_code.html +0 -0
  215. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/security/wan_register.html +0 -0
  216. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/security/wan_signin.html +0 -0
  217. {stkit-17.2.7 → stkit-17.3.1}/stk/templates/security/wan_verify.html +0 -0
  218. {stkit-17.2.7 → stkit-17.3.1}/stk/user/__init__.py +0 -0
  219. {stkit-17.2.7 → stkit-17.3.1}/stk/user/forms.py +0 -0
  220. {stkit-17.2.7 → stkit-17.3.1}/stk/user/views.py +0 -0
  221. {stkit-17.2.7 → stkit-17.3.1}/stk/utils/email.py +0 -0
  222. {stkit-17.2.7 → stkit-17.3.1}/stk/utils/netguard.py +0 -0
  223. {stkit-17.2.7 → stkit-17.3.1}/stk/utils/ratelimit.py +0 -0
  224. {stkit-17.2.7 → stkit-17.3.1}/stk/websocket.py +0 -0
  225. {stkit-17.2.7 → stkit-17.3.1}/tests/test_agent_operability.py +0 -0
  226. {stkit-17.2.7 → stkit-17.3.1}/tests/test_app_index.py +0 -0
  227. {stkit-17.2.7 → stkit-17.3.1}/tests/test_branding.py +0 -0
  228. {stkit-17.2.7 → stkit-17.3.1}/tests/test_builder_ux.py +0 -0
  229. {stkit-17.2.7 → stkit-17.3.1}/tests/test_cli.py +0 -0
  230. {stkit-17.2.7 → stkit-17.3.1}/tests/test_composition.py +0 -0
  231. {stkit-17.2.7 → stkit-17.3.1}/tests/test_csrf_exempt.py +0 -0
  232. {stkit-17.2.7 → stkit-17.3.1}/tests/test_error_handler.py +0 -0
  233. {stkit-17.2.7 → stkit-17.3.1}/tests/test_files.py +0 -0
  234. {stkit-17.2.7 → stkit-17.3.1}/tests/test_jobs.py +0 -0
  235. {stkit-17.2.7 → stkit-17.3.1}/tests/test_mcp.py +0 -0
  236. {stkit-17.2.7 → stkit-17.3.1}/tests/test_mcp_migration.py +0 -0
  237. {stkit-17.2.7 → stkit-17.3.1}/tests/test_migration_drift.py +0 -0
  238. {stkit-17.2.7 → stkit-17.3.1}/tests/test_netguard.py +0 -0
  239. {stkit-17.2.7 → stkit-17.3.1}/tests/test_notifications.py +0 -0
  240. {stkit-17.2.7 → stkit-17.3.1}/tests/test_production_security.py +0 -0
  241. {stkit-17.2.7 → stkit-17.3.1}/tests/test_revision_imports.py +0 -0
  242. {stkit-17.2.7 → stkit-17.3.1}/tests/test_route_collisions.py +0 -0
  243. {stkit-17.2.7 → stkit-17.3.1}/tests/test_scaffolder.py +0 -0
  244. {stkit-17.2.7 → stkit-17.3.1}/tests/test_shell.py +0 -0
  245. {stkit-17.2.7 → stkit-17.3.1}/tests/test_smoke_pages.py +0 -0
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## v17.3.1 (2026-10-31)
4
+
5
+ No migration required.
6
+
7
+ - Versioned static responses were marked public and immutable for a year even when they were a 404 or carried a session cookie change, such as clearing a stale login. A shared cache could then replay them to other visitors. Only a 200 with no cookie change is cached publicly now; a response that changes the session is `private, no-store`.
8
+ - `stk smoke`'s raw-score rule could take tens of seconds on a page with a long run of blanks after the word "score", from two adjacent optional-blank patterns backtracking. It is linear now (Build Loop).
9
+
10
+ ## v17.3.0 (2026-10-31)
11
+
12
+ Run `uv run stk db upgrade`: `stk_0007` marks every existing account confirmed. Requires quart-security 2.1.0.
13
+
14
+ - Password reset by email at `/reset`, on by default once `MAIL_SERVER` and `STK_PUBLIC_URL` are both set (`SECURITY_RECOVERABLE` overrides). Links work once, for one hour, and die if the email or password changes. A reset signs out every session, revokes MCP tokens and clears a lockout (Authentication).
15
+ - Public sign-up now requires email confirmation (`SECURITY_CONFIRMABLE`, default on). An unconfirmed account fails login exactly like a wrong password, which closes the last way to find accounts through sign-up. With `SECURITY_REGISTERABLE=true`, startup fails without `MAIL_SERVER`; set `SECURITY_CONFIRMABLE=false` to keep unconfirmed sign-up (Authentication).
16
+ - Users created by code, the CLI, admins or OAuth start confirmed: `User()` sets `confirmed_at` unless given one.
17
+ - A locked account now fails login with the same message as a wrong password.
18
+ - Auth mail links come from `STK_PUBLIC_URL` (as `SECURITY_PUBLIC_URL`), never the Host header. The 17.2.7 sign-up notice now comes from quart-security.
19
+ - The reset, resend and confirm pages use the current auth layout.
20
+
3
21
  ## v17.2.7 (2026-10-31)
4
22
 
5
23
  No migration required. Requires quart-security 2.0.6.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: stkit
3
- Version: 17.2.7
3
+ Version: 17.3.1
4
4
  Summary: An async Quart framework optimized for AI-assisted development workflows
5
5
  Project-URL: Documentation, https://docs.stkit.dev
6
6
  Project-URL: Source, https://github.com/level09/stk
@@ -28,7 +28,7 @@ Requires-Dist: pillow>=13.0.0
28
28
  Requires-Dist: psycopg2-binary>=2.9.10
29
29
  Requires-Dist: python-dotenv>=1.2.2
30
30
  Requires-Dist: qrcode>=8.2
31
- Requires-Dist: quart-security<3,>=2.0.6
31
+ Requires-Dist: quart-security<3,>=2.1.0
32
32
  Requires-Dist: quart-session>=3.0.0
33
33
  Requires-Dist: quart>=0.20.0
34
34
  Requires-Dist: rich>=14.0.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stkit"
3
- version = "17.2.7"
3
+ version = "17.3.1"
4
4
  description = "An async Quart framework optimized for AI-assisted development workflows"
5
5
  authors = [
6
6
  {name = "Level09", email = "hello@level09.com"}
@@ -30,7 +30,7 @@ dependencies = [
30
30
  "python-dotenv>=1.2.2",
31
31
  "qrcode>=8.2",
32
32
  "quart>=0.20.0",
33
- "quart-security>=2.0.6,<3",
33
+ "quart-security>=2.1.0,<3",
34
34
  "quart-session>=3.0.0",
35
35
  "rich>=14.0.0",
36
36
  "sqlalchemy[asyncio]>=2.0.41",
@@ -3,7 +3,7 @@ stk Framework
3
3
  A modern Python web framework optimized for AI-assisted development.
4
4
  """
5
5
 
6
- __version__ = "17.2.7"
6
+ __version__ = "17.3.1"
7
7
 
8
8
 
9
9
  def __getattr__(name):
@@ -18,7 +18,8 @@ from quart import (
18
18
  send_from_directory,
19
19
  url_for,
20
20
  )
21
- from quart_security import Security, SQLAlchemyUserDatastore, user_register_conflict
21
+ from quart import session as quart_session
22
+ from quart_security import Security, SQLAlchemyUserDatastore
22
23
  from quart_security.views import _ensure_csrf_token
23
24
  from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
24
25
  from werkzeug.exceptions import HTTPException, NotFound
@@ -172,19 +173,37 @@ def register_extensions(app):
172
173
  except TimeoutError:
173
174
  app.logger.warning("Timed out while disposing SQLAlchemy engine")
174
175
 
175
- from stk.security import notify_register_conflict, register_security_controls
176
+ from stk.security import register_security_controls
177
+ from stk.utils.email import send_email
176
178
  from stk.utils.ratelimit import register_rate_limits
177
179
 
178
180
  register_rate_limits(app)
179
181
  register_security_controls(app)
180
182
 
183
+ config = app.config
184
+ # Links in auth email come from the public URL, never the Host header.
185
+ config.setdefault("SECURITY_PUBLIC_URL", config.get("STK_PUBLIC_URL"))
186
+ if config.get("SECURITY_RECOVERABLE") is None:
187
+ config["SECURITY_RECOVERABLE"] = bool(
188
+ config.get("MAIL_SERVER") and config["SECURITY_PUBLIC_URL"]
189
+ )
190
+ if (
191
+ config.get("SECURITY_REGISTERABLE")
192
+ and config.get("SECURITY_CONFIRMABLE", True)
193
+ and not config.get("MAIL_SERVER")
194
+ ):
195
+ raise RuntimeError(
196
+ "SECURITY_REGISTERABLE needs MAIL_SERVER to send confirmation links;"
197
+ " set it, or set SECURITY_CONFIRMABLE=false to allow unconfirmed sign-up"
198
+ )
199
+
181
200
  Security(
182
201
  app,
183
202
  user_datastore,
184
203
  register_form=ExtendedRegisterForm,
185
204
  change_password_form=OAuthAwareChangePasswordForm,
205
+ send_mail=send_email,
186
206
  )
187
- user_register_conflict.connect(notify_register_conflict, sender=app, weak=False)
188
207
  # The library resets its session before loading the user. Share that session
189
208
  # so signals and application routes participate in the same transaction.
190
209
  app.before_request(_open_session)
@@ -235,11 +254,15 @@ def register_static_versioning(app):
235
254
  @app.after_request
236
255
  async def _cache_static(response):
237
256
  if request.path.startswith(f"{app.static_url_path}/"):
238
- response.headers["Cache-Control"] = (
239
- "public, max-age=31536000, immutable"
240
- if request.args.get("v")
241
- else "no-cache"
242
- )
257
+ if quart_session.modified:
258
+ # This response sets or clears a session cookie; a shared cache
259
+ # must never replay it to someone else.
260
+ policy = "private, no-store"
261
+ elif request.args.get("v") and response.status_code == 200:
262
+ policy = "public, max-age=31536000, immutable"
263
+ else:
264
+ policy = "no-cache"
265
+ response.headers["Cache-Control"] = policy
243
266
  return response
244
267
 
245
268
  def import_map():
@@ -41,7 +41,8 @@ HEX = re.compile(r"[0-9a-f]{6,}", re.I)
41
41
  HASH_LABEL = re.compile(r"(?:sha-?256|sha|md5|hash|digest|fingerprint)\W{0,3}$", re.I)
42
42
  SNAKE = re.compile(r"(?<![\w./@-])[a-z][a-z0-9]*(?:_[a-z0-9]+)+(?![\w@-]|\.\w)")
43
43
  SCORE = re.compile(
44
- r"\b(?:score|similarity|confidence|distance|cosine)\b\s*[:=]?\s*-?\d*\.\d+", re.I
44
+ r"\b(?:score|similarity|confidence|distance|cosine)\b\s*(?:[:=]\s*)?-?\d*\.\d+",
45
+ re.I,
45
46
  )
46
47
  ONE = re.compile(r"(?<![\d.,:/-])\b1[ \t]+([A-Za-z]+s)\b")
47
48
  # Words ending in s after a 1 that are not plurals: "1 is", "1 status".
@@ -0,0 +1,28 @@
1
+ """Mark every existing account confirmed.
2
+
3
+ stk 17.3 turns on email confirmation, and an account with no `confirmed_at` can
4
+ no longer sign in. No account before this revision ever had to confirm, so all
5
+ of them count as confirmed; only sign-ups from now on wait for the email.
6
+ """
7
+
8
+ import sqlalchemy as sa
9
+ from alembic import op
10
+
11
+ revision = "stk_0007"
12
+ down_revision = "stk_0006"
13
+ branch_labels = None
14
+ depends_on = None
15
+
16
+
17
+ def upgrade() -> None:
18
+ user = sa.table("user", sa.column("confirmed_at", sa.DateTime()))
19
+ op.execute(
20
+ user.update()
21
+ .where(user.c.confirmed_at.is_(None))
22
+ .values(confirmed_at=sa.func.current_timestamp())
23
+ )
24
+
25
+
26
+ def downgrade() -> None:
27
+ # Which accounts were unconfirmed before is not recorded; leave them all.
28
+ pass
@@ -124,31 +124,6 @@ def register_security_controls(app):
124
124
  return response
125
125
 
126
126
 
127
- async def notify_register_conflict(app, user):
128
- """Tell an account's owner that someone tried to register with their email.
129
-
130
- Registration answers as if it succeeded, so this mail is the owner's only
131
- sign. It goes out in the background: waiting on SMTP would make this one
132
- response slower and reveal the account. Links come from STK_PUBLIC_URL,
133
- never the request's Host header, which the sender controls."""
134
- if not app.config.get("MAIL_SERVER"):
135
- app.logger.warning("Sign-up attempt for an existing account: no MAIL_SERVER")
136
- return
137
- from stk.utils.email import send_email
138
-
139
- public_url = app.config.get("STK_PUBLIC_URL")
140
- where = f" at {public_url}" if public_url else ""
141
- sign_in = f"\n\nSign in at {public_url.rstrip('/')}/login" if public_url else ""
142
- body = (
143
- f"Someone tried to create a {app.config['APP_NAME']} account{where} with"
144
- " this email address. You already have one, so nothing changed."
145
- f"{sign_in}\n\nIf it was not you, you can ignore this email."
146
- )
147
- app.add_background_task(
148
- send_email, "Sign-up attempt with your email", user.email, body
149
- )
150
-
151
-
152
127
  async def check_password_policy(password):
153
128
  from quart import current_app
154
129
 
@@ -36,8 +36,10 @@ class Config:
36
36
  SECURITY_REGISTERABLE = (
37
37
  os.environ.get("SECURITY_REGISTERABLE", "False").lower() == "true"
38
38
  )
39
- SECURITY_RECOVERABLE = False
40
- SECURITY_CONFIRMABLE = False
39
+ # Self-registered accounts sign in only after confirming their email.
40
+ SECURITY_CONFIRMABLE = (
41
+ os.environ.get("SECURITY_CONFIRMABLE", "True").lower() == "true"
42
+ )
41
43
  SECURITY_CHANGEABLE = True
42
44
  SECURITY_TRACKABLE = True
43
45
  SECURITY_PASSWORD_HASH = "argon2"
@@ -106,6 +108,12 @@ class Config:
106
108
  MAIL_USERNAME = os.environ.get("MAIL_USERNAME")
107
109
  MAIL_PASSWORD = os.environ.get("MAIL_PASSWORD")
108
110
  SECURITY_EMAIL_SENDER = os.environ.get("SECURITY_EMAIL_SENDER", "info@domain.com")
111
+ # Password reset by email. Unset: on once MAIL_SERVER and STK_PUBLIC_URL are.
112
+ SECURITY_RECOVERABLE = (
113
+ os.environ["SECURITY_RECOVERABLE"].lower() == "true"
114
+ if "SECURITY_RECOVERABLE" in os.environ
115
+ else None
116
+ )
109
117
  SECURITY_SEND_PASSWORD_CHANGE_EMAIL = False
110
118
 
111
119
  # Google OAuth Settings
@@ -142,5 +150,6 @@ class TestingConfig(Config):
142
150
  DISABLE_MULTIPLE_SESSIONS = False
143
151
  STK_ENABLE_AGENT_LOGIN = False
144
152
  MAIL_SERVER = None
153
+ SECURITY_RECOVERABLE = False
145
154
  GOOGLE_AUTH_ENABLED = False
146
155
  GITHUB_AUTH_ENABLED = False
@@ -0,0 +1,30 @@
1
+ {% extends 'login-layout.html' %}
2
+
3
+ {% block content %}
4
+ <v-main class="d-flex flex-column align-center justify-center" style="min-height: 100vh; background: rgb(var(--v-theme-background));">
5
+ <v-card class="mx-auto pa-8 w-100" rounded="lg" style="max-width: 424px;">
6
+ <div class="text-center mb-6">
7
+ <div class="mb-4">{% include "_brand.html" %}</div>
8
+ <div class="text-headline-small font-weight-bold">Confirm your email</div>
9
+ <div class="text-body-medium text-medium-emphasis">Confirm only if you created this account. If you did not, close this page.</div>
10
+ </div>
11
+
12
+ <form action="{{ url_for_security('confirm_email', token=confirm_email_token) }}" method="POST">
13
+ {{ confirm_email_form.hidden_tag() }}
14
+ {% include "security/_messages.html" %}
15
+ <v-btn type="submit" color="primary" size="large" block class="mb-4">Confirm my email</v-btn>
16
+ </form>
17
+ </v-card>
18
+ </v-main>
19
+ {% endblock %}
20
+
21
+ {% block js %}
22
+ <script>
23
+ const { createApp } = Vue;
24
+ const { createVuetify } = Vuetify;
25
+ const vuetify = createVuetify(vuetifyConfig);
26
+
27
+ const app = createApp({ delimiters: delimiters });
28
+ app.use(vuetify).mount('#app');
29
+ </script>
30
+ {% endblock %}
@@ -0,0 +1,44 @@
1
+ {% extends 'login-layout.html' %}
2
+
3
+ {% block content %}
4
+ <v-main class="d-flex flex-column align-center justify-center" style="min-height: 100vh; background: rgb(var(--v-theme-background));">
5
+ <v-card class="mx-auto pa-8 w-100" rounded="lg" style="max-width: 424px;">
6
+ <div class="text-center mb-6">
7
+ <div class="mb-4">{% include "_brand.html" %}</div>
8
+ <div class="text-headline-small font-weight-bold">Reset password</div>
9
+ <div class="text-body-medium text-medium-emphasis">We will email you a link to choose a new password</div>
10
+ </div>
11
+
12
+ <form action="{{ url_for_security('forgot_password') }}" method="POST">
13
+ {{ forgot_password_form.hidden_tag() }}
14
+ <v-text-field name="email" type="email" label="Email" autocomplete="email" required class="mb-4"></v-text-field>
15
+
16
+ {% include "security/_messages.html" %}
17
+
18
+ {% for field, errors in forgot_password_form.errors.items() %}
19
+ <v-alert type="error" variant="tonal" density="compact" class="mb-4">{{ errors[0] }}</v-alert>
20
+ {% endfor %}
21
+
22
+ <v-btn type="submit" color="primary" size="large" block class="mb-4">Send reset link</v-btn>
23
+ </form>
24
+ </v-card>
25
+
26
+ <div class="mt-6 w-100" style="max-width: 424px;">
27
+ <v-btn href="{{ url_for_security('login') }}" variant="text" color="primary">
28
+ <i class="ti ti-chevron-left mr-1"></i>
29
+ Back to log in
30
+ </v-btn>
31
+ </div>
32
+ </v-main>
33
+ {% endblock %}
34
+
35
+ {% block js %}
36
+ <script>
37
+ const { createApp } = Vue;
38
+ const { createVuetify } = Vuetify;
39
+ const vuetify = createVuetify(vuetifyConfig);
40
+
41
+ const app = createApp({ delimiters: delimiters });
42
+ app.use(vuetify).mount('#app');
43
+ </script>
44
+ {% endblock %}
@@ -157,6 +157,11 @@
157
157
  <span class="text-medium-emphasis">New to {{ config.APP_NAME }}? </span>
158
158
  <a href="{{ url_for_security('register') }}" class="text-decoration-none">Sign up</a>
159
159
  </div>
160
+ {% if security.confirmable %}
161
+ <div class="text-center mt-2">
162
+ <a href="{{ url_for_security('send_confirmation') }}" class="text-decoration-none text-body-small">Resend confirmation link</a>
163
+ </div>
164
+ {% endif %}
160
165
  {% endif %}
161
166
  </v-card-text>
162
167
  </v-card>
@@ -0,0 +1,45 @@
1
+ {% extends 'login-layout.html' %}
2
+
3
+ {% block content %}
4
+ <v-main class="d-flex flex-column align-center justify-center" style="min-height: 100vh; background: rgb(var(--v-theme-background));">
5
+ <v-card class="mx-auto pa-8 w-100" rounded="lg" style="max-width: 424px;">
6
+ <div class="text-center mb-6">
7
+ <div class="mb-4">{% include "_brand.html" %}</div>
8
+ <div class="text-headline-small font-weight-bold">Choose a new password</div>
9
+ <div class="text-body-medium text-medium-emphasis">Every signed-in session will be signed out</div>
10
+ </div>
11
+
12
+ <form action="{{ url_for_security('reset_password', token=reset_password_token) }}" method="POST">
13
+ {{ reset_password_form.hidden_tag() }}
14
+ <v-text-field name="password" type="password" label="New password" autocomplete="new-password" required class="mb-2"></v-text-field>
15
+ <v-text-field name="password_confirm" type="password" label="Confirm new password" autocomplete="new-password" required class="mb-4"></v-text-field>
16
+
17
+ {% include "security/_messages.html" %}
18
+
19
+ {% for field, errors in reset_password_form.errors.items() %}
20
+ <v-alert type="error" variant="tonal" density="compact" class="mb-4">{{ errors[0] }}</v-alert>
21
+ {% endfor %}
22
+
23
+ <v-btn type="submit" color="primary" size="large" block class="mb-4">Reset password</v-btn>
24
+ </form>
25
+ </v-card>
26
+
27
+ <div class="mt-6 w-100" style="max-width: 424px;">
28
+ <v-btn href="{{ url_for_security('login') }}" variant="text" color="primary">
29
+ <i class="ti ti-chevron-left mr-1"></i>
30
+ Back to log in
31
+ </v-btn>
32
+ </div>
33
+ </v-main>
34
+ {% endblock %}
35
+
36
+ {% block js %}
37
+ <script>
38
+ const { createApp } = Vue;
39
+ const { createVuetify } = Vuetify;
40
+ const vuetify = createVuetify(vuetifyConfig);
41
+
42
+ const app = createApp({ delimiters: delimiters });
43
+ app.use(vuetify).mount('#app');
44
+ </script>
45
+ {% endblock %}
@@ -0,0 +1,44 @@
1
+ {% extends 'login-layout.html' %}
2
+
3
+ {% block content %}
4
+ <v-main class="d-flex flex-column align-center justify-center" style="min-height: 100vh; background: rgb(var(--v-theme-background));">
5
+ <v-card class="mx-auto pa-8 w-100" rounded="lg" style="max-width: 424px;">
6
+ <div class="text-center mb-6">
7
+ <div class="mb-4">{% include "_brand.html" %}</div>
8
+ <div class="text-headline-small font-weight-bold">Resend confirmation</div>
9
+ <div class="text-body-medium text-medium-emphasis">We will email you a new link to confirm your account</div>
10
+ </div>
11
+
12
+ <form action="{{ url_for_security('send_confirmation') }}" method="POST">
13
+ {{ send_confirmation_form.hidden_tag() }}
14
+ <v-text-field name="email" type="email" label="Email" autocomplete="email" required class="mb-4"></v-text-field>
15
+
16
+ {% include "security/_messages.html" %}
17
+
18
+ {% for field, errors in send_confirmation_form.errors.items() %}
19
+ <v-alert type="error" variant="tonal" density="compact" class="mb-4">{{ errors[0] }}</v-alert>
20
+ {% endfor %}
21
+
22
+ <v-btn type="submit" color="primary" size="large" block class="mb-4">Send link</v-btn>
23
+ </form>
24
+ </v-card>
25
+
26
+ <div class="mt-6 w-100" style="max-width: 424px;">
27
+ <v-btn href="{{ url_for_security('login') }}" variant="text" color="primary">
28
+ <i class="ti ti-chevron-left mr-1"></i>
29
+ Back to log in
30
+ </v-btn>
31
+ </div>
32
+ </v-main>
33
+ {% endblock %}
34
+
35
+ {% block js %}
36
+ <script>
37
+ const { createApp } = Vue;
38
+ const { createVuetify } = Vuetify;
39
+ const vuetify = createVuetify(vuetifyConfig);
40
+
41
+ const app = createApp({ delimiters: delimiters });
42
+ app.use(vuetify).mount('#app');
43
+ </script>
44
+ {% endblock %}
@@ -69,6 +69,13 @@ class User(Base, UserMixin):
69
69
  password_set = Column(Boolean, default=True, nullable=False)
70
70
  active = Column(Boolean, default=False, nullable=True)
71
71
 
72
+ def __init__(self, **kwargs):
73
+ # Accounts made by code, the CLI, admins or OAuth start confirmed. Only
74
+ # self-registration sets None afterwards, until the emailed link is
75
+ # opened; a column default would refill that None at insert.
76
+ kwargs.setdefault("confirmed_at", datetime.now())
77
+ super().__init__(**kwargs)
78
+
72
79
  @validates("email")
73
80
  def _normalize_email(self, key, value):
74
81
  # Login lowercases what it is given; store the same form so one address
@@ -0,0 +1,28 @@
1
+ """stk_0007 confirms every account that existed before email confirmation."""
2
+
3
+ import sqlite3
4
+ import tempfile
5
+ import unittest
6
+ from pathlib import Path
7
+
8
+ from alembic import command
9
+
10
+ from stk.migrations import build_alembic_config
11
+
12
+
13
+ class ConfirmExistingUsersTest(unittest.TestCase):
14
+ def test_existing_accounts_count_as_confirmed(self):
15
+ with tempfile.TemporaryDirectory() as tmp:
16
+ path = Path(tmp) / "db.sqlite"
17
+ config = build_alembic_config(url=f"sqlite:///{path}", quiet=True)
18
+ command.upgrade(config, "stk_0006")
19
+ with sqlite3.connect(path) as db:
20
+ db.execute(
21
+ "INSERT INTO user (email, password, password_set, active,"
22
+ " fs_uniquifier, created_at, failed_login_count) VALUES"
23
+ " ('a@example.com', 'h', 1, 1, 'u', '2026-01-01', 0)"
24
+ )
25
+ command.upgrade(config, "heads")
26
+ with sqlite3.connect(path) as db:
27
+ rows = db.execute("select confirmed_at from user").fetchall()
28
+ self.assertIsNotNone(rows[0][0])
@@ -1,3 +1,4 @@
1
+ import time
1
2
  import unittest
2
3
 
3
4
  from stk.cli.copy_rules import (
@@ -49,6 +50,11 @@ class CopyRuleTests(unittest.TestCase):
49
50
  ["jane_doe@example.com", "/static/app_icon.png", "STK_ENV", "Pending"],
50
51
  )
51
52
 
53
+ def test_raw_score_is_linear_on_long_blank_runs(self):
54
+ start = time.perf_counter()
55
+ self.assertEqual(raw_score("score" + " " * 50_000 + "!"), [])
56
+ self.assertLess(time.perf_counter() - start, 1)
57
+
52
58
  def test_raw_score(self):
53
59
  self.assertRule(
54
60
  raw_score,
@@ -1,5 +1,7 @@
1
1
  """Exercise stk's authentication integration with quart-security 2."""
2
2
 
3
+ import asyncio
4
+ import re
3
5
  import unittest
4
6
 
5
7
  import pyotp
@@ -146,34 +148,105 @@ class SecurityCompatibilityTests(unittest.IsolatedAsyncioTestCase):
146
148
  )
147
149
  self.assertNotEqual((await second.get("/dashboard/")).status_code, 200)
148
150
 
149
- async def test_registering_an_existing_email_mails_the_owner_in_background(self):
151
+ def mailbox(self):
152
+ """Turn on sign-up and reset with mail; collect what would be sent."""
150
153
  self.app.config.update(
151
154
  SECURITY_REGISTERABLE=True,
155
+ SECURITY_RECOVERABLE=True,
152
156
  MAIL_SERVER="smtp.example.com",
153
- STK_PUBLIC_URL="http://localhost",
157
+ SECURITY_PUBLIC_URL="http://localhost",
154
158
  )
155
- queued = []
156
- self.app.add_background_task = lambda func, *args: queued.append(args)
157
- client = self.app.test_client()
159
+ sent = []
160
+
161
+ async def send_mail(subject, recipient, body):
162
+ sent.append((subject, recipient, body))
163
+
164
+ self.app.extensions["security"].send_mail = send_mail
165
+ return sent
166
+
167
+ async def post(self, client, path, **form):
158
168
  await client.get("/login")
159
169
  async with client.session_transaction() as cookie:
160
- csrf = cookie["_csrf_token"]
161
- form = {
162
- "email": "USER@example.com",
163
- "password": "AnotherPassword123!",
164
- "password_confirm": "AnotherPassword123!",
165
- "csrf_token": csrf,
166
- }
167
- response = await client.post("/register", form=form)
170
+ form["csrf_token"] = cookie["_csrf_token"]
171
+ response = await client.post(path, form=form)
172
+ # Auth mail and account lookups run after the response; wait for them.
173
+ while self.app.background_tasks:
174
+ await asyncio.gather(*list(self.app.background_tasks))
175
+ return response
176
+
177
+ @staticmethod
178
+ def link(message):
179
+ return re.search(r"http://localhost(/\S+)", message[2]).group(1)
180
+
181
+ async def test_sign_up_waits_for_the_email_and_hides_existing_accounts(self):
182
+ sent = self.mailbox()
183
+ client = self.app.test_client()
184
+ password = "AnotherPassword123!"
185
+ for email in ("new@example.com", "USER@example.com"):
186
+ response = await self.post(
187
+ client,
188
+ "/register",
189
+ email=email,
190
+ password=password,
191
+ password_confirm=password,
192
+ )
193
+ self.assertEqual(response.status_code, 302)
194
+ self.assertEqual([m[1] for m in sent], ["new@example.com", "user@example.com"])
195
+
196
+ login = {"email": "new@example.com", "password": password}
197
+ self.assertNotIn("_user_id", await self.login_as(login))
198
+ # Opening the link confirms nothing; the button on that page does.
199
+ self.assertEqual((await client.get(self.link(sent[0]))).status_code, 200)
200
+ self.assertNotIn("_user_id", await self.login_as(login))
201
+ await self.post(client, self.link(sent[0]))
202
+ self.assertIn("_user_id", await self.login_as(login))
203
+
204
+ # A forged Host header never reaches the links in the mail.
205
+ await self.post(
206
+ client,
207
+ "/register",
208
+ email="user@example.com",
209
+ password=password,
210
+ password_confirm=password,
211
+ )
212
+ self.assertNotIn("evil.example", sent[-1][2])
213
+
214
+ async def login_as(self, form):
215
+ client = self.app.test_client()
216
+ await self.post(client, "/login", **form)
217
+ async with client.session_transaction() as cookie:
218
+ return dict(cookie)
219
+
220
+ async def test_password_reset_replaces_the_password_and_logs_activity(self):
221
+ sent = self.mailbox()
222
+ signed_in = self.app.test_client()
223
+ await self.login(signed_in)
224
+ client = self.app.test_client()
225
+ page = await client.get("/reset")
226
+ self.assertEqual(page.status_code, 200)
227
+ await self.post(client, "/reset", email="user@example.com")
228
+ path = self.link(sent[0])
229
+ self.assertEqual((await client.get(path)).status_code, 200)
230
+ new = "ReplacementPass123!"
231
+ response = await self.post(client, path, password=new, password_confirm=new)
168
232
  self.assertEqual(response.status_code, 302)
169
- self.assertEqual(len(queued), 1)
170
- subject, recipient, body = queued[0]
171
- self.assertEqual(recipient, "user@example.com")
172
- self.assertIn("http://localhost/login", body)
173
- # A forged Host header never reaches the mail's links.
174
- self.app.config["STK_PUBLIC_URL"] = None
175
- await client.post("/register", form=form, headers={"Host": "evil.example"})
176
- self.assertNotIn("evil.example", queued[1][2])
233
+ self.assertIn(
234
+ "_user_id",
235
+ await self.login_as({"email": "user@example.com", "password": new}),
236
+ )
237
+ self.assertNotEqual((await signed_in.get("/dashboard/")).status_code, 200)
238
+
239
+ def test_sign_up_without_mail_refuses_to_start(self):
240
+ class Config(SecurityConfig):
241
+ SECURITY_REGISTERABLE = True
242
+
243
+ with self.assertRaisesRegex(RuntimeError, "MAIL_SERVER"):
244
+ Stk(__name__, config=Config)
245
+
246
+ class Unconfirmed(Config):
247
+ SECURITY_CONFIRMABLE = False
248
+
249
+ Stk(__name__, config=Unconfirmed)
177
250
 
178
251
  def test_cookie_setting_preserves_local_http_support(self):
179
252
  self.assertFalse(self.app.config["SESSION_COOKIE_SECURE"])
@@ -22,6 +22,21 @@ class StaticVersioningTests(unittest.IsolatedAsyncioTestCase):
22
22
  bare = await client.get("/static/js/config.js")
23
23
  self.assertEqual(bare.headers["Cache-Control"], "no-cache")
24
24
 
25
+ async def test_errors_and_session_cookies_are_never_cached_publicly(self):
26
+ app = Stk(__name__, config=TestingConfig)
27
+ client = app.test_client()
28
+ missing = await client.get("/static/missing.css?v=1")
29
+ self.assertEqual(missing.status_code, 404)
30
+ self.assertNotIn("public", missing.headers["Cache-Control"])
31
+ # A stale login is cleared on any request, static ones included; that
32
+ # response carries a cookie change and must stay private.
33
+ async with client.session_transaction() as cookie:
34
+ cookie["_user_id"] = "gone"
35
+ async with app.test_request_context("/"):
36
+ url = url_for("static", filename="js/config.js")
37
+ cleared = await client.get(url)
38
+ self.assertEqual(cleared.headers["Cache-Control"], "private, no-store")
39
+
25
40
 
26
41
  if __name__ == "__main__":
27
42
  unittest.main()