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.
- ui_foundation_backend-4.25.0/PKG-INFO +121 -0
- ui_foundation_backend-4.25.0/README.md +100 -0
- ui_foundation_backend-4.25.0/pyproject.toml +57 -0
- ui_foundation_backend-4.25.0/setup.cfg +4 -0
- ui_foundation_backend-4.25.0/src/foundation_backend/__init__.py +13 -0
- ui_foundation_backend-4.25.0/src/foundation_backend/config.py +118 -0
- ui_foundation_backend-4.25.0/src/foundation_backend/db.py +17 -0
- ui_foundation_backend-4.25.0/src/foundation_backend/deploy_checks.py +105 -0
- ui_foundation_backend-4.25.0/src/foundation_backend/error_reporting.py +230 -0
- ui_foundation_backend-4.25.0/src/foundation_backend/errors.py +46 -0
- ui_foundation_backend-4.25.0/src/foundation_backend/login_limits.py +120 -0
- ui_foundation_backend-4.25.0/src/foundation_backend/migrations/0002_auth.py +61 -0
- ui_foundation_backend-4.25.0/src/foundation_backend/migrations/0010_login_limits_totp.py +135 -0
- ui_foundation_backend-4.25.0/src/foundation_backend/migrations/0011_oauth.py +150 -0
- ui_foundation_backend-4.25.0/src/foundation_backend/migrations/README.md +16 -0
- ui_foundation_backend-4.25.0/src/foundation_backend/models.py +131 -0
- ui_foundation_backend-4.25.0/src/foundation_backend/openapi_responses.py +38 -0
- ui_foundation_backend-4.25.0/src/foundation_backend/py.typed +0 -0
- ui_foundation_backend-4.25.0/src/foundation_backend/rate_window.py +32 -0
- ui_foundation_backend-4.25.0/src/foundation_backend/routers/__init__.py +0 -0
- ui_foundation_backend-4.25.0/src/foundation_backend/routers/auth.py +317 -0
- ui_foundation_backend-4.25.0/src/foundation_backend/routers/client_errors.py +80 -0
- ui_foundation_backend-4.25.0/src/foundation_backend/routers/environment.py +20 -0
- ui_foundation_backend-4.25.0/src/foundation_backend/routers/oauth.py +527 -0
- ui_foundation_backend-4.25.0/src/foundation_backend/schemas.py +106 -0
- ui_foundation_backend-4.25.0/src/foundation_backend/scripts/__init__.py +2 -0
- ui_foundation_backend-4.25.0/src/foundation_backend/scripts/disable_totp.py +49 -0
- ui_foundation_backend-4.25.0/src/foundation_backend/scripts/enable_totp.py +81 -0
- ui_foundation_backend-4.25.0/src/foundation_backend/scripts/set_password.py +67 -0
- ui_foundation_backend-4.25.0/src/foundation_backend/security.py +44 -0
- ui_foundation_backend-4.25.0/src/foundation_backend/spa.py +34 -0
- ui_foundation_backend-4.25.0/src/foundation_backend/testing/__init__.py +1 -0
- ui_foundation_backend-4.25.0/src/foundation_backend/testing/signed_in.py +32 -0
- ui_foundation_backend-4.25.0/src/foundation_backend/totp.py +90 -0
- ui_foundation_backend-4.25.0/src/ui_foundation_backend.egg-info/PKG-INFO +121 -0
- ui_foundation_backend-4.25.0/src/ui_foundation_backend.egg-info/SOURCES.txt +45 -0
- ui_foundation_backend-4.25.0/src/ui_foundation_backend.egg-info/dependency_links.txt +1 -0
- ui_foundation_backend-4.25.0/src/ui_foundation_backend.egg-info/requires.txt +12 -0
- ui_foundation_backend-4.25.0/src/ui_foundation_backend.egg-info/top_level.txt +1 -0
- ui_foundation_backend-4.25.0/tests/test_auth.py +240 -0
- ui_foundation_backend-4.25.0/tests/test_deploy_checks.py +86 -0
- ui_foundation_backend-4.25.0/tests/test_environment.py +30 -0
- ui_foundation_backend-4.25.0/tests/test_error_reporting.py +233 -0
- ui_foundation_backend-4.25.0/tests/test_login_limits.py +176 -0
- ui_foundation_backend-4.25.0/tests/test_signed_in.py +73 -0
- ui_foundation_backend-4.25.0/tests/test_spa.py +40 -0
- 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,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
|