paskia 0.9.1__tar.gz → 0.10.2__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.
- {paskia-0.9.1 → paskia-0.10.2}/PKG-INFO +14 -11
- {paskia-0.9.1 → paskia-0.10.2}/README.md +13 -10
- {paskia-0.9.1 → paskia-0.10.2}/paskia/_version.py +2 -2
- {paskia-0.9.1 → paskia-0.10.2}/paskia/bootstrap.py +8 -7
- {paskia-0.9.1 → paskia-0.10.2}/paskia/db/__init__.py +2 -0
- {paskia-0.9.1 → paskia-0.10.2}/paskia/db/background.py +5 -8
- {paskia-0.9.1 → paskia-0.10.2}/paskia/db/jsonl.py +2 -2
- {paskia-0.9.1 → paskia-0.10.2}/paskia/db/logging.py +130 -45
- {paskia-0.9.1 → paskia-0.10.2}/paskia/db/operations.py +25 -4
- {paskia-0.9.1 → paskia-0.10.2}/paskia/db/structs.py +3 -2
- {paskia-0.9.1 → paskia-0.10.2}/paskia/fastapi/__main__.py +33 -19
- {paskia-0.9.1 → paskia-0.10.2}/paskia/fastapi/admin.py +2 -2
- {paskia-0.9.1 → paskia-0.10.2}/paskia/fastapi/api.py +7 -3
- {paskia-0.9.1 → paskia-0.10.2}/paskia/fastapi/authz.py +11 -9
- {paskia-0.9.1 → paskia-0.10.2}/paskia/fastapi/logging.py +64 -21
- {paskia-0.9.1 → paskia-0.10.2}/paskia/fastapi/mainapp.py +8 -5
- {paskia-0.9.1 → paskia-0.10.2}/paskia/fastapi/remote.py +11 -37
- {paskia-0.9.1 → paskia-0.10.2}/paskia/fastapi/user.py +22 -0
- {paskia-0.9.1 → paskia-0.10.2}/paskia/fastapi/ws.py +12 -35
- paskia-0.10.2/paskia/fastapi/wschat.py +115 -0
- {paskia-0.9.1 → paskia-0.10.2}/paskia/fastapi/wsutil.py +2 -7
- {paskia-0.9.1 → paskia-0.10.2}/paskia/frontend-build/auth/admin/index.html +7 -6
- paskia-0.9.1/paskia/frontend-build/auth/assets/AccessDenied-DPkUS8LZ.css → paskia-0.10.2/paskia/frontend-build/auth/assets/AccessDenied-CVQZxSIL.css +1 -1
- paskia-0.10.2/paskia/frontend-build/auth/assets/AccessDenied-Licr0tqA.js +8 -0
- paskia-0.9.1/paskia/frontend-build/auth/assets/RestrictedAuth-CvR33_Z0.css → paskia-0.10.2/paskia/frontend-build/auth/assets/RestrictedAuth-0MFeNWS2.css +1 -1
- paskia-0.9.1/paskia/frontend-build/auth/assets/RestrictedAuth-DsJXicIw.js → paskia-0.10.2/paskia/frontend-build/auth/assets/RestrictedAuth-DWKMTEV3.js +1 -1
- paskia-0.10.2/paskia/frontend-build/auth/assets/_plugin-vue_export-helper-DJsHCwvl.js +33 -0
- paskia-0.10.2/paskia/frontend-build/auth/assets/_plugin-vue_export-helper-DUBf8-iM.css +1 -0
- paskia-0.9.1/paskia/frontend-build/auth/assets/admin-DzzjSg72.css → paskia-0.10.2/paskia/frontend-build/auth/assets/admin-B1H4YqM_.css +1 -1
- paskia-0.10.2/paskia/frontend-build/auth/assets/admin-CZKsX1OI.js +1 -0
- paskia-0.9.1/paskia/frontend-build/auth/assets/auth-C7k64Wad.css → paskia-0.10.2/paskia/frontend-build/auth/assets/auth-B4EpDxom.css +1 -1
- paskia-0.10.2/paskia/frontend-build/auth/assets/auth-Pe-PKe8b.js +1 -0
- paskia-0.10.2/paskia/frontend-build/auth/assets/forward-BC0p23CH.js +1 -0
- paskia-0.9.1/paskia/frontend-build/auth/assets/pow-2N9bxgAo.js → paskia-0.10.2/paskia/frontend-build/auth/assets/pow-DUr-T9XX.js +1 -1
- paskia-0.10.2/paskia/frontend-build/auth/assets/reset-B8PlNXuP.css +1 -0
- paskia-0.10.2/paskia/frontend-build/auth/assets/reset-CkY9h28U.js +1 -0
- paskia-0.10.2/paskia/frontend-build/auth/assets/restricted-C9cJlHkd.js +1 -0
- paskia-0.10.2/paskia/frontend-build/auth/assets/theme-C2WysaSw.js +1 -0
- {paskia-0.9.1 → paskia-0.10.2}/paskia/frontend-build/auth/index.html +8 -7
- {paskia-0.9.1 → paskia-0.10.2}/paskia/frontend-build/auth/restricted/index.html +7 -6
- {paskia-0.9.1 → paskia-0.10.2}/paskia/frontend-build/int/forward/index.html +6 -6
- {paskia-0.9.1 → paskia-0.10.2}/paskia/frontend-build/int/reset/index.html +4 -4
- paskia-0.10.2/paskia/frontend-build/paskia.webp +0 -0
- paskia-0.10.2/paskia/util/__init__.py +0 -0
- paskia-0.10.2/paskia/util/apistructs.py +110 -0
- paskia-0.10.2/paskia/util/frontend.py +75 -0
- paskia-0.10.2/paskia/util/hostutil.py +75 -0
- paskia-0.10.2/paskia/util/htmlutil.py +47 -0
- paskia-0.10.2/paskia/util/passphrase.py +20 -0
- paskia-0.10.2/paskia/util/permutil.py +43 -0
- paskia-0.10.2/paskia/util/pow.py +45 -0
- paskia-0.10.2/paskia/util/querysafe.py +11 -0
- paskia-0.10.2/paskia/util/sessionutil.py +38 -0
- paskia-0.10.2/paskia/util/startupbox.py +103 -0
- paskia-0.10.2/paskia/util/timeutil.py +47 -0
- paskia-0.10.2/paskia/util/useragent.py +10 -0
- paskia-0.10.2/paskia/util/userinfo.py +63 -0
- paskia-0.10.2/paskia/util/vitedev.py +71 -0
- paskia-0.10.2/paskia/util/wordlist.py +54 -0
- paskia-0.9.1/paskia/fastapi/wschat.py +0 -62
- paskia-0.9.1/paskia/frontend-build/auth/assets/AccessDenied-Fmeb6EtF.js +0 -8
- paskia-0.9.1/paskia/frontend-build/auth/assets/_plugin-vue_export-helper-BTzJAQlS.css +0 -1
- paskia-0.9.1/paskia/frontend-build/auth/assets/_plugin-vue_export-helper-nhjnO_bd.js +0 -2
- paskia-0.9.1/paskia/frontend-build/auth/assets/admin-CPE1pLMm.js +0 -1
- paskia-0.9.1/paskia/frontend-build/auth/assets/auth-YIZvPlW_.js +0 -1
- paskia-0.9.1/paskia/frontend-build/auth/assets/forward-DmqVHZ7e.js +0 -1
- paskia-0.9.1/paskia/frontend-build/auth/assets/reset-Chtv69AT.css +0 -1
- paskia-0.9.1/paskia/frontend-build/auth/assets/reset-s20PATTN.js +0 -1
- paskia-0.9.1/paskia/frontend-build/auth/assets/restricted-D3AJx3_6.js +0 -1
- {paskia-0.9.1 → paskia-0.10.2}/.gitignore +0 -0
- {paskia-0.9.1 → paskia-0.10.2}/paskia/__init__.py +0 -0
- {paskia-0.9.1 → paskia-0.10.2}/paskia/aaguid/__init__.py +0 -0
- {paskia-0.9.1 → paskia-0.10.2}/paskia/aaguid/combined_aaguid.json +0 -0
- {paskia-0.9.1 → paskia-0.10.2}/paskia/authsession.py +0 -0
- {paskia-0.9.1 → paskia-0.10.2}/paskia/config.py +0 -0
- {paskia-0.9.1 → paskia-0.10.2}/paskia/db/migrations.py +0 -0
- {paskia-0.9.1 → paskia-0.10.2}/paskia/fastapi/__init__.py +0 -0
- {paskia-0.9.1 → paskia-0.10.2}/paskia/fastapi/auth_host.py +0 -0
- {paskia-0.9.1 → paskia-0.10.2}/paskia/fastapi/reset.py +0 -0
- {paskia-0.9.1 → paskia-0.10.2}/paskia/fastapi/response.py +0 -0
- {paskia-0.9.1 → paskia-0.10.2}/paskia/fastapi/session.py +0 -0
- {paskia-0.9.1 → paskia-0.10.2}/paskia/frontend-build/auth/assets/helpers-DzjFIx78.js +0 -0
- {paskia-0.9.1 → paskia-0.10.2}/paskia/globals.py +0 -0
- {paskia-0.9.1 → paskia-0.10.2}/paskia/migrate/__init__.py +0 -0
- {paskia-0.9.1 → paskia-0.10.2}/paskia/migrate/sql.py +0 -0
- {paskia-0.9.1 → paskia-0.10.2}/paskia/remoteauth.py +0 -0
- {paskia-0.9.1 → paskia-0.10.2}/paskia/sansio.py +0 -0
- {paskia-0.9.1 → paskia-0.10.2}/pyproject.toml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: paskia
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.10.2
|
|
4
4
|
Summary: Passkey Auth made easy: all sites and APIs can be guarded even without any changes on the protected site.
|
|
5
5
|
Project-URL: Homepage, https://git.zi.fi/LeoVasanko/paskia
|
|
6
6
|
Project-URL: Repository, https://github.com/LeoVasanko/paskia
|
|
@@ -31,6 +31,8 @@ Description-Content-Type: text/markdown
|
|
|
31
31
|
|
|
32
32
|
# Paskia
|
|
33
33
|
|
|
34
|
+

|
|
35
|
+
|
|
34
36
|
An easy to install passkey-based authentication service that protects any web application with strong passwordless login.
|
|
35
37
|
|
|
36
38
|
## What is Paskia?
|
|
@@ -58,12 +60,12 @@ Single Sign-On (SSO): Users register once and authenticate across all applicatio
|
|
|
58
60
|
Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run:
|
|
59
61
|
|
|
60
62
|
```fish
|
|
61
|
-
uvx paskia
|
|
63
|
+
uvx paskia --rp-id example.com
|
|
62
64
|
```
|
|
63
65
|
|
|
64
|
-
On the first run it downloads the software and prints a registration link for the Admin.
|
|
66
|
+
On the first run it downloads the software and prints a registration link for the Admin. The server starts on [localhost:4401](http://localhost:4401), serving authentication for `*.example.com`. For local testing, leave out `--rp-id`.
|
|
65
67
|
|
|
66
|
-
|
|
68
|
+
For production you need a web server such as [Caddy](https://caddyserver.com/) to serve HTTPS on your actual domain names and proxy requests to Paskia and your backend apps (see documentation below).
|
|
67
69
|
|
|
68
70
|
For a permanent install of `paskia` CLI command, not needing `uvx`:
|
|
69
71
|
|
|
@@ -73,19 +75,20 @@ uv tool install paskia
|
|
|
73
75
|
|
|
74
76
|
## Configuration
|
|
75
77
|
|
|
76
|
-
There is no config file.
|
|
78
|
+
There is no config file. All settings are passed as CLI options:
|
|
77
79
|
|
|
78
80
|
```text
|
|
79
|
-
paskia
|
|
81
|
+
paskia [options]
|
|
82
|
+
paskia reset [user] # Generate passkey reset link
|
|
80
83
|
```
|
|
81
84
|
|
|
82
85
|
| Option | Description | Default |
|
|
83
86
|
|--------|-------------|---------|
|
|
84
|
-
|
|
|
85
|
-
| --rp-id *domain* | Main/top domain | **localhost** |
|
|
86
|
-
| --rp-name *"text"* | Name
|
|
87
|
-
| --origin *url* |
|
|
88
|
-
| --auth-host *
|
|
87
|
+
| -l, --listen *endpoint* | Listen address: *host*:*port*, :*port* (all interfaces), or */path.sock* | **localhost:4401** |
|
|
88
|
+
| --rp-id *domain* | Main/top domain for passkeys | **localhost** |
|
|
89
|
+
| --rp-name *"text"* | Name shown during passkey registration | Same as rp-id |
|
|
90
|
+
| --origin *url* | Restrict allowed origins for WebSocket auth (repeatable) | All under rp-id |
|
|
91
|
+
| --auth-host *url* | Dedicated authentication site, e.g. **auth.example.com** | Use **/auth/** path on each site |
|
|
89
92
|
|
|
90
93
|
## Further Documentation
|
|
91
94
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Paskia
|
|
2
2
|
|
|
3
|
+

|
|
4
|
+
|
|
3
5
|
An easy to install passkey-based authentication service that protects any web application with strong passwordless login.
|
|
4
6
|
|
|
5
7
|
## What is Paskia?
|
|
@@ -27,12 +29,12 @@ Single Sign-On (SSO): Users register once and authenticate across all applicatio
|
|
|
27
29
|
Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run:
|
|
28
30
|
|
|
29
31
|
```fish
|
|
30
|
-
uvx paskia
|
|
32
|
+
uvx paskia --rp-id example.com
|
|
31
33
|
```
|
|
32
34
|
|
|
33
|
-
On the first run it downloads the software and prints a registration link for the Admin.
|
|
35
|
+
On the first run it downloads the software and prints a registration link for the Admin. The server starts on [localhost:4401](http://localhost:4401), serving authentication for `*.example.com`. For local testing, leave out `--rp-id`.
|
|
34
36
|
|
|
35
|
-
|
|
37
|
+
For production you need a web server such as [Caddy](https://caddyserver.com/) to serve HTTPS on your actual domain names and proxy requests to Paskia and your backend apps (see documentation below).
|
|
36
38
|
|
|
37
39
|
For a permanent install of `paskia` CLI command, not needing `uvx`:
|
|
38
40
|
|
|
@@ -42,19 +44,20 @@ uv tool install paskia
|
|
|
42
44
|
|
|
43
45
|
## Configuration
|
|
44
46
|
|
|
45
|
-
There is no config file.
|
|
47
|
+
There is no config file. All settings are passed as CLI options:
|
|
46
48
|
|
|
47
49
|
```text
|
|
48
|
-
paskia
|
|
50
|
+
paskia [options]
|
|
51
|
+
paskia reset [user] # Generate passkey reset link
|
|
49
52
|
```
|
|
50
53
|
|
|
51
54
|
| Option | Description | Default |
|
|
52
55
|
|--------|-------------|---------|
|
|
53
|
-
|
|
|
54
|
-
| --rp-id *domain* | Main/top domain | **localhost** |
|
|
55
|
-
| --rp-name *"text"* | Name
|
|
56
|
-
| --origin *url* |
|
|
57
|
-
| --auth-host *
|
|
56
|
+
| -l, --listen *endpoint* | Listen address: *host*:*port*, :*port* (all interfaces), or */path.sock* | **localhost:4401** |
|
|
57
|
+
| --rp-id *domain* | Main/top domain for passkeys | **localhost** |
|
|
58
|
+
| --rp-name *"text"* | Name shown during passkey registration | Same as rp-id |
|
|
59
|
+
| --origin *url* | Restrict allowed origins for WebSocket auth (repeatable) | All under rp-id |
|
|
60
|
+
| --auth-host *url* | Dedicated authentication site, e.g. **auth.example.com** | Use **/auth/** path on each site |
|
|
58
61
|
|
|
59
62
|
## Further Documentation
|
|
60
63
|
|
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '0.
|
|
32
|
-
__version_tuple__ = version_tuple = (0,
|
|
31
|
+
__version__ = version = '0.10.2'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 10, 2)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
|
@@ -15,18 +15,18 @@ from paskia.util import hostutil, passphrase
|
|
|
15
15
|
logger = logging.getLogger(__name__)
|
|
16
16
|
|
|
17
17
|
# Shared log message template for admin reset links
|
|
18
|
-
ADMIN_RESET_MESSAGE = """
|
|
19
|
-
%s
|
|
20
|
-
|
|
18
|
+
ADMIN_RESET_MESSAGE = """
|
|
21
19
|
👤 Admin %s
|
|
22
20
|
- Use this link to register a Passkey for the admin user!
|
|
23
21
|
"""
|
|
24
22
|
|
|
25
23
|
|
|
26
|
-
def _log_reset_link(
|
|
24
|
+
def _log_reset_link(passphrase: str, message: str | None = None) -> str:
|
|
27
25
|
"""Log a reset link message and return the URL."""
|
|
28
26
|
reset_link = hostutil.reset_link_url(passphrase)
|
|
29
|
-
|
|
27
|
+
if message:
|
|
28
|
+
logger.info(message)
|
|
29
|
+
logger.info(ADMIN_RESET_MESSAGE, reset_link)
|
|
30
30
|
return reset_link
|
|
31
31
|
|
|
32
32
|
|
|
@@ -41,7 +41,7 @@ async def bootstrap_system() -> None:
|
|
|
41
41
|
reset_passphrase = db.bootstrap()
|
|
42
42
|
|
|
43
43
|
# Log the reset link (this is separate from the transaction log)
|
|
44
|
-
_log_reset_link("✅ Bootstrap completed!"
|
|
44
|
+
_log_reset_link(reset_passphrase, "✅ Bootstrap completed!")
|
|
45
45
|
|
|
46
46
|
|
|
47
47
|
async def check_admin_credentials() -> bool:
|
|
@@ -72,6 +72,7 @@ async def check_admin_credentials() -> bool:
|
|
|
72
72
|
|
|
73
73
|
if not db.get_user_credential_ids(admin_user.uuid):
|
|
74
74
|
# Admin exists but has no credentials, create reset link
|
|
75
|
+
logger.info("⚠️ Admin user has no credentials!")
|
|
75
76
|
|
|
76
77
|
token = passphrase.generate()
|
|
77
78
|
expiry = authsession.reset_expires()
|
|
@@ -81,7 +82,7 @@ async def check_admin_credentials() -> bool:
|
|
|
81
82
|
expiry=expiry,
|
|
82
83
|
token_type="admin registration",
|
|
83
84
|
)
|
|
84
|
-
_log_reset_link(
|
|
85
|
+
_log_reset_link(token)
|
|
85
86
|
return True
|
|
86
87
|
|
|
87
88
|
return False
|
|
@@ -64,6 +64,7 @@ from paskia.db.operations import (
|
|
|
64
64
|
update_user_display_name,
|
|
65
65
|
update_user_role,
|
|
66
66
|
update_user_role_in_organization,
|
|
67
|
+
update_user_theme,
|
|
67
68
|
)
|
|
68
69
|
from paskia.db.structs import (
|
|
69
70
|
DB,
|
|
@@ -147,4 +148,5 @@ __all__ = [
|
|
|
147
148
|
"update_user_display_name",
|
|
148
149
|
"update_user_role",
|
|
149
150
|
"update_user_role_in_organization",
|
|
151
|
+
"update_user_theme",
|
|
150
152
|
]
|
|
@@ -74,21 +74,18 @@ async def start_background():
|
|
|
74
74
|
_logger.debug("Background task in different event loop, restarting")
|
|
75
75
|
_background_task = None
|
|
76
76
|
else:
|
|
77
|
-
# Task is running in
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
"
|
|
77
|
+
# Task is already running in same loop - idempotent, just return
|
|
78
|
+
# This happens with dual IPv4+IPv6 endpoints sharing the same process
|
|
79
|
+
_logger.debug(
|
|
80
|
+
"Background task already running in same loop, skipping"
|
|
81
81
|
)
|
|
82
|
-
|
|
83
|
-
raise # Re-raise RuntimeError from above
|
|
82
|
+
return
|
|
84
83
|
except Exception as e:
|
|
85
84
|
_logger.debug("Error checking background task loop: %s, restarting", e)
|
|
86
85
|
_background_task = None
|
|
87
86
|
|
|
88
87
|
if _background_task is None:
|
|
89
88
|
_background_task = asyncio.create_task(_background_loop())
|
|
90
|
-
else:
|
|
91
|
-
_logger.debug("Background task already running: %s", _background_task)
|
|
92
89
|
|
|
93
90
|
|
|
94
91
|
async def stop_background():
|
|
@@ -198,7 +198,6 @@ class JsonlStore:
|
|
|
198
198
|
if not diff:
|
|
199
199
|
return
|
|
200
200
|
self._pending_changes.append(create_change_record(action, version, diff, user))
|
|
201
|
-
self._previous_builtins = copy.deepcopy(current)
|
|
202
201
|
|
|
203
202
|
# Log the change with user display name if available
|
|
204
203
|
user_display = None
|
|
@@ -210,7 +209,8 @@ class JsonlStore:
|
|
|
210
209
|
except (ValueError, KeyError):
|
|
211
210
|
user_display = user
|
|
212
211
|
|
|
213
|
-
log_change(action, diff, user_display)
|
|
212
|
+
log_change(action, diff, user_display, self._previous_builtins)
|
|
213
|
+
self._previous_builtins = copy.deepcopy(current)
|
|
214
214
|
|
|
215
215
|
@contextmanager
|
|
216
216
|
def transaction(
|
|
@@ -26,8 +26,7 @@ _RESET = "\033[0m"
|
|
|
26
26
|
_DIM = "\033[2m"
|
|
27
27
|
_PATH_PREFIX = "\033[1;30m" # Dark grey for path prefix (like host in access log)
|
|
28
28
|
_PATH_FINAL = "\033[0m" # Default for final element (like path in access log)
|
|
29
|
-
|
|
30
|
-
_DELETE = "\033[0;31m" # Red for deletions
|
|
29
|
+
_DELETE = "\033[1;31m" # Red for deletions
|
|
31
30
|
_ADD = "\033[0;32m" # Green for additions
|
|
32
31
|
_ACTION = "\033[1;34m" # Bold blue for action name
|
|
33
32
|
_USER = "\033[0;34m" # Blue for user display
|
|
@@ -93,18 +92,34 @@ def _format_path(path: list[str], use_color: bool) -> str:
|
|
|
93
92
|
return f"{_PATH_PREFIX}{prefix}.{_RESET}{_PATH_FINAL}{final}{_RESET}"
|
|
94
93
|
|
|
95
94
|
|
|
95
|
+
def _get_nested(data: dict | None, path: list[str]) -> Any:
|
|
96
|
+
"""Get a nested value from a dict by path, or None if not found."""
|
|
97
|
+
if data is None:
|
|
98
|
+
return None
|
|
99
|
+
current = data
|
|
100
|
+
for key in path:
|
|
101
|
+
if not isinstance(current, dict) or key not in current:
|
|
102
|
+
return None
|
|
103
|
+
current = current[key]
|
|
104
|
+
return current
|
|
105
|
+
|
|
106
|
+
|
|
96
107
|
def _collect_changes(
|
|
97
|
-
diff: dict,
|
|
108
|
+
diff: dict,
|
|
109
|
+
path: list[str],
|
|
110
|
+
changes: list[tuple[str, list[str], Any]],
|
|
111
|
+
previous: dict | None,
|
|
98
112
|
) -> None:
|
|
99
113
|
"""
|
|
100
114
|
Recursively collect changes from a diff into a flat list.
|
|
101
115
|
|
|
102
|
-
Each change is a tuple of (change_type, path, new_value
|
|
103
|
-
change_type is one of: '
|
|
116
|
+
Each change is a tuple of (change_type, path, new_value).
|
|
117
|
+
change_type is one of: 'add', 'update', 'delete'
|
|
104
118
|
"""
|
|
105
119
|
if not isinstance(diff, dict):
|
|
106
|
-
# Leaf value -
|
|
107
|
-
|
|
120
|
+
# Leaf value - check if it existed before
|
|
121
|
+
existed = _get_nested(previous, path) is not None
|
|
122
|
+
changes.append(("update" if existed else "add", path, diff))
|
|
108
123
|
return
|
|
109
124
|
|
|
110
125
|
for key, value in diff.items():
|
|
@@ -112,72 +127,136 @@ def _collect_changes(
|
|
|
112
127
|
# $delete contains a list of keys to delete
|
|
113
128
|
if isinstance(value, list):
|
|
114
129
|
for deleted_key in value:
|
|
115
|
-
changes.append(("delete", path + [str(deleted_key)], None
|
|
130
|
+
changes.append(("delete", path + [str(deleted_key)], None))
|
|
116
131
|
else:
|
|
117
|
-
changes.append(("delete", path + [str(value)], None
|
|
132
|
+
changes.append(("delete", path + [str(value)], None))
|
|
118
133
|
|
|
119
134
|
elif key == "$replace":
|
|
120
|
-
# $replace
|
|
135
|
+
# $replace replaces the entire collection at this path
|
|
136
|
+
# We need to track what was added and what was deleted
|
|
137
|
+
old_collection = _get_nested(previous, path)
|
|
138
|
+
old_keys = (
|
|
139
|
+
set(old_collection.keys())
|
|
140
|
+
if isinstance(old_collection, dict)
|
|
141
|
+
else set()
|
|
142
|
+
)
|
|
143
|
+
new_keys = set(value.keys()) if isinstance(value, dict) else set()
|
|
144
|
+
|
|
145
|
+
# Items that existed before but not in new = deleted
|
|
146
|
+
for deleted_key in old_keys - new_keys:
|
|
147
|
+
changes.append(("delete", path + [str(deleted_key)], None))
|
|
148
|
+
|
|
149
|
+
# Items in new collection
|
|
121
150
|
if isinstance(value, dict):
|
|
122
|
-
# Replacing with a dict - show each key as a replacement
|
|
123
151
|
for rkey, rval in value.items():
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
152
|
+
existed = rkey in old_keys
|
|
153
|
+
changes.append(
|
|
154
|
+
("update" if existed else "add", path + [str(rkey)], rval)
|
|
155
|
+
)
|
|
156
|
+
elif value or not old_keys:
|
|
157
|
+
# Non-dict replacement or empty replacement with nothing before
|
|
158
|
+
changes.append(
|
|
159
|
+
("update" if old_collection is not None else "add", path, value)
|
|
160
|
+
)
|
|
130
161
|
|
|
131
162
|
elif key.startswith("$"):
|
|
132
163
|
# Other special operations (future-proofing)
|
|
133
|
-
changes.append(("
|
|
164
|
+
changes.append(("add", path, {key: value}))
|
|
134
165
|
|
|
135
166
|
else:
|
|
136
|
-
# Regular nested key
|
|
137
|
-
|
|
167
|
+
# Regular nested key - check if this item existed before
|
|
168
|
+
new_path = path + [str(key)]
|
|
169
|
+
existed = _get_nested(previous, new_path) is not None
|
|
170
|
+
if existed:
|
|
171
|
+
# Item exists - recurse to show specific field changes
|
|
172
|
+
_collect_changes(value, new_path, changes, previous)
|
|
173
|
+
else:
|
|
174
|
+
# New item - record as add with full value, don't recurse
|
|
175
|
+
changes.append(("add", new_path, value))
|
|
138
176
|
|
|
139
177
|
|
|
140
|
-
def
|
|
178
|
+
def _format_change_lines(
|
|
141
179
|
change_type: str, path: list[str], value: Any, use_color: bool
|
|
142
|
-
) -> str:
|
|
143
|
-
"""Format a single change as
|
|
144
|
-
path_str = _format_path(path, use_color)
|
|
145
|
-
value_str = _format_value(value, use_color)
|
|
146
|
-
|
|
180
|
+
) -> list[str]:
|
|
181
|
+
"""Format a single change as one or more lines."""
|
|
147
182
|
if change_type == "delete":
|
|
148
|
-
if use_color:
|
|
149
|
-
return f"
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
183
|
+
if not use_color:
|
|
184
|
+
return [f" {'.'.join(path)} ✗"]
|
|
185
|
+
if len(path) == 1:
|
|
186
|
+
return [f" {_DELETE}{path[0]} ✗{_RESET}"]
|
|
187
|
+
prefix = ".".join(path[:-1])
|
|
188
|
+
final = path[-1]
|
|
189
|
+
return [f" {_PATH_PREFIX}{prefix}.{_RESET}{_DELETE}{final} ✗{_RESET}"]
|
|
190
|
+
|
|
191
|
+
if change_type == "add":
|
|
192
|
+
# New item being created - only final element in green
|
|
193
|
+
# For dict values, show children on separate indented lines
|
|
194
|
+
if isinstance(value, dict) and value:
|
|
195
|
+
lines = []
|
|
196
|
+
# First line: path with green final element and grey =
|
|
197
|
+
if not use_color:
|
|
198
|
+
lines.append(f" {'.'.join(path)} =")
|
|
199
|
+
elif len(path) == 1:
|
|
200
|
+
lines.append(f" {_ADD}{path[0]}{_RESET} {_DIM}={_RESET}")
|
|
201
|
+
else:
|
|
202
|
+
prefix = ".".join(path[:-1])
|
|
203
|
+
final = path[-1]
|
|
204
|
+
lines.append(
|
|
205
|
+
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_DIM}={_RESET}"
|
|
206
|
+
)
|
|
207
|
+
# Child lines: indented key: value, with aligned values
|
|
208
|
+
max_key_len = max(len(k) for k in value.keys())
|
|
209
|
+
field_width = max(max_key_len, 12) # minimum 12 chars
|
|
210
|
+
for k, v in value.items():
|
|
211
|
+
v_str = _format_value(v, use_color)
|
|
212
|
+
padding = " " * (field_width - len(k))
|
|
213
|
+
if use_color:
|
|
214
|
+
lines.append(f" {k}{_DIM}:{_RESET}{padding} {v_str}")
|
|
215
|
+
else:
|
|
216
|
+
lines.append(f" {k}:{padding} {v_str}")
|
|
217
|
+
return lines
|
|
218
|
+
else:
|
|
219
|
+
value_str = _format_value(value, use_color)
|
|
220
|
+
if not use_color:
|
|
221
|
+
return [f" {'.'.join(path)} = {value_str}"]
|
|
222
|
+
if len(path) == 1:
|
|
223
|
+
return [f" {_ADD}{path[0]}{_RESET} {_DIM}={_RESET} {value_str}"]
|
|
224
|
+
prefix = ".".join(path[:-1])
|
|
225
|
+
final = path[-1]
|
|
226
|
+
return [
|
|
227
|
+
f" {_PATH_PREFIX}{prefix}.{_RESET}{_ADD}{final}{_RESET} {_DIM}={_RESET} {value_str}"
|
|
228
|
+
]
|
|
229
|
+
|
|
230
|
+
# update: Existing item being updated - normal path colors
|
|
231
|
+
value_str = _format_value(value, use_color)
|
|
232
|
+
path_str = _format_path(path, use_color)
|
|
158
233
|
if use_color:
|
|
159
|
-
return f" {
|
|
160
|
-
return f"
|
|
234
|
+
return [f" {path_str} {_DIM}={_RESET} {value_str}"]
|
|
235
|
+
return [f" {path_str} = {value_str}"]
|
|
161
236
|
|
|
162
237
|
|
|
163
|
-
def format_diff(diff: dict) -> list[str]:
|
|
238
|
+
def format_diff(diff: dict, previous: dict | None = None) -> list[str]:
|
|
164
239
|
"""
|
|
165
240
|
Format a JSON diff as human-readable lines.
|
|
166
241
|
|
|
242
|
+
Args:
|
|
243
|
+
diff: The JSON diff dict
|
|
244
|
+
previous: The previous state dict (for determining add vs update)
|
|
245
|
+
|
|
167
246
|
Returns a list of formatted lines (without newlines).
|
|
168
247
|
Single changes return one line, multiple changes return multiple lines.
|
|
169
248
|
"""
|
|
170
249
|
use_color = _use_color()
|
|
171
|
-
changes: list[tuple[str, list[str], Any
|
|
172
|
-
_collect_changes(diff, [], changes)
|
|
250
|
+
changes: list[tuple[str, list[str], Any]] = []
|
|
251
|
+
_collect_changes(diff, [], changes, previous)
|
|
173
252
|
|
|
174
253
|
if not changes:
|
|
175
254
|
return []
|
|
176
255
|
|
|
177
256
|
# Format each change
|
|
178
257
|
lines = []
|
|
179
|
-
for change_type, path, value
|
|
180
|
-
lines.
|
|
258
|
+
for change_type, path, value in changes:
|
|
259
|
+
lines.extend(_format_change_lines(change_type, path, value, use_color))
|
|
181
260
|
|
|
182
261
|
return lines
|
|
183
262
|
|
|
@@ -198,7 +277,12 @@ def format_action_header(action: str, user_display: str | None = None) -> str:
|
|
|
198
277
|
return action
|
|
199
278
|
|
|
200
279
|
|
|
201
|
-
def log_change(
|
|
280
|
+
def log_change(
|
|
281
|
+
action: str,
|
|
282
|
+
diff: dict,
|
|
283
|
+
user_display: str | None = None,
|
|
284
|
+
previous: dict | None = None,
|
|
285
|
+
) -> None:
|
|
202
286
|
"""
|
|
203
287
|
Log a database change with pretty-printed diff.
|
|
204
288
|
|
|
@@ -206,9 +290,10 @@ def log_change(action: str, diff: dict, user_display: str | None = None) -> None
|
|
|
206
290
|
action: The action name (e.g., "login", "admin:delete_user")
|
|
207
291
|
diff: The JSON diff dict
|
|
208
292
|
user_display: Optional display name of the user who performed the action
|
|
293
|
+
previous: The previous state dict (for determining add vs update)
|
|
209
294
|
"""
|
|
210
295
|
header = format_action_header(action, user_display)
|
|
211
|
-
diff_lines = format_diff(diff)
|
|
296
|
+
diff_lines = format_diff(diff, previous)
|
|
212
297
|
|
|
213
298
|
if not diff_lines:
|
|
214
299
|
logger.info(header)
|
|
@@ -352,6 +352,23 @@ def update_user_display_name(
|
|
|
352
352
|
_db.users[uuid].display_name = display_name
|
|
353
353
|
|
|
354
354
|
|
|
355
|
+
def update_user_theme(
|
|
356
|
+
uuid: UUID,
|
|
357
|
+
theme: str,
|
|
358
|
+
*,
|
|
359
|
+
ctx: SessionContext | None = None,
|
|
360
|
+
) -> None:
|
|
361
|
+
"""Update user theme preference ('' for auto, 'light', 'dark')."""
|
|
362
|
+
if isinstance(uuid, str):
|
|
363
|
+
uuid = UUID(uuid)
|
|
364
|
+
if uuid not in _db.users:
|
|
365
|
+
raise ValueError(f"User {uuid} not found")
|
|
366
|
+
if theme not in ("", "light", "dark"):
|
|
367
|
+
raise ValueError(f"Invalid theme: {theme}")
|
|
368
|
+
with _db.transaction("update_user_theme", ctx):
|
|
369
|
+
_db.users[uuid].theme = theme
|
|
370
|
+
|
|
371
|
+
|
|
355
372
|
def update_user_role(
|
|
356
373
|
uuid: UUID,
|
|
357
374
|
role_uuid: UUID,
|
|
@@ -517,16 +534,18 @@ def set_session_host(key: str, host: str, *, ctx: SessionContext | None = None)
|
|
|
517
534
|
update_session(key, host=host, ctx=ctx)
|
|
518
535
|
|
|
519
536
|
|
|
520
|
-
def delete_session(
|
|
537
|
+
def delete_session(
|
|
538
|
+
key: str, *, ctx: SessionContext | None = None, action: str = "delete_session"
|
|
539
|
+
) -> None:
|
|
521
540
|
"""Delete a session.
|
|
522
541
|
|
|
523
542
|
The acting user should be logged via ctx.
|
|
524
|
-
For user logout, pass ctx of the user's session.
|
|
543
|
+
For user logout, pass ctx of the user's session and action="logout".
|
|
525
544
|
For admin terminating a session, pass admin's ctx.
|
|
526
545
|
"""
|
|
527
546
|
if key not in _db.sessions:
|
|
528
547
|
raise ValueError("Session not found")
|
|
529
|
-
with _db.transaction(
|
|
548
|
+
with _db.transaction(action, ctx):
|
|
530
549
|
del _db.sessions[key]
|
|
531
550
|
|
|
532
551
|
|
|
@@ -554,6 +573,7 @@ def create_reset_token(
|
|
|
554
573
|
token_type: str,
|
|
555
574
|
*,
|
|
556
575
|
ctx: SessionContext | None = None,
|
|
576
|
+
user: str | None = None,
|
|
557
577
|
) -> None:
|
|
558
578
|
"""Create a reset token from a passphrase.
|
|
559
579
|
|
|
@@ -561,13 +581,14 @@ def create_reset_token(
|
|
|
561
581
|
For self-service (user creating own recovery link), pass user's ctx.
|
|
562
582
|
For admin operations, pass admin's ctx.
|
|
563
583
|
For system operations (bootstrap), pass neither to log no user.
|
|
584
|
+
For API operations where ctx is not available but user is known, pass user.
|
|
564
585
|
"""
|
|
565
586
|
key = _reset_key(passphrase)
|
|
566
587
|
if key in _db.reset_tokens:
|
|
567
588
|
raise ValueError("Reset token already exists")
|
|
568
589
|
if user_uuid not in _db.users:
|
|
569
590
|
raise ValueError(f"User {user_uuid} not found")
|
|
570
|
-
with _db.transaction("create_reset_token", ctx):
|
|
591
|
+
with _db.transaction("create_reset_token", ctx, user=user):
|
|
571
592
|
_db.reset_tokens[key] = ResetToken(
|
|
572
593
|
user_uuid=user_uuid, expiry=expiry, token_type=token_type
|
|
573
594
|
)
|
|
@@ -147,10 +147,10 @@ class Role(msgspec.Struct, dict=True, omit_defaults=True):
|
|
|
147
147
|
return role
|
|
148
148
|
|
|
149
149
|
|
|
150
|
-
class User(msgspec.Struct, dict=True):
|
|
150
|
+
class User(msgspec.Struct, dict=True, omit_defaults=True):
|
|
151
151
|
"""User data structure.
|
|
152
152
|
|
|
153
|
-
Mutable fields: display_name, role_uuid, last_seen, visits
|
|
153
|
+
Mutable fields: display_name, role_uuid, last_seen, visits, theme
|
|
154
154
|
Immutable fields: created_at (set at creation, never modified)
|
|
155
155
|
uuid is derived from created_at using uuid7.
|
|
156
156
|
"""
|
|
@@ -160,6 +160,7 @@ class User(msgspec.Struct, dict=True):
|
|
|
160
160
|
created_at: datetime
|
|
161
161
|
last_seen: datetime | None = None
|
|
162
162
|
visits: int = 0
|
|
163
|
+
theme: str = "" # "" or "auto" = OS default, "light", "dark"
|
|
163
164
|
|
|
164
165
|
def __post_init__(self):
|
|
165
166
|
if not hasattr(self, "uuid"):
|