regstack 0.6.0__tar.gz → 0.8.0__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 (271) hide show
  1. {regstack-0.6.0 → regstack-0.8.0}/CHANGELOG.md +214 -0
  2. {regstack-0.6.0 → regstack-0.8.0}/PKG-INFO +3 -3
  3. {regstack-0.6.0 → regstack-0.8.0}/README.md +1 -1
  4. {regstack-0.6.0 → regstack-0.8.0}/pyproject.toml +33 -4
  5. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/app.py +54 -0
  6. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/auth/dependencies.py +36 -1
  7. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/auth/rate_limit.py +2 -0
  8. regstack-0.8.0/src/regstack/backends/mongo/indexes.py +192 -0
  9. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/backends/mongo/repositories/user_repo.py +18 -0
  10. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/backends/sql/migrations/versions/0002_oauth.py +23 -2
  11. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/cli/__main__.py +54 -15
  12. regstack-0.8.0/src/regstack/cli/_results.py +29 -0
  13. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/cli/doctor.py +2 -9
  14. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/cli/init.py +6 -21
  15. regstack-0.8.0/src/regstack/cli/validate/__init__.py +9 -0
  16. regstack-0.8.0/src/regstack/cli/validate/capture.py +56 -0
  17. regstack-0.8.0/src/regstack/cli/validate/cli.py +326 -0
  18. regstack-0.8.0/src/regstack/cli/validate/http.py +126 -0
  19. regstack-0.8.0/src/regstack/cli/validate/logtail.py +276 -0
  20. regstack-0.8.0/src/regstack/cli/validate/phases/__init__.py +5 -0
  21. regstack-0.8.0/src/regstack/cli/validate/phases/account.py +148 -0
  22. regstack-0.8.0/src/regstack/cli/validate/phases/cleanup.py +71 -0
  23. regstack-0.8.0/src/regstack/cli/validate/phases/core_auth.py +171 -0
  24. regstack-0.8.0/src/regstack/cli/validate/phases/feature_discover.py +68 -0
  25. regstack-0.8.0/src/regstack/cli/validate/phases/oauth.py +57 -0
  26. regstack-0.8.0/src/regstack/cli/validate/phases/password_reset.py +99 -0
  27. regstack-0.8.0/src/regstack/cli/validate/phases/reachability.py +50 -0
  28. regstack-0.8.0/src/regstack/cli/validate/phases/sms_mfa.py +123 -0
  29. regstack-0.8.0/src/regstack/cli/validate/report.py +33 -0
  30. regstack-0.8.0/src/regstack/cli/validate/runner.py +129 -0
  31. regstack-0.8.0/src/regstack/config/schema.py +373 -0
  32. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/email/composer.py +3 -1
  33. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/email/console.py +8 -2
  34. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/email/factory.py +1 -1
  35. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/email/ses.py +23 -2
  36. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/models/user.py +10 -4
  37. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/oauth/providers/google.py +1 -1
  38. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/routers/account.py +1 -2
  39. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/routers/admin.py +31 -2
  40. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/routers/oauth.py +19 -1
  41. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/routers/password.py +1 -2
  42. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/routers/register.py +1 -2
  43. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/routers/verify.py +16 -3
  44. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/sms/factory.py +1 -1
  45. regstack-0.8.0/src/regstack/sms/null.py +38 -0
  46. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/sms/sns.py +1 -1
  47. regstack-0.8.0/src/regstack/version.py +1 -0
  48. regstack-0.8.0/src/regstack/wizard/ses/__init__.py +5 -0
  49. regstack-0.8.0/src/regstack/wizard/ses/_aws.py +315 -0
  50. regstack-0.8.0/src/regstack/wizard/ses/cli.py +224 -0
  51. regstack-0.8.0/src/regstack/wizard/ses/routes.py +367 -0
  52. regstack-0.8.0/src/regstack/wizard/ses/server.py +87 -0
  53. regstack-0.8.0/src/regstack/wizard/ses/static/wizard.css +125 -0
  54. regstack-0.8.0/src/regstack/wizard/ses/static/wizard.js +266 -0
  55. regstack-0.8.0/src/regstack/wizard/ses/templates/wizard.html +25 -0
  56. regstack-0.8.0/src/regstack/wizard/ses/validators.py +319 -0
  57. regstack-0.8.0/src/regstack/wizard/ses/window.py +59 -0
  58. regstack-0.8.0/src/regstack/wizard/ses/writer.py +267 -0
  59. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/wizard/theme_designer/templates/designer.html +2 -2
  60. regstack-0.6.0/.github/workflows/publish.yml +0 -82
  61. regstack-0.6.0/.github/workflows/test.yml +0 -161
  62. regstack-0.6.0/.python-version +0 -1
  63. regstack-0.6.0/.readthedocs.yaml +0 -23
  64. regstack-0.6.0/CLAUDE.md +0 -358
  65. regstack-0.6.0/docs/_static/.gitkeep +0 -0
  66. regstack-0.6.0/docs/_templates/.gitkeep +0 -0
  67. regstack-0.6.0/docs/api.md +0 -351
  68. regstack-0.6.0/docs/architecture.md +0 -250
  69. regstack-0.6.0/docs/changelog.md +0 -783
  70. regstack-0.6.0/docs/cli.md +0 -187
  71. regstack-0.6.0/docs/conf.py +0 -98
  72. regstack-0.6.0/docs/configuration.md +0 -425
  73. regstack-0.6.0/docs/embedding.md +0 -252
  74. regstack-0.6.0/docs/index.md +0 -197
  75. regstack-0.6.0/docs/oauth.md +0 -154
  76. regstack-0.6.0/docs/quickstart.md +0 -162
  77. regstack-0.6.0/docs/security-reports/README.md +0 -15
  78. regstack-0.6.0/docs/security.md +0 -328
  79. regstack-0.6.0/docs/theming.md +0 -213
  80. regstack-0.6.0/examples/_common/app.py +0 -101
  81. regstack-0.6.0/examples/mongo/README.md +0 -30
  82. regstack-0.6.0/examples/mongo/branding/theme.css +0 -25
  83. regstack-0.6.0/examples/mongo/main.py +0 -31
  84. regstack-0.6.0/examples/mongo/regstack.toml +0 -30
  85. regstack-0.6.0/examples/postgres/README.md +0 -29
  86. regstack-0.6.0/examples/postgres/main.py +0 -30
  87. regstack-0.6.0/examples/postgres/regstack.toml +0 -29
  88. regstack-0.6.0/examples/sqlite/README.md +0 -33
  89. regstack-0.6.0/examples/sqlite/main.py +0 -34
  90. regstack-0.6.0/examples/sqlite/regstack.toml +0 -30
  91. regstack-0.6.0/scripts/ccr_coverage_setup.py +0 -242
  92. regstack-0.6.0/scripts/security-review-prompt.md +0 -583
  93. regstack-0.6.0/src/regstack/backends/mongo/indexes.py +0 -98
  94. regstack-0.6.0/src/regstack/cli/__init__.py +0 -0
  95. regstack-0.6.0/src/regstack/config/schema.py +0 -208
  96. regstack-0.6.0/src/regstack/sms/null.py +0 -26
  97. regstack-0.6.0/src/regstack/version.py +0 -1
  98. regstack-0.6.0/tasks/oauth-design.md +0 -729
  99. regstack-0.6.0/tasks.py +0 -415
  100. regstack-0.6.0/tests/__init__.py +0 -0
  101. regstack-0.6.0/tests/_fake_google/__init__.py +0 -14
  102. regstack-0.6.0/tests/_fake_google/provider.py +0 -166
  103. regstack-0.6.0/tests/conftest.py +0 -266
  104. regstack-0.6.0/tests/e2e/__init__.py +0 -0
  105. regstack-0.6.0/tests/e2e/conftest.py +0 -121
  106. regstack-0.6.0/tests/e2e/test_theme_designer.py +0 -87
  107. regstack-0.6.0/tests/e2e/test_wizard_oauth_flow.py +0 -144
  108. regstack-0.6.0/tests/integration/__init__.py +0 -0
  109. regstack-0.6.0/tests/integration/test_account_management.py +0 -314
  110. regstack-0.6.0/tests/integration/test_admin_router.py +0 -301
  111. regstack-0.6.0/tests/integration/test_happy_path.py +0 -174
  112. regstack-0.6.0/tests/integration/test_indexes.py +0 -43
  113. regstack-0.6.0/tests/integration/test_login_lockout.py +0 -82
  114. regstack-0.6.0/tests/integration/test_mfa.py +0 -353
  115. regstack-0.6.0/tests/integration/test_oauth_google_router.py +0 -735
  116. regstack-0.6.0/tests/integration/test_oauth_repos.py +0 -267
  117. regstack-0.6.0/tests/integration/test_oauth_ui.py +0 -168
  118. regstack-0.6.0/tests/integration/test_password_reset.py +0 -121
  119. regstack-0.6.0/tests/integration/test_rate_limits.py +0 -215
  120. regstack-0.6.0/tests/integration/test_sql_migrations.py +0 -89
  121. regstack-0.6.0/tests/integration/test_ui_router.py +0 -117
  122. regstack-0.6.0/tests/integration/test_verification.py +0 -143
  123. regstack-0.6.0/tests/unit/__init__.py +0 -0
  124. regstack-0.6.0/tests/unit/test_base_install_imports.py +0 -79
  125. regstack-0.6.0/tests/unit/test_cli.py +0 -133
  126. regstack-0.6.0/tests/unit/test_cli_doctor.py +0 -164
  127. regstack-0.6.0/tests/unit/test_cli_init.py +0 -150
  128. regstack-0.6.0/tests/unit/test_cli_migrate.py +0 -151
  129. regstack-0.6.0/tests/unit/test_cli_wizard_missing_extra.py +0 -59
  130. regstack-0.6.0/tests/unit/test_config_loader.py +0 -41
  131. regstack-0.6.0/tests/unit/test_jwt.py +0 -61
  132. regstack-0.6.0/tests/unit/test_lockout.py +0 -101
  133. regstack-0.6.0/tests/unit/test_mail_composer.py +0 -88
  134. regstack-0.6.0/tests/unit/test_mfa_code_repo.py +0 -126
  135. regstack-0.6.0/tests/unit/test_oauth_google.py +0 -445
  136. regstack-0.6.0/tests/unit/test_password.py +0 -16
  137. regstack-0.6.0/tests/unit/test_ses_backend.py +0 -26
  138. regstack-0.6.0/tests/unit/test_sms.py +0 -77
  139. regstack-0.6.0/tests/unit/test_smtp_backend.py +0 -62
  140. regstack-0.6.0/tests/unit/test_theme_designer_cli.py +0 -190
  141. regstack-0.6.0/tests/unit/test_theme_designer_routes.py +0 -179
  142. regstack-0.6.0/tests/unit/test_theme_designer_validators.py +0 -154
  143. regstack-0.6.0/tests/unit/test_theme_designer_writer.py +0 -156
  144. regstack-0.6.0/tests/unit/test_ui_env.py +0 -51
  145. regstack-0.6.0/tests/unit/test_wizard_oauth_cli.py +0 -230
  146. regstack-0.6.0/tests/unit/test_wizard_oauth_routes.py +0 -232
  147. regstack-0.6.0/tests/unit/test_wizard_oauth_validators.py +0 -257
  148. regstack-0.6.0/tests/unit/test_wizard_oauth_writer.py +0 -293
  149. regstack-0.6.0/uv.lock +0 -3314
  150. {regstack-0.6.0 → regstack-0.8.0}/.gitignore +0 -0
  151. {regstack-0.6.0 → regstack-0.8.0}/LICENSE +0 -0
  152. {regstack-0.6.0 → regstack-0.8.0}/NOTICE +0 -0
  153. {regstack-0.6.0 → regstack-0.8.0}/SECURITY.md +0 -0
  154. {regstack-0.6.0 → regstack-0.8.0}/regstack.toml.example +0 -0
  155. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/__init__.py +0 -0
  156. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/auth/__init__.py +0 -0
  157. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/auth/clock.py +0 -0
  158. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/auth/jwt.py +0 -0
  159. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/auth/lockout.py +0 -0
  160. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/auth/mfa.py +0 -0
  161. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/auth/password.py +0 -0
  162. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/auth/tokens.py +0 -0
  163. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/backends/__init__.py +0 -0
  164. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/backends/base.py +0 -0
  165. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/backends/factory.py +0 -0
  166. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/backends/mongo/__init__.py +0 -0
  167. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/backends/mongo/backend.py +0 -0
  168. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/backends/mongo/client.py +0 -0
  169. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/backends/mongo/repositories/__init__.py +0 -0
  170. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/backends/mongo/repositories/blacklist_repo.py +0 -0
  171. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/backends/mongo/repositories/login_attempt_repo.py +0 -0
  172. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/backends/mongo/repositories/mfa_code_repo.py +0 -0
  173. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/backends/mongo/repositories/oauth_identity_repo.py +0 -0
  174. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/backends/mongo/repositories/oauth_state_repo.py +0 -0
  175. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/backends/mongo/repositories/pending_repo.py +0 -0
  176. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/backends/protocols.py +0 -0
  177. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/backends/sql/__init__.py +0 -0
  178. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/backends/sql/backend.py +0 -0
  179. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/backends/sql/migrations/__init__.py +0 -0
  180. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/backends/sql/migrations/env.py +0 -0
  181. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/backends/sql/migrations/script.py.mako +0 -0
  182. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/backends/sql/migrations/versions/0001_initial_schema.py +0 -0
  183. {regstack-0.6.0/examples/_common → regstack-0.8.0/src/regstack/backends/sql/repositories}/__init__.py +0 -0
  184. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/backends/sql/repositories/blacklist_repo.py +0 -0
  185. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/backends/sql/repositories/login_attempt_repo.py +0 -0
  186. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/backends/sql/repositories/mfa_code_repo.py +0 -0
  187. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/backends/sql/repositories/oauth_identity_repo.py +0 -0
  188. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/backends/sql/repositories/oauth_state_repo.py +0 -0
  189. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/backends/sql/repositories/pending_repo.py +0 -0
  190. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/backends/sql/repositories/user_repo.py +0 -0
  191. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/backends/sql/schema.py +0 -0
  192. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/backends/sql/types.py +0 -0
  193. {regstack-0.6.0/src/regstack/backends/sql/repositories → regstack-0.8.0/src/regstack/cli}/__init__.py +0 -0
  194. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/cli/_runtime.py +0 -0
  195. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/cli/admin.py +0 -0
  196. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/cli/migrate.py +0 -0
  197. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/config/__init__.py +0 -0
  198. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/config/loader.py +0 -0
  199. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/config/secrets.py +0 -0
  200. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/email/__init__.py +0 -0
  201. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/email/base.py +0 -0
  202. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/email/smtp.py +0 -0
  203. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/email/templates/email_change.html +0 -0
  204. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/email/templates/email_change.subject.txt +0 -0
  205. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/email/templates/email_change.txt +0 -0
  206. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/email/templates/password_reset.html +0 -0
  207. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/email/templates/password_reset.subject.txt +0 -0
  208. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/email/templates/password_reset.txt +0 -0
  209. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/email/templates/sms_login_mfa.txt +0 -0
  210. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/email/templates/sms_phone_setup.txt +0 -0
  211. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/email/templates/verification.html +0 -0
  212. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/email/templates/verification.subject.txt +0 -0
  213. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/email/templates/verification.txt +0 -0
  214. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/hooks/__init__.py +0 -0
  215. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/hooks/events.py +0 -0
  216. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/models/__init__.py +0 -0
  217. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/models/_objectid.py +0 -0
  218. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/models/login_attempt.py +0 -0
  219. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/models/mfa_code.py +0 -0
  220. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/models/oauth_identity.py +0 -0
  221. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/models/oauth_state.py +0 -0
  222. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/models/pending_registration.py +0 -0
  223. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/oauth/__init__.py +0 -0
  224. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/oauth/base.py +0 -0
  225. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/oauth/errors.py +0 -0
  226. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/oauth/providers/__init__.py +0 -0
  227. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/oauth/registry.py +0 -0
  228. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/routers/__init__.py +0 -0
  229. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/routers/_helpers.py +0 -0
  230. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/routers/_schemas.py +0 -0
  231. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/routers/login.py +0 -0
  232. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/routers/logout.py +0 -0
  233. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/routers/phone.py +0 -0
  234. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/sms/__init__.py +0 -0
  235. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/sms/base.py +0 -0
  236. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/sms/twilio.py +0 -0
  237. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/ui/__init__.py +0 -0
  238. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/ui/pages.py +0 -0
  239. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/ui/static/css/core.css +0 -0
  240. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/ui/static/css/theme.css +0 -0
  241. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/ui/static/js/regstack.js +0 -0
  242. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/ui/templates/auth/email_change_confirm.html +0 -0
  243. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/ui/templates/auth/forgot.html +0 -0
  244. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/ui/templates/auth/login.html +0 -0
  245. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/ui/templates/auth/me.html +0 -0
  246. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/ui/templates/auth/mfa_confirm.html +0 -0
  247. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/ui/templates/auth/oauth_complete.html +0 -0
  248. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/ui/templates/auth/register.html +0 -0
  249. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/ui/templates/auth/reset.html +0 -0
  250. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/ui/templates/auth/verify.html +0 -0
  251. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/ui/templates/base.html +0 -0
  252. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/wizard/__init__.py +0 -0
  253. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/wizard/oauth_google/__init__.py +0 -0
  254. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/wizard/oauth_google/cli.py +0 -0
  255. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/wizard/oauth_google/routes.py +0 -0
  256. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/wizard/oauth_google/server.py +0 -0
  257. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/wizard/oauth_google/static/wizard.css +0 -0
  258. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/wizard/oauth_google/static/wizard.js +0 -0
  259. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/wizard/oauth_google/templates/wizard.html +0 -0
  260. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/wizard/oauth_google/validators.py +0 -0
  261. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/wizard/oauth_google/window.py +0 -0
  262. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/wizard/oauth_google/writer.py +0 -0
  263. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/wizard/theme_designer/__init__.py +0 -0
  264. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/wizard/theme_designer/cli.py +0 -0
  265. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/wizard/theme_designer/routes.py +0 -0
  266. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/wizard/theme_designer/server.py +0 -0
  267. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/wizard/theme_designer/static/designer.css +0 -0
  268. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/wizard/theme_designer/static/designer.js +0 -0
  269. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/wizard/theme_designer/validators.py +0 -0
  270. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/wizard/theme_designer/window.py +0 -0
  271. {regstack-0.6.0 → regstack-0.8.0}/src/regstack/wizard/theme_designer/writer.py +0 -0
