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.
Files changed (88) hide show
  1. {paskia-0.9.1 → paskia-0.10.2}/PKG-INFO +14 -11
  2. {paskia-0.9.1 → paskia-0.10.2}/README.md +13 -10
  3. {paskia-0.9.1 → paskia-0.10.2}/paskia/_version.py +2 -2
  4. {paskia-0.9.1 → paskia-0.10.2}/paskia/bootstrap.py +8 -7
  5. {paskia-0.9.1 → paskia-0.10.2}/paskia/db/__init__.py +2 -0
  6. {paskia-0.9.1 → paskia-0.10.2}/paskia/db/background.py +5 -8
  7. {paskia-0.9.1 → paskia-0.10.2}/paskia/db/jsonl.py +2 -2
  8. {paskia-0.9.1 → paskia-0.10.2}/paskia/db/logging.py +130 -45
  9. {paskia-0.9.1 → paskia-0.10.2}/paskia/db/operations.py +25 -4
  10. {paskia-0.9.1 → paskia-0.10.2}/paskia/db/structs.py +3 -2
  11. {paskia-0.9.1 → paskia-0.10.2}/paskia/fastapi/__main__.py +33 -19
  12. {paskia-0.9.1 → paskia-0.10.2}/paskia/fastapi/admin.py +2 -2
  13. {paskia-0.9.1 → paskia-0.10.2}/paskia/fastapi/api.py +7 -3
  14. {paskia-0.9.1 → paskia-0.10.2}/paskia/fastapi/authz.py +11 -9
  15. {paskia-0.9.1 → paskia-0.10.2}/paskia/fastapi/logging.py +64 -21
  16. {paskia-0.9.1 → paskia-0.10.2}/paskia/fastapi/mainapp.py +8 -5
  17. {paskia-0.9.1 → paskia-0.10.2}/paskia/fastapi/remote.py +11 -37
  18. {paskia-0.9.1 → paskia-0.10.2}/paskia/fastapi/user.py +22 -0
  19. {paskia-0.9.1 → paskia-0.10.2}/paskia/fastapi/ws.py +12 -35
  20. paskia-0.10.2/paskia/fastapi/wschat.py +115 -0
  21. {paskia-0.9.1 → paskia-0.10.2}/paskia/fastapi/wsutil.py +2 -7
  22. {paskia-0.9.1 → paskia-0.10.2}/paskia/frontend-build/auth/admin/index.html +7 -6
  23. 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
  24. paskia-0.10.2/paskia/frontend-build/auth/assets/AccessDenied-Licr0tqA.js +8 -0
  25. 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
  26. 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
  27. paskia-0.10.2/paskia/frontend-build/auth/assets/_plugin-vue_export-helper-DJsHCwvl.js +33 -0
  28. paskia-0.10.2/paskia/frontend-build/auth/assets/_plugin-vue_export-helper-DUBf8-iM.css +1 -0
  29. 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
  30. paskia-0.10.2/paskia/frontend-build/auth/assets/admin-CZKsX1OI.js +1 -0
  31. 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
  32. paskia-0.10.2/paskia/frontend-build/auth/assets/auth-Pe-PKe8b.js +1 -0
  33. paskia-0.10.2/paskia/frontend-build/auth/assets/forward-BC0p23CH.js +1 -0
  34. 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
  35. paskia-0.10.2/paskia/frontend-build/auth/assets/reset-B8PlNXuP.css +1 -0
  36. paskia-0.10.2/paskia/frontend-build/auth/assets/reset-CkY9h28U.js +1 -0
  37. paskia-0.10.2/paskia/frontend-build/auth/assets/restricted-C9cJlHkd.js +1 -0
  38. paskia-0.10.2/paskia/frontend-build/auth/assets/theme-C2WysaSw.js +1 -0
  39. {paskia-0.9.1 → paskia-0.10.2}/paskia/frontend-build/auth/index.html +8 -7
  40. {paskia-0.9.1 → paskia-0.10.2}/paskia/frontend-build/auth/restricted/index.html +7 -6
  41. {paskia-0.9.1 → paskia-0.10.2}/paskia/frontend-build/int/forward/index.html +6 -6
  42. {paskia-0.9.1 → paskia-0.10.2}/paskia/frontend-build/int/reset/index.html +4 -4
  43. paskia-0.10.2/paskia/frontend-build/paskia.webp +0 -0
  44. paskia-0.10.2/paskia/util/__init__.py +0 -0
  45. paskia-0.10.2/paskia/util/apistructs.py +110 -0
  46. paskia-0.10.2/paskia/util/frontend.py +75 -0
  47. paskia-0.10.2/paskia/util/hostutil.py +75 -0
  48. paskia-0.10.2/paskia/util/htmlutil.py +47 -0
  49. paskia-0.10.2/paskia/util/passphrase.py +20 -0
  50. paskia-0.10.2/paskia/util/permutil.py +43 -0
  51. paskia-0.10.2/paskia/util/pow.py +45 -0
  52. paskia-0.10.2/paskia/util/querysafe.py +11 -0
  53. paskia-0.10.2/paskia/util/sessionutil.py +38 -0
  54. paskia-0.10.2/paskia/util/startupbox.py +103 -0
  55. paskia-0.10.2/paskia/util/timeutil.py +47 -0
  56. paskia-0.10.2/paskia/util/useragent.py +10 -0
  57. paskia-0.10.2/paskia/util/userinfo.py +63 -0
  58. paskia-0.10.2/paskia/util/vitedev.py +71 -0
  59. paskia-0.10.2/paskia/util/wordlist.py +54 -0
  60. paskia-0.9.1/paskia/fastapi/wschat.py +0 -62
  61. paskia-0.9.1/paskia/frontend-build/auth/assets/AccessDenied-Fmeb6EtF.js +0 -8
  62. paskia-0.9.1/paskia/frontend-build/auth/assets/_plugin-vue_export-helper-BTzJAQlS.css +0 -1
  63. paskia-0.9.1/paskia/frontend-build/auth/assets/_plugin-vue_export-helper-nhjnO_bd.js +0 -2
  64. paskia-0.9.1/paskia/frontend-build/auth/assets/admin-CPE1pLMm.js +0 -1
  65. paskia-0.9.1/paskia/frontend-build/auth/assets/auth-YIZvPlW_.js +0 -1
  66. paskia-0.9.1/paskia/frontend-build/auth/assets/forward-DmqVHZ7e.js +0 -1
  67. paskia-0.9.1/paskia/frontend-build/auth/assets/reset-Chtv69AT.css +0 -1
  68. paskia-0.9.1/paskia/frontend-build/auth/assets/reset-s20PATTN.js +0 -1
  69. paskia-0.9.1/paskia/frontend-build/auth/assets/restricted-D3AJx3_6.js +0 -1
  70. {paskia-0.9.1 → paskia-0.10.2}/.gitignore +0 -0
  71. {paskia-0.9.1 → paskia-0.10.2}/paskia/__init__.py +0 -0
  72. {paskia-0.9.1 → paskia-0.10.2}/paskia/aaguid/__init__.py +0 -0
  73. {paskia-0.9.1 → paskia-0.10.2}/paskia/aaguid/combined_aaguid.json +0 -0
  74. {paskia-0.9.1 → paskia-0.10.2}/paskia/authsession.py +0 -0
  75. {paskia-0.9.1 → paskia-0.10.2}/paskia/config.py +0 -0
  76. {paskia-0.9.1 → paskia-0.10.2}/paskia/db/migrations.py +0 -0
  77. {paskia-0.9.1 → paskia-0.10.2}/paskia/fastapi/__init__.py +0 -0
  78. {paskia-0.9.1 → paskia-0.10.2}/paskia/fastapi/auth_host.py +0 -0
  79. {paskia-0.9.1 → paskia-0.10.2}/paskia/fastapi/reset.py +0 -0
  80. {paskia-0.9.1 → paskia-0.10.2}/paskia/fastapi/response.py +0 -0
  81. {paskia-0.9.1 → paskia-0.10.2}/paskia/fastapi/session.py +0 -0
  82. {paskia-0.9.1 → paskia-0.10.2}/paskia/frontend-build/auth/assets/helpers-DzjFIx78.js +0 -0
  83. {paskia-0.9.1 → paskia-0.10.2}/paskia/globals.py +0 -0
  84. {paskia-0.9.1 → paskia-0.10.2}/paskia/migrate/__init__.py +0 -0
  85. {paskia-0.9.1 → paskia-0.10.2}/paskia/migrate/sql.py +0 -0
  86. {paskia-0.9.1 → paskia-0.10.2}/paskia/remoteauth.py +0 -0
  87. {paskia-0.9.1 → paskia-0.10.2}/paskia/sansio.py +0 -0
  88. {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.9.1
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
+ ![Screenshot](https://git.zi.fi/leovasanko/paskia/raw/main/docs/screenshots/forbidden-light.webp)
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 serve --rp-id example.com
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. The server will start up on [localhost:4401](http://localhost:4401) *for authentication required*, serving for `*.example.com`. If you are going to be connecting `localhost` directly, for testing, leave out the rp-id.
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
- Otherwise you will 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).
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. Pass only the options on CLI:
78
+ There is no config file. All settings are passed as CLI options:
77
79
 
78
80
  ```text
79
- paskia serve [options]
81
+ paskia [options]
82
+ paskia reset [user] # Generate passkey reset link
80
83
  ```
81
84
 
82
85
  | Option | Description | Default |
83
86
  |--------|-------------|---------|
84
- | Listen address | One of *host***:***port* (default all hosts, port 4401) or **unix:***path***/paskia.socket** (Unix socket) | **localhost:4401** |
85
- | --rp-id *domain* | Main/top domain | **localhost** |
86
- | --rp-name *"text"* | Name of your company or site | Same as rp-id |
87
- | --origin *url* | Explicitly list the domain names served | **https://**_rp-id_ |
88
- | --auth-host *domain* | Dedicated authentication site (e.g., **auth.example.com**) | **Unspecified:** we use **/auth/** on **every** site under rp-id.|
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
+ ![Screenshot](https://git.zi.fi/leovasanko/paskia/raw/main/docs/screenshots/forbidden-light.webp)
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 serve --rp-id example.com
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. The server will start up on [localhost:4401](http://localhost:4401) *for authentication required*, serving for `*.example.com`. If you are going to be connecting `localhost` directly, for testing, leave out the rp-id.
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
- Otherwise you will 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).
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. Pass only the options on CLI:
47
+ There is no config file. All settings are passed as CLI options:
46
48
 
47
49
  ```text
48
- paskia serve [options]
50
+ paskia [options]
51
+ paskia reset [user] # Generate passkey reset link
49
52
  ```
50
53
 
51
54
  | Option | Description | Default |
52
55
  |--------|-------------|---------|
53
- | Listen address | One of *host***:***port* (default all hosts, port 4401) or **unix:***path***/paskia.socket** (Unix socket) | **localhost:4401** |
54
- | --rp-id *domain* | Main/top domain | **localhost** |
55
- | --rp-name *"text"* | Name of your company or site | Same as rp-id |
56
- | --origin *url* | Explicitly list the domain names served | **https://**_rp-id_ |
57
- | --auth-host *domain* | Dedicated authentication site (e.g., **auth.example.com**) | **Unspecified:** we use **/auth/** on **every** site under rp-id.|
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.9.1'
32
- __version_tuple__ = version_tuple = (0, 9, 1)
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(message: str, passphrase: str) -> str:
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
- logger.info(ADMIN_RESET_MESSAGE, message, reset_link)
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!", reset_passphrase)
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("⚠️ Admin user has no credentials!", token)
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 the same event loop - this is an error
78
- raise RuntimeError(
79
- "Background task is already running. "
80
- "start_background() must not be called multiple times in the same event loop."
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
- except RuntimeError:
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
- _REPLACE = "\033[0;33m" # Yellow for replacements
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, path: list[str], changes: list[tuple[str, list[str], Any, Any | None]]
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, old_value).
103
- change_type is one of: 'set', 'replace', 'delete'
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 - this is a set operation
107
- changes.append(("set", path, diff, None))
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, None))
130
+ changes.append(("delete", path + [str(deleted_key)], None))
116
131
  else:
117
- changes.append(("delete", path + [str(value)], None, None))
132
+ changes.append(("delete", path + [str(value)], None))
118
133
 
119
134
  elif key == "$replace":
120
- # $replace contains the new value for this path
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
- changes.append(("replace", path + [str(rkey)], rval, None))
125
- if not value:
126
- # Empty replacement - clearing the collection
127
- changes.append(("replace", path, {}, None))
128
- else:
129
- changes.append(("replace", path, value, None))
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(("set", path, {key: value}, None))
164
+ changes.append(("add", path, {key: value}))
134
165
 
135
166
  else:
136
- # Regular nested key
137
- _collect_changes(value, path + [str(key)], changes)
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 _format_change_line(
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 a one-line string."""
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" {path_str}"
150
- return f" - {path_str}"
151
-
152
- if change_type == "replace":
153
- if use_color:
154
- return f" {_REPLACE}{_RESET} {path_str} {_DIM}={_RESET} {value_str}"
155
- return f" ~ {path_str} = {value_str}"
156
-
157
- # Default: set/add
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" {_ADD}+{_RESET} {path_str} {_DIM}={_RESET} {value_str}"
160
- return f" + {path_str} = {value_str}"
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, Any | None]] = []
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, _ in changes:
180
- lines.append(_format_change_line(change_type, path, value, use_color))
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(action: str, diff: dict, user_display: str | None = None) -> None:
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(key: str, *, ctx: SessionContext | None = None) -> None:
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("delete_session", ctx):
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"):