regstack 0.8.0__tar.gz → 0.8.2__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.
- {regstack-0.8.0 → regstack-0.8.2}/PKG-INFO +2 -2
- {regstack-0.8.0 → regstack-0.8.2}/README.md +1 -1
- {regstack-0.8.0 → regstack-0.8.2}/pyproject.toml +1 -1
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/auth/lockout.py +13 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/auth/rate_limit.py +3 -0
- regstack-0.8.2/src/regstack/cli/_paths.py +84 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/cli/_results.py +9 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/cli/admin.py +9 -4
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/cli/doctor.py +130 -7
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/cli/init.py +57 -15
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/cli/migrate.py +14 -5
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/cli/validate/cli.py +3 -1
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/config/schema.py +15 -9
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/email/composer.py +4 -1
- regstack-0.8.2/src/regstack/email/templates/sms_phone_setup.txt +1 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/email/templates/verification.html +1 -1
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/email/templates/verification.txt +1 -1
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/hooks/events.py +1 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/oauth/providers/google.py +9 -1
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/routers/account.py +4 -2
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/routers/admin.py +6 -1
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/routers/login.py +24 -7
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/routers/password.py +18 -3
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/routers/phone.py +6 -1
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/routers/register.py +1 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/routers/verify.py +3 -2
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/sms/null.py +6 -5
- regstack-0.8.2/src/regstack/ui/templates/auth/_token_handoff.html +11 -0
- regstack-0.8.2/src/regstack/ui/templates/auth/email_change_confirm.html +6 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/ui/templates/auth/reset.html +1 -0
- regstack-0.8.2/src/regstack/ui/templates/auth/verify.html +6 -0
- regstack-0.8.2/src/regstack/version.py +1 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/wizard/oauth_google/cli.py +58 -14
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/wizard/oauth_google/writer.py +23 -7
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/wizard/ses/cli.py +60 -16
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/wizard/ses/writer.py +16 -6
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/wizard/theme_designer/cli.py +64 -12
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/wizard/theme_designer/writer.py +8 -3
- regstack-0.8.0/src/regstack/email/templates/sms_phone_setup.txt +0 -1
- regstack-0.8.0/src/regstack/ui/templates/auth/email_change_confirm.html +0 -10
- regstack-0.8.0/src/regstack/ui/templates/auth/verify.html +0 -10
- regstack-0.8.0/src/regstack/version.py +0 -1
- {regstack-0.8.0 → regstack-0.8.2}/.gitignore +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/CHANGELOG.md +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/LICENSE +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/NOTICE +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/SECURITY.md +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/regstack.toml.example +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/app.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/auth/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/auth/clock.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/auth/dependencies.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/auth/jwt.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/auth/mfa.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/auth/password.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/auth/tokens.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/backends/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/backends/base.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/backends/factory.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/backends/mongo/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/backends/mongo/backend.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/backends/mongo/client.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/backends/mongo/indexes.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/backends/mongo/repositories/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/backends/mongo/repositories/blacklist_repo.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/backends/mongo/repositories/login_attempt_repo.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/backends/mongo/repositories/mfa_code_repo.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/backends/mongo/repositories/oauth_identity_repo.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/backends/mongo/repositories/oauth_state_repo.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/backends/mongo/repositories/pending_repo.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/backends/mongo/repositories/user_repo.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/backends/protocols.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/backends/sql/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/backends/sql/backend.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/backends/sql/migrations/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/backends/sql/migrations/env.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/backends/sql/migrations/script.py.mako +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/backends/sql/migrations/versions/0001_initial_schema.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/backends/sql/migrations/versions/0002_oauth.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/backends/sql/repositories/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/backends/sql/repositories/blacklist_repo.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/backends/sql/repositories/login_attempt_repo.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/backends/sql/repositories/mfa_code_repo.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/backends/sql/repositories/oauth_identity_repo.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/backends/sql/repositories/oauth_state_repo.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/backends/sql/repositories/pending_repo.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/backends/sql/repositories/user_repo.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/backends/sql/schema.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/backends/sql/types.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/cli/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/cli/__main__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/cli/_runtime.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/cli/validate/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/cli/validate/capture.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/cli/validate/http.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/cli/validate/logtail.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/cli/validate/phases/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/cli/validate/phases/account.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/cli/validate/phases/cleanup.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/cli/validate/phases/core_auth.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/cli/validate/phases/feature_discover.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/cli/validate/phases/oauth.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/cli/validate/phases/password_reset.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/cli/validate/phases/reachability.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/cli/validate/phases/sms_mfa.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/cli/validate/report.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/cli/validate/runner.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/config/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/config/loader.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/config/secrets.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/email/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/email/base.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/email/console.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/email/factory.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/email/ses.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/email/smtp.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/email/templates/email_change.html +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/email/templates/email_change.subject.txt +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/email/templates/email_change.txt +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/email/templates/password_reset.html +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/email/templates/password_reset.subject.txt +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/email/templates/password_reset.txt +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/email/templates/sms_login_mfa.txt +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/email/templates/verification.subject.txt +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/hooks/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/models/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/models/_objectid.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/models/login_attempt.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/models/mfa_code.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/models/oauth_identity.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/models/oauth_state.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/models/pending_registration.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/models/user.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/oauth/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/oauth/base.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/oauth/errors.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/oauth/providers/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/oauth/registry.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/routers/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/routers/_helpers.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/routers/_schemas.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/routers/logout.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/routers/oauth.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/sms/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/sms/base.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/sms/factory.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/sms/sns.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/sms/twilio.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/ui/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/ui/pages.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/ui/static/css/core.css +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/ui/static/css/theme.css +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/ui/static/js/regstack.js +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/ui/templates/auth/forgot.html +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/ui/templates/auth/login.html +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/ui/templates/auth/me.html +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/ui/templates/auth/mfa_confirm.html +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/ui/templates/auth/oauth_complete.html +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/ui/templates/auth/register.html +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/ui/templates/base.html +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/wizard/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/wizard/oauth_google/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/wizard/oauth_google/routes.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/wizard/oauth_google/server.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/wizard/oauth_google/static/wizard.css +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/wizard/oauth_google/static/wizard.js +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/wizard/oauth_google/templates/wizard.html +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/wizard/oauth_google/validators.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/wizard/oauth_google/window.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/wizard/ses/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/wizard/ses/_aws.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/wizard/ses/routes.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/wizard/ses/server.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/wizard/ses/static/wizard.css +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/wizard/ses/static/wizard.js +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/wizard/ses/templates/wizard.html +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/wizard/ses/validators.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/wizard/ses/window.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/wizard/theme_designer/__init__.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/wizard/theme_designer/routes.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/wizard/theme_designer/server.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/wizard/theme_designer/static/designer.css +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/wizard/theme_designer/static/designer.js +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/wizard/theme_designer/templates/designer.html +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/wizard/theme_designer/validators.py +0 -0
- {regstack-0.8.0 → regstack-0.8.2}/src/regstack/wizard/theme_designer/window.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: regstack
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.2
|
|
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
|
|
@@ -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), `regstack doctor` (config validator), and `regstack validate` (end-to-end probe of a deployed install)
|
|
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 ses setup` (guided SES configuration that validates against AWS), `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), `regstack doctor` (config validator), and `regstack validate` (end-to-end probe of a deployed install)
|
|
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 ses setup` (guided SES configuration that validates against AWS), `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
|
|
|
@@ -93,6 +93,19 @@ class LockoutService:
|
|
|
93
93
|
)
|
|
94
94
|
return LockoutDecision(locked=False, retry_after_seconds=0)
|
|
95
95
|
|
|
96
|
+
async def attempts_remaining(self, email: str) -> int | None:
|
|
97
|
+
"""Return how many failures are left before lockout fires.
|
|
98
|
+
|
|
99
|
+
Used by the login route after a wrong-password 401 so we can
|
|
100
|
+
surface the same "N attempts remaining" message MFA shows.
|
|
101
|
+
Returns ``None`` when rate-limiting is disabled (tests) — the
|
|
102
|
+
route should then suppress the line entirely.
|
|
103
|
+
"""
|
|
104
|
+
if self._config.rate_limit_disabled:
|
|
105
|
+
return None
|
|
106
|
+
count = await self._attempts.count_recent(email, window=self._window, now=self._clock.now())
|
|
107
|
+
return max(self._config.login_lockout_threshold - count, 0)
|
|
108
|
+
|
|
96
109
|
async def record_failure(self, email: str, *, ip: str | None = None) -> None:
|
|
97
110
|
"""Record one failed login. No-op when rate limiting is disabled.
|
|
98
111
|
|
|
@@ -54,6 +54,9 @@ ROUTE_LIMIT_MAP: dict[str, str] = {
|
|
|
54
54
|
"confirm_email_change_rate_limit": "/confirm-email-change",
|
|
55
55
|
"delete_account_rate_limit": "/account",
|
|
56
56
|
"oauth_exchange_rate_limit": "/oauth/exchange",
|
|
57
|
+
"phone_start_rate_limit": "/phone/start",
|
|
58
|
+
"phone_confirm_rate_limit": "/phone/confirm",
|
|
59
|
+
"phone_disable_rate_limit": "/phone",
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Shared CLI helpers for resolving the regstack config target.
|
|
2
|
+
|
|
3
|
+
As of 0.8.x the canonical CLI flag is ``--config``. It accepts either:
|
|
4
|
+
|
|
5
|
+
* a path to a regstack TOML file (``./regstack.toml``), or
|
|
6
|
+
* a path to a directory containing or to-receive that file (``./conf/``).
|
|
7
|
+
|
|
8
|
+
A legacy ``--target`` flag is still accepted on commands that write
|
|
9
|
+
config (``init``, ``oauth setup``, ``ses setup``, ``theme design``);
|
|
10
|
+
using it emits a deprecation warning.
|
|
11
|
+
|
|
12
|
+
This module is the single source of truth for that resolution so each
|
|
13
|
+
command stays a thin Click wrapper.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import click
|
|
21
|
+
|
|
22
|
+
CONFIG_FILE = "regstack.toml"
|
|
23
|
+
|
|
24
|
+
_DEPRECATION_HINT = (
|
|
25
|
+
"Deprecation: --target is deprecated; use --config (accepts a file or a "
|
|
26
|
+
"directory). --target will be removed in 1.0."
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def resolve_target_dir(
|
|
31
|
+
*,
|
|
32
|
+
config: Path | None,
|
|
33
|
+
target: Path | None,
|
|
34
|
+
) -> Path:
|
|
35
|
+
"""Resolve the directory the command should write into.
|
|
36
|
+
|
|
37
|
+
``config`` is the canonical flag. ``target`` is the legacy alias;
|
|
38
|
+
if it's the one supplied, a deprecation warning is emitted to
|
|
39
|
+
stderr (once per invocation) before the value is honoured.
|
|
40
|
+
|
|
41
|
+
Raises ``click.UsageError`` if both are supplied.
|
|
42
|
+
"""
|
|
43
|
+
if config is not None and target is not None:
|
|
44
|
+
raise click.UsageError(
|
|
45
|
+
"--config and --target are mutually exclusive. Use --config "
|
|
46
|
+
"(--target is the deprecated alias)."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
if target is not None:
|
|
50
|
+
click.echo(click.style(_DEPRECATION_HINT, fg="yellow"), err=True)
|
|
51
|
+
value: Path | None = target
|
|
52
|
+
else:
|
|
53
|
+
value = config
|
|
54
|
+
|
|
55
|
+
if value is None:
|
|
56
|
+
return Path.cwd().resolve()
|
|
57
|
+
|
|
58
|
+
p = value.resolve()
|
|
59
|
+
# File or dir? If it's a file (or looks like one — name ends with .toml),
|
|
60
|
+
# operate in its parent directory.
|
|
61
|
+
if p.is_file() or p.suffix == ".toml":
|
|
62
|
+
return p.parent
|
|
63
|
+
return p
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def resolve_toml_path(config: Path | None) -> Path | None:
|
|
67
|
+
"""Resolve ``--config`` for read-only commands (doctor, migrate, create-admin).
|
|
68
|
+
|
|
69
|
+
Returns the path to the TOML file, or ``None`` if the flag was
|
|
70
|
+
omitted (caller falls back to env/cwd discovery).
|
|
71
|
+
|
|
72
|
+
If a directory is supplied, looks for ``regstack.toml`` inside it.
|
|
73
|
+
The returned path is not required to exist — load_runtime_config
|
|
74
|
+
surfaces the error with a clearer message.
|
|
75
|
+
"""
|
|
76
|
+
if config is None:
|
|
77
|
+
return None
|
|
78
|
+
p = config.resolve()
|
|
79
|
+
if p.is_dir():
|
|
80
|
+
return p / CONFIG_FILE
|
|
81
|
+
return p
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
__all__ = ["CONFIG_FILE", "resolve_target_dir", "resolve_toml_path"]
|
|
@@ -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)
|
|
@@ -6,6 +6,7 @@ from pathlib import Path
|
|
|
6
6
|
|
|
7
7
|
import click
|
|
8
8
|
|
|
9
|
+
from regstack.cli._paths import resolve_toml_path
|
|
9
10
|
from regstack.cli._runtime import open_regstack
|
|
10
11
|
|
|
11
12
|
|
|
@@ -21,17 +22,21 @@ from regstack.cli._runtime import open_regstack
|
|
|
21
22
|
)
|
|
22
23
|
@click.option(
|
|
23
24
|
"--config",
|
|
24
|
-
"
|
|
25
|
-
type=click.Path(exists=True,
|
|
25
|
+
"config_path_in",
|
|
26
|
+
type=click.Path(exists=True, path_type=Path),
|
|
26
27
|
default=None,
|
|
27
|
-
help=
|
|
28
|
+
help=(
|
|
29
|
+
"Path to regstack.toml (or a directory containing it). "
|
|
30
|
+
"Default: search cwd / $REGSTACK_CONFIG."
|
|
31
|
+
),
|
|
28
32
|
)
|
|
29
|
-
def create_admin(email: str, password: str | None,
|
|
33
|
+
def create_admin(email: str, password: str | None, config_path_in: Path | None) -> None:
|
|
30
34
|
if password is None:
|
|
31
35
|
password = click.prompt("Password", hide_input=True, confirmation_prompt=True)
|
|
32
36
|
if len(password) < 8:
|
|
33
37
|
raise click.UsageError("Password must be at least 8 characters.")
|
|
34
38
|
|
|
39
|
+
toml_path = resolve_toml_path(config_path_in)
|
|
35
40
|
asyncio.run(_run(email=email, password=password, toml_path=toml_path))
|
|
36
41
|
|
|
37
42
|
|
|
@@ -9,6 +9,7 @@ import click
|
|
|
9
9
|
import dns.resolver
|
|
10
10
|
|
|
11
11
|
from regstack.backends.factory import build_backend, detect_backend_kind
|
|
12
|
+
from regstack.cli._paths import resolve_toml_path
|
|
12
13
|
from regstack.cli._results import CheckResult
|
|
13
14
|
from regstack.cli._runtime import load_runtime_config
|
|
14
15
|
from regstack.email.factory import build_email_service
|
|
@@ -23,10 +24,13 @@ if TYPE_CHECKING:
|
|
|
23
24
|
)
|
|
24
25
|
@click.option(
|
|
25
26
|
"--config",
|
|
26
|
-
"
|
|
27
|
-
type=click.Path(exists=True,
|
|
27
|
+
"config_path_in",
|
|
28
|
+
type=click.Path(exists=True, path_type=Path),
|
|
28
29
|
default=None,
|
|
29
|
-
help=
|
|
30
|
+
help=(
|
|
31
|
+
"Path to regstack.toml (or a directory containing it). "
|
|
32
|
+
"Default: search cwd / $REGSTACK_CONFIG."
|
|
33
|
+
),
|
|
30
34
|
)
|
|
31
35
|
@click.option(
|
|
32
36
|
"--check-dns",
|
|
@@ -39,17 +43,30 @@ if TYPE_CHECKING:
|
|
|
39
43
|
default=None,
|
|
40
44
|
help="Send a probe email to this address through the configured backend.",
|
|
41
45
|
)
|
|
42
|
-
def doctor(
|
|
46
|
+
def doctor(config_path_in: Path | None, check_dns: bool, test_recipient: str | None) -> None:
|
|
47
|
+
toml_path = resolve_toml_path(config_path_in)
|
|
43
48
|
results = asyncio.run(
|
|
44
49
|
_run(toml_path=toml_path, check_dns=check_dns, test_recipient=test_recipient)
|
|
45
50
|
)
|
|
46
51
|
failed = sum(1 for r in results if not r.ok)
|
|
52
|
+
warned = sum(1 for r in results if r.warn)
|
|
47
53
|
for r in results:
|
|
48
|
-
|
|
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")
|
|
49
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)
|
|
50
63
|
if failed:
|
|
51
|
-
click.echo(click.style(f"
|
|
52
|
-
|
|
64
|
+
click.echo(click.style(f"{failed} check(s) failed.", fg="red"), err=True)
|
|
65
|
+
# Clamp to 0/1 so a shell `regstack doctor && deploy` is predictable;
|
|
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.)
|
|
69
|
+
sys.exit(1 if failed else 0)
|
|
53
70
|
|
|
54
71
|
|
|
55
72
|
async def _run(
|
|
@@ -71,6 +88,9 @@ async def _run(
|
|
|
71
88
|
|
|
72
89
|
out.append(await _check_backend(config))
|
|
73
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)
|
|
74
94
|
out.append(_check_email_factory(config))
|
|
75
95
|
|
|
76
96
|
if check_dns:
|
|
@@ -148,6 +168,109 @@ async def _check_schema(config: RegStackConfig) -> CheckResult:
|
|
|
148
168
|
await backend.aclose()
|
|
149
169
|
|
|
150
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
|
+
|
|
151
274
|
def _check_email_factory(config: RegStackConfig) -> CheckResult:
|
|
152
275
|
try:
|
|
153
276
|
service = build_email_service(config.email)
|
|
@@ -6,34 +6,65 @@ from urllib.parse import urlsplit
|
|
|
6
6
|
|
|
7
7
|
import click
|
|
8
8
|
|
|
9
|
+
from regstack.cli._paths import CONFIG_FILE, resolve_target_dir
|
|
9
10
|
from regstack.config.secrets import generate_secret
|
|
10
11
|
|
|
11
|
-
CONFIG_FILE = "regstack.toml"
|
|
12
12
|
SECRETS_FILE = "regstack.secrets.env"
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
@click.command(help="Interactive wizard that writes regstack.toml + regstack.secrets.env.")
|
|
16
|
+
@click.option(
|
|
17
|
+
"--config",
|
|
18
|
+
"config_path_in",
|
|
19
|
+
type=click.Path(path_type=Path),
|
|
20
|
+
default=None,
|
|
21
|
+
help=("Path to regstack.toml or the directory to write it into (default: current directory)."),
|
|
22
|
+
)
|
|
16
23
|
@click.option(
|
|
17
24
|
"--target",
|
|
18
|
-
"
|
|
25
|
+
"target_path_in",
|
|
19
26
|
type=click.Path(file_okay=False, dir_okay=True, path_type=Path),
|
|
20
|
-
default=
|
|
21
|
-
|
|
22
|
-
help="Directory to write config files into.",
|
|
27
|
+
default=None,
|
|
28
|
+
help="DEPRECATED: use --config. Directory to write config files into.",
|
|
23
29
|
)
|
|
24
30
|
@click.option("--force", is_flag=True, help="Overwrite existing config files without prompting.")
|
|
25
|
-
|
|
26
|
-
|
|
31
|
+
@click.option(
|
|
32
|
+
"--if-missing",
|
|
33
|
+
is_flag=True,
|
|
34
|
+
help=(
|
|
35
|
+
"Exit 0 silently when either config file already exists. Useful "
|
|
36
|
+
"for idempotent infrastructure-as-code: `regstack init --if-missing` "
|
|
37
|
+
"in a Dockerfile entrypoint produces config the first time and "
|
|
38
|
+
"no-ops on every subsequent boot."
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
def init(
|
|
42
|
+
config_path_in: Path | None,
|
|
43
|
+
target_path_in: Path | None,
|
|
44
|
+
*,
|
|
45
|
+
force: bool,
|
|
46
|
+
if_missing: bool,
|
|
47
|
+
) -> None:
|
|
48
|
+
if force and if_missing:
|
|
49
|
+
raise click.UsageError("--force and --if-missing are mutually exclusive.")
|
|
50
|
+
target_dir = resolve_target_dir(config=config_path_in, target=target_path_in)
|
|
27
51
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
28
52
|
|
|
29
53
|
config_path = target_dir / CONFIG_FILE
|
|
30
54
|
secrets_path = target_dir / SECRETS_FILE
|
|
31
55
|
|
|
32
|
-
if
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
56
|
+
if config_path.exists() or secrets_path.exists():
|
|
57
|
+
if if_missing:
|
|
58
|
+
click.echo(
|
|
59
|
+
f"Config already present at {config_path} or {secrets_path}; "
|
|
60
|
+
"no action taken (--if-missing).",
|
|
61
|
+
)
|
|
62
|
+
sys.exit(0)
|
|
63
|
+
if not force:
|
|
64
|
+
click.confirm(
|
|
65
|
+
f"Config already exists at {config_path} or {secrets_path}. Overwrite?",
|
|
66
|
+
abort=True,
|
|
67
|
+
)
|
|
37
68
|
|
|
38
69
|
click.echo(click.style("regstack init — app configuration only.\n", bold=True))
|
|
39
70
|
click.echo("This wizard never provisions infrastructure. It only writes config files.\n")
|
|
@@ -95,11 +126,12 @@ def init(target_dir: Path, *, force: bool) -> None:
|
|
|
95
126
|
type=click.Choice(["console", "smtp", "ses"]),
|
|
96
127
|
default="console",
|
|
97
128
|
)
|
|
98
|
-
if email_backend
|
|
129
|
+
if email_backend == "ses":
|
|
99
130
|
click.echo(
|
|
100
131
|
click.style(
|
|
101
|
-
|
|
102
|
-
"
|
|
132
|
+
"Tip: `regstack ses setup` is a guided wizard that validates "
|
|
133
|
+
"credentials and sender-domain verification against AWS before "
|
|
134
|
+
"writing the config. Consider that instead.",
|
|
103
135
|
fg="yellow",
|
|
104
136
|
)
|
|
105
137
|
)
|
|
@@ -116,6 +148,16 @@ def init(target_dir: Path, *, force: bool) -> None:
|
|
|
116
148
|
smtp_starttls = click.confirm("Use STARTTLS?", default=True)
|
|
117
149
|
smtp_user = click.prompt("SMTP username", default="")
|
|
118
150
|
smtp_pass = click.prompt("SMTP password", default="", hide_input=True) or None
|
|
151
|
+
if smtp_user and smtp_pass is None:
|
|
152
|
+
click.echo(
|
|
153
|
+
click.style(
|
|
154
|
+
"Warning: SMTP username set but no password — leaving "
|
|
155
|
+
"REGSTACK_EMAIL__SMTP_PASSWORD unset. Set it via the secrets "
|
|
156
|
+
"file or env var before the app starts, or authenticated "
|
|
157
|
+
"SMTP sends will fail.",
|
|
158
|
+
fg="yellow",
|
|
159
|
+
)
|
|
160
|
+
)
|
|
119
161
|
elif email_backend == "ses":
|
|
120
162
|
ses_region = click.prompt("AWS region", default="eu-west-1")
|
|
121
163
|
|
|
@@ -6,6 +6,7 @@ from pathlib import Path
|
|
|
6
6
|
import click
|
|
7
7
|
|
|
8
8
|
from regstack.backends.factory import detect_backend_kind
|
|
9
|
+
from regstack.cli._paths import resolve_toml_path
|
|
9
10
|
from regstack.cli._runtime import load_runtime_config
|
|
10
11
|
|
|
11
12
|
|
|
@@ -15,18 +16,26 @@ from regstack.cli._runtime import load_runtime_config
|
|
|
15
16
|
)
|
|
16
17
|
@click.option(
|
|
17
18
|
"--config",
|
|
18
|
-
"
|
|
19
|
-
type=click.Path(exists=True,
|
|
19
|
+
"config_path_in",
|
|
20
|
+
type=click.Path(exists=True, path_type=Path),
|
|
20
21
|
default=None,
|
|
21
|
-
help=
|
|
22
|
+
help=(
|
|
23
|
+
"Path to regstack.toml (or a directory containing it). "
|
|
24
|
+
"Default: search cwd / $REGSTACK_CONFIG."
|
|
25
|
+
),
|
|
22
26
|
)
|
|
23
27
|
@click.option(
|
|
24
28
|
"--target",
|
|
25
29
|
default="head",
|
|
26
30
|
show_default=True,
|
|
27
|
-
help=
|
|
31
|
+
help=(
|
|
32
|
+
"Alembic revision to upgrade to (e.g. 'head', '0001', '+1'). "
|
|
33
|
+
"Note: distinct from the global --target/--config flag pair on "
|
|
34
|
+
"config-writing commands."
|
|
35
|
+
),
|
|
28
36
|
)
|
|
29
|
-
def migrate(
|
|
37
|
+
def migrate(config_path_in: Path | None, target: str) -> None:
|
|
38
|
+
toml_path = resolve_toml_path(config_path_in)
|
|
30
39
|
"""Idempotent: re-running on a DB at the target revision is a no-op.
|
|
31
40
|
|
|
32
41
|
Mongo backends are silently skipped — TTL indexes are installed by
|
|
@@ -251,7 +251,9 @@ def validate(
|
|
|
251
251
|
timeout=timeout,
|
|
252
252
|
)
|
|
253
253
|
)
|
|
254
|
-
|
|
254
|
+
# Clamp to 0/1 for predictable shell composition; the per-check
|
|
255
|
+
# failure count is rendered in the report above. (Review #4.)
|
|
256
|
+
sys.exit(1 if exit_code else 0)
|
|
255
257
|
|
|
256
258
|
|
|
257
259
|
async def _run(
|
|
@@ -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 =
|
|
102
|
-
"""When True
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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):
|
|
@@ -229,10 +233,12 @@ class RegStackConfig(BaseSettings):
|
|
|
229
233
|
confirm_email_change_rate_limit: str | None = None
|
|
230
234
|
delete_account_rate_limit: str | None = None
|
|
231
235
|
oauth_exchange_rate_limit: str | None = None
|
|
232
|
-
#
|
|
233
|
-
#
|
|
234
|
-
|
|
235
|
-
|
|
236
|
+
# Phone routes send paid SMS and brute-force a 6-digit code, so
|
|
237
|
+
# they need IP throttles regardless of the per-code attempt counter
|
|
238
|
+
# in mfa_codes. (Review #6.)
|
|
239
|
+
phone_start_rate_limit: str | None = None
|
|
240
|
+
phone_confirm_rate_limit: str | None = None
|
|
241
|
+
phone_disable_rate_limit: str | None = None
|
|
236
242
|
|
|
237
243
|
# Sub-configs
|
|
238
244
|
email: EmailConfig = Field(default_factory=EmailConfig)
|
|
@@ -89,7 +89,9 @@ class MailComposer:
|
|
|
89
89
|
|
|
90
90
|
# --- Public renderers -------------------------------------------------
|
|
91
91
|
|
|
92
|
-
def verification(
|
|
92
|
+
def verification(
|
|
93
|
+
self, *, to: str, full_name: str | None, url: str, ttl_hours: int
|
|
94
|
+
) -> EmailMessage:
|
|
93
95
|
return self._compose(
|
|
94
96
|
kind="verification",
|
|
95
97
|
to=to,
|
|
@@ -97,6 +99,7 @@ class MailComposer:
|
|
|
97
99
|
"app_name": self._app_name,
|
|
98
100
|
"full_name": full_name or "",
|
|
99
101
|
"url": url,
|
|
102
|
+
"ttl_hours": ttl_hours,
|
|
100
103
|
},
|
|
101
104
|
)
|
|
102
105
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{{ app_name }} verification code: {{ code }}. Expires in {{ ttl_minutes }} minutes.
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
</head>
|
|
7
7
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; color: #222; line-height: 1.5;">
|
|
8
8
|
<p>Hi{% if full_name %} {{ full_name }}{% endif %},</p>
|
|
9
|
-
<p>Thanks for signing up to <strong>{{ app_name }}</strong>. Please confirm your email address by clicking the link below.</p>
|
|
9
|
+
<p>Thanks for signing up to <strong>{{ app_name }}</strong>. Please confirm your email address by clicking the link below. It is valid for {{ ttl_hours }} hours.</p>
|
|
10
10
|
<p><a href="{{ url }}" style="display:inline-block;padding:10px 20px;background:#222;color:#fff;text-decoration:none;border-radius:4px;">Confirm my account</a></p>
|
|
11
11
|
<p>If the button doesn't work, paste this URL into your browser:</p>
|
|
12
12
|
<p style="word-break: break-all;"><a href="{{ url }}">{{ url }}</a></p>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Hi{% if full_name %} {{ full_name }}{% endif %},
|
|
2
2
|
|
|
3
|
-
Thanks for signing up to {{ app_name }}. Please confirm your email address by visiting the link below
|
|
3
|
+
Thanks for signing up to {{ app_name }}. Please confirm your email address by visiting the link below. It is valid for {{ ttl_hours }} hours.
|
|
4
4
|
|
|
5
5
|
{{ url }}
|
|
6
6
|
|