regstack 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. regstack-0.1.0/.github/workflows/publish.yml +61 -0
  2. regstack-0.1.0/.github/workflows/test.yml +71 -0
  3. regstack-0.1.0/.gitignore +34 -0
  4. regstack-0.1.0/.python-version +1 -0
  5. regstack-0.1.0/.readthedocs.yaml +23 -0
  6. regstack-0.1.0/CHANGELOG.md +14 -0
  7. regstack-0.1.0/CLAUDE.md +305 -0
  8. regstack-0.1.0/LICENSE +202 -0
  9. regstack-0.1.0/NOTICE +5 -0
  10. regstack-0.1.0/PKG-INFO +209 -0
  11. regstack-0.1.0/README.md +153 -0
  12. regstack-0.1.0/SECURITY.md +49 -0
  13. regstack-0.1.0/docs/_static/.gitkeep +0 -0
  14. regstack-0.1.0/docs/_templates/.gitkeep +0 -0
  15. regstack-0.1.0/docs/api.md +184 -0
  16. regstack-0.1.0/docs/architecture.md +134 -0
  17. regstack-0.1.0/docs/changelog.md +75 -0
  18. regstack-0.1.0/docs/cli.md +86 -0
  19. regstack-0.1.0/docs/conf.py +91 -0
  20. regstack-0.1.0/docs/configuration.md +262 -0
  21. regstack-0.1.0/docs/embedding.md +146 -0
  22. regstack-0.1.0/docs/index.md +55 -0
  23. regstack-0.1.0/docs/quickstart.md +115 -0
  24. regstack-0.1.0/docs/security.md +170 -0
  25. regstack-0.1.0/docs/theming.md +168 -0
  26. regstack-0.1.0/examples/minimal/README.md +30 -0
  27. regstack-0.1.0/examples/minimal/branding/theme.css +25 -0
  28. regstack-0.1.0/examples/minimal/main.py +109 -0
  29. regstack-0.1.0/examples/minimal/regstack.toml +29 -0
  30. regstack-0.1.0/pyproject.toml +108 -0
  31. regstack-0.1.0/regstack.toml.example +49 -0
  32. regstack-0.1.0/src/regstack/__init__.py +5 -0
  33. regstack-0.1.0/src/regstack/app.py +150 -0
  34. regstack-0.1.0/src/regstack/auth/__init__.py +21 -0
  35. regstack-0.1.0/src/regstack/auth/clock.py +29 -0
  36. regstack-0.1.0/src/regstack/auth/dependencies.py +102 -0
  37. regstack-0.1.0/src/regstack/auth/jwt.py +145 -0
  38. regstack-0.1.0/src/regstack/auth/lockout.py +59 -0
  39. regstack-0.1.0/src/regstack/auth/mfa.py +29 -0
  40. regstack-0.1.0/src/regstack/auth/password.py +20 -0
  41. regstack-0.1.0/src/regstack/auth/tokens.py +19 -0
  42. regstack-0.1.0/src/regstack/cli/__init__.py +0 -0
  43. regstack-0.1.0/src/regstack/cli/__main__.py +27 -0
  44. regstack-0.1.0/src/regstack/cli/_runtime.py +39 -0
  45. regstack-0.1.0/src/regstack/cli/admin.py +45 -0
  46. regstack-0.1.0/src/regstack/cli/doctor.py +186 -0
  47. regstack-0.1.0/src/regstack/cli/init.py +236 -0
  48. regstack-0.1.0/src/regstack/config/__init__.py +4 -0
  49. regstack-0.1.0/src/regstack/config/loader.py +114 -0
  50. regstack-0.1.0/src/regstack/config/schema.py +148 -0
  51. regstack-0.1.0/src/regstack/config/secrets.py +22 -0
  52. regstack-0.1.0/src/regstack/db/__init__.py +17 -0
  53. regstack-0.1.0/src/regstack/db/client.py +26 -0
  54. regstack-0.1.0/src/regstack/db/indexes.py +70 -0
  55. regstack-0.1.0/src/regstack/db/repositories/__init__.py +0 -0
  56. regstack-0.1.0/src/regstack/db/repositories/blacklist_repo.py +28 -0
  57. regstack-0.1.0/src/regstack/db/repositories/login_attempt_repo.py +27 -0
  58. regstack-0.1.0/src/regstack/db/repositories/mfa_code_repo.py +99 -0
  59. regstack-0.1.0/src/regstack/db/repositories/pending_repo.py +76 -0
  60. regstack-0.1.0/src/regstack/db/repositories/user_repo.py +169 -0
  61. regstack-0.1.0/src/regstack/email/__init__.py +12 -0
  62. regstack-0.1.0/src/regstack/email/base.py +23 -0
  63. regstack-0.1.0/src/regstack/email/composer.py +142 -0
  64. regstack-0.1.0/src/regstack/email/console.py +28 -0
  65. regstack-0.1.0/src/regstack/email/factory.py +23 -0
  66. regstack-0.1.0/src/regstack/email/ses.py +47 -0
  67. regstack-0.1.0/src/regstack/email/smtp.py +46 -0
  68. regstack-0.1.0/src/regstack/email/templates/email_change.html +15 -0
  69. regstack-0.1.0/src/regstack/email/templates/email_change.subject.txt +1 -0
  70. regstack-0.1.0/src/regstack/email/templates/email_change.txt +7 -0
  71. regstack-0.1.0/src/regstack/email/templates/password_reset.html +15 -0
  72. regstack-0.1.0/src/regstack/email/templates/password_reset.subject.txt +1 -0
  73. regstack-0.1.0/src/regstack/email/templates/password_reset.txt +7 -0
  74. regstack-0.1.0/src/regstack/email/templates/sms_login_mfa.txt +1 -0
  75. regstack-0.1.0/src/regstack/email/templates/sms_phone_setup.txt +1 -0
  76. regstack-0.1.0/src/regstack/email/templates/verification.html +15 -0
  77. regstack-0.1.0/src/regstack/email/templates/verification.subject.txt +1 -0
  78. regstack-0.1.0/src/regstack/email/templates/verification.txt +7 -0
  79. regstack-0.1.0/src/regstack/hooks/__init__.py +3 -0
  80. regstack-0.1.0/src/regstack/hooks/events.py +59 -0
  81. regstack-0.1.0/src/regstack/models/__init__.py +15 -0
  82. regstack-0.1.0/src/regstack/models/_objectid.py +30 -0
  83. regstack-0.1.0/src/regstack/models/login_attempt.py +31 -0
  84. regstack-0.1.0/src/regstack/models/mfa_code.py +40 -0
  85. regstack-0.1.0/src/regstack/models/pending_registration.py +38 -0
  86. regstack-0.1.0/src/regstack/models/user.py +104 -0
  87. regstack-0.1.0/src/regstack/routers/__init__.py +37 -0
  88. regstack-0.1.0/src/regstack/routers/_schemas.py +34 -0
  89. regstack-0.1.0/src/regstack/routers/account.py +274 -0
  90. regstack-0.1.0/src/regstack/routers/admin.py +187 -0
  91. regstack-0.1.0/src/regstack/routers/login.py +223 -0
  92. regstack-0.1.0/src/regstack/routers/logout.py +39 -0
  93. regstack-0.1.0/src/regstack/routers/password.py +114 -0
  94. regstack-0.1.0/src/regstack/routers/phone.py +242 -0
  95. regstack-0.1.0/src/regstack/routers/register.py +99 -0
  96. regstack-0.1.0/src/regstack/routers/verify.py +116 -0
  97. regstack-0.1.0/src/regstack/sms/__init__.py +5 -0
  98. regstack-0.1.0/src/regstack/sms/base.py +24 -0
  99. regstack-0.1.0/src/regstack/sms/factory.py +23 -0
  100. regstack-0.1.0/src/regstack/sms/null.py +26 -0
  101. regstack-0.1.0/src/regstack/sms/sns.py +42 -0
  102. regstack-0.1.0/src/regstack/sms/twilio.py +49 -0
  103. regstack-0.1.0/src/regstack/ui/__init__.py +3 -0
  104. regstack-0.1.0/src/regstack/ui/pages.py +148 -0
  105. regstack-0.1.0/src/regstack/ui/static/css/core.css +204 -0
  106. regstack-0.1.0/src/regstack/ui/static/css/theme.css +43 -0
  107. regstack-0.1.0/src/regstack/ui/static/js/regstack.js +411 -0
  108. regstack-0.1.0/src/regstack/ui/templates/auth/email_change_confirm.html +10 -0
  109. regstack-0.1.0/src/regstack/ui/templates/auth/forgot.html +14 -0
  110. regstack-0.1.0/src/regstack/ui/templates/auth/login.html +24 -0
  111. regstack-0.1.0/src/regstack/ui/templates/auth/me.html +110 -0
  112. regstack-0.1.0/src/regstack/ui/templates/auth/mfa_confirm.html +14 -0
  113. regstack-0.1.0/src/regstack/ui/templates/auth/register.html +23 -0
  114. regstack-0.1.0/src/regstack/ui/templates/auth/reset.html +13 -0
  115. regstack-0.1.0/src/regstack/ui/templates/auth/verify.html +10 -0
  116. regstack-0.1.0/src/regstack/ui/templates/base.html +46 -0
  117. regstack-0.1.0/src/regstack/version.py +1 -0
  118. regstack-0.1.0/tasks.py +98 -0
  119. regstack-0.1.0/tests/__init__.py +0 -0
  120. regstack-0.1.0/tests/conftest.py +144 -0
  121. regstack-0.1.0/tests/integration/__init__.py +0 -0
  122. regstack-0.1.0/tests/integration/test_account_management.py +294 -0
  123. regstack-0.1.0/tests/integration/test_admin_router.py +175 -0
  124. regstack-0.1.0/tests/integration/test_happy_path.py +134 -0
  125. regstack-0.1.0/tests/integration/test_indexes.py +29 -0
  126. regstack-0.1.0/tests/integration/test_login_lockout.py +82 -0
  127. regstack-0.1.0/tests/integration/test_mfa.py +243 -0
  128. regstack-0.1.0/tests/integration/test_password_reset.py +121 -0
  129. regstack-0.1.0/tests/integration/test_ui_router.py +117 -0
  130. regstack-0.1.0/tests/integration/test_verification.py +143 -0
  131. regstack-0.1.0/tests/unit/__init__.py +0 -0
  132. regstack-0.1.0/tests/unit/test_cli.py +132 -0
  133. regstack-0.1.0/tests/unit/test_config_loader.py +41 -0
  134. regstack-0.1.0/tests/unit/test_jwt.py +61 -0
  135. regstack-0.1.0/tests/unit/test_lockout.py +85 -0
  136. regstack-0.1.0/tests/unit/test_mail_composer.py +88 -0
  137. regstack-0.1.0/tests/unit/test_mfa_code_repo.py +112 -0
  138. regstack-0.1.0/tests/unit/test_password.py +16 -0
  139. regstack-0.1.0/tests/unit/test_ses_backend.py +26 -0
  140. regstack-0.1.0/tests/unit/test_sms.py +77 -0
  141. regstack-0.1.0/tests/unit/test_smtp_backend.py +62 -0
  142. regstack-0.1.0/tests/unit/test_ui_env.py +50 -0
  143. regstack-0.1.0/uv.lock +2708 -0