@@ -5,6 +5,220 @@ authoritative copy lives at
5
5
  [`docs/changelog.md`](docs/changelog.md) and is rendered into the
6
6
  Sphinx docs.
7
7
 
8
+ ## Unreleased
9
+
10
+ ## 0.8.0 — 2026-05-19
11
+
12
+ `regstack ses setup` guided wizard, plus two security fixes from
13
+ the 2026-05-18 daily review.
14
+
15
+ **Added: `regstack ses setup`.** A pywebview wizard for the SES
16
+ email backend, mirroring the existing `regstack oauth setup` flow.
17
+ Nine steps walk through region selection, credential source
18
+ (`profile` / `explicit` / `chain`), sender-domain identity
19
+ verification (via SES `GetIdentityVerificationAttributes`),
20
+ sandbox detection (via `GetAccount` with `GetSendQuota` heuristic
21
+ fallback for IAM-restricted policies), and a live test send.
22
+ Non-clobbering tomlkit + secrets.env merge. Headless
23
+ `--print-only` mode for CI / scripting. Gated behind the joint
24
+ extra: `pip install 'regstack[wizard,ses]'`.
25
+
26
+ **Fixed: theme-designer preview no longer ships well-known credentials
27
+ in the wheel.** `designer.html` had `alice@example.com` /
28
+ `hunter2hunter2` as `value=` attributes on its login-form preview;
29
+ flipped to `placeholder=` so the wheel doesn't carry well-known
30
+ example creds that could be mistaken for real fixtures.
31
+ (Daily security review 2026-05-18 · I-1.)
32
+
33
+ **Fixed: Google OAuth token-exchange error no longer echoes the
34
+ response body.** `exchange_code()` previously raised
35
+ `OAuthTokenExchangeError(f"... {body!r}")` on the rare 200-without-id_token
36
+ edge case. The body can contain a live short-lived `access_token` in
37
+ that path, and the OAuth router logs the exception text at WARNING.
38
+ Dropped `{body!r}` from the message; regression test in
39
+ `tests/unit/test_oauth_google.py` pins that a planted token never
40
+ appears in the exception's `str()` or `args`.
41
+ (Daily security review 2026-05-18 · I-2.)
42
+
43
+ ## 0.7.0 — 2026-05-17
44
+
45
+ Two-week sprint that lands the `regstack validate` end-to-end probe,
46
+ seven security-review findings, a clutch of host-integration
47
+ ergonomic wins (per-link email URL templates, optional auth
48
+ dependency, admin `promote_pending`, explicit SES credentials), and
49
+ two breaking API trims (`UserPublic._id` → `id`,
50
+ `TokenTransport = "bearer"` only).
51
+
52
+ The headline is `regstack validate` — a new CLI command that drives
53
+ a real deployed install through every auth flow (register, verify,
54
+ login, logout, password reset, change-email, OAuth start, SMS 2FA)
55
+ from a remote operator workstation, scraping one-time tokens out of
56
+ the deployment's stdout via a `--log-source` of your choice
57
+ (`file:`, `ssh:`, `docker:`, `cmd:`). The companion to `regstack
58
+ doctor`: doctor checks the loaded config, validate checks the
59
+ running service.
60
+
61
+ **Breaking.**
62
+
63
+ - **`UserPublic` JSON key is `id`, not `_id`.** The `alias="_id"`
64
+ on `UserPublic.id` (and the accompanying `populate_by_name=True`)
65
+ is removed. Every endpoint returning a `UserPublic` —
66
+ `POST /api/auth/register`, `GET /api/auth/me`, `PATCH /api/auth/me`,
67
+ and the admin user endpoints — now sends `id` on the wire.
68
+ `BaseUser` (the Mongo-document model) keeps the alias because it
69
+ round-trips to BSON via `to_mongo()`; only the API contract is
70
+ touched. Clients that read `body["_id"]` should switch to
71
+ `body["id"]`. Hosts hand-rolling a `/me` override solely to swap
72
+ the key shape can drop it.
73
+ - **`TokenTransport` literal narrowed to `Literal["bearer"]`.**
74
+ `"cookie"` was previously accepted by config validation but
75
+ silently no-op'd (no router ever set `Set-Cookie`). Hosts that
76
+ set `transport = "cookie"` now get a clear pydantic
77
+ `literal_error` at startup instead of a silent
78
+ security-misconfiguration. `RegStackConfig.cookie_domain` is
79
+ removed along with it. `regstack init` no longer offers the
80
+ cookie option either.
81
+
82
+ **Added.**
83
+
84
+ - **`regstack validate`.** End-to-end probe of a deployed install
85
+ — registers a throwaway user, walks every auth flow, then
86
+ deletes it. Reads one-time tokens out of the deployment's
87
+ stdout via `--log-source` (file / ssh / docker / arbitrary
88
+ command). Skip phases with `--skip`. Companion to `regstack
89
+ doctor` (which only validates loaded config). See
90
+ `regstack validate --help` for the full operator runbook.
91
+ - **`email.log_bodies` and `sms.log_bodies` config flags** to
92
+ promote the console / null backends' body log lines from
93
+ DEBUG → INFO without enabling DEBUG globally. `email.log_bodies`
94
+ defaults to `False`; `sms.log_bodies` defaults to `True`
95
+ (preserves prior null-SMS behaviour). Other backends ignore.
96
+ - **`RegStackConfig.email_link_prefix` + auto-resolve from
97
+ `ui_prefix`.** Verification / reset / email-change links now
98
+ default to `<base_url><ui_prefix>/verify?token=...` when the
99
+ bundled UI router is enabled, instead of bare `/verify`. Hosts
100
+ whose SPA owns the auth pages can pin a path explicitly via
101
+ `email_link_prefix`; the bundled UI hosts get the right links
102
+ automatically.
103
+ - **`EmailConfig.from_name` defaults to `app_name`** when unset.
104
+ Hosts that change `app_name` to brand outgoing email also get
105
+ the matching `From:` header automatically. Explicit `from_name`
106
+ values still win.
107
+ - **Per-link email URL templates.** Three new optional fields on
108
+ `RegStackConfig` — `verify_url_template`,
109
+ `password_reset_url_template`, `email_change_url_template` —
110
+ let SPAs whose router shape doesn't fit
111
+ `/verify?token=...` rewrite the email links. Templates
112
+ substitute `{base_url}` and `{token}` literally. Hash-routed
113
+ SPA: `"{base_url}/#/verify/{token}"`. Sibling subdomain:
114
+ `"https://auth.example.com/verify/{token}"`. Default unset
115
+ falls back to the prefix-based composition above. New helpers
116
+ `RegStackConfig.resolve_{verify,password_reset,email_change}_url(token)`.
117
+ - **`current_user_optional` dependency.** Companion to
118
+ `current_user` / `current_admin` on `regstack.deps`. Returns
119
+ `BaseUser | None` instead of raising 401, for endpoints that
120
+ render differently for signed-in vs anonymous callers (cart
121
+ icon, comment-author prefill). Every form of auth failure —
122
+ missing header, wrong scheme, malformed / expired / revoked
123
+ token, deleted or bulk-revoked user — collapses to `None`.
124
+ - **`RegStack.promote_pending(email)` + admin route.** Converts
125
+ a `PendingRegistration` row directly into a verified active
126
+ user, bypassing the email-link round-trip. Hashed password and
127
+ full name carry over verbatim. Fires the same `user_verified`
128
+ hook as `POST /verify`. Useful for admin rescue of stuck
129
+ signups, batch seeding from a known-good list, and dev
130
+ fixtures. Exposed as `POST /admin/pending/{email}/promote`
131
+ when the admin router is enabled.
132
+ - **Explicit SES credential fields on `EmailConfig`.** New
133
+ `ses_access_key_id` / `ses_secret_access_key` (both `SecretStr |
134
+ None`) let hosts pass AWS creds directly instead of relying on
135
+ boto3's env-var fallthrough. Validated as a pair, mutually
136
+ exclusive with `ses_profile`.
137
+
138
+ **Security.**
139
+
140
+ - **CVE-2026-42561 — `python-multipart>=0.0.27`.** Closes a
141
+ network-exploitable DoS via unbounded multipart part-header
142
+ parsing (CVSS 7.5). Previous floor `>=0.0.26` had the earlier
143
+ CVE-2026-40347 fix only.
144
+ - **sdist no longer ships internal docs to PyPI.** Added a
145
+ `[tool.hatch.build.targets.sdist]` exclude block. The published
146
+ source tarball used to contain `CLAUDE.md` (with a developer
147
+ home-directory path), the security-review prompt, the full test
148
+ suite, build tooling, and (when built from a worktree) a `.git`
149
+ text file pointing at the operator's worktrees directory.
150
+ - **Defensive `ObjectId.is_valid()` on nine Mongo UserRepo
151
+ mutations.** `set_last_login`, `set_tokens_invalidated_after`,
152
+ `update_password`, `set_active`, `set_superuser`, `set_full_name`,
153
+ `set_phone`, `set_mfa_enabled`, and `update_email` now match
154
+ `get_by_id` / `delete`: invalid input no-ops instead of raising
155
+ `bson.errors.InvalidId` (which would have surfaced as a 500 on
156
+ any future caller passing raw external input).
157
+ - **Per-IP rate-limit map covers `/login/mfa-confirm` and
158
+ `/oauth/exchange`.** Two new config fields:
159
+ `login_mfa_confirm_rate_limit`, `oauth_exchange_rate_limit`.
160
+ The per-code attempt counter on `mfa_codes` defends each
161
+ individual code; this adds the per-IP layer against distributed
162
+ guessing across many source IPs.
163
+ - **OAuth callback `error` query parameter sanitized before
164
+ logging.** A compromised or malicious OAuth provider could
165
+ previously inject newlines / ANSI escapes into the log stream
166
+ via the `error=...` redirect. The callback now strips control
167
+ characters and caps length at 200 before logging.
168
+ - **`oauth_states.mode` validated at the MongoDB storage layer.**
169
+ A `$jsonSchema` validator on the collection enforces
170
+ `mode IN ('signin', 'link')`, matching the SQL backend's
171
+ existing `CheckConstraint`. `OAuthState.model_validate()`
172
+ already enforced this at the app layer; this is defence-in-depth.
173
+ - **Migration `0002` downgrade refuses to roll back when OAuth-only
174
+ users exist.** The downgrade re-applies `NOT NULL` to
175
+ `users.hashed_password`; if any row has `NULL` (OAuth-only
176
+ signup), it now raises `RuntimeError` with a clear remediation
177
+ message instead of silently succeeding on SQLite (where
178
+ `batch_alter_table`'s CREATE-COPY-DROP-RENAME path skipped
179
+ NOT NULL enforcement).
180
+ - **PEP 740 sigstore attestations on the PyPI publish workflow.**
181
+ Each published wheel / sdist is now cryptographically bound to
182
+ the specific GitHub Actions run that produced it, so consumers
183
+ can verify the artefact came from this repo's CI.
184
+ - **`workflow_dispatch` removed from `publish.yml`.** Manual runs
185
+ previously uploaded artefacts to Actions storage with no
186
+ version validation, where they could be confused with a real
187
+ release build. Tag-push is the only supported trigger.
188
+
189
+ **Fixed.**
190
+
191
+ - **`regstack doctor --send-test-email` honours the new `from_name`
192
+ fall-back.** Before, the probe path passed `config.email.from_name`
193
+ (now `Optional[str]`) straight into `EmailMessage.from_name`
194
+ (typed `str`), producing a `None <addr>` From: header when
195
+ unset.
196
+ - **`install_schema()` survives a legacy unnamed unique-on-email
197
+ index.** A host that previously ran
198
+ `db.users.create_index([("email", 1)], unique=True)` from its
199
+ own pre-regstack auth code has a Mongo-auto-named `email_1`
200
+ index. `install_indexes` previously crashed on first boot with
201
+ `IndexOptionsConflict`. It now detects any unnamed/legacy
202
+ unique index over `{"email": 1}`, drops it, and proceeds.
203
+ Idempotent on a healthy DB.
204
+ - **`POST /verify` no longer 500s on the admin-promote-meets-user-
205
+ clicks-verify race.** The endpoint now catches
206
+ `UserAlreadyExistsError` from `users.create` and returns a
207
+ graceful 400 ("This email is already registered. Please sign
208
+ in.") instead of letting the unique-constraint violation bubble
209
+ up as a 500.
210
+
211
+ **Internal.**
212
+
213
+ - **GitHub Actions pinned ahead of Node 20 deprecation.**
214
+ `actions/checkout` v4→v6.0.2, `astral-sh/setup-uv` v3→v8.1.0,
215
+ `actions/upload-artifact` v4→v7.0.1, `actions/download-artifact`
216
+ v4→v8.0.1. All pins remain commit SHAs.
217
+ - **Daily scheduled security-review reports** land under
218
+ `docs/security-reports/` for 2026-05-15 through 2026-05-17.
219
+ The 2026-05-17 report is `[security-clean]`: all warnings from
220
+ the prior two days resolved in this release.
221
+
8
222
  ## 0.6.0 — 2026-05-14
9
223
 
10
224
  **Breaking change for wizard users.** The GUI setup wizards
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: regstack
3
- Version: 0.6.0
3
+ Version: 0.8.0
4
4
  Summary: Embeddable user registration, login, and account management for FastAPI apps. SQLite / Postgres / MongoDB.
5
5
  Project-URL: Homepage, https://github.com/jdrumgoole/regstack
6
6
  Project-URL: Repository, https://github.com/jdrumgoole/regstack
@@ -28,7 +28,7 @@ Requires-Dist: pwdlib[argon2]>=0.2.1
28
28
  Requires-Dist: pydantic-settings>=2.2
29
29
  Requires-Dist: pydantic>=2.6
30
30
  Requires-Dist: pyjwt>=2.12.1
31
- Requires-Dist: python-multipart>=0.0.26
31
+ Requires-Dist: python-multipart>=0.0.27
32
32
  Requires-Dist: sqlalchemy[asyncio]>=2.0
33
33
  Provides-Extra: dev
34
34
  Requires-Dist: anyio>=4.3; extra == 'dev'
@@ -151,7 +151,7 @@ result everywhere is what regstack is for.
151
151
  ✔ Server-rendered HTML pages, theme with one CSS file
152
152
  ✔ Pluggable email (console / SMTP / Amazon SES) and SMS (Amazon SNS / Twilio)
153
153
  ✔ Argon2 password hashing, CSP-friendly templates
154
- ✔ Setup wizards (live in their own pywebview windows): `regstack init` (project bootstrap), `regstack oauth setup` (guided Google OAuth client config), `regstack theme design` (live theme designer with preview), and `regstack doctor` (config validator)
154
+ ✔ Setup wizards (live in their own pywebview windows): `regstack init` (project bootstrap), `regstack oauth setup` (guided Google OAuth client config), `regstack theme design` (live theme designer with preview), `regstack doctor` (config validator), and `regstack validate` (end-to-end probe of a deployed install)
155
155
  ✔ Three storage backends: SQLite, PostgreSQL, MongoDB — chosen by URL
156
156
  ```
157
157
 
@@ -72,7 +72,7 @@ result everywhere is what regstack is for.
72
72
  ✔ Server-rendered HTML pages, theme with one CSS file
73
73
  ✔ Pluggable email (console / SMTP / Amazon SES) and SMS (Amazon SNS / Twilio)
74
74
  ✔ Argon2 password hashing, CSP-friendly templates
75
- ✔ Setup wizards (live in their own pywebview windows): `regstack init` (project bootstrap), `regstack oauth setup` (guided Google OAuth client config), `regstack theme design` (live theme designer with preview), and `regstack doctor` (config validator)
75
+ ✔ Setup wizards (live in their own pywebview windows): `regstack init` (project bootstrap), `regstack oauth setup` (guided Google OAuth client config), `regstack theme design` (live theme designer with preview), `regstack doctor` (config validator), and `regstack validate` (end-to-end probe of a deployed install)
76
76
  ✔ Three storage backends: SQLite, PostgreSQL, MongoDB — chosen by URL
77
77
  ```
78
78
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "regstack"
3
- version = "0.6.0"
3
+ version = "0.8.0"
4
4
  description = "Embeddable user registration, login, and account management for FastAPI apps. SQLite / Postgres / MongoDB."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -31,9 +31,10 @@ dependencies = [
31
31
  "jinja2>=3.1.6",
32
32
  "click>=8.1",
33
33
  "dnspython>=2.6",
34
- # python-multipart>=0.0.26 picks up CVE-2026-40347 (DoS via oversized
35
- # multipart preamble).
36
- "python-multipart>=0.0.26",
34
+ # python-multipart>=0.0.27 picks up CVE-2026-40347 (DoS via oversized
35
+ # multipart preamble) and CVE-2026-42561 (DoS via unbounded part-header
36
+ # count/size — CVSS 7.5, network-exploitable).
37
+ "python-multipart>=0.0.27",
37
38
  "email-validator>=2.1",
38
39
  "aiosmtplib>=3.0",
39
40
  # SQL stack — bundled by default because SQLite is the default backend.
@@ -118,6 +119,34 @@ packages = ["src/regstack"]
118
119
  # Hatch's package autodiscovery already picks up template / static assets
119
120
  # under src/regstack — no force-include needed (it would double-pack).
120
121
 
122
+ [tool.hatch.build.targets.sdist]
123
+ # Hatchling's sdist default includes every git-tracked file. Without this
124
+ # block the published source tarball ships internal planning docs,
125
+ # security-review prompts, the full test suite, and CI configuration to
126
+ # anyone who runs `pip download --no-binary`. The wheel target above is
127
+ # already tight (src/regstack only); this list is the matching sdist
128
+ # discipline. Flagged as W-2 in the 2026-05-15 / 2026-05-16 security
129
+ # reviews — CLAUDE.md in particular contains a developer home path.
130
+ exclude = [
131
+ ".git",
132
+ ".github/",
133
+ ".python-version",
134
+ ".readthedocs.yaml",
135
+ "CLAUDE.md",
136
+ "docs/",
137
+ "examples/",
138
+ "scripts/",
139
+ "tasks.py",
140
+ "tasks/",
141
+ "tests/",
142
+ "uv.lock",
143
+ ]
144
+ # Note on `.git`: hatchling normally skips it because it's a directory, but
145
+ # when this repo is built from a git *worktree* (e.g. release prep on a
146
+ # branch), `.git` is a 1-line text file containing an absolute path to the
147
+ # primary repo's worktrees dir — which leaks the developer's home directory
148
+ # into PyPI. Excluding it explicitly is harmless for non-worktree builds.
149
+
121
150
  [tool.pytest.ini_options]
122
151
  minversion = "8.0"
123
152
  asyncio_mode = "auto"
@@ -341,6 +341,60 @@ class RegStack:
341
341
  )
342
342
  return await self.users.create(user)
343
343
 
344
+ async def promote_pending(self, email: str) -> BaseUser:
345
+ """Convert a pending registration directly into a verified user.
346
+
347
+ Bypasses the email-link round-trip. Useful when:
348
+
349
+ - a user lost their verification link and ``resend-verification``
350
+ isn't an option (admin-triggered onboarding, dev fixtures);
351
+ - a CLI batch operation seeds users from a known-good list;
352
+ - an admin is rescuing a stuck signup.
353
+
354
+ The pending row's ``hashed_password`` and ``full_name`` carry
355
+ over verbatim — the user logs in with the password they
356
+ originally registered with. The pending row is deleted on
357
+ success. Fires the ``user_verified`` hook so analytics /
358
+ downstream listeners see the same event the email-driven
359
+ ``POST /verify`` produces.
360
+
361
+ Args:
362
+ email: The email address whose pending registration should
363
+ be promoted.
364
+
365
+ Returns:
366
+ The newly persisted, active, verified
367
+ :class:`~regstack.models.user.BaseUser`.
368
+
369
+ Raises:
370
+ LookupError: If no pending registration exists for that
371
+ email (caller's job to surface as 404 / CLI error).
372
+ UserAlreadyExistsError: If a non-pending user with that
373
+ email already exists (caller's job to surface as 409).
374
+ """
375
+ pending = await self.pending.find_by_email(email)
376
+ if pending is None:
377
+ raise LookupError(f"No pending registration for {email!r}.")
378
+ # Match ``POST /verify``'s contract: an expired pending row is
379
+ # not a valid promotion target. Mongo's TTL reap is eventual
380
+ # and the SQL backends only purge on ``purge_expired()``, so a
381
+ # row past its window can still appear here. Treat it as
382
+ # missing rather than silently promoting a stale invitation.
383
+ if pending.expires_at <= self.clock.now():
384
+ await self.pending.delete_by_email(pending.email)
385
+ raise LookupError(f"Pending registration for {email!r} has expired.")
386
+ user = BaseUser(
387
+ email=pending.email,
388
+ hashed_password=pending.hashed_password,
389
+ full_name=pending.full_name,
390
+ is_active=True,
391
+ is_verified=True,
392
+ )
393
+ user = await self.users.create(user)
394
+ await self.pending.delete_by_email(pending.email)
395
+ await self.hooks.fire("user_verified", user=user)
396
+ return user
397
+
344
398
  # --- Extension surface ------------------------------------------------
345
399
 
346
400
  def set_email_backend(self, service: EmailService) -> None:
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from collections.abc import Awaitable, Callable
4
- from typing import TYPE_CHECKING
4
+ from typing import TYPE_CHECKING, Optional
5
5
 
6
6
  from fastapi import Depends, HTTPException, Request, status
7
7
  from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
@@ -14,6 +14,10 @@ if TYPE_CHECKING:
14
14
  from regstack.models.user import BaseUser
15
15
 
16
16
  UserDependency = Callable[..., Awaitable["BaseUser"]]
17
+ # typing.Optional avoids a PEP-604 union-inside-string-forward-ref,
18
+ # which some mypy / pyright configurations refuse to resolve when
19
+ # BaseUser is only imported under TYPE_CHECKING.
20
+ OptionalUserDependency = Callable[..., Awaitable[Optional["BaseUser"]]]
17
21
 
18
22
  _bearer = HTTPBearer(auto_error=False)
19
23
 
@@ -82,6 +86,37 @@ class AuthDependencies:
82
86
 
83
87
  return _dep
84
88
 
89
+ def current_user_optional(self) -> OptionalUserDependency:
90
+ """Return a FastAPI dependency that yields the user or ``None``.
91
+
92
+ For endpoints that render differently for authenticated vs
93
+ anonymous callers (think "show your cart icon if logged in").
94
+ Treats every form of auth failure — missing header, bad scheme,
95
+ expired token, revoked jti, deleted user, bulk-revoked session
96
+ — as anonymous and returns ``None`` rather than raising 401.
97
+
98
+ On success the user is stashed on
99
+ ``request.state.regstack_user`` (same as :meth:`current_user`),
100
+ so downstream middleware sees the same shape for either path.
101
+
102
+ Returns:
103
+ A callable suitable for ``Depends(...)``. Never raises an
104
+ ``HTTPException`` — auth problems collapse to ``None``.
105
+ """
106
+
107
+ async def _dep(
108
+ request: Request,
109
+ creds: HTTPAuthorizationCredentials | None = Depends(_bearer),
110
+ ) -> BaseUser | None:
111
+ try:
112
+ user = await self._authenticate(creds)
113
+ except HTTPException:
114
+ return None
115
+ request.state.regstack_user = user
116
+ return user
117
+
118
+ return _dep
119
+
85
120
  def current_admin(self) -> UserDependency:
86
121
  """Return a FastAPI dependency that yields a *superuser*.
87
122
 
@@ -43,6 +43,7 @@ if TYPE_CHECKING:
43
43
  # decoration walks the assembled APIRouter and matches by `route.path`.
44
44
  ROUTE_LIMIT_MAP: dict[str, str] = {
45
45
  "login_rate_limit": "/login",
46
+ "login_mfa_confirm_rate_limit": "/login/mfa-confirm",
46
47
  "register_rate_limit": "/register",
47
48
  "forgot_password_rate_limit": "/forgot-password",
48
49
  "reset_password_rate_limit": "/reset-password",
@@ -52,6 +53,7 @@ ROUTE_LIMIT_MAP: dict[str, str] = {
52
53
  "change_email_rate_limit": "/change-email",
53
54
  "confirm_email_change_rate_limit": "/confirm-email-change",
54
55
  "delete_account_rate_limit": "/account",
56
+ "oauth_exchange_rate_limit": "/oauth/exchange",
55
57
  }
56
58
 
57
59
 
@@ -0,0 +1,192 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ from pymongo import ASCENDING, IndexModel
7
+
8
+ if TYPE_CHECKING:
9
+ from pymongo.asynchronous.database import AsyncDatabase
10
+
11
+ from regstack.backends.mongo.client import MongoDoc
12
+ from regstack.config.schema import RegStackConfig
13
+
14
+ log = logging.getLogger(__name__)
15
+
16
+
17
+ async def install_indexes(db: AsyncDatabase[MongoDoc], config: RegStackConfig) -> None:
18
+ """Create the indexes regstack relies on. Safe to call repeatedly."""
19
+ users = db[config.user_collection]
20
+ await _drop_conflicting_email_index(users)
21
+ await users.create_indexes(
22
+ [IndexModel([("email", ASCENDING)], unique=True, name="email_unique")]
23
+ )
24
+
25
+ blacklist = db[config.blacklist_collection]
26
+ # TTL on `exp` lets MongoDB reap revoked tokens when they would have
27
+ # expired anyway. expireAfterSeconds=0 means "delete when the date is
28
+ # in the past" — the value at `exp` is the deletion deadline.
29
+ await blacklist.create_indexes(
30
+ [
31
+ IndexModel([("jti", ASCENDING)], unique=True, name="jti_unique"),
32
+ IndexModel([("exp", ASCENDING)], expireAfterSeconds=0, name="exp_ttl"),
33
+ ]
34
+ )
35
+
36
+ pending = db[config.pending_collection]
37
+ await pending.create_indexes(
38
+ [
39
+ IndexModel([("email", ASCENDING)], unique=True, name="pending_email_unique"),
40
+ IndexModel([("token_hash", ASCENDING)], unique=True, name="pending_token_unique"),
41
+ IndexModel([("expires_at", ASCENDING)], expireAfterSeconds=0, name="pending_ttl"),
42
+ ]
43
+ )
44
+
45
+ attempts = db[config.login_attempt_collection]
46
+ # Sparse-ish TTL — rows survive `login_lockout_window_seconds` after
47
+ # `when`. The TTL value comes from config so tightening the lockout
48
+ # window also tightens cleanup.
49
+ await attempts.create_indexes(
50
+ [
51
+ IndexModel([("email", ASCENDING), ("when", ASCENDING)], name="email_when"),
52
+ IndexModel(
53
+ [("when", ASCENDING)],
54
+ expireAfterSeconds=config.login_lockout_window_seconds,
55
+ name="when_ttl",
56
+ ),
57
+ ]
58
+ )
59
+
60
+ mfa = db[config.mfa_code_collection]
61
+ await mfa.create_indexes(
62
+ [
63
+ IndexModel(
64
+ [("user_id", ASCENDING), ("kind", ASCENDING)],
65
+ unique=True,
66
+ name="user_kind_unique",
67
+ ),
68
+ IndexModel([("expires_at", ASCENDING)], expireAfterSeconds=0, name="mfa_ttl"),
69
+ ]
70
+ )
71
+
72
+ oauth_identities = db[config.oauth_identity_collection]
73
+ await oauth_identities.create_indexes(
74
+ [
75
+ IndexModel(
76
+ [("provider", ASCENDING), ("subject_id", ASCENDING)],
77
+ unique=True,
78
+ name="provider_subject_unique",
79
+ ),
80
+ IndexModel(
81
+ [("user_id", ASCENDING), ("provider", ASCENDING)],
82
+ unique=True,
83
+ name="user_provider_unique",
84
+ ),
85
+ ]
86
+ )
87
+
88
+ oauth_states = db[config.oauth_state_collection]
89
+ await oauth_states.create_indexes(
90
+ [
91
+ IndexModel(
92
+ [("expires_at", ASCENDING)],
93
+ expireAfterSeconds=0,
94
+ name="oauth_state_ttl",
95
+ ),
96
+ ]
97
+ )
98
+ await _ensure_oauth_states_validator(db, config.oauth_state_collection)
99
+
100
+ log.info("regstack indexes installed on database %s", db.name)
101
+
102
+
103
+ async def _drop_conflicting_email_index(users: Any) -> None:
104
+ """Drop an unnamed unique index on ``email`` left over from a host's
105
+ pre-regstack auth code.
106
+
107
+ Mongo cannot rename an index in place. Hosts that previously ran
108
+ ``db.users.create_index([("email", ASCENDING)], unique=True)`` from
109
+ their own code end up with the auto-generated name ``email_1`` on
110
+ the same key + uniqueness regstack wants under ``email_unique``.
111
+ A fresh ``install_indexes`` then raises ``IndexOptionsConflict``
112
+ on its first boot under regstack.
113
+
114
+ This helper detects ANY index over exactly ``{"email": 1}`` with
115
+ ``unique=True`` that is not already named ``email_unique``, and
116
+ drops it so the canonical-name create on the next line succeeds.
117
+ We deliberately do not require the legacy name to be exactly
118
+ ``email_1`` — any other rename (e.g. a host that named theirs
119
+ ``users_email_uq``) would hit the same conflict.
120
+
121
+ Idempotent — re-running on a healthy database is a no-op because
122
+ the loop only matches indexes that aren't already the canonical
123
+ one. Safe even if the collection doesn't exist yet (the
124
+ ``index_information`` call returns an empty dict).
125
+ """
126
+ try:
127
+ existing = await users.index_information()
128
+ except Exception: # pragma: no cover — defensive; missing namespace
129
+ return
130
+
131
+ for name, info in existing.items():
132
+ if name in ("_id_", "email_unique"):
133
+ continue
134
+ key = info.get("key")
135
+ if key != [("email", 1)]:
136
+ continue
137
+ if not info.get("unique"):
138
+ continue
139
+ log.warning(
140
+ "Dropping legacy unique-on-email index %r on %s.users to "
141
+ "make room for email_unique (regstack canonical name).",
142
+ name,
143
+ users.database.name,
144
+ )
145
+ await users.drop_index(name)
146
+
147
+
148
+ async def _ensure_oauth_states_validator(db: AsyncDatabase[MongoDoc], collection_name: str) -> None:
149
+ """Pin ``oauth_states.mode`` to ``signin`` / ``link`` at the DB level.
150
+
151
+ Mirrors the SQL backend's ``CheckConstraint("mode IN ('signin', 'link')")``.
152
+ ``OAuthState.model_validate()`` already enforces this at the application
153
+ layer on every read, so this is defence-in-depth rather than a closed
154
+ exploit path — flagged as I-5 in the 2026-05-15 / 2026-05-16 security
155
+ reviews.
156
+
157
+ Uses ``validationLevel="moderate"`` so re-running ``install_indexes``
158
+ on a populated collection does not retroactively reject pre-existing
159
+ rows; the constraint applies to inserts and updates that touch
160
+ ``mode``.
161
+ """
162
+ from pymongo.errors import OperationFailure
163
+
164
+ validator = {
165
+ "$jsonSchema": {
166
+ "bsonType": "object",
167
+ "properties": {
168
+ "mode": {"enum": ["signin", "link"]},
169
+ },
170
+ }
171
+ }
172
+ try:
173
+ await db.command(
174
+ {
175
+ "collMod": collection_name,
176
+ "validator": validator,
177
+ "validationLevel": "moderate",
178
+ "validationAction": "error",
179
+ }
180
+ )
181
+ except OperationFailure as exc:
182
+ # collMod fails on a non-existent collection. Create it with the
183
+ # validator attached instead; either path leaves the collection
184
+ # with the schema in place.
185
+ if "NamespaceNotFound" not in str(exc) and exc.code != 26: # 26 = NamespaceNotFound
186
+ raise
187
+ await db.create_collection(
188
+ collection_name,
189
+ validator=validator,
190
+ validationLevel="moderate",
191
+ validationAction="error",
192
+ )