regstack 0.8.1__tar.gz → 0.8.3__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 (185) hide show
  1. {regstack-0.8.1 → regstack-0.8.3}/CHANGELOG.md +55 -0
  2. {regstack-0.8.1 → regstack-0.8.3}/PKG-INFO +1 -1
  3. {regstack-0.8.1 → regstack-0.8.3}/pyproject.toml +4 -1
  4. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/auth/clock.py +13 -4
  5. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/mongo/repositories/oauth_state_repo.py +2 -1
  6. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/protocols.py +5 -0
  7. regstack-0.8.3/src/regstack/backends/sql/migrations/versions/0003_oauth_state_result_was_new.py +44 -0
  8. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/sql/repositories/oauth_state_repo.py +3 -1
  9. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/sql/schema.py +1 -0
  10. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/_results.py +9 -0
  11. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/doctor.py +119 -4
  12. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/config/schema.py +9 -5
  13. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/models/oauth_state.py +7 -0
  14. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/oauth/providers/google.py +33 -3
  15. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/routers/oauth.py +4 -2
  16. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/sms/null.py +6 -5
  17. regstack-0.8.3/src/regstack/version.py +1 -0
  18. regstack-0.8.1/src/regstack/version.py +0 -1
  19. {regstack-0.8.1 → regstack-0.8.3}/.gitignore +0 -0
  20. {regstack-0.8.1 → regstack-0.8.3}/LICENSE +0 -0
  21. {regstack-0.8.1 → regstack-0.8.3}/NOTICE +0 -0
  22. {regstack-0.8.1 → regstack-0.8.3}/README.md +0 -0
  23. {regstack-0.8.1 → regstack-0.8.3}/SECURITY.md +0 -0
  24. {regstack-0.8.1 → regstack-0.8.3}/regstack.toml.example +0 -0
  25. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/__init__.py +0 -0
  26. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/app.py +0 -0
  27. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/auth/__init__.py +0 -0
  28. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/auth/dependencies.py +0 -0
  29. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/auth/jwt.py +0 -0
  30. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/auth/lockout.py +0 -0
  31. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/auth/mfa.py +0 -0
  32. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/auth/password.py +0 -0
  33. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/auth/rate_limit.py +0 -0
  34. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/auth/tokens.py +0 -0
  35. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/__init__.py +0 -0
  36. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/base.py +0 -0
  37. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/factory.py +0 -0
  38. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/mongo/__init__.py +0 -0
  39. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/mongo/backend.py +0 -0
  40. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/mongo/client.py +0 -0
  41. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/mongo/indexes.py +0 -0
  42. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/mongo/repositories/__init__.py +0 -0
  43. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/mongo/repositories/blacklist_repo.py +0 -0
  44. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/mongo/repositories/login_attempt_repo.py +0 -0
  45. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/mongo/repositories/mfa_code_repo.py +0 -0
  46. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/mongo/repositories/oauth_identity_repo.py +0 -0
  47. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/mongo/repositories/pending_repo.py +0 -0
  48. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/mongo/repositories/user_repo.py +0 -0
  49. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/sql/__init__.py +0 -0
  50. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/sql/backend.py +0 -0
  51. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/sql/migrations/__init__.py +0 -0
  52. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/sql/migrations/env.py +0 -0
  53. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/sql/migrations/script.py.mako +0 -0
  54. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/sql/migrations/versions/0001_initial_schema.py +0 -0
  55. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/sql/migrations/versions/0002_oauth.py +0 -0
  56. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/sql/repositories/__init__.py +0 -0
  57. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/sql/repositories/blacklist_repo.py +0 -0
  58. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/sql/repositories/login_attempt_repo.py +0 -0
  59. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/sql/repositories/mfa_code_repo.py +0 -0
  60. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/sql/repositories/oauth_identity_repo.py +0 -0
  61. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/sql/repositories/pending_repo.py +0 -0
  62. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/sql/repositories/user_repo.py +0 -0
  63. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/backends/sql/types.py +0 -0
  64. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/__init__.py +0 -0
  65. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/__main__.py +0 -0
  66. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/_paths.py +0 -0
  67. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/_runtime.py +0 -0
  68. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/admin.py +0 -0
  69. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/init.py +0 -0
  70. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/migrate.py +0 -0
  71. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/validate/__init__.py +0 -0
  72. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/validate/capture.py +0 -0
  73. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/validate/cli.py +0 -0
  74. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/validate/http.py +0 -0
  75. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/validate/logtail.py +0 -0
  76. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/validate/phases/__init__.py +0 -0
  77. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/validate/phases/account.py +0 -0
  78. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/validate/phases/cleanup.py +0 -0
  79. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/validate/phases/core_auth.py +0 -0
  80. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/validate/phases/feature_discover.py +0 -0
  81. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/validate/phases/oauth.py +0 -0
  82. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/validate/phases/password_reset.py +0 -0
  83. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/validate/phases/reachability.py +0 -0
  84. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/validate/phases/sms_mfa.py +0 -0
  85. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/validate/report.py +0 -0
  86. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/cli/validate/runner.py +0 -0
  87. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/config/__init__.py +0 -0
  88. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/config/loader.py +0 -0
  89. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/config/secrets.py +0 -0
  90. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/email/__init__.py +0 -0
  91. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/email/base.py +0 -0
  92. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/email/composer.py +0 -0
  93. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/email/console.py +0 -0
  94. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/email/factory.py +0 -0
  95. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/email/ses.py +0 -0
  96. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/email/smtp.py +0 -0
  97. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/email/templates/email_change.html +0 -0
  98. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/email/templates/email_change.subject.txt +0 -0
  99. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/email/templates/email_change.txt +0 -0
  100. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/email/templates/password_reset.html +0 -0
  101. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/email/templates/password_reset.subject.txt +0 -0
  102. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/email/templates/password_reset.txt +0 -0
  103. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/email/templates/sms_login_mfa.txt +0 -0
  104. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/email/templates/sms_phone_setup.txt +0 -0
  105. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/email/templates/verification.html +0 -0
  106. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/email/templates/verification.subject.txt +0 -0
  107. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/email/templates/verification.txt +0 -0
  108. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/hooks/__init__.py +0 -0
  109. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/hooks/events.py +0 -0
  110. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/models/__init__.py +0 -0
  111. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/models/_objectid.py +0 -0
  112. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/models/login_attempt.py +0 -0
  113. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/models/mfa_code.py +0 -0
  114. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/models/oauth_identity.py +0 -0
  115. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/models/pending_registration.py +0 -0
  116. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/models/user.py +0 -0
  117. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/oauth/__init__.py +0 -0
  118. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/oauth/base.py +0 -0
  119. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/oauth/errors.py +0 -0
  120. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/oauth/providers/__init__.py +0 -0
  121. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/oauth/registry.py +0 -0
  122. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/routers/__init__.py +0 -0
  123. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/routers/_helpers.py +0 -0
  124. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/routers/_schemas.py +0 -0
  125. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/routers/account.py +0 -0
  126. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/routers/admin.py +0 -0
  127. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/routers/login.py +0 -0
  128. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/routers/logout.py +0 -0
  129. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/routers/password.py +0 -0
  130. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/routers/phone.py +0 -0
  131. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/routers/register.py +0 -0
  132. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/routers/verify.py +0 -0
  133. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/sms/__init__.py +0 -0
  134. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/sms/base.py +0 -0
  135. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/sms/factory.py +0 -0
  136. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/sms/sns.py +0 -0
  137. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/sms/twilio.py +0 -0
  138. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/ui/__init__.py +0 -0
  139. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/ui/pages.py +0 -0
  140. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/ui/static/css/core.css +0 -0
  141. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/ui/static/css/theme.css +0 -0
  142. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/ui/static/js/regstack.js +0 -0
  143. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/ui/templates/auth/_token_handoff.html +0 -0
  144. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/ui/templates/auth/email_change_confirm.html +0 -0
  145. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/ui/templates/auth/forgot.html +0 -0
  146. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/ui/templates/auth/login.html +0 -0
  147. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/ui/templates/auth/me.html +0 -0
  148. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/ui/templates/auth/mfa_confirm.html +0 -0
  149. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/ui/templates/auth/oauth_complete.html +0 -0
  150. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/ui/templates/auth/register.html +0 -0
  151. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/ui/templates/auth/reset.html +0 -0
  152. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/ui/templates/auth/verify.html +0 -0
  153. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/ui/templates/base.html +0 -0
  154. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/__init__.py +0 -0
  155. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/oauth_google/__init__.py +0 -0
  156. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/oauth_google/cli.py +0 -0
  157. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/oauth_google/routes.py +0 -0
  158. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/oauth_google/server.py +0 -0
  159. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/oauth_google/static/wizard.css +0 -0
  160. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/oauth_google/static/wizard.js +0 -0
  161. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/oauth_google/templates/wizard.html +0 -0
  162. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/oauth_google/validators.py +0 -0
  163. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/oauth_google/window.py +0 -0
  164. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/oauth_google/writer.py +0 -0
  165. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/ses/__init__.py +0 -0
  166. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/ses/_aws.py +0 -0
  167. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/ses/cli.py +0 -0
  168. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/ses/routes.py +0 -0
  169. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/ses/server.py +0 -0
  170. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/ses/static/wizard.css +0 -0
  171. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/ses/static/wizard.js +0 -0
  172. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/ses/templates/wizard.html +0 -0
  173. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/ses/validators.py +0 -0
  174. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/ses/window.py +0 -0
  175. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/ses/writer.py +0 -0
  176. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/theme_designer/__init__.py +0 -0
  177. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/theme_designer/cli.py +0 -0
  178. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/theme_designer/routes.py +0 -0
  179. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/theme_designer/server.py +0 -0
  180. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/theme_designer/static/designer.css +0 -0
  181. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/theme_designer/static/designer.js +0 -0
  182. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/theme_designer/templates/designer.html +0 -0
  183. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/theme_designer/validators.py +0 -0
  184. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/theme_designer/window.py +0 -0
  185. {regstack-0.8.1 → regstack-0.8.3}/src/regstack/wizard/theme_designer/writer.py +0 -0