@@ -0,0 +1,61 @@
1
+ name: publish
2
+
3
+ # Pushing a `vX.Y.Z` tag triggers a build and an OIDC-trusted publish to PyPI.
4
+ # Configure the PyPI project (https://pypi.org/manage/account/publishing/)
5
+ # with the publisher: this repository, environment name `pypi`, workflow
6
+ # `publish.yml`. No PyPI tokens live in GitHub Actions secrets.
7
+
8
+ on:
9
+ push:
10
+ tags:
11
+ - "v*"
12
+ workflow_dispatch:
13
+
14
+ jobs:
15
+ build:
16
+ runs-on: ubuntu-latest
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ with:
20
+ fetch-depth: 0
21
+
22
+ - uses: astral-sh/setup-uv@v3
23
+ with:
24
+ enable-cache: true
25
+
26
+ - run: uv python pin 3.11
27
+
28
+ - name: Verify tag matches package version
29
+ if: startsWith(github.ref, 'refs/tags/v')
30
+ run: |
31
+ tag="${GITHUB_REF##*/}" # e.g. v0.1.0
32
+ tag_version="${tag#v}" # e.g. 0.1.0
33
+ pkg_version=$(uv run python -c 'from regstack.version import __version__; print(__version__)')
34
+ if [ "$tag_version" != "$pkg_version" ]; then
35
+ echo "Tag $tag does not match package version $pkg_version" >&2
36
+ exit 1
37
+ fi
38
+
39
+ - name: Build sdist + wheel
40
+ run: uv build
41
+
42
+ - uses: actions/upload-artifact@v4
43
+ with:
44
+ name: dist
45
+ path: dist/
46
+
47
+ publish:
48
+ needs: build
49
+ runs-on: ubuntu-latest
50
+ if: startsWith(github.ref, 'refs/tags/v')
51
+ environment:
52
+ name: pypi
53
+ url: https://pypi.org/p/regstack
54
+ permissions:
55
+ id-token: write # OIDC trusted-publisher
56
+ steps:
57
+ - uses: actions/download-artifact@v4
58
+ with:
59
+ name: dist
60
+ path: dist/
61
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,71 @@
1
+ name: test
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_dispatch:
8
+
9
+ concurrency:
10
+ group: test-${{ github.ref }}
11
+ cancel-in-progress: true
12
+
13
+ jobs:
14
+ pytest:
15
+ runs-on: ubuntu-latest
16
+ strategy:
17
+ fail-fast: false
18
+ matrix:
19
+ python-version: ["3.11", "3.12"]
20
+ services:
21
+ mongodb:
22
+ image: mongo:7
23
+ ports:
24
+ - 27017:27017
25
+ # Wait until mongo answers a `ping` before the tests start.
26
+ options: >-
27
+ --health-cmd "mongosh --quiet --eval 'db.runCommand({ping:1}).ok' --port 27017"
28
+ --health-interval 5s
29
+ --health-timeout 5s
30
+ --health-retries 12
31
+ --health-start-period 10s
32
+ env:
33
+ REGSTACK_MONGODB_URL: mongodb://localhost:27017
34
+ steps:
35
+ - uses: actions/checkout@v4
36
+
37
+ - uses: astral-sh/setup-uv@v3
38
+ with:
39
+ enable-cache: true
40
+ cache-dependency-glob: "uv.lock"
41
+
42
+ - name: Pin Python ${{ matrix.python-version }}
43
+ run: uv python pin ${{ matrix.python-version }}
44
+
45
+ - name: Sync dependencies
46
+ run: uv sync --extra dev
47
+
48
+ - name: ruff check
49
+ run: uv run ruff check src tests
50
+
51
+ - name: ruff format check
52
+ run: uv run ruff format --check src tests
53
+
54
+ - name: pytest (parallel)
55
+ run: uv run python -m pytest -n auto --tb=short
56
+
57
+ docs:
58
+ runs-on: ubuntu-latest
59
+ steps:
60
+ - uses: actions/checkout@v4
61
+ - uses: astral-sh/setup-uv@v3
62
+ with:
63
+ enable-cache: true
64
+ cache-dependency-glob: "uv.lock"
65
+ - run: uv python pin 3.11
66
+ - run: uv sync --extra docs --extra dev
67
+ - run: uv run sphinx-build -b html -W --keep-going docs docs/_build/html
68
+ - uses: actions/upload-artifact@v4
69
+ with:
70
+ name: docs-html
71
+ path: docs/_build/html
@@ -0,0 +1,34 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.so
5
+ .Python
6
+ .venv/
7
+ venv/
8
+ env/
9
+ build/
10
+ dist/
11
+ *.egg-info/
12
+ *.egg
13
+ .pytest_cache/
14
+ .mypy_cache/
15
+ .ruff_cache/
16
+ .coverage
17
+ coverage.xml
18
+ htmlcov/
19
+ .tox/
20
+ .nox/
21
+ .idea/
22
+ .vscode/
23
+ *.swp
24
+ .DS_Store
25
+ docs/_build/
26
+ docs/_autosummary/
27
+
28
+ # regstack runtime files (anchored to repo root so the demo config under
29
+ # examples/minimal/regstack.toml is still tracked)
30
+ /regstack.toml
31
+ /regstack.secrets.env
32
+ /regstack-bootstrap.json
33
+ **/regstack.secrets.env
34
+ **/regstack-bootstrap.json
@@ -0,0 +1 @@
1
+ 3.11
@@ -0,0 +1,23 @@
1
+ version: 2
2
+
3
+ build:
4
+ os: ubuntu-24.04
5
+ tools:
6
+ python: "3.11"
7
+ jobs:
8
+ pre_install:
9
+ - pip install --upgrade uv
10
+ install:
11
+ - uv sync --extra docs
12
+ build:
13
+ html:
14
+ - uv run sphinx-build -b html -W --keep-going docs $READTHEDOCS_OUTPUT/html
15
+
16
+ sphinx:
17
+ configuration: docs/conf.py
18
+ fail_on_warning: true
19
+
20
+ # `formats` deliberately omitted: RTD's default htmlzip build runs
21
+ # `python -m sphinx` outside our uv-managed venv, where sphinx is not
22
+ # installed. The HTML build above is enough; htmlzip can be re-added
23
+ # once we add a uv-aware htmlzip job to the `build:` section.
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The
4
+ authoritative copy lives at
5
+ [`docs/changelog.md`](docs/changelog.md) and is rendered into the
6
+ Sphinx docs.
7
+
8
+ ## 0.1.0 — 2026-04-27
9
+
10
+ First tagged release. Bundles M1–M6 from the development plan into a
11
+ single Apache-2.0 package on PyPI.
12
+
13
+ See [`docs/changelog.md`](docs/changelog.md) for the per-milestone
14
+ breakdown of M1 through M6.
@@ -0,0 +1,305 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## What this is
6
+
7
+ `regstack` is an embeddable account-management module for FastAPI / MongoDB
8
+ host apps. It exposes one `RegStack` façade that wires config, repos, JWT,
9
+ hooks, email, and a router; hosts mount `regstack.router` on their own
10
+ FastAPI app. Two existing apps (`winebox`, `putplace`) are the design inputs —
11
+ their requirements drove the API surface — but **no host integration code lives
12
+ here**. regstack ships standalone and the hosts adopt it later in their own
13
+ repos.
14
+
15
+ The full plan, including milestone scope and deferred items, lives at
16
+ `/Users/jdrumgoole/.claude/plans/we-want-to-create-mutable-turing.md`.
17
+
18
+ ## Current milestone status
19
+
20
+ - **M1 — done.** Skeleton, config + TOML/env loader, BaseUser, UserRepo,
21
+ BlacklistRepo, JWT (per-purpose derived secrets, per-token blacklist,
22
+ bulk revoke), Argon2 password hashing, console email, register/login/me/logout
23
+ router, `regstack init` wizard, `examples/minimal/` demo.
24
+ - **M2 — done.** Durable `pending_registrations` (hashed-token TTL store),
25
+ email verification + resend endpoints, forgot-password + reset-password
26
+ with bulk revoke, login lockout (`LoginAttemptRepo` + `LockoutService` →
27
+ HTTP 429 + `Retry-After`), SMTP backend (aiosmtplib), SES backend (lazy
28
+ aioboto3 import), `MailComposer` with Jinja2 `ChoiceLoader` so hosts can
29
+ override any email template by dropping a same-named file into a registered
30
+ template directory.
31
+ - **M3 — done.** Account management (`PATCH /me`, `POST /change-password`,
32
+ `POST /change-email` + `POST /confirm-email-change`, `DELETE /account`),
33
+ JSON admin router (`/admin/{stats,users,users/{id},users/{id}/resend-verification}`)
34
+ conditionally mounted on `enable_admin_router`, `regstack create-admin` CLI
35
+ (idempotent), `regstack doctor` CLI (jwt secret, mongo ping, indexes,
36
+ email factory; opt-in DNS + send-test-email). Float-precision JWT `iat`
37
+ with `<=` bulk-revoke comparison so a login completing microseconds after
38
+ a password / email change keeps its session.
39
+ - **M4 — done.** SSR `ui_router` (mounted on `enable_ui_router`) with
40
+ Jinja2 pages: `login`, `register`, `forgot`, `reset`, `verify`,
41
+ `confirm-email-change`, `me`. `core.css` (structural only) +
42
+ `theme.css` (CSS custom properties, light + `prefers-color-scheme: dark`).
43
+ Hosts override visuals by serving their own `theme.css` and pointing
44
+ `config.theme_css_url` at it (loaded after the bundled defaults), or by
45
+ registering a template directory via `regstack.add_template_dir(path)`
46
+ for full template overrides. Bundled `regstack.js` is the only client
47
+ code — reads endpoints from `<body data-rs-api data-rs-ui>` so it works
48
+ for any prefix layout. CSP-friendly (no inline `<style>` or `style="…"`
49
+ attributes anywhere).
50
+ - **M5 — done.** SMS abstraction (`SmsService` ABC with `null` /
51
+ `sns` (lazy `aioboto3`) / `twilio` (lazy SDK) backends). Phone routes
52
+ (`POST /phone/start`, `POST /phone/confirm`, `DELETE /phone`) and a
53
+ two-step login flow (`POST /login` returns `mfa_required` + a short-lived
54
+ `mfa_pending_token`; `POST /login/mfa-confirm` completes with the SMS
55
+ code). Mounted only when `enable_sms_2fa=True`. `MfaCodeRepo` stores
56
+ hashed 6-digit codes with attempts tracking, TTL on `expires_at`, unique
57
+ on `(user_id, kind)` so a re-issue overwrites a previous code. SSR
58
+ picked up an `mfa-confirm` page and a "SMS two-factor authentication"
59
+ section on `/account/me` (set up + disable). E.164 phone validation.
60
+ Phone setup uses a separate signed `phone_setup` JWT carrying the
61
+ proposed phone as a custom claim — same per-purpose key derivation as
62
+ password-reset and email-change.
63
+ - **OAuth** — explicitly deferred. The `oauth/` package will hold a provider
64
+ ABC only; concrete providers (Google first) come post-v1.
65
+
66
+ ## Three kinds of single-use proof
67
+
68
+ When extending regstack, watch which of these to use:
69
+
70
+ - **Email verification token** — random 32-byte URL-safe string,
71
+ SHA-256 hashed in `pending_registrations.token_hash`. Long TTL (24h
72
+ default). The raw token only exists in the email body and the click URL.
73
+ - **Password-reset / email-change / phone-setup / login-MFA tokens** —
74
+ signed JWTs with purpose-derived keys (`derive_secret(jwt_secret, purpose)`).
75
+ Carry whatever extra claim the flow needs (`new_email`, `phone`). Short
76
+ TTL (5–60 min). No DB row.
77
+ - **SMS one-time codes** — 6-digit numeric, SHA-256 hashed in
78
+ `mfa_codes.code_hash`. Short TTL (5 min default). Per-user-per-kind
79
+ unique so a re-issued code overwrites the old one. `attempts` field
80
+ bounds brute force; the row is deleted on success or after
81
+ `max_attempts` wrong guesses. The pending JWT (purpose `login_mfa` or
82
+ `phone_setup`) carries `sub=user_id` and is what links the second-step
83
+ request to the right code in the DB.
84
+
85
+ ## Commands
86
+
87
+ All commands assume `uv` and a local MongoDB on `mongodb://localhost:27017`.
88
+
89
+ ```bash
90
+ uv sync --extra dev # install + dev extras
91
+ uv run python -m invoke test # parallel pytest (xdist auto)
92
+ uv run python -m invoke test -k <expr> # filter by node-id substring
93
+ uv run python -m invoke test-serial # serial run (diagnose flakes)
94
+ uv run python -m invoke lint # ruff check + format check + mypy
95
+ uv run python -m invoke fmt # ruff format + ruff check --fix
96
+ uv run python -m invoke run-example # boot examples/minimal on :8000
97
+ uv run regstack init # interactive wizard
98
+ ```
99
+
100
+ Run a single test file or test:
101
+
102
+ ```bash
103
+ uv run python -m pytest tests/integration/test_happy_path.py -k test_token_expires -vv
104
+ ```
105
+
106
+ Boot the demo with an ephemeral JWT secret (no wizard needed):
107
+
108
+ ```bash
109
+ export REGSTACK_JWT_SECRET=$(python -c 'import secrets; print(secrets.token_urlsafe(64))')
110
+ export REGSTACK_MONGODB_URL=mongodb://localhost:27017
111
+ uv run uvicorn examples.minimal.main:app --reload --port 8000
112
+ ```
113
+
114
+ ## Architecture (the parts that need cross-file reading)
115
+
116
+ ### One façade, instance-scoped dependencies
117
+
118
+ `RegStack` (`src/regstack/app.py`) is constructed once per host app and owns
119
+ all collaborators: `PasswordHasher`, `JwtCodec`, `UserRepo`, `BlacklistRepo`,
120
+ `HookRegistry`, an `EmailService`, and an `AuthDependencies` factory.
121
+
122
+ FastAPI dependencies (`current_user`, `current_admin`) are produced by
123
+ `AuthDependencies.current_user()` — this returns a closure-bound callable so
124
+ two `RegStack` instances in the same process don't share state via module
125
+ globals. Routers receive the `RegStack` instance and capture it in closures
126
+ (`build_router(rs)` in `routers/__init__.py`), so a router never depends on a
127
+ module-level singleton.
128
+
129
+ ### Configuration loading
130
+
131
+ `RegStackConfig` (pydantic-settings v2) loads with priority:
132
+ **kwargs > env vars > secrets.env > TOML > defaults.** The merge happens in
133
+ `config/loader.load_config`, which flattens TOML into the same `REGSTACK_*`
134
+ namespace pydantic-settings reads from `os.environ`, with `__` as the nested
135
+ delimiter (e.g. `REGSTACK_EMAIL__FROM_ADDRESS`).
136
+
137
+ `RegStackConfig.load(...)` is the convenience wrapper most call sites use.
138
+ For tests, pass `toml_path=Path("/dev/null")` and `secrets_env_path=Path("/dev/null")`
139
+ to suppress accidental discovery of a stray local config.
140
+
141
+ ### JWT revocation: two complementary mechanisms
142
+
143
+ regstack supports both per-token and bulk revocation. Both are checked on
144
+ every authenticated request, in `AuthDependencies._authenticate`:
145
+
146
+ 1. **Per-token blacklist** — `BlacklistRepo` stores `{jti, exp}`. Logout
147
+ inserts a row; the dependency rejects any token whose `jti` is present.
148
+ The `exp` field has a TTL index (`expireAfterSeconds=0`) so MongoDB
149
+ reaps rows automatically once they would have expired anyway.
150
+ 2. **Bulk revocation** — `User.tokens_invalidated_after` is a timestamp.
151
+ Any token with `iat < tokens_invalidated_after` is rejected. Password
152
+ changes (and similar "log everyone out" events) bump this field. This
153
+ is O(1) at write time and avoids enumerating every outstanding `jti`.
154
+
155
+ JWT signing keys are **derived per purpose** (`session`, `verification`,
156
+ `password_reset`, …) from the master `jwt_secret` via HMAC-SHA256. Compromise
157
+ of one derived key does not compromise others. See
158
+ `config/secrets.derive_secret` and `JwtCodec._key`.
159
+
160
+ `JwtCodec` deliberately **disables pyjwt's `exp`/`iat` checks** and validates
161
+ both against the injected `Clock`. This is the seam that lets `FrozenClock`
162
+ fast-forward tests without monkeypatching `time.time`.
163
+
164
+ ### Bulk revocation: float `iat` and `<=` cutoff
165
+
166
+ The bulk-revoke check in `is_payload_bulk_revoked` is `payload.iat <= cutoff`,
167
+ not `<`. Tokens issued at exactly the cutoff instant are revoked
168
+ (conservative — at the instant of a password change we don't know whether
169
+ the token came before or after); tokens issued microseconds later are valid.
170
+
171
+ To make "microseconds later" meaningful, regstack emits the JWT `iat` claim
172
+ as a float (RFC 7519 NumericDate explicitly allows this), so a login
173
+ completing in the same wall-clock second as a `change-password` /
174
+ `change-email` / `forgot-password` flow has an `iat` strictly greater than
175
+ the cutoff stored on the user document. Don't change `iat` back to `int` —
176
+ under integer-second `iat`, a same-second login would compare equal-or-less
177
+ to the microsecond-precision cutoff and be falsely revoked.
178
+
179
+ `UserRepo` is constructed with the same `Clock` as the JWT codec
180
+ (`UserRepo(db, collection_name, clock=self.clock)` in `RegStack.__init__`),
181
+ so `FrozenClock`-driven tests see consistent timestamps on both sides of
182
+ the comparison.
183
+
184
+ ### MongoDB datetimes are tz-aware
185
+
186
+ `db.client.make_client` constructs `AsyncMongoClient(..., tz_aware=True)`.
187
+ Every other layer assumes UTC-aware datetimes. If you instantiate a client
188
+ elsewhere (in a script or extra test fixture), keep `tz_aware=True` or the
189
+ bulk-revocation comparison will raise `TypeError: can't compare offset-naive
190
+ and offset-aware datetimes`.
191
+
192
+ ### Verification tokens are random + hashed; reset tokens are JWTs
193
+
194
+ These two flows look symmetric but use different mechanisms on purpose.
195
+
196
+ - **Verification** uses a 32-byte URL-safe random token. Only its SHA-256
197
+ hash lives in `pending_registrations.token_hash` — a database read does
198
+ not yield usable tokens. The `pending_registrations` collection is
199
+ authoritative: TTL on `expires_at` reaps unused rows; resend
200
+ `find_one_and_replace`s the row so the previous link silently dies.
201
+ - **Password reset** uses a JWT minted by `JwtCodec.encode(..., purpose="password_reset",
202
+ ttl_seconds=config.password_reset_token_ttl_seconds)`. Validation reuses
203
+ the regular decode path with the matching purpose. No DB row is needed
204
+ because the token is self-contained and short-lived.
205
+
206
+ The reset endpoint always calls `users.update_password` (which bumps
207
+ `tokens_invalidated_after`) **plus** `lockout.clear` so a stolen-then-reset
208
+ session can't outlive the password change and the legitimate user isn't
209
+ still locked out from prior failed attempts.
210
+
211
+ ### Anti-enumeration on `/forgot-password` and `/resend-verification`
212
+
213
+ Both endpoints return the same 202 response regardless of whether the email
214
+ exists. This deliberately prevents probing for valid accounts. The mail
215
+ itself is the only side-effect distinguishing the two cases. Don't add
216
+ "email not found" error paths to either route.
217
+
218
+ ### Login lockout is sliding-window over a TTL-indexed collection
219
+
220
+ `login_attempts` has a TTL index whose `expireAfterSeconds` matches
221
+ `config.login_lockout_window_seconds` — Mongo reaps old failures
222
+ automatically, so `LockoutService.count_recent` is a simple count over docs
223
+ where `when >= now - window`. Successful login calls `lockout.clear(email)`
224
+ to wipe accumulated failures eagerly. When `rate_limit_disabled=True` (tests),
225
+ both `record_failure` and `check` short-circuit — no docs are written.
226
+
227
+ ### Email-change uses a JWT with a custom claim, not a separate collection
228
+
229
+ `POST /change-email` mints a short-lived JWT (purpose `email_change`) that
230
+ carries the new address as a `new_email` custom claim — no separate
231
+ `pending_email_changes` collection. The encoder/decoder live in
232
+ `routers/account.py` (not `JwtCodec`) precisely because of the custom
233
+ claim; both still derive their signing key from
234
+ `config/secrets.derive_secret(jwt_secret, "email_change")`, so a session
235
+ token cannot satisfy the email-change endpoint and vice versa.
236
+
237
+ Confirmation calls `users.update_email`, which uniquely-constraints on
238
+ `email` at the DB level and bumps `tokens_invalidated_after`; the user has
239
+ to log in again with the new address.
240
+
241
+ ### Hosts override email AND UI templates via a single directory
242
+
243
+ `RegStack.add_template_dir(path)` prepends to BOTH the email composer and
244
+ the SSR UI Jinja `ChoiceLoader`s. A host wanting a custom verification email
245
+ drops `verification.subject.txt` / `verification.html` / `verification.txt`
246
+ into its template dir; a host wanting a custom login page drops
247
+ `auth/login.html`. Jinja resolves host-first and falls back to regstack's
248
+ defaults for anything not overridden. The defaults live at
249
+ `src/regstack/email/templates/` and `src/regstack/ui/templates/`.
250
+
251
+ For visual changes that don't need template surgery, hosts ship a custom
252
+ `theme.css` (overriding the `--rs-*` CSS custom properties) and point
253
+ `config.theme_css_url` at where they serve it. The base template loads it
254
+ after the bundled `theme.css` so host values win without touching the
255
+ package. See `examples/minimal/branding/theme.css` for a wine-themed
256
+ example that flips every page from blue+sans to burgundy+serif by changing
257
+ only one stylesheet URL.
258
+
259
+ ### SSR pages are stateless; the bundled `regstack.js` does the actual work
260
+
261
+ The HTML returned by `ui_router` contains forms but no auth state — the
262
+ templates render the same regardless of whether the user is signed in.
263
+ `regstack.js` reads `data-rs-api` and `data-rs-ui` from `<body>`, dispatches
264
+ on `data-rs-page`, submits forms via `fetch`, stores the access token in
265
+ `localStorage` under `regstack.access_token`, and redirects unauthenticated
266
+ users to `<ui_prefix>/login`. This avoids cookie-based sessions (and the
267
+ CSRF middleware that comes with them) while keeping the JSON API the
268
+ single source of truth for auth state.
269
+
270
+ The verify and confirm-email-change pages take their token from the
271
+ query-string and auto-POST it on page load — the user just clicks the link
272
+ in their email and waits a second for the success/failure banner.
273
+
274
+ ### Hooks are best-effort
275
+
276
+ `HookRegistry.fire(event, **kwargs)` runs all registered handlers
277
+ concurrently and **swallows exceptions** (logged via `log.exception`). A
278
+ failing webhook side-effect must never break the primary auth flow. Known
279
+ event names live in `hooks/events.KNOWN_EVENTS`.
280
+
281
+ ### Tests are parallel-safe by construction
282
+
283
+ `tests/conftest.py` gives each pytest-xdist worker a unique database name
284
+ (`regstack_test_{worker_id}_{random_hex}`) that is dropped at fixture
285
+ teardown. **Don't introduce session-scoped state, fixed ports, or shared
286
+ collections.** The full suite must pass under `pytest -n auto` reliably —
287
+ flaky tests are bugs, not noise. Time-dependent assertions use the
288
+ `frozen_clock` fixture, never `time.sleep` or wall-clock delays.
289
+
290
+ ## Conventions
291
+
292
+ - **Python 3.11+.** Use `from __future__ import annotations` in every module.
293
+ - **Typehints on everything** (`mypy --strict` is wired up but not yet on CI).
294
+ - **No bash scripts.** All build/admin tasks go in `tasks.py` (invoke).
295
+ - **`uv run`** in front of every Python command.
296
+ - **Don't add a feature flag without a code path that uses it.** The
297
+ `RegStackConfig` flags `enable_admin_router`, `enable_ui_router`,
298
+ `enable_sms_2fa`, `enable_oauth` are reserved for their respective
299
+ milestones — adding a flag is a future-milestone marker, not a stub.
300
+ - **Email backends:** `console`, `smtp` (aiosmtplib), and `ses` (lazy
301
+ aioboto3) all ship as of M2. SES requires the `ses` extra
302
+ (`uv sync --extra ses`); other backends are no-extra installs.
303
+ - **Comments are rare.** Add one only when *why* is non-obvious — a
304
+ workaround, a constraint that isn't visible from the code, an invariant
305
+ a future reader would otherwise break. Don't restate *what* the code does.