ui-foundation-backend 4.25.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. ui_foundation_backend-4.25.0/PKG-INFO +121 -0
  2. ui_foundation_backend-4.25.0/README.md +100 -0
  3. ui_foundation_backend-4.25.0/pyproject.toml +57 -0
  4. ui_foundation_backend-4.25.0/setup.cfg +4 -0
  5. ui_foundation_backend-4.25.0/src/foundation_backend/__init__.py +13 -0
  6. ui_foundation_backend-4.25.0/src/foundation_backend/config.py +118 -0
  7. ui_foundation_backend-4.25.0/src/foundation_backend/db.py +17 -0
  8. ui_foundation_backend-4.25.0/src/foundation_backend/deploy_checks.py +105 -0
  9. ui_foundation_backend-4.25.0/src/foundation_backend/error_reporting.py +230 -0
  10. ui_foundation_backend-4.25.0/src/foundation_backend/errors.py +46 -0
  11. ui_foundation_backend-4.25.0/src/foundation_backend/login_limits.py +120 -0
  12. ui_foundation_backend-4.25.0/src/foundation_backend/migrations/0002_auth.py +61 -0
  13. ui_foundation_backend-4.25.0/src/foundation_backend/migrations/0010_login_limits_totp.py +135 -0
  14. ui_foundation_backend-4.25.0/src/foundation_backend/migrations/0011_oauth.py +150 -0
  15. ui_foundation_backend-4.25.0/src/foundation_backend/migrations/README.md +16 -0
  16. ui_foundation_backend-4.25.0/src/foundation_backend/models.py +131 -0
  17. ui_foundation_backend-4.25.0/src/foundation_backend/openapi_responses.py +38 -0
  18. ui_foundation_backend-4.25.0/src/foundation_backend/py.typed +0 -0
  19. ui_foundation_backend-4.25.0/src/foundation_backend/rate_window.py +32 -0
  20. ui_foundation_backend-4.25.0/src/foundation_backend/routers/__init__.py +0 -0
  21. ui_foundation_backend-4.25.0/src/foundation_backend/routers/auth.py +317 -0
  22. ui_foundation_backend-4.25.0/src/foundation_backend/routers/client_errors.py +80 -0
  23. ui_foundation_backend-4.25.0/src/foundation_backend/routers/environment.py +20 -0
  24. ui_foundation_backend-4.25.0/src/foundation_backend/routers/oauth.py +527 -0
  25. ui_foundation_backend-4.25.0/src/foundation_backend/schemas.py +106 -0
  26. ui_foundation_backend-4.25.0/src/foundation_backend/scripts/__init__.py +2 -0
  27. ui_foundation_backend-4.25.0/src/foundation_backend/scripts/disable_totp.py +49 -0
  28. ui_foundation_backend-4.25.0/src/foundation_backend/scripts/enable_totp.py +81 -0
  29. ui_foundation_backend-4.25.0/src/foundation_backend/scripts/set_password.py +67 -0
  30. ui_foundation_backend-4.25.0/src/foundation_backend/security.py +44 -0
  31. ui_foundation_backend-4.25.0/src/foundation_backend/spa.py +34 -0
  32. ui_foundation_backend-4.25.0/src/foundation_backend/testing/__init__.py +1 -0
  33. ui_foundation_backend-4.25.0/src/foundation_backend/testing/signed_in.py +32 -0
  34. ui_foundation_backend-4.25.0/src/foundation_backend/totp.py +90 -0
  35. ui_foundation_backend-4.25.0/src/ui_foundation_backend.egg-info/PKG-INFO +121 -0
  36. ui_foundation_backend-4.25.0/src/ui_foundation_backend.egg-info/SOURCES.txt +45 -0
  37. ui_foundation_backend-4.25.0/src/ui_foundation_backend.egg-info/dependency_links.txt +1 -0
  38. ui_foundation_backend-4.25.0/src/ui_foundation_backend.egg-info/requires.txt +12 -0
  39. ui_foundation_backend-4.25.0/src/ui_foundation_backend.egg-info/top_level.txt +1 -0
  40. ui_foundation_backend-4.25.0/tests/test_auth.py +240 -0
  41. ui_foundation_backend-4.25.0/tests/test_deploy_checks.py +86 -0
  42. ui_foundation_backend-4.25.0/tests/test_environment.py +30 -0
  43. ui_foundation_backend-4.25.0/tests/test_error_reporting.py +233 -0
  44. ui_foundation_backend-4.25.0/tests/test_login_limits.py +176 -0
  45. ui_foundation_backend-4.25.0/tests/test_signed_in.py +73 -0
  46. ui_foundation_backend-4.25.0/tests/test_spa.py +40 -0
  47. ui_foundation_backend-4.25.0/tests/test_totp.py +227 -0
@@ -0,0 +1,121 @@
1
+ Metadata-Version: 2.4
2
+ Name: ui-foundation-backend
3
+ Version: 4.25.0
4
+ Summary: The auth and security layer shared by FastAPI + SQLModel backends built on ui-foundation: sessions, sign-in limits, TOTP, error reporting, deploy checks, AI chats' OAuth.
5
+ License-Expression: MIT
6
+ Project-URL: Repository, https://github.com/Tristan2828/ui-foundation
7
+ Project-URL: Changelog, https://github.com/Tristan2828/ui-foundation/blob/main/CHANGELOG.md
8
+ Requires-Python: >=3.11
9
+ Description-Content-Type: text/markdown
10
+ Requires-Dist: fastapi==0.141.1
11
+ Requires-Dist: sqlmodel==0.0.42
12
+ Requires-Dist: pydantic==2.13.5
13
+ Requires-Dist: python-dotenv==1.2.3
14
+ Requires-Dist: email-validator==2.3.0
15
+ Provides-Extra: dev
16
+ Requires-Dist: mypy==2.3.1; extra == "dev"
17
+ Requires-Dist: pytest==9.1.1; extra == "dev"
18
+ Requires-Dist: pytest-asyncio==1.4.0; extra == "dev"
19
+ Requires-Dist: httpx==0.28.1; extra == "dev"
20
+ Requires-Dist: aiosqlite==0.22.1; extra == "dev"
21
+
22
+ # ui-foundation-backend
23
+
24
+ The auth and security layer shared by the FastAPI + SQLModel backends of
25
+ apps built on [ui-foundation](https://github.com/Tristan2828/ui-foundation):
26
+ the half of every app's backend that apps never change on purpose. Import
27
+ package `foundation_backend`. Released with `@tristan2828/ui-foundation`,
28
+ under the same version number, from the same commit.
29
+
30
+ ## What's in it
31
+
32
+ | Module | What it is |
33
+ |---|---|
34
+ | `config` | The settings it reads from the environment (database, cookies and sessions, sign-in limits, TOTP, error reporting, AI chats' OAuth, the seeded account) and `database_connect_args()` |
35
+ | `db` | The async engine and the `get_session` dependency |
36
+ | `models` | `User`, `Session`, `LoginFailure`, `RecoveryCode`, `PendingLogin`, `OAuthClient`, `OAuthCode`, `OAuthGrant` (table names `users`, `sessions`, …) |
37
+ | `schemas` | `CamelModel`, `Page`, the sign-in and error-envelope schemas |
38
+ | `security`, `totp`, `login_limits`, `rate_window` | Password and token hashing, the TOTP step, failed sign-in limits, an in-memory rate window |
39
+ | `errors`, `error_reporting`, `openapi_responses` | Error bodies pinned to the contract, `report_error`, the shared `responses=` fragments |
40
+ | `deploy_checks`, `spa` | What `APP_ENV=production` refuses to start with; the SPA mount with its client-route fallback |
41
+ | `routers.auth` | `router` (login, the TOTP step, logout, me) and `register_router` (`POST /auth/register`), with `get_current_user` |
42
+ | `routers.environment`, `routers.client_errors`, `routers.oauth` | `GET /environment`, `POST /client-errors`, AI chats' OAuth sign-in (off unless `MCP_ENABLED`) |
43
+ | `scripts.set_password`, `scripts.enable_totp`, `scripts.disable_totp` | Run as `python -m foundation_backend.scripts.<name> <email>` from the app's `backend/` |
44
+ | `testing.signed_in` | `session_cookie`, to sign a test client in for real |
45
+ | `migrations/` | Reference copies of the Alembic revisions that create these tables; never run from here ([`migrations/README.md`](src/foundation_backend/migrations/README.md)) |
46
+
47
+ An app keeps everything else: its entities, models, routers and
48
+ migrations, and, in the template, MCP's tools, scheduled jobs and Web
49
+ Push.
50
+
51
+ ## How an app uses it
52
+
53
+ `backend/pyproject.toml` pins it to the release the app is on, the same
54
+ version as its `@tristan2828/ui-foundation`:
55
+
56
+ ```toml
57
+ dependencies = [
58
+ "ui-foundation-backend==4.25.0",
59
+ ...
60
+ ]
61
+ ```
62
+
63
+ The app's own modules re-export it, so `from app.models import User`
64
+ keeps working (the template's `app/config.py`, `db.py`, `models.py`,
65
+ `schemas.py`, `openapi_responses.py`, `security.py`, `errors.py`, `spa.py`
66
+ do this), and its `main.py` includes the routers it offers:
67
+
68
+ ```python
69
+ from foundation_backend.routers import auth, client_errors, environment, oauth
70
+
71
+ app.include_router(auth.router, prefix="/api")
72
+ app.include_router(auth.register_router, prefix="/api") # only if people make their own accounts
73
+ app.include_router(client_errors.router, prefix="/api")
74
+ app.include_router(environment.router, prefix="/api")
75
+ app.include_router(oauth.router) # only with AI chats (MCP)
76
+ ```
77
+
78
+ **`register_router` is separate on purpose.** An app where accounts are
79
+ made by whoever runs it leaves it out, with `/auth/register` out of its
80
+ `openapi.yaml` (the path is `x-optional`) and `LoginRoute
81
+ registerPath={null}`. Including `router` alone serves sign-in with no way
82
+ to make an account through the API.
83
+
84
+ A setting the package reads is changed at runtime (a test's
85
+ `monkeypatch`) on `foundation_backend.config` or on the module that
86
+ imported it, never on the app's re-export, which is a copy.
87
+
88
+ Upgrading: bump the pin with the npm package, run the migrations the
89
+ [changelog](https://github.com/Tristan2828/ui-foundation/blob/main/CHANGELOG.md)
90
+ names for that release, then `npm run verify:backend`
91
+ ([`docs/consuming.md`](https://github.com/Tristan2828/ui-foundation/blob/main/docs/consuming.md)).
92
+
93
+ ## In this repo
94
+
95
+ `template/backend/` depends on `ui-foundation-backend` unpinned. Install
96
+ both editable **in one pip call**, so the dependency resolves to this
97
+ folder and never to PyPI:
98
+
99
+ ```bash
100
+ cd template/backend
101
+ python -m venv .venv
102
+ .venv/Scripts/python -m pip install -e ../../packages/backend-foundation -e ".[dev]" # .venv/bin/python elsewhere
103
+ ```
104
+
105
+ `template/backend/scripts/dev.sh` and the cloud session hook do this.
106
+ Installing `template/backend` on its own would pull the published version
107
+ (or fail before the first release), and its tests would run against code
108
+ that isn't this commit's.
109
+
110
+ `npm run verify:backend` (repo root) runs this package's gate,
111
+ `scripts/verify.sh` (mypy strict, then pytest over SQLite against
112
+ `tests/minimal_app.py`, an app made of the package alone), then the
113
+ template's. On its own: `cd packages/backend-foundation && pytest`.
114
+
115
+ ## Releasing
116
+
117
+ `.github/workflows/release.yml` builds it (`build-py`, with the release's
118
+ version written into `pyproject.toml`) and publishes it to PyPI by trusted
119
+ publishing (`publish-py`), beside the npm package, before tagging. The
120
+ committed version in `pyproject.toml` is a floor, like
121
+ `packages/ui-foundation/package.json`'s; keep the two the same.
@@ -0,0 +1,100 @@
1
+ # ui-foundation-backend
2
+
3
+ The auth and security layer shared by the FastAPI + SQLModel backends of
4
+ apps built on [ui-foundation](https://github.com/Tristan2828/ui-foundation):
5
+ the half of every app's backend that apps never change on purpose. Import
6
+ package `foundation_backend`. Released with `@tristan2828/ui-foundation`,
7
+ under the same version number, from the same commit.
8
+
9
+ ## What's in it
10
+
11
+ | Module | What it is |
12
+ |---|---|
13
+ | `config` | The settings it reads from the environment (database, cookies and sessions, sign-in limits, TOTP, error reporting, AI chats' OAuth, the seeded account) and `database_connect_args()` |
14
+ | `db` | The async engine and the `get_session` dependency |
15
+ | `models` | `User`, `Session`, `LoginFailure`, `RecoveryCode`, `PendingLogin`, `OAuthClient`, `OAuthCode`, `OAuthGrant` (table names `users`, `sessions`, …) |
16
+ | `schemas` | `CamelModel`, `Page`, the sign-in and error-envelope schemas |
17
+ | `security`, `totp`, `login_limits`, `rate_window` | Password and token hashing, the TOTP step, failed sign-in limits, an in-memory rate window |
18
+ | `errors`, `error_reporting`, `openapi_responses` | Error bodies pinned to the contract, `report_error`, the shared `responses=` fragments |
19
+ | `deploy_checks`, `spa` | What `APP_ENV=production` refuses to start with; the SPA mount with its client-route fallback |
20
+ | `routers.auth` | `router` (login, the TOTP step, logout, me) and `register_router` (`POST /auth/register`), with `get_current_user` |
21
+ | `routers.environment`, `routers.client_errors`, `routers.oauth` | `GET /environment`, `POST /client-errors`, AI chats' OAuth sign-in (off unless `MCP_ENABLED`) |
22
+ | `scripts.set_password`, `scripts.enable_totp`, `scripts.disable_totp` | Run as `python -m foundation_backend.scripts.<name> <email>` from the app's `backend/` |
23
+ | `testing.signed_in` | `session_cookie`, to sign a test client in for real |
24
+ | `migrations/` | Reference copies of the Alembic revisions that create these tables; never run from here ([`migrations/README.md`](src/foundation_backend/migrations/README.md)) |
25
+
26
+ An app keeps everything else: its entities, models, routers and
27
+ migrations, and, in the template, MCP's tools, scheduled jobs and Web
28
+ Push.
29
+
30
+ ## How an app uses it
31
+
32
+ `backend/pyproject.toml` pins it to the release the app is on, the same
33
+ version as its `@tristan2828/ui-foundation`:
34
+
35
+ ```toml
36
+ dependencies = [
37
+ "ui-foundation-backend==4.25.0",
38
+ ...
39
+ ]
40
+ ```
41
+
42
+ The app's own modules re-export it, so `from app.models import User`
43
+ keeps working (the template's `app/config.py`, `db.py`, `models.py`,
44
+ `schemas.py`, `openapi_responses.py`, `security.py`, `errors.py`, `spa.py`
45
+ do this), and its `main.py` includes the routers it offers:
46
+
47
+ ```python
48
+ from foundation_backend.routers import auth, client_errors, environment, oauth
49
+
50
+ app.include_router(auth.router, prefix="/api")
51
+ app.include_router(auth.register_router, prefix="/api") # only if people make their own accounts
52
+ app.include_router(client_errors.router, prefix="/api")
53
+ app.include_router(environment.router, prefix="/api")
54
+ app.include_router(oauth.router) # only with AI chats (MCP)
55
+ ```
56
+
57
+ **`register_router` is separate on purpose.** An app where accounts are
58
+ made by whoever runs it leaves it out, with `/auth/register` out of its
59
+ `openapi.yaml` (the path is `x-optional`) and `LoginRoute
60
+ registerPath={null}`. Including `router` alone serves sign-in with no way
61
+ to make an account through the API.
62
+
63
+ A setting the package reads is changed at runtime (a test's
64
+ `monkeypatch`) on `foundation_backend.config` or on the module that
65
+ imported it, never on the app's re-export, which is a copy.
66
+
67
+ Upgrading: bump the pin with the npm package, run the migrations the
68
+ [changelog](https://github.com/Tristan2828/ui-foundation/blob/main/CHANGELOG.md)
69
+ names for that release, then `npm run verify:backend`
70
+ ([`docs/consuming.md`](https://github.com/Tristan2828/ui-foundation/blob/main/docs/consuming.md)).
71
+
72
+ ## In this repo
73
+
74
+ `template/backend/` depends on `ui-foundation-backend` unpinned. Install
75
+ both editable **in one pip call**, so the dependency resolves to this
76
+ folder and never to PyPI:
77
+
78
+ ```bash
79
+ cd template/backend
80
+ python -m venv .venv
81
+ .venv/Scripts/python -m pip install -e ../../packages/backend-foundation -e ".[dev]" # .venv/bin/python elsewhere
82
+ ```
83
+
84
+ `template/backend/scripts/dev.sh` and the cloud session hook do this.
85
+ Installing `template/backend` on its own would pull the published version
86
+ (or fail before the first release), and its tests would run against code
87
+ that isn't this commit's.
88
+
89
+ `npm run verify:backend` (repo root) runs this package's gate,
90
+ `scripts/verify.sh` (mypy strict, then pytest over SQLite against
91
+ `tests/minimal_app.py`, an app made of the package alone), then the
92
+ template's. On its own: `cd packages/backend-foundation && pytest`.
93
+
94
+ ## Releasing
95
+
96
+ `.github/workflows/release.yml` builds it (`build-py`, with the release's
97
+ version written into `pyproject.toml`) and publishes it to PyPI by trusted
98
+ publishing (`publish-py`), beside the npm package, before tagging. The
99
+ committed version in `pyproject.toml` is a floor, like
100
+ `packages/ui-foundation/package.json`'s; keep the two the same.
@@ -0,0 +1,57 @@
1
+ [project]
2
+ name = "ui-foundation-backend"
3
+ # The release workflow writes the release's version here, the same as
4
+ # @tristan2828/ui-foundation's (packages/ui-foundation/package.json).
5
+ version = "4.25.0"
6
+ description = "The auth and security layer shared by FastAPI + SQLModel backends built on ui-foundation: sessions, sign-in limits, TOTP, error reporting, deploy checks, AI chats' OAuth."
7
+ readme = "README.md"
8
+ license = "MIT"
9
+ requires-python = ">=3.11"
10
+ # Exactly the template backend's versions (template/backend/pyproject.toml),
11
+ # so an app that pins the same ones resolves to one set.
12
+ dependencies = [
13
+ "fastapi==0.141.1",
14
+ "sqlmodel==0.0.42",
15
+ "pydantic==2.13.5",
16
+ "python-dotenv==1.2.3",
17
+ "email-validator==2.3.0",
18
+ ]
19
+
20
+ [project.urls]
21
+ Repository = "https://github.com/Tristan2828/ui-foundation"
22
+ Changelog = "https://github.com/Tristan2828/ui-foundation/blob/main/CHANGELOG.md"
23
+
24
+ [project.optional-dependencies]
25
+ dev = [
26
+ "mypy==2.3.1",
27
+ "pytest==9.1.1",
28
+ "pytest-asyncio==1.4.0",
29
+ "httpx==0.28.1",
30
+ "aiosqlite==0.22.1",
31
+ ]
32
+
33
+ [tool.mypy]
34
+ python_version = "3.11"
35
+ strict = true
36
+ plugins = ["pydantic.mypy"]
37
+ # Reference copies for an app to take, not modules of this package
38
+ # (src/foundation_backend/migrations/README.md).
39
+ exclude = ["src/foundation_backend/migrations/"]
40
+
41
+ [[tool.mypy.overrides]]
42
+ module = "alembic.*"
43
+ ignore_missing_imports = true
44
+
45
+ [tool.pytest.ini_options]
46
+ asyncio_mode = "auto"
47
+ testpaths = ["tests"]
48
+
49
+ [build-system]
50
+ requires = ["setuptools>=77"]
51
+ build-backend = "setuptools.build_meta"
52
+
53
+ [tool.setuptools.packages.find]
54
+ where = ["src"]
55
+
56
+ [tool.setuptools.package-data]
57
+ foundation_backend = ["py.typed", "migrations/*.py", "migrations/README.md"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,13 @@
1
+ """The auth and security layer every app built on ui-foundation shares
2
+ (the PyPI package `ui-foundation-backend`). README.md says what is in it
3
+ and how an app uses it.
4
+ """
5
+
6
+ # The release version, the same as @tristan2828/ui-foundation's. The release
7
+ # workflow writes it into pyproject.toml; this reads it back.
8
+ from importlib.metadata import PackageNotFoundError, version
9
+
10
+ try:
11
+ __version__ = version("ui-foundation-backend")
12
+ except PackageNotFoundError: # imported from a source tree, not installed
13
+ __version__ = "0.0.0"
@@ -0,0 +1,118 @@
1
+ """The settings the foundation's backend reads, each from the environment
2
+ once, at import. An app's own `config.py` star-imports this module and
3
+ adds its own settings after it; it calls `load_dotenv()` first, from its
4
+ own file, so its `backend/.env` is read wherever the process starts.
5
+
6
+ Here `.env` is looked for from the working directory up (`backend/` when
7
+ the app runs, its scripts run, or `python -m foundation_backend.scripts.*`
8
+ runs), since this file is installed in site-packages, not in the app.
9
+ Values already in the environment win over the file's.
10
+ """
11
+
12
+ import os
13
+ import ssl
14
+
15
+ from dotenv import find_dotenv, load_dotenv
16
+
17
+ load_dotenv(find_dotenv(usecwd=True))
18
+
19
+ DATABASE_URL: str = os.environ.get(
20
+ "DATABASE_URL",
21
+ "postgresql+asyncpg://ui_foundation:ui_foundation@localhost:5432/ui_foundation",
22
+ )
23
+
24
+ # Cloud Postgres support (docs/cloud-postgres.md).
25
+ # asyncpg's own SSL flag — postgresql+asyncpg:// doesn't parse libpq's
26
+ # sslmode= query param, so this can't be folded into DATABASE_URL itself.
27
+ DATABASE_SSL: bool = os.environ.get("DATABASE_SSL", "false").lower() == "true"
28
+
29
+ # Some managed providers (confirmed on Supabase) serve their Postgres TLS
30
+ # cert from a private CA that isn't in the OS trust store, unlike their own
31
+ # HTTPS endpoints — connecting with just DATABASE_SSL=true then fails
32
+ # certificate verification. Point this at a local PEM file (that CA's root
33
+ # cert) to verify against it explicitly instead of the system trust store.
34
+ DATABASE_SSL_CA_FILE: str | None = os.environ.get("DATABASE_SSL_CA_FILE") or None
35
+
36
+
37
+ def database_connect_args() -> dict[str, object]:
38
+ """asyncpg connect_args for DATABASE_URL — shared by db.py and an
39
+ app's migrations/env.py, which each construct their own engine."""
40
+ if not DATABASE_SSL:
41
+ return {}
42
+ if DATABASE_SSL_CA_FILE:
43
+ return {"ssl": ssl.create_default_context(cafile=DATABASE_SSL_CA_FILE)}
44
+ return {"ssl": True}
45
+
46
+ # "production" turns on deploy_checks.py at startup: the app refuses to
47
+ # start with an insecure cookie or a still-default seeded account. Anything
48
+ # else (the default) is development, with no startup checks.
49
+ APP_ENV: str = os.environ.get("APP_ENV", "development")
50
+
51
+ # Which data this backend serves when it isn't production's: "dev" for a
52
+ # dev branch of the hosted database, "local" for the docker-compose one.
53
+ # Served unauthenticated at GET /api/environment, and the app names it in
54
+ # a banner on every screen (DataEnvironmentBanner: "Dev data: changes here
55
+ # don't reach production"). Unset, the default, means production data and
56
+ # no banner; APP_ENV=production refuses to start with it set.
57
+ DATA_LABEL: str | None = os.environ.get("DATA_LABEL", "").strip() or None
58
+
59
+ # Session cookie. Local dev is plain
60
+ # HTTP, so the Secure flag defaults off; a real deployment sets it.
61
+ COOKIE_SECURE: bool = os.environ.get("COOKIE_SECURE", "false").lower() == "true"
62
+ SESSION_TTL_DAYS: int = int(os.environ.get("SESSION_TTL_DAYS", "7"))
63
+
64
+ # Failed sign-in limits (login_limits.py, docs/deploy.md). Over either,
65
+ # POST /auth/login answers 429 before the password is checked. Per IP needs
66
+ # uvicorn's --proxy-headers behind a proxy it trusts, or every request
67
+ # appears to come from the proxy and this becomes one global limit.
68
+ LOGIN_MAX_FAILURES_PER_IP: int = int(os.environ.get("LOGIN_MAX_FAILURES_PER_IP", "10"))
69
+ LOGIN_IP_WINDOW_SECONDS: int = int(os.environ.get("LOGIN_IP_WINDOW_SECONDS", str(15 * 60)))
70
+ LOGIN_MAX_FAILURES_PER_ACCOUNT: int = int(os.environ.get("LOGIN_MAX_FAILURES_PER_ACCOUNT", "20"))
71
+ LOGIN_ACCOUNT_WINDOW_SECONDS: int = int(os.environ.get("LOGIN_ACCOUNT_WINDOW_SECONDS", str(60 * 60)))
72
+ # A header a CDN or tunnel always overwrites with the client's address
73
+ # (CF-Connecting-IP, True-Client-IP, Fly-Client-IP), read for the per-IP key
74
+ # instead of X-Forwarded-For, which such proxies append to. Unset: ignored.
75
+ # Safe only if every request reaches the app through that proxy.
76
+ CLIENT_IP_HEADER: str | None = os.environ.get("CLIENT_IP_HEADER", "").strip() or None
77
+
78
+ # The TOTP second step (totp.py): how long a sign-in waits for its code
79
+ # after the password, and how many wrong codes it gets before it ends.
80
+ PENDING_LOGIN_TTL_SECONDS: int = int(os.environ.get("PENDING_LOGIN_TTL_SECONDS", str(5 * 60)))
81
+ PENDING_LOGIN_MAX_TRIES: int = int(os.environ.get("PENDING_LOGIN_MAX_TRIES", "5"))
82
+ # The name an authenticator app lists the account under.
83
+ TOTP_ISSUER: str = os.environ.get("TOTP_ISSUER", "UI Foundation")
84
+
85
+ # Error reporting (error_reporting.py, docs/deploy.md "Error reporting").
86
+ # A Sentry-compatible DSN (Sentry, or a self-hosted GlitchTip):
87
+ # https://<key>@<host>/<project id>. Unset, the default, errors are only logged.
88
+ ERROR_REPORTING_DSN: str | None = os.environ.get("ERROR_REPORTING_DSN", "").strip() or None
89
+ # The build a report names: the commit, set by the deploy (unset is fine).
90
+ RELEASE: str | None = os.environ.get("RELEASE", "").strip() or None
91
+ # How many reports one session may send per minute from the browser, after
92
+ # duplicates (the same error) are dropped to one a minute.
93
+ CLIENT_ERRORS_PER_MINUTE: int = int(os.environ.get("CLIENT_ERRORS_PER_MINUTE", "10"))
94
+
95
+ # AI chats reading and writing the app's records through MCP (an app's routers/mcp.py,
96
+ # routers/oauth.py, docs/deploy.md "AI chats (MCP)"). Off by default: with
97
+ # it off, /mcp, /oauth/* and the /.well-known/oauth-* documents answer 404.
98
+ MCP_ENABLED: bool = os.environ.get("MCP_ENABLED", "false").lower() == "true"
99
+ # The app's public https origin, e.g. https://app.example.com: the OAuth
100
+ # issuer, and the base of every address the sign-in documents hand out.
101
+ # Required when MCP_ENABLED and APP_ENV=production (a proxy hides the real
102
+ # one from the app); in development the request's own is used.
103
+ MCP_PUBLIC_URL: str | None = os.environ.get("MCP_PUBLIC_URL", "").strip().rstrip("/") or None
104
+ MCP_ACCESS_TOKEN_TTL_SECONDS: int = int(os.environ.get("MCP_ACCESS_TOKEN_TTL_SECONDS", str(60 * 60)))
105
+ MCP_REFRESH_TOKEN_TTL_DAYS: int = int(os.environ.get("MCP_REFRESH_TOKEN_TTL_DAYS", "30"))
106
+ # How many AI chats may be registered at once, and how many requests one
107
+ # connection may make a minute (counted in each worker's memory).
108
+ MCP_MAX_CLIENTS: int = int(os.environ.get("MCP_MAX_CLIENTS", "50"))
109
+ MCP_REQUESTS_PER_MINUTE: int = int(os.environ.get("MCP_REQUESTS_PER_MINUTE", "120"))
110
+
111
+ # Seeded dev user (migration 0002). Not a
112
+ # production credential; a real deployment sets its own before going live.
113
+ # The defaults are published in the README, which is why production refuses
114
+ # to start while any account still accepts DEFAULT_SEED_PASSWORD.
115
+ DEFAULT_SEED_EMAIL = "dev@example.com"
116
+ DEFAULT_SEED_PASSWORD = "dev-password-123"
117
+ SEED_USER_EMAIL: str = os.environ.get("SEED_USER_EMAIL", DEFAULT_SEED_EMAIL)
118
+ SEED_USER_PASSWORD: str = os.environ.get("SEED_USER_PASSWORD", DEFAULT_SEED_PASSWORD)
@@ -0,0 +1,17 @@
1
+ """Async engine + session dependency. No repository layer on purpose —
2
+ routers call the session directly.
3
+ """
4
+
5
+ from collections.abc import AsyncGenerator
6
+
7
+ from sqlmodel.ext.asyncio.session import AsyncSession
8
+ from sqlalchemy.ext.asyncio import create_async_engine
9
+
10
+ from foundation_backend.config import DATABASE_URL, database_connect_args
11
+
12
+ engine = create_async_engine(DATABASE_URL, echo=False, connect_args=database_connect_args())
13
+
14
+
15
+ async def get_session() -> AsyncGenerator[AsyncSession, None]:
16
+ async with AsyncSession(engine) as session:
17
+ yield session
@@ -0,0 +1,105 @@
1
+ """Startup safety checks for APP_ENV=production (see docs/deploy.md).
2
+
3
+ Development needs none of this and runs no startup query at all. A real
4
+ deployment is refused outright rather than warned about: a warning in a log
5
+ nobody reads is how a published default password ends up on the internet.
6
+ """
7
+
8
+ import json
9
+ from pathlib import Path
10
+
11
+ from fastapi.concurrency import run_in_threadpool
12
+ from sqlmodel import select
13
+ from sqlmodel.ext.asyncio.session import AsyncSession
14
+
15
+ from foundation_backend.config import DEFAULT_SEED_EMAIL, DEFAULT_SEED_PASSWORD, SEED_USER_EMAIL
16
+ from foundation_backend.models import User
17
+ from foundation_backend.security import verify_password
18
+
19
+
20
+ async def production_problems(
21
+ session: AsyncSession,
22
+ *,
23
+ cookie_secure: bool,
24
+ data_label: str | None = None,
25
+ mcp_enabled: bool = False,
26
+ mcp_public_url: str | None = None,
27
+ static_dir: str | None = None,
28
+ ) -> list[str]:
29
+ """Everything that makes this deployment unsafe to serve; empty if none.
30
+
31
+ `static_dir` is the built frontend (STATIC_DIR); None skips its check.
32
+ """
33
+ problems: list[str] = []
34
+
35
+ if static_dir is not None:
36
+ problem = frontend_build_problem(Path(static_dir))
37
+ if problem is not None:
38
+ problems.append(problem)
39
+
40
+ # A label says the data isn't production's, and the app shows it on
41
+ # every screen. Production serves production data, so a label here is
42
+ # a dev backend/.env deployed by mistake.
43
+ if data_label is not None:
44
+ problems.append(
45
+ f"DATA_LABEL is set ({data_label!r}); production data has no label, and every "
46
+ "screen would say it isn't production's. Unset it."
47
+ )
48
+
49
+ # Chats are sent to the issuer's address to sign in, and the OAuth
50
+ # documents name it; behind a proxy the app can't know it, and the
51
+ # protocol requires https.
52
+ if mcp_enabled and (mcp_public_url is None or not mcp_public_url.startswith("https://")):
53
+ problems.append(
54
+ "MCP_ENABLED is true but MCP_PUBLIC_URL is not an https:// address; AI chats can't "
55
+ "sign in without the app's public address (e.g. https://app.example.com)."
56
+ )
57
+
58
+ if not cookie_secure:
59
+ problems.append(
60
+ "COOKIE_SECURE is not true; the session cookie would be sent over plain HTTP."
61
+ )
62
+
63
+ # Migration 0002 seeds whatever SEED_USER_* held when it ran, so check
64
+ # the stored hash, not the environment: both the configured seed email
65
+ # and the published default one, in case the env changed after seeding.
66
+ for email in sorted({DEFAULT_SEED_EMAIL, SEED_USER_EMAIL}):
67
+ result = await session.exec(select(User).where(User.email == email))
68
+ user = result.first()
69
+ if user is not None and await run_in_threadpool(
70
+ verify_password, DEFAULT_SEED_PASSWORD, user.password_hash
71
+ ):
72
+ problems.append(
73
+ f"User {email} still accepts the published default password. Change it "
74
+ "with `python -m foundation_backend.scripts.set_password` before serving this deployment."
75
+ )
76
+
77
+ return problems
78
+
79
+
80
+ BUILD_COMMAND = "Build it with `npm run build:real`, never a plain `npm run build`"
81
+
82
+
83
+ def frontend_build_problem(static_dir: Path) -> str | None:
84
+ """Why the frontend at `static_dir` mustn't be served, or None.
85
+
86
+ The build writes build-info.json (the foundation's Vite plugin) saying
87
+ which API the bundle calls. A mock bundle runs MSW in the browser: the
88
+ app would show fake data to real users and save nothing, with nothing
89
+ saying so. A missing file means a build that predates the plugin, or
90
+ no build at all, so it can't be told apart and is refused too.
91
+ """
92
+ info_file = static_dir / "build-info.json"
93
+ try:
94
+ info = json.loads(info_file.read_text(encoding="utf-8"))
95
+ except FileNotFoundError:
96
+ return f"The frontend has no {info_file}, so it can't be told from a mock build. {BUILD_COMMAND}."
97
+ except (OSError, ValueError):
98
+ return f"{info_file} can't be read as JSON. {BUILD_COMMAND}."
99
+ api = info.get("api") if isinstance(info, dict) else None
100
+ if api != "real":
101
+ return (
102
+ f"The frontend in {static_dir} was built with mocks (api: {api!r} in build-info.json); "
103
+ f"it would serve fake data. {BUILD_COMMAND}."
104
+ )
105
+ return None