@@ -7,6 +7,61 @@ Sphinx docs.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ## 0.8.3 — 2026-06-12
11
+
12
+ Closes out the 2026-05-15 → 2026-05-22 daily security-review series,
13
+ fixes `was_new_account` on the OAuth exchange (new migration `0003`),
14
+ and stops the test suite leaking MongoDB databases.
15
+
16
+ **Fixed: Google JWKS fetch now has a timeout.** `PyJWKClient` was
17
+ constructed without a `timeout`, so the synchronous `urllib` fetch
18
+ (offloaded to `asyncio.to_thread`) could pin a worker thread
19
+ indefinitely during a Google JWKS outage and, under sustained load,
20
+ exhaust the bounded asyncio thread pool. Added a 5-second
21
+ `JWKS_FETCH_TIMEOUT_SECONDS`. (Daily security review 2026-05-22 · W-1.)
22
+
23
+ **Fixed: Google OAuth token-exchange error no longer logs the full
24
+ provider response body at WARNING.** On a non-200 token exchange the
25
+ provider's response body is now logged at DEBUG; the raised
26
+ `OAuthTokenExchangeError` (which the router logs at WARNING) carries only
27
+ `HTTP <status>`. (Daily security review 2026-05-22 · I-3.)
28
+
29
+ **Fixed: `/oauth/exchange` now reports `was_new_account` accurately.**
30
+ The field was hardcoded `False`; the callback computed whether it created
31
+ a brand-new account but had nowhere to persist it. Added
32
+ `oauth_states.result_was_new` (migration `0003`) which the callback sets
33
+ and the exchange endpoint reads. (Daily security review 2026-05-22 · I-1.)
34
+
35
+ **Documented: `phone_number` exposure in the admin user listing.**
36
+ `docs/security.md` now spells out that `UserPublic.phone_number` is
37
+ returned in plaintext on `GET /admin/users` (regulated PII in some
38
+ jurisdictions) and that hosts wanting to mask/omit it should wrap the
39
+ admin listing in their own response model. (Daily security review
40
+ 2026-05-21 · I-2.)
41
+
42
+ **Fixed: `FrozenClock` now defaults to the far future (2125-01-01).**
43
+ MongoDB's TTL monitor uses real wall-clock time, so the old 2025-01-01
44
+ default meant every TTL-indexed test row was born already-expired and
45
+ could be reaped mid-test when a ~60s TTL sweep landed — a rare,
46
+ mongo-only parallel flake. A far-future pin keeps the reaper out of
47
+ reach while staying deterministic.
48
+
49
+ **Fixed: test runs no longer leak MongoDB databases.** Tests using the
50
+ `make_client` factory bypassed the only fixture that dropped the
51
+ per-test database, leaking one `regstack_test_*` DB per test per run.
52
+ A teardown fixture wired into `config` now drops the DB on every path,
53
+ a run-token-scoped `pytest_sessionfinish` sweep catches anything a
54
+ crashed worker leaves behind, and `inv clean-test-dbs` purges leftovers
55
+ from hard-killed runs.
56
+
57
+ **Triaged: CVE-2026-2978 / CVE-2026-2979 not applicable.** Flagged for
58
+ monitoring in the 2026-05-22 review; published NVD details show both
59
+ affect the third-party "FastApiAdmin" project, not the `fastapi`
60
+ library. regstack has no `fastapi-admin` dependency and no fastapi-core
61
+ advisories affect versions ≥ 0.120.0 (our floor). Triage note recorded
62
+ beside the floor in `pyproject.toml`. (Daily security review
63
+ 2026-05-22 · I-2.)
64
+
10
65
  ## 0.8.0 — 2026-05-19
