Status: the second working backend. Register, log in, refresh, log out,
manage users and roles — all implemented, all authenticated with real JWTs,
all backed by a real Postgres database, and all proven green against
contract/conformance, unchanged from the run against the .NET backend
(see "Conformance" below for the real run). import-linter enforces
docs/STRUCTURE.md's dependency rules, not just convention — a violation
fails lint-imports, not merely a review comment (see "Architecture" below).
This is docs/ROADMAP.md step 3's target. What is not yet true: no second
provider (SQL Server/MySQL), no second backend feature, and none of the
Tier 2 "professional" capabilities beyond the minimal defaults shared/
already ships (see below). Frontends and mobile do not exist yet.
backends/python/
├── pyproject.toml dependencies, pytest config, import-linter contracts
├── alembic.ini
├── requirements-lock.txt uv pip freeze — the exact resolved graph, audited
├── scripts/ start/stop a throwaway local Postgres for integration tests
├── src/app/
│ ├── shared/ cross-cutting plumbing — see below
│ ├── database/postgres/ asyncpg wiring, Alembic migrations, seed data — see below
│ ├── features/identity/
│ │ ├── domain/ entities, value objects, domain events — depends on nothing
│ │ ├── persistence/ SQLAlchemy models, repositories — provider-agnostic
│ │ ├── contracts/ Pydantic DTOs and requests mirroring contract/openapi.yaml
│ │ ├── application/ every command and query the contract needs
│ │ └── endpoints/ the HTTP surface — FastAPI routers, permission-gated
│ └── host/ composition root — JWT, settings, DI wiring, migrations/seed on startup, OpenAPI docs
└── tests/
├── shared/ real tests, all passing
├── features/identity/{domain,application,mapping}/ all passing
├── integration/ real local Postgres, all passing
└── architecture/ placeholder — the layering rule lives in pyproject.toml, enforced by import-linter
.NET dispatches commands through a source-generated mediator; this backend
resolves handlers directly from FastAPI's own dependency graph — no
mediator library. Same folders, same names, same flow, each idiomatic (see
docs/STRUCTURE.md).
$ CONFORMANCE_ADMIN_EMAIL="admin@stackbraid.local" CONFORMANCE_ADMIN_PASSWORD="<seeded>" \
node cli/run.mjs http://127.0.0.1:<port>
43 passed, 0 failed, 0 skipped, 43 total.
This output is the python-conformance job of
.github/workflows/conformance.yml, run against a genuinely fresh, empty
Postgres migrated and seeded by this backend's own startup path.
scripts/start-local-postgres.sh reproduces the same run on a local cluster
with no Docker and no Testcontainers. Every check in contract/conformance
passes: the byte-identical served contract at /openapi.yaml, browsable API
documentation at /docs served from a vendored copy of Swagger UI rather
than a CDN, absence of code-derived documentation endpoints, schema shape,
the RFC 9457 Problem envelope on every documented error, offset pagination
arithmetic, UtcDateTime's exact Z-suffixed format, both token-delivery
paths (body and httpOnly cookie) for login/refresh/logout, refresh-token
rotation and revocation, permission-gated
admin endpoints, and the realtime payloads below — byte-identical in shape
to the .NET run with only the base URL changed. Nothing is skipped here
because the expiry check has what it needs: a short-lived access token TTL
(STACKBRAID_JWT_ACCESS_TOKEN_LIFETIME_SECONDS). Run without it, that one
check skips rather than waiting out the default lifetime.
Both generated clients (clients/typescript, clients/dart) called
this backend successfully with no hand edits — register, log in, and read
the caller's own account, exercised directly against a running instance.
One real bug an earlier run caught and fixed: Pydantic's EmailStr pulls in
email-validator's deliverability/special-use-domain checks, which reject a
.local address outright — locking the seeded admin@stackbraid.local
account out of its own login endpoint. Replaced with a plain pattern
matching the domain's own Email value object and the contract's
format: email, removing a dependency in the process.
features/identity/endpoints/realtime.py (/v1/ws/notifications,
/v1/ws/jobs/{jobId}) pushes the same RealtimeMessage shapes the .NET
SignalR hubs emit, over a native WebSocket instead — the access token
travels as an access_token query parameter here too, the same
accommodation for a browser WebSocket carrying no custom headers.
shared/realtime/publisher.py's RealtimePublisher Protocol is what the
existing deactivate/assign-role/revoke-role command handlers call; no new
feature, no new REST endpoint.
InProcessRealtimePublisher delivers to this process's own connections
with no package call at all. RedisRealtimePublisher publishes through
redis.asyncio only when STACKBRAID_REALTIME_REDIS_URL is configured — a
Redis or Valkey URL both work unchanged, since the client speaks the plain
wire protocol. Proven for real, both here and in
contract/conformance's checks/realtime.mjs: two instances of this
backend behind the same Valkey process, a client connected to instance A
receiving a user.role_changed, user.deactivated and job.progress
raised through a REST call or a job start on instance B —
scripts/verify-realtime-fanout.mjs at the repository root reproduces this
against any two running instances.
-
persistence—OrmBase(the one sharedDeclarativeBaseevery feature's SQLAlchemy models register against) andUnitOfWorkon top of a plainAsyncSession. No provider-specific type appears here — that isdatabase/postgres/'s job. -
web— the RFC 9457 Problem envelope the contract requires (errors.py,problem.py,exception_handling.py), a correlation-ID middleware, and the one rate-limit policy every backend needs on day one (rate_limit.py, an in-process fixed-window counter). -
localization—AppLocalizer, backed by two embedded JSON catalogues (English and Spanish) — genuinely resolves perAccept-Language, not a stub. -
security—Pbkdf2PasswordHasher(PBKDF2-HMAC-SHA256, OWASP's current minimum, built on the standard library'shashlib) andopaque_token(a fast SHA-256 hash for refresh tokens — deliberately not PBKDF2, which would punish the read-heavy per-request lookup a slow hash is not meant for). No third-party dependency for either. -
messaging,jobs,documents,caching,storage,mailing— one Protocol and one working implementation each, exactly asdocs/STRUCTURE.mddescribes for this layer, mirroring the .NET backend's own minimal defaults:Protocol Today's implementation Real integration planned MessagePublisherin-process asyncio.Queue, loggedRabbitMQ JobSchedulerin-process background queue a real job runner PdfGeneratorhand-written minimal PDF writer a real PDF library ExcelExporterRFC 4180 CSV a real Excel library Cachein-process, per-key TTL Redis FileStoragelocal disk + S3-compatible EmailSenderSMTP when configured, logged otherwise (same — SMTP is the real integration) Every one of these is a genuine, tested implementation of its Protocol — none is a no-op — and every one is swappable for its planned counterpart without changing a caller. See docs/DEPENDENCIES.md for what is and is not a dependency of this backend yet.
domain—User,Role,RefreshTokenand theUserRolelink, each owning its own invariants (deactivating twice is a no-op, assigning a held role is a no-op, revoking an unheld role is treated as success — matchingcontract/openapi.yamlexactly, and matching the .NET domain's behaviour). Five domain events. Imports nothing outside this package and the standard library — genuinely depends on nothing, enforced byimport-linter, not merely documented.persistence— SQLAlchemy models (models.py) mapped to and from the domain entities by the repositories (repositories.py); the domain never sees a SQLAlchemy object. One deliberate ORM detail worth recording:RefreshTokenModel.usercarries an explicitrelationship(), not just the column-levelForeignKey— SQLAlchemy only ordersINSERTstatements across two mapped classes by their foreign-key dependency when an ORM relationship links them, so a token and its brand-new user added in the same flush could otherwise hit the constraint before either commits.contracts— Pydantic DTOs and requests, camelCase on the wire via one sharedCamelModelbase, and aUtcDateTimetype that always serializes with a trailingZ(datetime_utils.py) rather than Python's own+00:00default — the same divergence a spike caught between the two backends before either shipped.application— one handler class per contract operation (11 total), hand-written mapping (mapping.py) with its own test file. Login/refresh depend onAccessTokenIssuer, a Protocol with its real implementation at the edge (endpoints/security.py), not inapplication/— the port is declared here, the JWT-specific implementation lives where the token is also verified on the way back in.endpoints— FastAPI routers per contract'sAuth/Users/Rolestags,require_permission(...)dependencies gating admin routes, and the httpOnlyrefreshTokencookie (HttpOnly; Secure; SameSite=Strict; Path=/v1/auth) alongside the body token every login/refresh returns.
Four import-linter contracts, defined in pyproject.toml:
sharednever imports a feature.- The Identity feature's own layers —
endpointsmay depend onpersistence,applicationanddomain;persistencemay depend onapplicationanddomain;applicationmay depend only ondomain. - The Identity domain depends on nothing — not
shared, notsqlalchemy, notpydantic, notfastapi, not this feature's ownapplication/persistence/contracts/endpoints. - Contracts depend on nothing feature-internal — not this feature's
own
domain/application/persistence/endpoints.
Every one of these was seen to genuinely fail before being trusted: each
rule was violated on purpose (one import added to domain/entities.py
reaching into shared), run in isolation, watched fail with the violating
import named in the output, then reverted — never committed.
cd backends/python
uv venv --python 3.12 .venv
source .venv/bin/activate
uv pip install -e ".[dev]"
# Unit tests — no database needed
PYTHONPATH=src pytest tests/shared tests/features
# Architecture rules
cd src && lint-imports --config ../pyproject.toml
# Integration tests need a real Postgres
cd backends/python
export STACKBRAID_POSTGRES_DSN="$(./scripts/start-local-postgres.sh)"
PYTHONPATH=src pytest tests/integration
./scripts/stop-local-postgres.shcd backends/python
export STACKBRAID_POSTGRES_DSN="$(./scripts/start-local-postgres.sh)"
STACKBRAID_JWT_ACCESS_TOKEN_LIFETIME_SECONDS=5 \
PYTHONPATH=src uvicorn app.host.main:app --port 8080 &
cd ../../contract/conformance
CONFORMANCE_ADMIN_EMAIL="admin@stackbraid.local" CONFORMANCE_ADMIN_PASSWORD="ChangeMe!123" \
CONFORMANCE_MAX_EXPIRY_WAIT_MS=65000 node cli/run.mjs http://127.0.0.1:8080
cd ../../backends/python && ./scripts/stop-local-postgres.shMigrations and seeding run automatically on startup
(STACKBRAID_RUN_MIGRATIONS_ON_STARTUP / STACKBRAID_SEED_ON_STARTUP,
both default true) — no manual step between starting the server and
calling it.
No origin is trusted by default — a browser's cross-origin request (every local frontend dev server, since it runs on a different port) is refused until its origin is listed explicitly:
STACKBRAID_CORS_ALLOWED_ORIGINS_RAW="http://127.0.0.1:3000" \
PYTHONPATH=src uvicorn app.host.main:app --port 8080See frontends/nextjs/README.md for a
frontend that actually depends on this.