Changelog¶
All notable changes to this project are documented here. Versions follow
Semantic Versioning once 1.0.0 ships.
Unreleased¶
0.8.2 — 2026-05-21¶
Headline¶
Security hardening from two daily reviews, and a doctor check that
nudges operators off vulnerable MongoDB servers.
This release closes the findings from the 2026-05-19 and 2026-05-20
security reviews. regstack doctor learns a new advisory check: on a
Mongo backend it reads the connected server’s version and warns when
it’s behind the CVE-2025-14847 (“MongoBleed”) patched baseline — a
server-side bug the pymongo driver isn’t exposed to, but one host
operators should still patch. The warning is advisory (a yellow ⚠),
so it surfaces the risk without failing regstack doctor && deploy.
Two smaller security fixes round it out: the null SMS backend no
longer logs the 6-digit MFA code by default (symmetric with the email
backend, so a misconfigured deployment can’t leak codes into shared
logs), and Google ID-token verification no longer runs its JWKS fetch
on the event loop. Plus the weekly coverage routine now survives
restricted-network CI containers instead of refusing to produce a
number.
Added¶
regstack doctorflags out-of-date MongoDB servers. A new advisory check (mongo backend only) compares the connected server’s version against the CVE-2025-14847 (“MongoBleed”) patched baseline and prints a ⚠ warning when it’s behind. Advisory warnings are surfaced but do not fail the command (exit stays 0), soregstack doctor && deployis unaffected.CheckResultgained awarnstate and doctor renders three symbols now: ✔ / ⚠ / ✘. Minimum supported server versions are documented in embedding.md. (Security review 2026-05-20 · I-3.)
Security¶
sms.log_bodiesnow defaults toFalse. ThenullSMS backend used to log the message body (including the 6-digit MFA code) at INFO by default, asymmetric withemail.log_bodies. A misconfigured deployment left on thenullbackend could leak codes into shared logs. The default is now off; flip it on for local dev when you want the code in stdout (the bundled examples surface it via anmfa_login_startedhook, so they’re unaffected). (Security review 2026-05-19.)
Changed¶
OAuth JWKS fetch no longer blocks the event loop. Google ID-token verification ran
PyJWKClient.get_signing_key_from_jwt(synchronousurllib) directly on the event loop; a JWKS cache-miss fetch could stall concurrent requests for the round-trip to Google. It now runs viaasyncio.to_thread. (Security review 2026-05-20 · I-2.)Coverage routine survives restricted-network containers. The weekly CCR coverage run used to refuse any partial-matrix invocation outright (issue #71).
scripts/ccr_coverage_setup.pyis now tolerant: each backend (Mongo, Postgres, Playwright) is attempted independently and the script reports a status table showing which came up; failures don’t abort siblings.inv coveragegained--backends=<csv>(defaultsqlite,mongo,postgres) and--allow-partial. The default still hard-fails when something’s missing — partial-matrix numbers are not a release gate — but--allow-partialproduces a number with aCOVERAGE_PARTIALbanner naming the excluded backends, which is useful for tracking trends in restricted environments. CI continues to run the full matrix on every push.
0.8.1 — 2026-05-19¶
Headline¶
CLI ergonomics: one --config flag everywhere, --headless and
--dry-run on the wizards.
The 0.8.0 system-consistency review left five deferred items on the
table. Three of them ship in this release. Every command that loads
or writes regstack config now takes a single --config flag — and
it accepts either a TOML file path or a directory containing one, so
the same value flows through init and the read-only commands without
the old file-vs-directory dance. --target survives as a deprecated
alias on the four config-writing commands (init, oauth setup,
ses setup, theme design) and emits a one-line warning; it goes
away in 1.0. migrate --target keeps its existing meaning (Alembic
revision) since the conflict is contextual — the helptext now spells
that out.
Alongside that, the three pywebview wizards swap their misleadingly-
named --print-only flag (which actually wrote to disk) for two
honest flags: --headless writes the config from CLI flags and prints
a JSON summary; --dry-run validates and prints the same diff but
leaves the filesystem untouched. --print-only survives as a
deprecated alias for --headless; it also goes away in 1.0. The
emitted JSON now carries "dry_run": <bool> so scripted consumers
can branch on it.
Plus a small refactor: the two SSR token-handoff pages
(verify.html, email_change_confirm.html) used to be near-duplicate
templates. They now share a Jinja macro, auth/_token_handoff.html —
rendered HTML is unchanged, but the divergence is gone for the next
time someone needs to touch both at once.
Changed¶
CLI flag unification.
--configis now the canonical flag on every command that loads or writes regstack config. Accepts either a path toregstack.tomlor a directory containing it.--targetis retained as a deprecated alias oninit,oauth setup,ses setup, andtheme design(warning emitted; removed in 1.0).migrate --targetkeeps its existing meaning (Alembic revision) since the conflict is contextual.Wizard mode flags. The three pywebview wizards now expose
--headless(“skip the GUI, write the config from CLI flags, emit a JSON summary”) and--dry-run(“validate and print the diff but do not touch the files”).--print-onlyis the deprecated alias for--headless; removed in 1.0. The emitted JSON gains a"dry_run": <bool>field for scripted consumers.Token-handoff SSR pages share a macro.
verify.htmlandemail_change_confirm.htmlwere near-duplicates — same DOM, samedata-rs-token/data-rs-statusshape, only the heading and pending-message strings differed. The shared body lives inauth/_token_handoff.html. Rendered HTML is unchanged; hosts that override either file individually still win against the bundled default viaRegStack.add_template_dir(...).
Deprecated¶
--targetonregstack init,regstack oauth setup,regstack ses setup,regstack theme design. Use--config. Removed in 1.0.--print-onlyonregstack oauth setup,regstack ses setup,regstack theme design. Use--headless(or--dry-runfor no-write validation). Removed in 1.0.
0.8.0 — 2026-05-19¶
Headline¶
regstack ses setup — guided SES configuration that validates
against AWS as you go.
A new pywebview wizard mirrors the existing regstack oauth setup
flow: pick a region, pick a credential source (named profile /
explicit access keys / IAM role chain), confirm the sender domain
is verified in SES, detect the AWS account’s sandbox state, fire
one live test send, then merge the result into regstack.toml
regstack.secrets.envnon-destructively. Available behind bothwizardandsesextras:pip install 'regstack[wizard,ses]'.
Plus two security fixes from yesterday’s daily review (the theme-designer wheel was shipping well-known example credentials in its preview HTML; the Google OAuth exchange path could echo a live short-lived access token in an error message when the response shape was malformed).
Added¶
regstack ses setupCLI command. Guided pywebview wizard for the SES email backend. Nine steps walk through region selection, credential source (profile/explicit/chain), sender- domain identity verification (via SESGetIdentityVerificationAttributes), sandbox detection (viaGetAccountwith aGetSendQuotaheuristic fallback for IAM-restricted policies), and a live test send (viaSendEmail). Non-clobbering tomlkit + secrets.env merge.Headless
--print-onlymode runs the same merge without the GUI for CI / scripting. Gated behind the joint extra:pip install 'regstack[wizard,ses]'. Companion to the existingregstack doctor --send-test-emailfor post-config verification.
Security¶
Theme-designer preview no longer ships well-known credentials. The
designer.htmlpreview card hadalice@example.com/hunter2hunter2asvalue=attributes on its mock sign-in form, meaning every wheel that bundled the wizard carried well-known example creds someone could mistake for real fixtures. Both are nowplaceholder=attributes so the form starts empty. (Daily security review 2026-05-18 · I-1.)Google OAuth
exchange_code()no longer echoes the response body. In the rare 200-without-id_tokenedge case (misconfigured client missing theopenidscope), the response body can carry a live short-livedaccess_token. The previousOAuthTokenExchangeError(f"... {body!r}")then surfaced that token through the router’s WARNING-level log. Dropped{body!r}from the exception message; regression testtest_exchange_code_error_message_does_not_leak_token_bodyplants a recognisable token in the response and asserts it never appears in the exception’sstr()orargstuple. (Daily security review 2026-05-18 · I-2.)
0.7.0 — 2026-05-17¶
Headline¶
regstack validate end-to-end probe, seven security-review findings closed, and a clutch of host-integration ergonomic wins.
Two-week sprint covering 21 commits since 0.6.0. The headline feature is regstack validate — a new CLI command that drives a real deployed install through every auth flow (register, verify, login, logout, password reset, change-email, OAuth start, SMS 2FA) from a remote operator workstation, scraping one-time tokens out of the deployment’s stdout via a --log-source of your choice (file:, ssh:, docker:, cmd:). It is the companion to regstack doctor: doctor checks the loaded config, validate checks the running service.
Alongside validate, the release lands a handful of integration-ergonomic wins for embedding hosts — per-link email URL templates so SPAs with non-canonical routes don’t need to override templates, current_user_optional for endpoints that render differently for anonymous callers, RegStack.promote_pending for admin rescue of stuck signups, and explicit SES credential fields so hosts loading AWS creds from a secrets store don’t need to re-export them as environment variables. Two breaking trims (UserPublic._id → id and TokenTransport = Literal["bearer"] only) drop dead API surface that was already broken or misleading.
The security half of the release closes everything outstanding from the 2026-05-15 and 2026-05-16 daily reviews — the python-multipart CVE-2026-42561 floor bump, sdist-leak exclusions, defensive ObjectId guards on nine Mongo mutations, rate-limit coverage for /login/mfa-confirm and /oauth/exchange, OAuth callback log-injection sanitization, MongoDB-side oauth_states.mode validation, the alembic 0002 downgrade NULL guard, PEP 740 sigstore attestations on the publish workflow, and the removal of workflow_dispatch from publish.yml. The 2026-05-17 review (docs/security-reports/2026-05-17.md) verified all of them resolved.
Changed (BREAKING)¶
UserPublicserialises the user identifier asid, not_id. Thealias="_id"onUserPublic.id(withpopulate_by_name=True) is removed. Every endpoint returning aUserPublic—POST /api/auth/register,GET /api/auth/me,PATCH /api/auth/me, the admin user endpoints — now sendsidon the wire.BaseUser(the Mongo-document model) keeps the alias becauseto_mongo()round-trips viamodel_dump(by_alias=True); the API surface is the only thing that changes.Migration: any client (browser or service) that read
body["_id"]should switch tobody["id"]. Hosts that bolted a hand-rolled/meoverride on top of the previous shape to get a conventionalidkey can drop that adapter entirely.TokenTransportliteral narrowed toLiteral["bearer"]."cookie"was previously accepted by config validation but silently no-op’d (no router ever setSet-Cookie). Hosts that settransport = "cookie"now get a pydanticliteral_errorat startup instead of a silent security misconfiguration.RegStackConfig.cookie_domainis removed along with it.regstack initno longer offers the cookie option either.
Added¶
regstack validateCLI command. End-to-end probe of a deployed install — registers a throwaway user, walks every auth flow, then deletes the user. Reads one-time tokens out of the deployment’s stdout via--log-source(file / ssh / docker / arbitrary command). Skip phases with--skip. Companion toregstack doctor(which only validates loaded config). Seeregstack validate --helpfor the full operator runbook (preparation steps + phase list).email.log_bodiesandsms.log_bodiesconfig flags to promote the console / null backends’ body log lines from DEBUG → INFO without enabling DEBUG globally.email.log_bodiesdefaults toFalse;sms.log_bodiesdefaults toTrue(preserves prior null-SMS behaviour). Other backends ignore.RegStackConfig.email_link_prefix+ auto-resolve fromui_prefix. Verification / reset / email-change links now default to<base_url><ui_prefix>/verify?token=...when the bundled UI router is enabled, instead of bare/verify. Hosts whose SPA owns the auth pages can pin a path explicitly viaemail_link_prefix; the bundled UI hosts get the right links automatically.EmailConfig.from_namedefaults toapp_namewhen unset. Hosts that changeapp_nameto brand outgoing email also get the matchingFrom:header automatically. Explicitfrom_namevalues still win.Per-link email URL templates for SPAs whose router shape doesn’t take the canonical
/verify?token=...//reset-password?token=...//confirm-email-change?token=...form. Three new optionalRegStackConfigfields:verify_url_template: str | None = None password_reset_url_template: str | None = None email_change_url_template: str | None = None
Each accepts
{base_url}(trailing slash trimmed) and{token}(literal, no URL-encoding) placeholders. A hash-routed SPA can setverify_url_template = "{base_url}/#/verify/{token}"; a host whose auth pages live on a sibling subdomain can setverify_url_template = "https://auth.example.com/verify/{token}".When a template is
None, the resolver falls back to theemail_link_prefix-based composition above — no behaviour change for anyone who hasn’t opted in. New helpersRegStackConfig.resolve_verify_url(token),resolve_password_reset_url(token),resolve_email_change_url(token)are now the single source of truth for those URLs across the four router call sites.current_user_optionaldependency. Companion to the existingcurrent_user/current_adminfactories onregstack.deps. ReturnsBaseUser | Noneinstead of raising 401 — for endpoints that render differently for authenticated vs anonymous callers (cart icon, comment author prefill, “your recent X” sections):from fastapi import Depends from regstack.models.user import BaseUser @app.get("/products/{slug}") async def view_product( slug: str, user: BaseUser | None = Depends(regstack.deps.current_user_optional()), ): ...Every form of auth failure — missing header, wrong scheme, malformed/expired/revoked token, deleted or bulk-revoked user — collapses to
None(noHTTPExceptionraised). On success the user is stashed onrequest.state.regstack_user, same ascurrent_user.RegStack.promote_pending(email)+ admin route for converting aPendingRegistrationrow directly into a verified active user, bypassing the email-link round-trip:user = await regstack.promote_pending("alice@example.com")The pending row’s
hashed_passwordandfull_namecarry over verbatim so the user can log in with their original password. Fires the sameuser_verifiedhook asPOST /verifyso downstream analytics see one event regardless of trigger.Useful for:
admin rescue of users who lost their verification link;
CLI batch seeding from a known-good list;
dev fixtures.
Exposed over HTTP as
POST /admin/pending/{email}/promotewhen the admin router is enabled. Returns 201 on success, 404 when no pending row exists for that email (also when the row has expired past its TTL), 409 when a user with that email is already registered.Explicit SES credential fields on
EmailConfig. Two new optionalSecretStrfields:ses_access_key_id: SecretStr | None = None ses_secret_access_key: SecretStr | None = None
Set them to pass AWS credentials directly into the SES backend instead of relying on boto3’s environment-variable fallthrough. Useful when the host already loads its AWS creds from a secrets store (Vault, AWS Secrets Manager,
secrets.env) and would rather not re-export them into the process environment.Validated as a pair: setting just one raises a
ValidationError, and combining them withses_profileraises (boto3 silently resolves explicit creds over profile, which makes the active identity ambiguous from config alone). Default unset → no behaviour change — boto3’s existing credential chain (env vars, shared credentials file, EC2/ECS instance profile) keeps working.
Security¶
CVE-2026-42561 —
python-multipart>=0.0.27. Closes a network-exploitable DoS via unbounded multipart part-header parsing (CVSS 7.5). Previous floor>=0.0.26had the earlier CVE-2026-40347 fix only.sdist no longer ships internal docs to PyPI. Added a
[tool.hatch.build.targets.sdist]exclude block topyproject.toml. The published source tarball used to containCLAUDE.md(with a developer home-directory path), the security-review prompt, the full test suite, build tooling, and (when built from a worktree) a.gittext file pointing at the operator’s worktrees directory.Defensive
ObjectId.is_valid()on nine Mongo UserRepo mutations.set_last_login,set_tokens_invalidated_after,update_password,set_active,set_superuser,set_full_name,set_phone,set_mfa_enabled, andupdate_emailnow matchget_by_id/delete: invalid input no-ops instead of raisingbson.errors.InvalidId(which would have surfaced as a 500 on any future caller passing raw external input).Per-IP rate-limit map covers
/login/mfa-confirmand/oauth/exchange. Two new config fields:login_mfa_confirm_rate_limit,oauth_exchange_rate_limit. The per-code attempt counter onmfa_codesdefends each individual code; this adds the per-IP layer against distributed guessing across many source IPs.OAuth callback
errorquery parameter sanitized before logging. A compromised or malicious OAuth provider could previously inject newlines / ANSI escapes into the log stream via theerror=...redirect. The callback now strips control characters and caps length at 200 before logging.oauth_states.modevalidated at the MongoDB storage layer. A$jsonSchemavalidator on the collection enforcesmode IN ('signin', 'link'), matching the SQL backend’s existingCheckConstraint.OAuthState.model_validate()already enforced this at the app layer; this is defence-in-depth.Migration
0002downgrade refuses to roll back when OAuth-only users exist. The downgrade re-appliesNOT NULLtousers.hashed_password; if any row hasNULL(OAuth-only signup), it now raisesRuntimeErrorwith a clear remediation message instead of silently succeeding on SQLite (wherebatch_alter_table’s CREATE-COPY-DROP-RENAME path skipped NOT NULL enforcement).PEP 740 sigstore attestations on the PyPI publish workflow. Each published wheel / sdist is now cryptographically bound to the specific GitHub Actions run that produced it, so consumers can verify the artefact came from this repo’s CI.
workflow_dispatchremoved frompublish.yml. Manual runs previously uploaded artefacts to Actions storage with no version validation, where they could be confused with a real release build. Tag-push is the only supported trigger.
Fixed¶
regstack doctor --send-test-emailhonours the newfrom_namefall-back. Before, the probe path passedconfig.email.from_name(nowOptional[str]) straight intoEmailMessage.from_name(typedstr), producing aNone <addr>From: header when unset.install_schema()survives a legacy unnamed unique-on-email index. A host that previously randb.users.create_index([("email", 1)], unique=True)from its own pre-regstack auth code has a Mongo-auto-namedemail_1index.install_indexespreviously crashed on first boot withIndexOptionsConflictbecause it tried to createemail_uniqueover the same key. It now detects any uniquely-indexed-by-email index whose name isn’temail_unique, drops it, and proceeds. Idempotent — re-running on a healthy database leaves indexes alone.POST /verifyno longer 500s on the admin-promote-meets- user-clicks-verify race. The endpoint now catchesUserAlreadyExistsErrorfromusers.createand returns a graceful 400 (“This email is already registered. Please sign in.”) instead of letting the unique-constraint violation bubble up as a 500.
Internal¶
GitHub Actions pinned ahead of Node 20 deprecation.
actions/checkoutv4→v6.0.2,astral-sh/setup-uvv3→v8.1.0,actions/upload-artifactv4→v7.0.1,actions/download-artifactv4→v8.0.1. All pins remain commit SHAs.Daily scheduled security-review reports land under
docs/security-reports/for 2026-05-15 through 2026-05-17. The 2026-05-17 report is[security-clean]: all warnings from the prior two days were resolved in this release.
0.6.0 — 2026-05-14¶
Changed (BREAKING — wizard install)¶
GUI setup wizards now require the optional
wizardextra.regstack oauth setupandregstack theme designpreviously worked from a barepip install regstackbecause their deps —pywebview,tomlkit, anduvicorn[standard]— were in the base dependency list. That meant every library consumer (including pure FastAPI host apps that never run a setup wizard) was paying for a platform browser engine and an ASGI server at install time. Those three packages now live in a newwizardextra; the base install is significantly slimmer.Migration.
If you embed regstack but don’t use the setup wizards: no action needed.
import regstack,RegStack(...), and theregstack init/regstack doctor/regstack migrate/regstack create-adminCLIs all work on the bare install.If you use either setup wizard:
pip install 'regstack[wizard]'oruv sync --extra wizard.The
devextra continues to pull in the wizard deps directly, soinv test-allkeeps working without an explicit--extra wizard.
Running a wizard subcommand without the extra installed now exits with a one-line install hint and a non-zero exit code, rather than an
ImportErrortraceback from deep inside the wizard subtree.Bumped to 0.6.0 rather than 0.5.12 because removing top-level dependencies can surprise downstream callers; the version line signals it’s worth a glance at the migration note.
0.5.11 — 2026-05-14¶
CI / workflow hygiene. No runtime code changes.
Security¶
All third-party GitHub Actions pinned to commit SHAs.
actions/checkout@v4,astral-sh/setup-uv@v3,actions/upload-artifact@v4, andactions/download-artifact@v4now use commit SHAs across both workflows (pypa/gh-action-pypi-publishwas already SHA-pinned in 0.5.6). Tag swaps upstream can no longer substitute a malicious version.permissions:blocks declared on every workflow + job. Both workflows declare apermissions: contents: readdefault at the workflow level and re-state it per job. Thepublishjob continues to addid-token: writefor the OIDC trusted-publisher exchange — that’s the only scope above read-only anywhere in the workflows.
Internal¶
.gitignoregained.env,.env.*, and the common credential-file patterns (*.pem,*.key,*.p12,*.pfx,*.jks,*.crt). Belt-and-braces for misconfigured local dev envs; nothing tracked today depends on these.
0.5.10 — 2026-05-14¶
Security fixes from the 2026-05-13 / 2026-05-14 daily review reports.
Security¶
Open-redirect bypass in OAuth
redirect_to._validate_redirectwas forwardingurlsplit’s judgment, but browsers normalize values like/\evil.comand////evil.cominto the protocol-relative//evil.com. Both forms now rejected.CVE-2025-62727 —
fastapi>=0.120.0(was>=0.110). Starlette DoS via large request bodies after multipart processing.CVE-2025-27516 —
jinja2>=3.1.6(was>=3.1). Sandbox breakout via the|attrfilter.Login lockout coverage extended.
POST /loginwas returning HTTP 403 foris_active=Falseand (whenrequire_verification=True) unverified accounts without recording a failure — an attacker guessing passwords against either category had unbounded probing. The endpoint now:Verifies the password before the active/verified checks (so an unauthenticated attacker can’t distinguish disabled vs active accounts by HTTP code).
Records a failure before raising 403 in either branch.
POST /change-emailanti-enumeration. An authenticated attacker could walk the registered-email namespace via the 409 vs 202 distinction. The endpoint now always returns 202; clashes are logged server-side and the confirmation email is silently skipped. Matches the existing stance on/forgot-passwordand/resend-verification.Admin resend-verification rejects OAuth-only users with a clear 400 instead of attempting to construct a
PendingRegistrationwithhashed_password=None(which corrupted the pending-registrations row).
0.5.9 — 2026-05-13¶
Security¶
OAuthConfig.enforce_mfa_on_oauth_signinis now wired. The flag has been on the config since 0.3.0 and surfaced through the OAuth setup wizard, but the callback never read it — operators who enabled it still got OAuth sign-ins that bypassed the SMS second factor. The High #1 finding from the post-0.5.6 consistency audit.When the flag is
trueand the resolved user has SMS MFA set up (is_mfa_enabled=Trueplus aphone_number), the OAuth callback now sends the SMS code, stashes a short-livedlogin_mfapending JWT in the state row instead of a session token, and the SPA’sregstack.jsoauth-completehandler reroutes through the existing/account/mfa-confirmpage →POST /login/mfa-confirmflow.Link flows (
mode="link") are intentionally exempt: the user was already authenticated when they kicked off the link, so re-MFAing is friction without a threat-model win.
Changed¶
ExchangeResponsegained two optional fields:mfa_required: bool = Falsemfa_pending_token: str | None = None
access_tokendefaults to""(instead of being required) so the MFA branch can return without one. Existing SPAs that readaccess_tokenkeep working — they just need to branch onmfa_requiredfirst.oauth_signin_completedhook now carriesmfa_required=<bool>alongside the existinguser,provider,mode,was_newkwargs so observability handlers can distinguish “session minted” from “MFA second-step in progress” outcomes.
0.5.8 — 2026-05-13¶
Audit-driven consistency cleanup — small fixes across the API surface flagged by the post-0.5.6 consistency review.
Security¶
oauth.completion_ttl_secondsis now enforced. This flag has been onOAuthConfigsince the OAuth router shipped, but the callback never used it. A state row stayed valid for the fullstate_ttl_seconds(300s default) between callback completion and the SPA’s/oauth/exchangecall. The callback now shortens the row’sexpires_attonow + completion_ttl_seconds(30s default) when it stashes the result token, so the blast radius of a stolen state_id post-callback is the documented 30-second window.
Changed (UserPublic surface)¶
UserPublicnow serialisesupdated_atandtokens_invalidated_after. SPAs comparing the latter against their cached session JWT’siatcan detect a forced sign-out after a password / email change without an extra round-trip.
Changed (hook payloads)¶
oauth_signin_startedinmode="link"now carries the authenticateduser=kwarg, matchingoauth_signin_completedandoauth_account_linked. Themode="signin"call site stays user-less (there isn’t one yet — sign-in is what produces it).
Internal¶
OAuthStateRepoProtocol.set_result_tokengrew an optionalnew_expires_at=kwarg so the callback can re-set the row’s expiry atomically with the token write. Both Mongo and SQL impls updated.MessageResponseinrouters/oauth.pydeleted; the router now uses the shared one fromrouters/_schemas.py. OpenAPI no longer carries two identically-named schemas.MongoBlacklistRepo.purge_expiredswitched from$lteto$ltto match the rest of thepurge_expiredfamily. Bulk-revoke (which DOES use<=for the conservative same-instant interpretation) is unchanged.Dead
create()/delete_by_id()methods removed fromMongoPendingRepo— neither was in the protocol or the SQL impl.OAuth
startandcallbackendpoints now declareresponse_class=RedirectResponseandstatus_code=302.Custom-claim JWT encoder in
routers/account.py(email-change token) now emitsiatas a float instead ofint, matching the three other custom-claim encoders.routers/verify.pycreated_atfor resent pending registrations now goes throughrs.clock.now()instead of wall-clockdatetime.now(UTC).BaseUser.model_config = ConfigDict(extra="allow")is now documented inline (it’s the only model in the package that doesn’textra="forbid", on purpose).
0.5.7 — 2026-05-13¶
Documentation-only follow-up to 0.5.6 — no runtime code changes.
Docs¶
docs/configuration.mdnow documents the per-route*_rate_limitfamily (added in 0.5.4) instead of pointing atlogin_max_per_minute/login_max_per_houras reserved future fields.docs/security.mdno longer referencesPasswordHasher.needs_rehash(removed in 0.5.6). Replacement guidance points hosts atpwdlib.PasswordHash.verify_and_updateinside auser_logged_inhook.Root
CHANGELOG.mdbackfilled with 0.4.0 and 0.5.0 entries so it matches this file. The two changelogs are now in sync.
0.5.6 — 2026-05-13¶
A rollup release that consolidates 11 days of security-review
remediation, supply-chain hardening, a full mypy --strict pass,
and the per-route rate-limits feature.
Added¶
Per-route IP rate limits. Opt-in via the new
rate_limitextra (or a host-suppliedslowapi.Limiter) plus any of the newRegStackConfig.*_rate_limitfields (login_rate_limit,register_rate_limit,forgot_password_rate_limit,reset_password_rate_limit,verify_rate_limit,resend_verification_rate_limit,change_password_rate_limit,change_email_rate_limit,confirm_email_change_rate_limit,delete_account_rate_limit). Each accepts a slowapi-syntax string ("5/minute","5/minute;20/hour").New constructor argument
RegStack(rate_limiter=...). When at least one*_rate_limitfield is set, regstack expects either this argument or therate_limitextra; failure to provide one raisesRuntimeErroron first access toregstack.router— failing closed beats silently disabling the protection. Hosts remain responsible forapp.state.limiterand theRateLimitExceededexception handler; slowapi owns the 429 response shape.user_logged_outhook now fires. The event was listed inKNOWN_EVENTSsince M1 but no router ever emitted it.routers/logout.pynow firesuser_logged_out(with auser=kwarg) immediately after the bearer token is revoked.
Changed (security)¶
JWT 401 responses no longer leak the pyjwt error reason. Replaced
f"Invalid token: {exc}"with the static"Invalid or expired token.". The pyjwt error text disclosed why a token was rejected (signature mismatch, expired, malformed, audience mismatch) — useful signal for an attacker probing the auth surface.OAuth sign-in honours
allow_registration=False./registeralready did; the OAuth_resolve_user“brand-new account” branch did not, so an operator who disabled self-service signup still got accounts created via “Sign in with Google”. The OAuth callback now redirects with?error=registration_disabledif no existing account matches and registration is disabled.Admin
DELETE /admin/users/{id}now cascadesoauth_identities. Matches the user-initiatedDELETE /accountflow; previously left orphan rows that blocked the Google subject from re-registering.POST /phone/startandDELETE /phoneguard against OAuth-only users. Both endpoints previously crashed with HTTP 500 for users withhashed_password=None. Both now return 400 with a message pointing to forgot-password (which doubles as a “set initial password” path).
Changed (BREAKING — hook contracts)¶
mfa_login_startedandphone_setup_startedno longer include the raw OTP code in their kwargs. Hooks are best-effort observability and are the documented integration surface for analytics / logging / Slack notifications, so a plaintext OTP in**kwargsis a leak waiting to happen — a host addinglogger.info(kw)to a hook handler is enough to put OTPs in a log stream. Hosts that subscribed to either event to take over SMS delivery should migrate to a customSmsServicesubclass (the supported delivery override). The other kwargs (user,phone) remain.
Changed (deps)¶
pyjwt>=2.12.1(was>=2.8). Picks up CVE-2026-32597 (critheader bypass, CVSS 7.5).cryptography>=46.0.7added explicitly to theoauthextra (was pulled transitively, unbounded). Picks up CVE-2026-26007 (ECC subgroup attack on the JWKS code path, CVSS 8.2) plus CVE-2026-34073 and CVE-2026-39892.python-multipart>=0.0.26(was>=0.0.9). Picks up CVE-2026-40347 (DoS via oversized multipart preamble).pypa/gh-action-pypi-publishinpublish.ymlpinned to a commit SHA instead of the mutablerelease/v1branch. The publish job holdsid-token: write, so a tag/branch swap upstream would let an attacker push a malicious wheel under our OIDC identity.
Removed¶
PasswordHasher.needs_rehash— called pwdlib’s non-existentcheck_needs_rehashand wouldAttributeErrorif anyone invoked it. No callers in src or tests. If you were planning to use it, callpwdlib.PasswordHash.verify_and_updatedirectly.
Internal¶
72
mypy --stricterrors cleared across 35 files.inv lintis green end-to-end (ruff + mypy). Still local-only — not yet a CI gate.Mongo
BlacklistRepo.purge_expiredadded (was missing from the Mongo impl; SQL impl already had it). Mongo’s TTL index still reaps automatically; the explicitdelete_manyis for protocol parity and for tests that can’t wait for the 60-second TTL monitor.KNOWN_EVENTSreconciled with reality: 7 previously-undeclared events added (verification_requested,email_change_requested,email_changed,phone_setup_started,mfa_login_started,mfa_enabled,mfa_disabled).routers/_helpers.require_password_setfactored out ofrouters/account.pyand reused inrouters/phone.py.AsyncDatabase[MongoDoc]/AsyncMongoClient[MongoDoc]parameterized across the Mongo backend so pymongo’s typed stubs are satisfied.
Notes¶
LockoutService(per-account, sliding-window failure counter) is unchanged and continues to defend/loginagainst credential-stuffing against a single account. Per-route IP limits are orthogonal: they defend each endpoint against a single source IP spamming requests across many accounts.The previously-reserved
login_max_per_minute/login_max_per_hourconfig fields are kept for back-compat but no longer have any effect. Switch to the per-route fields when you next touch your config.
0.5.0 — 2026-05-02¶
Added¶
Theme designer.
regstack theme designopens a native pywebview window with controls for every--rs-*CSS custom property and a real-time preview of the bundled SSR widgets (sign-in form, success / error banners, danger-zone button). Saving writesregstack-theme.css; the designer round-trips values back into the form on next launch so iteration is non-destructive.--print-onlymode takes repeatable--var NAME=VALUEpairs (with adark:prefix for dark-scheme overrides) and writes the file headlessly. Lives inregstack.wizard.theme_designer; registered as a lazy Click subgroup soregstack init/doctordon’t pay the pywebview/uvicorn import cost.“Why use regstack” pitch in
docs/index.mdupdated to surface the two pywebview tools (oauth setup+theme design) as a distinguishing feature vs. fastapi-users / Auth0 / Keycloak.
Docs¶
New “About the examples” convention block at the top of
docs/index.md. Every URL, email, smtp host, and admin command across the docs now extrapolates from the same fictional app atapp.example.comwith<username>/<password>placeholders — no moreuser:pw@host/dbname/db.internal/myappmishmash.
0.4.0 — 2026-05-02¶
Added¶
OAuth setup wizard.
regstack oauth setupopens a native webview window that walks an operator through registering a Google OAuth 2.0 client and merges the credentials intoregstack.toml+regstack.secrets.envnon-destructively (preserves comments, other tables, unrelated keys). 12-step SPA inside a local-only 127.0.0.1 FastAPI server, gated by a per-launch random token. Each Next click hits a server-side validator so the Write step can never be reached with bad data.--print-onlymode skips the GUI for headless / CI use.Three new base dependencies:
pywebview>=5.0,tomlkit>=0.13,uvicorn[standard]>=0.29(the wizard’s local server).pytest-playwrightadded to thedevextra; newinv test-e2etask chained intoinv test-all.
0.3.0 — 2026-04-30¶
OAuth — Sign in with Google. Built across four PRs (M1–M4 of
tasks/oauth-design.md);
this is the release cut that wraps them up.
Added¶
New optional extra
oauth = ["pyjwt[crypto]>=2.8"].New
enable_oauthflag andOAuthConfigsub-model (google_client_id,google_client_secret,google_redirect_uri,auto_link_verified_emails,enforce_mfa_on_oauth_signin,state_ttl_seconds,completion_ttl_seconds).regstack.oauthpackage —OAuthProviderABC,OAuthRegistry,OAuthTokens,OAuthUserInfo, error hierarchy, and the concreteGoogleProvider(Authorization Code with PKCE, ID-token verification viapyjwt[crypto]+PyJWKClientagainst Google’s JWKS).Five JSON endpoints (mounted lazily when
enable_oauth=Trueand a provider is registered):GET /oauth/{provider}/startGET /oauth/{provider}/callbackPOST /oauth/exchangePOST /oauth/{provider}/link/start(auth)DELETE /oauth/{provider}/link(auth)GET /oauth/providers(auth)
New SSR page
/account/oauth-complete(token-handoff round-trip).“Sign in with Google” button on
/account/loginand a Connected- accounts panel on/account/me. Login page surfaces callback errors via?error=<code>with translated banners.Two new repo protocols:
OAuthIdentityRepoProtocol,OAuthStateRepoProtocol. Mongo + SQL implementations with parametrized integration tests over all three backends.Four new hook events:
oauth_signin_started,oauth_signin_completed,oauth_account_linked,oauth_account_unlinked.tests/_fake_google/— in-process provider stub so the OAuth test suite stays offline and parallel-safe.New docs page
docs/oauth.md— host guide.
Changed (potentially breaking)¶
BaseUser.hashed_password: str→str | None. OAuth-only users have no password. The login route rejects password attempts on these accounts with the same generic 401 wrong-password gets (no enumeration).change-password,change-email, anddelete-accountall return 400 for OAuth-only users with a pointer at the password-reset flow, which doubles as a “set initial password” path.users.hashed_passwordis now nullable in the SQL schema — migration0002_oauth.pyflips the column viabatch_alter_table(SQLite-safe). Existing rows are unaffected.New SQL tables
oauth_identitiesandoauth_states. Mongo collections + indexes added byinstall_schema().
Security defaults¶
Account-linking policy defaults to refuse. When a Google sign-in carries an email that already belongs to a regstack user, the callback returns
?error=email_in_use. Hosts can opt into auto-linking viaoauth.auto_link_verified_emails = true, which also requiresemail_verified=trueon the ID token. The threat model is intasks/oauth-design.md§ 1.Server-side PKCE.
code_verifieris stored on theoauth_statesrow and never enters the URL.One-time token-handoff.
/oauth/exchangeconsumes the state row atomically; second exchange returns 404.Refuse to unlink the only sign-in method. Returns 400 for OAuth-only users attempting to unlink their only provider.
OAuth sessions are normal session JWTs — the existing
tokens_invalidated_afterbulk-revoke applies, so a password change kills any OAuth-issued session too.
Migration notes¶
Install the extra:
uv add 'regstack[oauth]'.Configure: set
enable_oauth = trueand provideoauth.google_client_id+oauth.google_client_secret(the secret inregstack.secrets.envasREGSTACK_OAUTH__GOOGLE_CLIENT_SECRET).Schema: roll forward via
regstack migrateor rely oninstall_schema()at first boot.
0.2.6 — 2026-04-28¶
Bug fix.
Fixed¶
/admin/statsreportedpending_registrations: 0on every SQL backend. The route reached into the Mongo repo’s private_collectionattribute and silently fell back to0when the attribute was absent — the kind of failure that survives a multi-backend refactor when the integration tests don’t pin the number.
Added¶
PendingRepoProtocol.count_unexpired(now=None) -> int, with Mongo and SQL implementations. “Unexpired” rather than a raw row count because SQL backends accumulate dead rows untilpurge_expiredruns; an admin looking at “pending: 47” wants 47 live rows.The admin stats route now routes the count through
rs.clock.now(). Without this,FrozenClock-driven tests would see every row as “expired” because the route would be reading wall-clock time while the rest of the system runs on the injected clock. Same shape of clock-injection drift the bulk-revoke fix closed earlier.New parametrized integration test
test_stats_pending_registrations_count_unexpiredruns against SQLite + Mongo + Postgres and confirms the count excludes expired rows on every backend.
0.2.5 — 2026-04-28¶
Bug fix + tooling.
Fixed¶
regstack doctoragainst a SQL backend crashed withasyncio.run() cannot be called from a running event loop. The schema check calledregstack.backends.sql.migrations.current(), which usedasyncio.run()internally — invalid inside doctor’s ownasyncio.run. Addedcurrent_async()and switched the doctor command to use it. Synccurrent()is preserved for the migrate CLI (which runs outside an event loop).
Added¶
inv coverage [--no-html] [--fail-under=N]— runs the full three-backend matrix under coverage, combines per-pytest-xdist-worker.coveragefiles, prints the term-with-missing report, and writeshtmlcov/. Branch coverage is on by default.[tool.coverage.*]config inpyproject.toml.tests/unit/test_cli_init.py— six tests driving theregstack initwizard viaCliRunner(input=...). Liftscli/init.pyfrom 14% → 88%.tests/unit/test_cli_doctor.py— four tests for the SQLiteregstack doctorpaths. Liftscli/doctor.pyfrom 61% → 87%.
Total line coverage on the full backend matrix: 85% → 87.1% (branch coverage is also newly enabled).
0.2.4 — 2026-04-28¶
Breaking — every back-compat shim left over from the multi-backend refactor has been removed.
Removed¶
RegStack.install_indexes()— the 0.1.x alias forinstall_schema(). Callinstall_schema().ObjectIdStralias forIdStrinregstack.models._objectid. ImportIdStrdirectly.__all__-based re-exports ofUserAlreadyExistsError,PendingAlreadyExistsError,MfaVerifyOutcome, andMfaVerifyResultfromregstack.backends.mongo.repositories.*and the package__init__. Their canonical home isregstack.backends.protocols; that’s where every consumer in the package itself already imports them.
Migration¶
Old |
New |
|---|---|
|
|
|
|
|
|
|
|
|
|
The internal Mongo helper
regstack.backends.mongo.indexes.install_indexes(db, config) is
unchanged — that’s the function MongoBackend.install_schema calls
to actually create the indexes.
0.2.3 — 2026-04-28¶
Docs-only release. API reference rewritten around the current package layout, public surface gained proper Google-style docstrings.
Changed¶
docs/api.mdrestructured around the post-multi-backend package layout (regstack.backends.{base,protocols,factory,mongo,sql}and friends). Each section now opens with a one-paragraph orientation before the autodoc directives. The pre-refactorregstack.db.repositories.*references that rendered empty are gone.Added Google-style docstrings (purpose summary + Args / Returns / Raises) to the most-touched public methods on
RegStack,JwtCodec,PasswordHasher,LockoutService,AuthDependencies,HookRegistry,EmailService,SmsService,build_router,build_ui_router,build_ui_environment,default_static_dir,Clock/SystemClock/FrozenClock.Dataclass field documentation moved to PEP 258 attribute docstrings on
TokenPayload,LockoutDecision,EmailMessage,SmsMessage,MfaVerifyResult— autodoc now renders each field with its description without the “duplicate object description” warnings the napoleonAttributes:block was triggering.MfaVerifyOutcomeenum docstring reformatted as a bullet list (the napoleonMembers:block isn’t a recognised section).
0.2.2 — 2026-04-28¶
Docs-only release.
Changed¶
README and
docs/index.mdboth now lead with the same pitch — a tagline (“Production-grade user accounts for your FastAPI app — without the vendor lock-in, the second service to run, or the homegrown auth bugs”), a “The problem regstack solves” section (Argon2, JWT revocation, account enumeration, bulk session invalidation, hashed one-time tokens, E.164 phone numbers), and a “Why not just use…?” comparison table covering hosted SaaS (Auth0 / Clerk / WorkOS / Stytch), self-hosted IAM (Keycloak / Authentik / Authelia / Ory Kratos),fastapi-users, and DIY.Trimmed hyperlink density back. Only major external packages, products, and JWT (RFC 7519) are linked. Wikipedia articles on CS concepts (façade pattern, multitenancy, idempotence, E.164, SHA-256, HMAC), MDN web platform basics (CSP, fetch, localStorage, HTTP 429, Retry-After, HTTPS, CSS custom properties), OWASP article links, Python stdlib pages, and deep-dependency helper-class docs (pwdlib, pydantic, asyncpg, pymongo, ChoiceLoader, TypeDecorator, StaticFiles, ProxyHeadersMiddleware, slowapi, APScheduler, pytest-xdist, Kubernetes probes) were removed.
0.2.1 — 2026-04-28¶
Hotfix for 0.2.0. import regstack was broken on any install that
didn’t include the new mongo extra: models/_objectid.py imported
bson unconditionally, and four routers + the SQL MFA repo imported
shared error / enum types out of regstack.backends.mongo.*, which
in turn imports pymongo at module top level.
Fixed¶
models/_objectid.pynow importsbson.ObjectIdlazily inside atry / except ImportErrorand only uses it forisinstancechecks when present.UserAlreadyExistsError,PendingAlreadyExistsError,MfaVerifyOutcome, andMfaVerifyResultmoved from their backend modules toregstack.backends.protocols(the backend-agnostic location). Mongo modules re-export them for backwards compatibility.All consumer modules (
routers/register.py,routers/account.py,routers/login.py,routers/phone.py, the SQL MFA repo) updated to import fromregstack.backends.protocols.
Added¶
New
base-install-smoketestCI job: builds the wheel and runsimport regstack+ a SQLite end-to-end RegStack lifecycle in a fresh venv with no extras. Will catch any future regression.New
tests/unit/test_base_install_imports.pyregression test that usessys.meta_pathto blockbson/pymongoand confirmimport regstackstill succeeds.
0.2.0 — 2026-04-28¶
Multi-backend support + Alembic migrations. SQLite is now the
default; Postgres and
MongoDB are switched in by changing database_url. Embedding API
breaking change: RegStack(config=, db=) → RegStack(config=, backend=None); the backend is auto-built from the URL scheme.
This release also includes a documentation rewrite for less-expert
readers: the README and core docs now lead with the problem regstack
solves, hyperlink external standards (Argon2, RFC 7519, OWASP
enumeration, E.164, CSP, …), and compare regstack to the alternatives
(hosted SaaS, self-hosted IAM, fastapi-users, DIY).
Added¶
Alembic migrations bundled.
regstack.backends.sql.migrationsships an in-package Alembic env (noalembic.inion disk).SqlBackend.install_schema()runsalembic upgrade headinstead ofMetaData.create_all, so schema evolutions land as new revision files. Newregstack migrate [--target REV]CLI for deploy-step migrations. New autogen-drift test catchesschema.py↔ migration mismatches before users see them.regstack doctorschema check is now Alembic-aware: it reports the deployed revision vs the bundled head and tells you to runregstack migrateif they diverge.Per-backend invoke tasks:
inv test-sqlite(zero infra),inv test-mongo(needs local Mongo),inv test-postgres [--url=...](needs local Postgres),inv test-all(all three). Driven by a newREGSTACK_TEST_BACKENDSenv var that the parametrized backend fixture honours; mongo-only unit tests skip cleanly when mongo isn’t in the active backend set.regstack.backends.protocols—Protocolclasses for the five repos plus the sharedUserAlreadyExistsError.regstack.backends.base.BackendABC andregstack.backends.factory.build_backend(config)URL-scheme router.regstack.backends.mongo— relocated Mongo code with aMongoBackendclass.regstack.backends.sql— new SQLAlchemy 2 async backend driving SQLite (aiosqlite) and Postgres (asyncpg). Five protocol-conforming repos,UtcDateTimeTypeDecorator for cross-database tz-aware datetimes,install_schema()that creates all tables idempotently.RegStack.aclose()to tear down the backend’s connection pool.regstack.backends.protocols.UserRepoProtocol.purge_expired(...)on every repo so SQL backends can drive cleanup uniformly (Mongo still relies on TTL indexes in normal operation).examples/sqlite/,examples/postgres/,examples/mongo/— one demo per backend sharing a FastAPI scaffold underexamples/_common/.
Changed¶
RegStackConfig.mongodb_url→database_url. Default issqlite+aiosqlite:///./regstack.db.mongodb_databaseretained for Mongo URLs without a/dbnamepath.RegStack.install_indexes()→install_schema()(alias kept).UserRepo.count(filter_=...)→count(*, is_active=, is_verified=, is_superuser=).UserRepo.list_paged(sort=...)→list_paged(*, sort_by_created_at_desc=).MfaCodeRepo.findreturnsMfaCode | Noneinstead ofdict.regstack initwizard asks which backend to use and writes the appropriatedatabase_url.regstack doctoris backend-agnostic (Backend.ping(), schema check per backend kind).pyproject:
pymongoandasyncpgmoved to optional extras (mongo/postgres); SQLAlchemy + aiosqlite + Alembic in base deps.
0.1.1 — 2026-04-27¶
Rewrite the README’s relative links (
examples/minimal/,docs/security.md,LICENSE,SECURITY.md, etc.) as absolute GitHub / Read the Docs URLs so they resolve on the PyPI project page, not just on GitHub. README-only release.
0.1.0 — 2026-04-27¶
First tagged release. Bundles M1–M6 from the development plan into a single Apache-2.0 package on PyPI.
M1 — skeleton¶
RegStackfaçade,RegStackConfig(env + TOML loader),BaseUser,UserRepo,BlacklistRepo.JWT codec with per-purpose derived keys, per-token blacklist, bulk revocation via
tokens_invalidated_after.Argon2 password hashing via
pwdlib.JSON router:
register,login,logout,me.Console email backend.
regstack initwizard.examples/minimal/embedding demo.
M2 — verification + reset¶
Durable
pending_registrationscollection (hashed tokens, TTL).verify,resend-verification,forgot-password,reset-password.Login lockout (
LoginAttemptRepo+LockoutService→ 429 +Retry-After).SMTP backend (aiosmtplib) and SES backend (lazy aioboto3).
MailComposerwith Jinja2ChoiceLoaderfor host-overridable email templates.
M3 — account management + admin¶
PATCH /me,change-password,change-email+confirm-email-change,DELETE /account.JSON admin router (
/admin/{stats,users,users/{id},users/{id}/resend-verification}) behindenable_admin_router.regstack create-adminandregstack doctorCLIs.Float-precision JWT
iatwith<=bulk-revoke comparison so a login completing microseconds after a password / email change keeps its session.
M4 — SSR pages + theming¶
ui_routerbehindenable_ui_router: login, register, verify, forgot, reset, confirm-email-change, account dashboard.core.css+theme.csswith CSS-custom-property theming, light +prefers-color-scheme: dark.Bundled
regstack.jsreads endpoints from<body data-rs-api data-rs-ui>.theme_css_urlfor stylesheet override;add_template_dirfor full template overrides (shared with the email composer).CSP-friendly: no inline
<style>orstyle="…".
M5 — SMS + optional 2FA¶
SmsServiceABC withnull/sns/twiliobackends.Phone routes (
/phone/start,/phone/confirm,DELETE /phone).Two-step MFA login:
mfa_requiredresponse on/login→/login/mfa-confirmwith the SMS code.MfaCodeRepowith hashed 6-digit codes, attempt tracking, TTL onexpires_at, unique on(user_id, kind).SSR
mfa-confirmpage and “SMS two-factor authentication” section on/account/me(set up + disable).E.164 phone validation.
M6 — docs + CI + release¶
Sphinx documentation (markdown via myst-parser, Furo theme).
Quickstart, configuration, architecture, security, embedding, theming, CLI, and API reference pages.
GitHub Actions: parallel test matrix on push/PR; OIDC PyPI publish on
v*tags.CHANGELOG.mdandSECURITY.md.