11
66
 
12
67
  `regstack ses setup` guided wizard, plus two security fixes from
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: regstack
3
- Version: 0.8.1
3
+ Version: 0.8.3
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "regstack"
3
- version = "0.8.1"
3
+ version = "0.8.3"
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"
@@ -19,6 +19,9 @@ classifiers = [
19
19
  dependencies = [
20
20
  # fastapi>=0.120.0 picks up CVE-2025-62727 (Starlette DoS via large
21
21
  # request bodies after multipart processing).
22
+ # CVE-2026-2978 / CVE-2026-2979 (flagged for monitoring in the
23
+ # 2026-05-22 security review, I-2) affect the third-party
24
+ # "FastApiAdmin" project, not fastapi itself — no floor bump needed.
22
25
  "fastapi>=0.120.0",
23
26
  "pydantic>=2.6",
24
27
  "pydantic-settings>=2.2",
@@ -42,14 +42,23 @@ class FrozenClock:
42
42
  """
43
43
 
44
44
  def __init__(self, start: datetime | None = None) -> None:
45
- """Pin the clock at ``start`` (default 2025-01-01 UTC).
45
+ """Pin the clock at ``start`` (default 2125-01-01 UTC).
46
+
47
+ The default is deliberately ~100 years in the *future*. MongoDB's
48
+ TTL monitor deletes documents by comparing their ``expires_at``
49
+ against real wall-clock time on a ~60s cycle — it knows nothing
50
+ about this injected clock. A frozen "now" in the past would give
51
+ every TTL-indexed test row (oauth_states, pending_registrations,
52
+ mfa_codes, login_attempts, blacklist) an already-elapsed
53
+ ``expires_at``, and any test straddling a TTL sweep would have
54
+ its rows reaped mid-flight. A far-future pin keeps the reaper
55
+ permanently out of reach while staying deterministic.
46
56
 
47
57
  Args:
48
58
  start: The initial timestamp. Should be tz-aware. Defaults
49
- to ``2025-01-01T00:00:00Z`` so test datetimes are
50
- memorable.
59
+ to ``2125-01-01T00:00:00Z``.
51
60
  """
52
- self._now = start or datetime(2025, 1, 1, tzinfo=UTC)
61
+ self._now = start or datetime(2125, 1, 1, tzinfo=UTC)
53
62
 
54
63
  def now(self) -> datetime:
55
64
  """Return the currently-pinned timestamp."""
@@ -31,8 +31,9 @@ class MongoOAuthStateRepo:
31
31
  token: str,
32
32
  *,
33
33
  new_expires_at: datetime | None = None,
34
+ was_new: bool = False,
34
35
  ) -> None:
35
- updates: dict[str, Any] = {"result_token": token}
36
+ updates: dict[str, Any] = {"result_token": token, "result_was_new": was_new}
36
37
  if new_expires_at is not None:
37
38
  updates["expires_at"] = new_expires_at
38
39
  await self._collection.update_one(
@@ -295,6 +295,7 @@ class OAuthStateRepoProtocol(Protocol):
295
295
  token: str,
296
296
  *,
297
297
  new_expires_at: datetime | None = None,
298
+ was_new: bool = False,
298
299
  ) -> None:
299
300
  """Stash the session JWT after a successful callback so the
300
301
  SPA can pick it up via :meth:`consume`.
@@ -305,6 +306,10 @@ class OAuthStateRepoProtocol(Protocol):
305
306
  ``oauth.state_ttl_seconds`` (covering the round-trip with
306
307
  the provider) to ``oauth.completion_ttl_seconds`` (covering
307
308
  only the SPA's exchange call after the callback lands).
309
+
310
+ ``was_new`` records whether the callback created a brand-new
311
+ account, so the exchange response can report
312
+ ``was_new_account`` accurately.
308
313
  """
309
314
  ...
310
315
 
@@ -0,0 +1,44 @@
1
+ """Add oauth_states.result_was_new.
2
+
3
+ Revision ID: 0003
4
+ Revises: 0002
5
+ Create Date: 2026-06-04
6
+
7
+ The OAuth callback computes whether it created a brand-new account
8
+ (vs. signing an existing one in) but had nowhere to persist it, so the
9
+ ``POST /oauth/exchange`` response always reported
10
+ ``was_new_account=False``. This adds the column the callback writes and
11
+ the exchange endpoint reads (Security review 2026-05-22 · I-1).
12
+
13
+ The column is NOT NULL with a ``False`` server default so the in-flight
14
+ state rows that exist at migration time (if any) get a sensible value
15
+ without a backfill. ``batch_alter_table`` keeps the ADD COLUMN safe on
16
+ SQLite.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import sqlalchemy as sa
22
+ from alembic import op
23
+
24
+ revision: str = "0003"
25
+ down_revision: str | None = "0002"
26
+ branch_labels: tuple[str, ...] | None = None
27
+ depends_on: str | None = None
28
+
29
+
30
+ def upgrade() -> None:
31
+ with op.batch_alter_table("oauth_states") as batch_op:
32
+ batch_op.add_column(
33
+ sa.Column(
34
+ "result_was_new",
35
+ sa.Boolean(),
36
+ nullable=False,
37
+ server_default=sa.false(),
38
+ )
39
+ )
40
+
41
+
42
+ def downgrade() -> None:
43
+ with op.batch_alter_table("oauth_states") as batch_op:
44
+ batch_op.drop_column("result_was_new")
@@ -34,6 +34,7 @@ class SqlOAuthStateRepo:
34
34
  "created_at": state.created_at,
35
35
  "expires_at": state.expires_at,
36
36
  "result_token": state.result_token,
37
+ "result_was_new": state.result_was_new,
37
38
  }
38
39
  async with self._engine.begin() as conn:
39
40
  await conn.execute(self._t.insert().values(values))
@@ -50,8 +51,9 @@ class SqlOAuthStateRepo:
50
51
  token: str,
51
52
  *,
52
53
  new_expires_at: datetime | None = None,
54
+ was_new: bool = False,
53
55
  ) -> None:
54
- values: dict[str, Any] = {"result_token": token}
56
+ values: dict[str, Any] = {"result_token": token, "result_was_new": was_new}
55
57
  if new_expires_at is not None:
56
58
  values["expires_at"] = new_expires_at
57
59
  stmt = update(self._t).where(self._t.c.id == state_id).values(**values)
@@ -162,6 +162,7 @@ def _oauth_states(table_name: str) -> Table:
162
162
  Column("created_at", UtcDateTime(), nullable=False),
163
163
  Column("expires_at", UtcDateTime(), nullable=False),
164
164
  Column("result_token", Text, nullable=True),
165
+ Column("result_was_new", Boolean, nullable=False, default=False),
165
166
  Index("ix_oauth_states_expires_at", "expires_at"),
166
167
  CheckConstraint("mode IN ('signin', 'link')", name="mode_valid"),
167
168
  )
@@ -15,6 +15,7 @@ class CheckResult:
15
15
  ok: bool
16
16
  detail: str
17
17
  skipped: bool = False
18
+ warn: bool = False
18
19
 
19
20
  @classmethod
20
21
  def passed(cls, name: str, detail: str) -> CheckResult:
@@ -27,3 +28,11 @@ class CheckResult:
27
28
  @classmethod
28
29
  def skip(cls, name: str, detail: str) -> CheckResult:
29
30
  return cls(name=name, ok=True, detail=detail, skipped=True)
31
+
32
+ @classmethod
33
+ def warned(cls, name: str, detail: str) -> CheckResult:
34
+ """An advisory finding: not a hard failure (``ok=True`` so it
35
+ doesn't fail the command), but surfaced distinctly so operators
36
+ notice it. Used for things outside regstack's control that the
37
+ operator should act on — e.g. an out-of-date database server."""
38
+ return cls(name=name, ok=True, detail=detail, warn=True)
@@ -49,14 +49,23 @@ def doctor(config_path_in: Path | None, check_dns: bool, test_recipient: str | N
49
49
  _run(toml_path=toml_path, check_dns=check_dns, test_recipient=test_recipient)
50
50
  )
51
51
  failed = sum(1 for r in results if not r.ok)
52
+ warned = sum(1 for r in results if r.warn)
52
53
  for r in results:
53
- symbol = click.style("✔", fg="green") if r.ok else click.style("✘", fg="red")
54
+ if r.warn:
55
+ symbol = click.style("⚠", fg="yellow")
56
+ elif r.ok:
57
+ symbol = click.style("✔", fg="green")
58
+ else:
59
+ symbol = click.style("✘", fg="red")
54
60
  click.echo(f"{symbol} {r.name}: {r.detail}")
61
+ if warned:
62
+ click.echo(click.style(f"\n{warned} advisory warning(s).", fg="yellow"), err=True)
55
63
  if failed:
56
- click.echo(click.style(f"\n{failed} check(s) failed.", fg="red"), err=True)
64
+ click.echo(click.style(f"{failed} check(s) failed.", fg="red"), err=True)
57
65
  # Clamp to 0/1 so a shell `regstack doctor && deploy` is predictable;
58
- # the failure count appears on the stderr line above for operators
59
- # who want it. (Review #4.)
66
+ # advisory warnings (e.g. an out-of-date DB server) do NOT fail the
67
+ # command they're surfaced but exit 0. The failure count appears on
68
+ # the stderr line above for operators who want it. (Review #4.)
60
69
  sys.exit(1 if failed else 0)
61
70
 
62
71
 
@@ -79,6 +88,9 @@ async def _run(
79
88
 
80
89
  out.append(await _check_backend(config))
81
90
  out.append(await _check_schema(config))
91
+ mongo_version = await _check_mongo_server_version(config)
92
+ if mongo_version is not None:
93
+ out.append(mongo_version)
82
94
  out.append(_check_email_factory(config))
83
95
 
84
96
  if check_dns:
@@ -156,6 +168,109 @@ async def _check_schema(config: RegStackConfig) -> CheckResult:
156
168
  await backend.aclose()
157
169
 
158
170
 
171
+ # CVE-2025-14847 ("MongoBleed", CVSS 8.7): an unauthenticated network
172
+ # attacker can leak server memory via crafted zlib-compressed messages.
173
+ # The pymongo driver is not the vulnerable component — the server binary
174
+ # is. Patched server releases by LTS track. Keyed (major, minor) →
175
+ # minimum safe (major, minor, patch). See docs/security-reports/2026-05-20.md.
176
+ _MONGO_PATCHED_BASELINE: dict[tuple[int, int], tuple[int, int, int]] = {
177
+ (8, 2): (8, 2, 3),
178
+ (8, 0): (8, 0, 17),
179
+ (7, 0): (7, 0, 28),
180
+ (6, 0): (6, 0, 27),
181
+ (5, 0): (5, 0, 32),
182
+ (4, 4): (4, 4, 30),
183
+ }
184
+
185
+ # The newest LTS track we have a baseline for. A server on a track newer
186
+ # than this (e.g. 8.3+, 9.x) post-dates the advisory and is treated as safe.
187
+ _MONGO_NEWEST_KNOWN_TRACK = max(_MONGO_PATCHED_BASELINE)
188
+ # The oldest LTS track the advisory lists. Anything below it (3.6, 4.0,
189
+ # 4.2, …) is EOL and below every patched release — warn.
190
+ _MONGO_OLDEST_KNOWN_TRACK = min(_MONGO_PATCHED_BASELINE)
191
+
192
+
193
+ def _parse_mongo_version(version: str) -> tuple[int, int, int] | None:
194
+ """Parse a MongoDB version string like ``"7.0.5"`` into ``(7, 0, 5)``.
195
+
196
+ Tolerates a release-candidate / pre-release suffix (``"8.0.0-rc1"``)
197
+ by splitting on the first ``-``. Returns ``None`` if the leading
198
+ three dotted components aren't all integers.
199
+ """
200
+ core = version.split("-", 1)[0]
201
+ parts = core.split(".")
202
+ if len(parts) < 3:
203
+ return None
204
+ try:
205
+ return (int(parts[0]), int(parts[1]), int(parts[2]))
206
+ except ValueError:
207
+ return None
208
+
209
+
210
+ def _assess_mongo_server_version(version: str) -> tuple[bool, str]:
211
+ """Decide whether ``version`` is safe against CVE-2025-14847.
212
+
213
+ Returns ``(ok, detail)``. ``ok`` is False only when the server is on
214
+ a known-affected LTS track *below* its patched baseline — that's the
215
+ case worth a WARNING. Newer-than-advisory and patched servers return
216
+ True; an unrecognised/older track returns True with a "verify
217
+ manually" note rather than crying wolf on every odd build string.
218
+ """
219
+ parsed = _parse_mongo_version(version)
220
+ if parsed is None:
221
+ return True, f"server {version} (could not parse version; verify against CVE-2025-14847)"
222
+ track = (parsed[0], parsed[1])
223
+ baseline = _MONGO_PATCHED_BASELINE.get(track)
224
+ if baseline is not None:
225
+ if parsed < baseline:
226
+ patched = ".".join(str(n) for n in baseline)
227
+ return False, (
228
+ f"server {version} is vulnerable to CVE-2025-14847 (MongoBleed); "
229
+ f"upgrade to ≥ {patched}, or disable zlib compression in mongod.conf"
230
+ )
231
+ return True, f"server {version} (≥ {'.'.join(str(n) for n in baseline)}, patched)"
232
+ if track > _MONGO_NEWEST_KNOWN_TRACK:
233
+ return True, f"server {version} (newer than CVE-2025-14847 advisory tracks)"
234
+ if track < _MONGO_OLDEST_KNOWN_TRACK:
235
+ return False, (
236
+ f"server {version} is end-of-life and below every CVE-2025-14847 "
237
+ "patched release; upgrade to a supported, patched MongoDB version"
238
+ )
239
+ return True, (
240
+ f"server {version} on an unrecognised release track; verify against CVE-2025-14847 manually"
241
+ )
242
+
243
+
244
+ async def _check_mongo_server_version(config: RegStackConfig) -> CheckResult | None:
245
+ """Advisory check: warn if the MongoDB *server* is below the
246
+ CVE-2025-14847 patched baseline. Returns None for non-Mongo backends
247
+ (the check doesn't apply).
248
+ """
249
+ from regstack.backends.base import BackendKind
250
+
251
+ backend = build_backend(config)
252
+ try:
253
+ if backend.kind is not BackendKind.MONGO:
254
+ return None
255
+ from regstack.backends.mongo import MongoBackend
256
+
257
+ assert isinstance(backend, MongoBackend)
258
+ info = await backend.database.command("buildInfo")
259
+ version = str(info.get("version", ""))
260
+ if not version:
261
+ return CheckResult.warned(
262
+ "mongo server", "buildInfo returned no version; verify against CVE-2025-14847"
263
+ )
264
+ ok, detail = _assess_mongo_server_version(version)
265
+ if ok:
266
+ return CheckResult.passed("mongo server", detail)
267
+ return CheckResult.warned("mongo server", detail)
268
+ except Exception as exc:
269
+ return CheckResult.warned("mongo server", f"version check failed: {exc}")
270
+ finally:
271
+ await backend.aclose()
272
+
273
+
159
274
  def _check_email_factory(config: RegStackConfig) -> CheckResult:
160
275
  try:
161
276
  service = build_email_service(config.email)
@@ -98,11 +98,15 @@ class SmsConfig(BaseModel):
98
98
  twilio_account_sid: str | None = None
99
99
  twilio_auth_token: SecretStr | None = None
100
100
 
101
- log_bodies: bool = True
102
- """When True (default), the ``null`` backend logs the SMS body
103
- (including the 6-digit code) at INFO. Set to False to silence
104
- code logging in shared environments. Other backends ignore this
105
- flag they never log message bodies."""
101
+ log_bodies: bool = False
102
+ """When True, the ``null`` backend logs the SMS body (including the
103
+ 6-digit MFA code) at INFO. Defaults to False so a misconfigured
104
+ deployment can't leak codes into shared logs symmetric with
105
+ ``email.log_bodies``. Set it True for local dev when you want to
106
+ read the code out of stdout (the bundled examples instead surface
107
+ it via an ``mfa_login_started`` hook, so they don't need this).
108
+ Other backends ignore this flag — they never log message bodies.
109
+ (Security review 2026-05-19.)"""
106
110
 
107
111
 
108
112
  class OAuthConfig(BaseModel):
@@ -77,6 +77,13 @@ class OAuthState(BaseModel):
77
77
  ``id`` for this value via ``POST /oauth/exchange``; the row is
78
78
  deleted on exchange."""
79
79
 
80
+ result_was_new: bool = False
81
+ """Whether the callback created a brand-new account (vs. signing in
82
+ an existing one). Set alongside ``result_token`` so the
83
+ ``POST /oauth/exchange`` response can surface ``was_new_account``
84
+ accurately — the callback computes this but had no field to persist
85
+ it on before (Security review 2026-05-22 · I-1)."""
86
+
80
87
  def to_mongo(self) -> dict[str, Any]:
81
88
  data = self.model_dump(by_alias=True, exclude_none=True)
82
89
  return data
@@ -16,6 +16,8 @@ installed and turns ``enable_oauth`` on.
16
16
 
17
17
  from __future__ import annotations
18
18
 
19
+ import asyncio
20
+ import logging
19
21
  from typing import TYPE_CHECKING, Any
20
22
  from urllib.parse import urlencode
21
23
 
@@ -29,6 +31,8 @@ from regstack.oauth.errors import OAuthIdTokenError, OAuthTokenExchangeError
29
31
  if TYPE_CHECKING:
30
32
  from collections.abc import Iterable
31
33
 
34
+ log = logging.getLogger("regstack.oauth.google")
35
+
32
36
  GOOGLE_ISSUER = "https://accounts.google.com"
33
37
  GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
34
38
  GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
@@ -36,6 +40,13 @@ GOOGLE_JWKS_URL = "https://www.googleapis.com/oauth2/v3/certs"
36
40
 
37
41
  DEFAULT_SCOPES: tuple[str, ...] = ("openid", "email", "profile")
38
42
 
43
+ # Bound the synchronous JWKS fetch so a slow or unreachable Google JWKS
44
+ # endpoint can't pin a worker thread indefinitely. The fetch is offloaded
45
+ # to `asyncio.to_thread`, but `urllib` defaults to no timeout — under a
46
+ # sustained JWKS outage that would let cache-miss requests exhaust the
47
+ # bounded asyncio thread pool. (Security review 2026-05-22 · W-1.)
48
+ JWKS_FETCH_TIMEOUT_SECONDS = 5
49
+
39
50
 
40
51
  class GoogleProvider(OAuthProvider):
41
52
  """OIDC provider for Google.
@@ -79,7 +90,9 @@ class GoogleProvider(OAuthProvider):
79
90
  self._owns_http = http is None
80
91
  self._issuer = issuer
81
92
  self._scopes = tuple(scopes)
82
- self._jwks_client = PyJWKClient(jwks_url, cache_keys=True)
93
+ self._jwks_client = PyJWKClient(
94
+ jwks_url, cache_keys=True, timeout=JWKS_FETCH_TIMEOUT_SECONDS
95
+ )
83
96
 
84
97
  @property
85
98
  def name(self) -> str:
@@ -140,8 +153,18 @@ class GoogleProvider(OAuthProvider):
140
153
  if self._owns_http and client is not self._http:
141
154
  await client.aclose()
142
155
  if response.status_code != 200:
156
+ # Keep the provider's response body at DEBUG only. It doesn't
157
+ # carry our client secret or the auth code, but Google's error
158
+ # bodies are verbose and there's no reason to put them in
159
+ # production WARNING logs — the status code is the actionable
160
+ # signal. (Security review 2026-05-22 · I-3.)
161
+ log.debug(
162
+ "google token exchange response body (HTTP %s): %s",
163
+ response.status_code,
164
+ response.text,
165
+ )
143
166
  raise OAuthTokenExchangeError(
144
- f"google token exchange failed: {response.status_code} {response.text}"
167
+ f"google token exchange failed: HTTP {response.status_code}"
145
168
  )
146
169
  body: dict[str, Any] = response.json()
147
170
  try:
@@ -162,7 +185,14 @@ class GoogleProvider(OAuthProvider):
162
185
  expected_nonce: str,
163
186
  ) -> OAuthUserInfo:
164
187
  try:
165
- signing_key = self._jwks_client.get_signing_key_from_jwt(id_token).key
188
+ # PyJWKClient's fetch is synchronous urllib; on a cache miss it
189
+ # would block the event loop for the round-trip to Google's JWKS
190
+ # endpoint. Push it to a worker thread so concurrent requests
191
+ # aren't stalled. (Security review 2026-05-20 · I-2.)
192
+ signing_key_obj = await asyncio.to_thread(
193
+ self._jwks_client.get_signing_key_from_jwt, id_token
194
+ )
195
+ signing_key = signing_key_obj.key
166
196
  except Exception as exc: # PyJWKClient raises a grab-bag — collapse to ours
167
197
  raise OAuthIdTokenError(f"jwks lookup failed: {exc}") from exc
168
198
  try:
@@ -168,7 +168,7 @@ def build_oauth_router(rs: RegStack) -> APIRouter:
168
168
  if _is_mfa_pending_token(state.result_token):
169
169
  return ExchangeResponse(
170
170
  redirect_to=state.redirect_to,
171
- was_new_account=False,
171
+ was_new_account=state.result_was_new,
172
172
  expires_in=rs.config.mfa_pending_token_ttl_seconds,
173
173
  mfa_required=True,
174
174
  mfa_pending_token=state.result_token,
@@ -176,7 +176,7 @@ def build_oauth_router(rs: RegStack) -> APIRouter:
176
176
  return ExchangeResponse(
177
177
  access_token=state.result_token,
178
178
  redirect_to=state.redirect_to,
179
- was_new_account=False,
179
+ was_new_account=state.result_was_new,
180
180
  expires_in=rs.config.jwt_ttl_seconds,
181
181
  )
182
182
 
@@ -361,6 +361,7 @@ def build_oauth_router(rs: RegStack) -> APIRouter:
361
361
  pending_token,
362
362
  new_expires_at=rs.clock.now()
363
363
  + timedelta(seconds=rs.config.mfa_pending_token_ttl_seconds),
364
+ was_new=was_new,
364
365
  )
365
366
  else:
366
367
  # Mint the session JWT, stash it on the state row for the
@@ -376,6 +377,7 @@ def build_oauth_router(rs: RegStack) -> APIRouter:
376
377
  token,
377
378
  new_expires_at=rs.clock.now()
378
379
  + timedelta(seconds=rs.config.oauth.completion_ttl_seconds),
380
+ was_new=was_new,
379
381
  )
380
382
 
381
383
  await rs.hooks.fire(
@@ -10,14 +10,15 @@ log = logging.getLogger("regstack.sms.null")
10
10
  class NullSmsService(SmsService):
11
11
  """Default backend. Records messages in ``self.outbox`` so tests and dev
12
12
  runs can inspect them without contacting a real SMS gateway. Logs each
13
- send at INFO so the demo can grep the code out of stdout.
13
+ send at INFO.
14
14
 
15
- ``log_bodies`` (default True) controls whether the message body
16
- (containing the 6-digit code) is included in the log line. Operators
17
- in shared environments who want call-only audit lines can flip it off.
15
+ ``log_bodies`` (default False) controls whether the message body
16
+ (containing the 6-digit code) is included in the log line. It defaults
17
+ off so a misconfigured deployment can't leak codes into shared logs;
18
+ flip it on for local dev when you want to read the code out of stdout.
18
19
  """
19
20
 
20
- def __init__(self, *, log_bodies: bool = True) -> None:
21
+ def __init__(self, *, log_bodies: bool = False) -> None:
21
22
  self.outbox: list[SmsMessage] = []
22
23
  self._log_bodies = log_bodies
23
24
 
@@ -0,0 +1 @@
1
+ __version__ = "0.8.3"
@@ -1 +0,0 @@
1
- __version__ = "0.8.1"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes