regstack 0.7.0__tar.gz → 0.8.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {regstack-0.7.0 → regstack-0.8.1}/CHANGELOG.md +33 -0
- {regstack-0.7.0 → regstack-0.8.1}/PKG-INFO +2 -2
- {regstack-0.7.0 → regstack-0.8.1}/README.md +1 -1
- {regstack-0.7.0 → regstack-0.8.1}/pyproject.toml +1 -1
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/auth/lockout.py +13 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/auth/rate_limit.py +3 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/cli/__main__.py +52 -15
- regstack-0.8.1/src/regstack/cli/_paths.py +84 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/cli/admin.py +9 -4
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/cli/doctor.py +13 -5
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/cli/init.py +57 -15
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/cli/migrate.py +14 -5
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/cli/validate/cli.py +3 -1
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/config/schema.py +6 -4
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/email/composer.py +4 -1
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/email/ses.py +5 -1
- regstack-0.8.1/src/regstack/email/templates/sms_phone_setup.txt +1 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/email/templates/verification.html +1 -1
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/email/templates/verification.txt +1 -1
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/hooks/events.py +1 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/oauth/providers/google.py +1 -1
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/routers/account.py +4 -2
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/routers/admin.py +6 -1
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/routers/login.py +24 -7
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/routers/password.py +18 -3
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/routers/phone.py +6 -1
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/routers/register.py +1 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/routers/verify.py +3 -2
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/sms/sns.py +1 -1
- regstack-0.8.1/src/regstack/ui/templates/auth/_token_handoff.html +11 -0
- regstack-0.8.1/src/regstack/ui/templates/auth/email_change_confirm.html +6 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/ui/templates/auth/reset.html +1 -0
- regstack-0.8.1/src/regstack/ui/templates/auth/verify.html +6 -0
- regstack-0.8.1/src/regstack/version.py +1 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/wizard/oauth_google/cli.py +58 -14
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/wizard/oauth_google/writer.py +23 -7
- regstack-0.8.1/src/regstack/wizard/ses/__init__.py +5 -0
- regstack-0.8.1/src/regstack/wizard/ses/_aws.py +315 -0
- regstack-0.8.1/src/regstack/wizard/ses/cli.py +268 -0
- regstack-0.8.1/src/regstack/wizard/ses/routes.py +367 -0
- regstack-0.8.1/src/regstack/wizard/ses/server.py +87 -0
- regstack-0.8.1/src/regstack/wizard/ses/static/wizard.css +125 -0
- regstack-0.8.1/src/regstack/wizard/ses/static/wizard.js +266 -0
- regstack-0.8.1/src/regstack/wizard/ses/templates/wizard.html +25 -0
- regstack-0.8.1/src/regstack/wizard/ses/validators.py +319 -0
- regstack-0.8.1/src/regstack/wizard/ses/window.py +59 -0
- regstack-0.8.1/src/regstack/wizard/ses/writer.py +277 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/wizard/theme_designer/cli.py +64 -12
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/wizard/theme_designer/templates/designer.html +2 -2
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/wizard/theme_designer/writer.py +8 -3
- regstack-0.7.0/src/regstack/email/templates/sms_phone_setup.txt +0 -1
- regstack-0.7.0/src/regstack/ui/templates/auth/email_change_confirm.html +0 -10
- regstack-0.7.0/src/regstack/ui/templates/auth/verify.html +0 -10
- regstack-0.7.0/src/regstack/version.py +0 -1
- {regstack-0.7.0 → regstack-0.8.1}/.gitignore +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/LICENSE +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/NOTICE +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/SECURITY.md +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/regstack.toml.example +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/app.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/auth/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/auth/clock.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/auth/dependencies.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/auth/jwt.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/auth/mfa.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/auth/password.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/auth/tokens.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/backends/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/backends/base.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/backends/factory.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/backends/mongo/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/backends/mongo/backend.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/backends/mongo/client.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/backends/mongo/indexes.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/backends/mongo/repositories/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/backends/mongo/repositories/blacklist_repo.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/backends/mongo/repositories/login_attempt_repo.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/backends/mongo/repositories/mfa_code_repo.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/backends/mongo/repositories/oauth_identity_repo.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/backends/mongo/repositories/oauth_state_repo.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/backends/mongo/repositories/pending_repo.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/backends/mongo/repositories/user_repo.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/backends/protocols.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/backends/sql/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/backends/sql/backend.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/backends/sql/migrations/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/backends/sql/migrations/env.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/backends/sql/migrations/script.py.mako +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/backends/sql/migrations/versions/0001_initial_schema.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/backends/sql/migrations/versions/0002_oauth.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/backends/sql/repositories/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/backends/sql/repositories/blacklist_repo.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/backends/sql/repositories/login_attempt_repo.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/backends/sql/repositories/mfa_code_repo.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/backends/sql/repositories/oauth_identity_repo.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/backends/sql/repositories/oauth_state_repo.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/backends/sql/repositories/pending_repo.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/backends/sql/repositories/user_repo.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/backends/sql/schema.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/backends/sql/types.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/cli/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/cli/_results.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/cli/_runtime.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/cli/validate/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/cli/validate/capture.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/cli/validate/http.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/cli/validate/logtail.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/cli/validate/phases/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/cli/validate/phases/account.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/cli/validate/phases/cleanup.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/cli/validate/phases/core_auth.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/cli/validate/phases/feature_discover.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/cli/validate/phases/oauth.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/cli/validate/phases/password_reset.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/cli/validate/phases/reachability.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/cli/validate/phases/sms_mfa.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/cli/validate/report.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/cli/validate/runner.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/config/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/config/loader.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/config/secrets.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/email/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/email/base.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/email/console.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/email/factory.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/email/smtp.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/email/templates/email_change.html +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/email/templates/email_change.subject.txt +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/email/templates/email_change.txt +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/email/templates/password_reset.html +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/email/templates/password_reset.subject.txt +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/email/templates/password_reset.txt +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/email/templates/sms_login_mfa.txt +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/email/templates/verification.subject.txt +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/hooks/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/models/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/models/_objectid.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/models/login_attempt.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/models/mfa_code.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/models/oauth_identity.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/models/oauth_state.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/models/pending_registration.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/models/user.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/oauth/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/oauth/base.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/oauth/errors.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/oauth/providers/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/oauth/registry.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/routers/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/routers/_helpers.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/routers/_schemas.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/routers/logout.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/routers/oauth.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/sms/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/sms/base.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/sms/factory.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/sms/null.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/sms/twilio.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/ui/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/ui/pages.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/ui/static/css/core.css +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/ui/static/css/theme.css +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/ui/static/js/regstack.js +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/ui/templates/auth/forgot.html +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/ui/templates/auth/login.html +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/ui/templates/auth/me.html +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/ui/templates/auth/mfa_confirm.html +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/ui/templates/auth/oauth_complete.html +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/ui/templates/auth/register.html +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/ui/templates/base.html +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/wizard/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/wizard/oauth_google/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/wizard/oauth_google/routes.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/wizard/oauth_google/server.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/wizard/oauth_google/static/wizard.css +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/wizard/oauth_google/static/wizard.js +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/wizard/oauth_google/templates/wizard.html +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/wizard/oauth_google/validators.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/wizard/oauth_google/window.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/wizard/theme_designer/__init__.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/wizard/theme_designer/routes.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/wizard/theme_designer/server.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/wizard/theme_designer/static/designer.css +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/wizard/theme_designer/static/designer.js +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/wizard/theme_designer/validators.py +0 -0
- {regstack-0.7.0 → regstack-0.8.1}/src/regstack/wizard/theme_designer/window.py +0 -0
|
@@ -7,6 +7,39 @@ Sphinx docs.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
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
|
+
|
|
10
43
|
## 0.7.0 — 2026-05-17
|
|
11
44
|
|
|
12
45
|
Two-week sprint that lands the `regstack validate` end-to-end probe,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: regstack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.1
|
|
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),
|
|
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),
|
|
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
|
|
|
@@ -9,26 +9,40 @@ from regstack.cli.migrate import migrate as migrate_cmd
|
|
|
9
9
|
from regstack.cli.validate.cli import validate as validate_cmd
|
|
10
10
|
from regstack.version import __version__
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
|
|
12
|
+
_EXTRA_BLURB = {
|
|
13
|
+
"wizard": "'wizard' extra (pywebview + tomlkit + uvicorn)",
|
|
14
|
+
"ses": "'ses' extra (aioboto3 for AWS API calls)",
|
|
15
|
+
"oauth": "'oauth' extra (pyjwt[crypto] for OAuth ID-token verification)",
|
|
16
|
+
}
|
|
17
17
|
|
|
18
18
|
|
|
19
|
-
def
|
|
19
|
+
def _missing_extra(subcommand: str, *extras: str) -> click.Command:
|
|
20
20
|
"""Click command that prints a clear install hint and exits non-zero.
|
|
21
21
|
|
|
22
|
-
Used as the fallback when a wizard subcommand is invoked but
|
|
23
|
-
|
|
24
|
-
message instead of an ImportError traceback
|
|
25
|
-
wizard package.
|
|
22
|
+
Used as the fallback when a wizard subcommand is invoked but one or
|
|
23
|
+
more of the required optional extras is not installed. Gives a
|
|
24
|
+
one-line actionable message instead of an ImportError traceback
|
|
25
|
+
from deep inside the wizard package.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
subcommand: Display name of the subcommand (e.g.
|
|
29
|
+
``"regstack ses setup"``). Used in the error message.
|
|
30
|
+
*extras: One or more extra names the subcommand requires. The
|
|
31
|
+
hint instructs the operator to install all of them
|
|
32
|
+
together via a single ``pip install regstack[a,b,...]``.
|
|
26
33
|
"""
|
|
27
34
|
name = subcommand.split()[-1]
|
|
28
|
-
|
|
29
|
-
|
|
35
|
+
blurbs = ", ".join(_EXTRA_BLURB.get(x, f"'{x}' extra") for x in extras)
|
|
36
|
+
extras_arg = ",".join(extras)
|
|
37
|
+
hint = (
|
|
38
|
+
f"The `{subcommand}` command requires the {blurbs}. "
|
|
39
|
+
f"Install with `pip install 'regstack[{extras_arg}]'` "
|
|
40
|
+
f"or `uv sync {' '.join(f'--extra {x}' for x in extras)}`."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
@click.command(name=name, help=f"(needs `regstack[{extras_arg}]`)")
|
|
30
44
|
def _stub() -> None:
|
|
31
|
-
click.echo(
|
|
45
|
+
click.echo(hint, err=True)
|
|
32
46
|
raise SystemExit(2)
|
|
33
47
|
|
|
34
48
|
return _stub
|
|
@@ -53,7 +67,7 @@ class _LazyOauthGroup(click.Group):
|
|
|
53
67
|
try:
|
|
54
68
|
from regstack.wizard.oauth_google.cli import setup as setup_cmd
|
|
55
69
|
except ImportError:
|
|
56
|
-
return
|
|
70
|
+
return _missing_extra("regstack oauth setup", "wizard")
|
|
57
71
|
return setup_cmd
|
|
58
72
|
|
|
59
73
|
|
|
@@ -69,10 +83,32 @@ class _LazyThemeGroup(click.Group):
|
|
|
69
83
|
try:
|
|
70
84
|
from regstack.wizard.theme_designer.cli import design as design_cmd
|
|
71
85
|
except ImportError:
|
|
72
|
-
return
|
|
86
|
+
return _missing_extra("regstack theme design", "wizard")
|
|
73
87
|
return design_cmd
|
|
74
88
|
|
|
75
89
|
|
|
90
|
+
class _LazySesGroup(click.Group):
|
|
91
|
+
"""Same lazy-import pattern for the SES setup wizard.
|
|
92
|
+
|
|
93
|
+
Needs BOTH ``wizard`` (pywebview, uvicorn, tomlkit for the SPA
|
|
94
|
+
shell) AND ``ses`` (aioboto3 for AWS API calls). The hint surfaces
|
|
95
|
+
the joint install command so an operator missing either gets a
|
|
96
|
+
single actionable fix.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def list_commands(self, ctx: click.Context) -> list[str]:
|
|
100
|
+
return ["setup"]
|
|
101
|
+
|
|
102
|
+
def get_command(self, ctx: click.Context, name: str) -> click.Command | None:
|
|
103
|
+
if name != "setup":
|
|
104
|
+
return None
|
|
105
|
+
try:
|
|
106
|
+
from regstack.wizard.ses.cli import setup as setup_cmd
|
|
107
|
+
except ImportError:
|
|
108
|
+
return _missing_extra("regstack ses setup", "wizard", "ses")
|
|
109
|
+
return setup_cmd
|
|
110
|
+
|
|
111
|
+
|
|
76
112
|
@click.group(help="regstack — embeddable account registration for FastAPI apps.")
|
|
77
113
|
@click.version_option(__version__, prog_name="regstack")
|
|
78
114
|
def cli() -> None:
|
|
@@ -86,6 +122,7 @@ cli.add_command(migrate_cmd)
|
|
|
86
122
|
cli.add_command(validate_cmd)
|
|
87
123
|
cli.add_command(_LazyOauthGroup(name="oauth", help="OAuth provider setup wizards."))
|
|
88
124
|
cli.add_command(_LazyThemeGroup(name="theme", help="Theme designer for the SSR pages."))
|
|
125
|
+
cli.add_command(_LazySesGroup(name="ses", help="SES email backend setup wizard."))
|
|
89
126
|
|
|
90
127
|
|
|
91
128
|
def main() -> None:
|
|
@@ -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"]
|
|
@@ -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,7 +43,8 @@ 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
|
)
|
|
@@ -49,7 +54,10 @@ def doctor(toml_path: Path | None, check_dns: bool, test_recipient: str | None)
|
|
|
49
54
|
click.echo(f"{symbol} {r.name}: {r.detail}")
|
|
50
55
|
if failed:
|
|
51
56
|
click.echo(click.style(f"\n{failed} check(s) failed.", fg="red"), err=True)
|
|
52
|
-
|
|
57
|
+
# 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.)
|
|
60
|
+
sys.exit(1 if failed else 0)
|
|
53
61
|
|
|
54
62
|
|
|
55
63
|
async def _run(
|
|
@@ -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(
|
|
@@ -229,10 +229,12 @@ class RegStackConfig(BaseSettings):
|
|
|
229
229
|
confirm_email_change_rate_limit: str | None = None
|
|
230
230
|
delete_account_rate_limit: str | None = None
|
|
231
231
|
oauth_exchange_rate_limit: str | None = None
|
|
232
|
-
#
|
|
233
|
-
#
|
|
234
|
-
|
|
235
|
-
|
|
232
|
+
# Phone routes send paid SMS and brute-force a 6-digit code, so
|
|
233
|
+
# they need IP throttles regardless of the per-code attempt counter
|
|
234
|
+
# in mfa_codes. (Review #6.)
|
|
235
|
+
phone_start_rate_limit: str | None = None
|
|
236
|
+
phone_confirm_rate_limit: str | None = None
|
|
237
|
+
phone_disable_rate_limit: str | None = None
|
|
236
238
|
|
|
237
239
|
# Sub-configs
|
|
238
240
|
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
|
|
|
@@ -15,7 +15,11 @@ class SesEmailService(EmailService):
|
|
|
15
15
|
|
|
16
16
|
def __init__(self, config: EmailConfig) -> None:
|
|
17
17
|
try:
|
|
18
|
-
|
|
18
|
+
# Bare `type: ignore` because aioboto3 lacks py.typed; with
|
|
19
|
+
# the ses extra installed mypy emits import-untyped, without
|
|
20
|
+
# it import-not-found. A compound code list trips
|
|
21
|
+
# unused-ignore in whichever environment doesn't match.
|
|
22
|
+
import aioboto3 # type: ignore # noqa: F401 (import-time check)
|
|
19
23
|
except ImportError as exc:
|
|
20
24
|
raise RuntimeError(
|
|
21
25
|
"The SES email backend requires the 'ses' extra. "
|
|
@@ -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
|
|
|
@@ -152,7 +152,7 @@ class GoogleProvider(OAuthProvider):
|
|
|
152
152
|
)
|
|
153
153
|
except KeyError as exc:
|
|
154
154
|
raise OAuthTokenExchangeError(
|
|
155
|
-
f"google token response missing field {exc.args[0]!r}
|
|
155
|
+
f"google token response missing field {exc.args[0]!r}"
|
|
156
156
|
) from exc
|
|
157
157
|
|
|
158
158
|
async def verify_id_token(
|