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.
- regstack-0.1.0/.github/workflows/publish.yml +61 -0
- regstack-0.1.0/.github/workflows/test.yml +71 -0
- regstack-0.1.0/.gitignore +34 -0
- regstack-0.1.0/.python-version +1 -0
- regstack-0.1.0/.readthedocs.yaml +23 -0
- regstack-0.1.0/CHANGELOG.md +14 -0
- regstack-0.1.0/CLAUDE.md +305 -0
- regstack-0.1.0/LICENSE +202 -0
- regstack-0.1.0/NOTICE +5 -0
- regstack-0.1.0/PKG-INFO +209 -0
- regstack-0.1.0/README.md +153 -0
- regstack-0.1.0/SECURITY.md +49 -0
- regstack-0.1.0/docs/_static/.gitkeep +0 -0
- regstack-0.1.0/docs/_templates/.gitkeep +0 -0
- regstack-0.1.0/docs/api.md +184 -0
- regstack-0.1.0/docs/architecture.md +134 -0
- regstack-0.1.0/docs/changelog.md +75 -0
- regstack-0.1.0/docs/cli.md +86 -0
- regstack-0.1.0/docs/conf.py +91 -0
- regstack-0.1.0/docs/configuration.md +262 -0
- regstack-0.1.0/docs/embedding.md +146 -0
- regstack-0.1.0/docs/index.md +55 -0
- regstack-0.1.0/docs/quickstart.md +115 -0
- regstack-0.1.0/docs/security.md +170 -0
- regstack-0.1.0/docs/theming.md +168 -0
- regstack-0.1.0/examples/minimal/README.md +30 -0
- regstack-0.1.0/examples/minimal/branding/theme.css +25 -0
- regstack-0.1.0/examples/minimal/main.py +109 -0
- regstack-0.1.0/examples/minimal/regstack.toml +29 -0
- regstack-0.1.0/pyproject.toml +108 -0
- regstack-0.1.0/regstack.toml.example +49 -0
- regstack-0.1.0/src/regstack/__init__.py +5 -0
- regstack-0.1.0/src/regstack/app.py +150 -0
- regstack-0.1.0/src/regstack/auth/__init__.py +21 -0
- regstack-0.1.0/src/regstack/auth/clock.py +29 -0
- regstack-0.1.0/src/regstack/auth/dependencies.py +102 -0
- regstack-0.1.0/src/regstack/auth/jwt.py +145 -0
- regstack-0.1.0/src/regstack/auth/lockout.py +59 -0
- regstack-0.1.0/src/regstack/auth/mfa.py +29 -0
- regstack-0.1.0/src/regstack/auth/password.py +20 -0
- regstack-0.1.0/src/regstack/auth/tokens.py +19 -0
- regstack-0.1.0/src/regstack/cli/__init__.py +0 -0
- regstack-0.1.0/src/regstack/cli/__main__.py +27 -0
- regstack-0.1.0/src/regstack/cli/_runtime.py +39 -0
- regstack-0.1.0/src/regstack/cli/admin.py +45 -0
- regstack-0.1.0/src/regstack/cli/doctor.py +186 -0
- regstack-0.1.0/src/regstack/cli/init.py +236 -0
- regstack-0.1.0/src/regstack/config/__init__.py +4 -0
- regstack-0.1.0/src/regstack/config/loader.py +114 -0
- regstack-0.1.0/src/regstack/config/schema.py +148 -0
- regstack-0.1.0/src/regstack/config/secrets.py +22 -0
- regstack-0.1.0/src/regstack/db/__init__.py +17 -0
- regstack-0.1.0/src/regstack/db/client.py +26 -0
- regstack-0.1.0/src/regstack/db/indexes.py +70 -0
- regstack-0.1.0/src/regstack/db/repositories/__init__.py +0 -0
- regstack-0.1.0/src/regstack/db/repositories/blacklist_repo.py +28 -0
- regstack-0.1.0/src/regstack/db/repositories/login_attempt_repo.py +27 -0
- regstack-0.1.0/src/regstack/db/repositories/mfa_code_repo.py +99 -0
- regstack-0.1.0/src/regstack/db/repositories/pending_repo.py +76 -0
- regstack-0.1.0/src/regstack/db/repositories/user_repo.py +169 -0
- regstack-0.1.0/src/regstack/email/__init__.py +12 -0
- regstack-0.1.0/src/regstack/email/base.py +23 -0
- regstack-0.1.0/src/regstack/email/composer.py +142 -0
- regstack-0.1.0/src/regstack/email/console.py +28 -0
- regstack-0.1.0/src/regstack/email/factory.py +23 -0
- regstack-0.1.0/src/regstack/email/ses.py +47 -0
- regstack-0.1.0/src/regstack/email/smtp.py +46 -0
- regstack-0.1.0/src/regstack/email/templates/email_change.html +15 -0
- regstack-0.1.0/src/regstack/email/templates/email_change.subject.txt +1 -0
- regstack-0.1.0/src/regstack/email/templates/email_change.txt +7 -0
- regstack-0.1.0/src/regstack/email/templates/password_reset.html +15 -0
- regstack-0.1.0/src/regstack/email/templates/password_reset.subject.txt +1 -0
- regstack-0.1.0/src/regstack/email/templates/password_reset.txt +7 -0
- regstack-0.1.0/src/regstack/email/templates/sms_login_mfa.txt +1 -0
- regstack-0.1.0/src/regstack/email/templates/sms_phone_setup.txt +1 -0
- regstack-0.1.0/src/regstack/email/templates/verification.html +15 -0
- regstack-0.1.0/src/regstack/email/templates/verification.subject.txt +1 -0
- regstack-0.1.0/src/regstack/email/templates/verification.txt +7 -0
- regstack-0.1.0/src/regstack/hooks/__init__.py +3 -0
- regstack-0.1.0/src/regstack/hooks/events.py +59 -0
- regstack-0.1.0/src/regstack/models/__init__.py +15 -0
- regstack-0.1.0/src/regstack/models/_objectid.py +30 -0
- regstack-0.1.0/src/regstack/models/login_attempt.py +31 -0
- regstack-0.1.0/src/regstack/models/mfa_code.py +40 -0
- regstack-0.1.0/src/regstack/models/pending_registration.py +38 -0
- regstack-0.1.0/src/regstack/models/user.py +104 -0
- regstack-0.1.0/src/regstack/routers/__init__.py +37 -0
- regstack-0.1.0/src/regstack/routers/_schemas.py +34 -0
- regstack-0.1.0/src/regstack/routers/account.py +274 -0
- regstack-0.1.0/src/regstack/routers/admin.py +187 -0
- regstack-0.1.0/src/regstack/routers/login.py +223 -0
- regstack-0.1.0/src/regstack/routers/logout.py +39 -0
- regstack-0.1.0/src/regstack/routers/password.py +114 -0
- regstack-0.1.0/src/regstack/routers/phone.py +242 -0
- regstack-0.1.0/src/regstack/routers/register.py +99 -0
- regstack-0.1.0/src/regstack/routers/verify.py +116 -0
- regstack-0.1.0/src/regstack/sms/__init__.py +5 -0
- regstack-0.1.0/src/regstack/sms/base.py +24 -0
- regstack-0.1.0/src/regstack/sms/factory.py +23 -0
- regstack-0.1.0/src/regstack/sms/null.py +26 -0
- regstack-0.1.0/src/regstack/sms/sns.py +42 -0
- regstack-0.1.0/src/regstack/sms/twilio.py +49 -0
- regstack-0.1.0/src/regstack/ui/__init__.py +3 -0
- regstack-0.1.0/src/regstack/ui/pages.py +148 -0
- regstack-0.1.0/src/regstack/ui/static/css/core.css +204 -0
- regstack-0.1.0/src/regstack/ui/static/css/theme.css +43 -0
- regstack-0.1.0/src/regstack/ui/static/js/regstack.js +411 -0
- regstack-0.1.0/src/regstack/ui/templates/auth/email_change_confirm.html +10 -0
- regstack-0.1.0/src/regstack/ui/templates/auth/forgot.html +14 -0
- regstack-0.1.0/src/regstack/ui/templates/auth/login.html +24 -0
- regstack-0.1.0/src/regstack/ui/templates/auth/me.html +110 -0
- regstack-0.1.0/src/regstack/ui/templates/auth/mfa_confirm.html +14 -0
- regstack-0.1.0/src/regstack/ui/templates/auth/register.html +23 -0
- regstack-0.1.0/src/regstack/ui/templates/auth/reset.html +13 -0
- regstack-0.1.0/src/regstack/ui/templates/auth/verify.html +10 -0
- regstack-0.1.0/src/regstack/ui/templates/base.html +46 -0
- regstack-0.1.0/src/regstack/version.py +1 -0
- regstack-0.1.0/tasks.py +98 -0
- regstack-0.1.0/tests/__init__.py +0 -0
- regstack-0.1.0/tests/conftest.py +144 -0
- regstack-0.1.0/tests/integration/__init__.py +0 -0
- regstack-0.1.0/tests/integration/test_account_management.py +294 -0
- regstack-0.1.0/tests/integration/test_admin_router.py +175 -0
- regstack-0.1.0/tests/integration/test_happy_path.py +134 -0
- regstack-0.1.0/tests/integration/test_indexes.py +29 -0
- regstack-0.1.0/tests/integration/test_login_lockout.py +82 -0
- regstack-0.1.0/tests/integration/test_mfa.py +243 -0
- regstack-0.1.0/tests/integration/test_password_reset.py +121 -0
- regstack-0.1.0/tests/integration/test_ui_router.py +117 -0
- regstack-0.1.0/tests/integration/test_verification.py +143 -0
- regstack-0.1.0/tests/unit/__init__.py +0 -0
- regstack-0.1.0/tests/unit/test_cli.py +132 -0
- regstack-0.1.0/tests/unit/test_config_loader.py +41 -0
- regstack-0.1.0/tests/unit/test_jwt.py +61 -0
- regstack-0.1.0/tests/unit/test_lockout.py +85 -0
- regstack-0.1.0/tests/unit/test_mail_composer.py +88 -0
- regstack-0.1.0/tests/unit/test_mfa_code_repo.py +112 -0
- regstack-0.1.0/tests/unit/test_password.py +16 -0
- regstack-0.1.0/tests/unit/test_ses_backend.py +26 -0
- regstack-0.1.0/tests/unit/test_sms.py +77 -0
- regstack-0.1.0/tests/unit/test_smtp_backend.py +62 -0
- regstack-0.1.0/tests/unit/test_ui_env.py +50 -0
- 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.
|
regstack-0.1.0/CLAUDE.md
ADDED
|
@@ -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.
|