paskia 0.9.1__py3-none-any.whl → 0.10.2__py3-none-any.whl

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 (71) hide show
  1. paskia/_version.py +2 -2
  2. paskia/bootstrap.py +8 -7
  3. paskia/db/__init__.py +2 -0
  4. paskia/db/background.py +5 -8
  5. paskia/db/jsonl.py +2 -2
  6. paskia/db/logging.py +130 -45
  7. paskia/db/operations.py +25 -4
  8. paskia/db/structs.py +3 -2
  9. paskia/fastapi/__main__.py +33 -19
  10. paskia/fastapi/admin.py +2 -2
  11. paskia/fastapi/api.py +7 -3
  12. paskia/fastapi/authz.py +11 -9
  13. paskia/fastapi/logging.py +64 -21
  14. paskia/fastapi/mainapp.py +8 -5
  15. paskia/fastapi/remote.py +11 -37
  16. paskia/fastapi/user.py +22 -0
  17. paskia/fastapi/ws.py +12 -35
  18. paskia/fastapi/wschat.py +55 -2
  19. paskia/fastapi/wsutil.py +2 -7
  20. paskia/frontend-build/auth/admin/index.html +7 -6
  21. paskia/frontend-build/auth/assets/{AccessDenied-DPkUS8LZ.css → AccessDenied-CVQZxSIL.css} +1 -1
  22. paskia/frontend-build/auth/assets/AccessDenied-Licr0tqA.js +8 -0
  23. paskia/frontend-build/auth/assets/{RestrictedAuth-CvR33_Z0.css → RestrictedAuth-0MFeNWS2.css} +1 -1
  24. paskia/frontend-build/auth/assets/{RestrictedAuth-DsJXicIw.js → RestrictedAuth-DWKMTEV3.js} +1 -1
  25. paskia/frontend-build/auth/assets/_plugin-vue_export-helper-DJsHCwvl.js +33 -0
  26. paskia/frontend-build/auth/assets/_plugin-vue_export-helper-DUBf8-iM.css +1 -0
  27. paskia/frontend-build/auth/assets/{admin-DzzjSg72.css → admin-B1H4YqM_.css} +1 -1
  28. paskia/frontend-build/auth/assets/admin-CZKsX1OI.js +1 -0
  29. paskia/frontend-build/auth/assets/{auth-C7k64Wad.css → auth-B4EpDxom.css} +1 -1
  30. paskia/frontend-build/auth/assets/auth-Pe-PKe8b.js +1 -0
  31. paskia/frontend-build/auth/assets/forward-BC0p23CH.js +1 -0
  32. paskia/frontend-build/auth/assets/{pow-2N9bxgAo.js → pow-DUr-T9XX.js} +1 -1
  33. paskia/frontend-build/auth/assets/reset-B8PlNXuP.css +1 -0
  34. paskia/frontend-build/auth/assets/reset-CkY9h28U.js +1 -0
  35. paskia/frontend-build/auth/assets/restricted-C9cJlHkd.js +1 -0
  36. paskia/frontend-build/auth/assets/theme-C2WysaSw.js +1 -0
  37. paskia/frontend-build/auth/index.html +8 -7
  38. paskia/frontend-build/auth/restricted/index.html +7 -6
  39. paskia/frontend-build/int/forward/index.html +6 -6
  40. paskia/frontend-build/int/reset/index.html +4 -4
  41. paskia/frontend-build/paskia.webp +0 -0
  42. paskia/util/__init__.py +0 -0
  43. paskia/util/apistructs.py +110 -0
  44. paskia/util/frontend.py +75 -0
  45. paskia/util/hostutil.py +75 -0
  46. paskia/util/htmlutil.py +47 -0
  47. paskia/util/passphrase.py +20 -0
  48. paskia/util/permutil.py +43 -0
  49. paskia/util/pow.py +45 -0
  50. paskia/util/querysafe.py +11 -0
  51. paskia/util/sessionutil.py +38 -0
  52. paskia/util/startupbox.py +103 -0
  53. paskia/util/timeutil.py +47 -0
  54. paskia/util/useragent.py +10 -0
  55. paskia/util/userinfo.py +63 -0
  56. paskia/util/vitedev.py +71 -0
  57. paskia/util/wordlist.py +54 -0
  58. {paskia-0.9.1.dist-info → paskia-0.10.2.dist-info}/METADATA +14 -11
  59. paskia-0.10.2.dist-info/RECORD +78 -0
  60. paskia/frontend-build/auth/assets/AccessDenied-Fmeb6EtF.js +0 -8
  61. paskia/frontend-build/auth/assets/_plugin-vue_export-helper-BTzJAQlS.css +0 -1
  62. paskia/frontend-build/auth/assets/_plugin-vue_export-helper-nhjnO_bd.js +0 -2
  63. paskia/frontend-build/auth/assets/admin-CPE1pLMm.js +0 -1
  64. paskia/frontend-build/auth/assets/auth-YIZvPlW_.js +0 -1
  65. paskia/frontend-build/auth/assets/forward-DmqVHZ7e.js +0 -1
  66. paskia/frontend-build/auth/assets/reset-Chtv69AT.css +0 -1
  67. paskia/frontend-build/auth/assets/reset-s20PATTN.js +0 -1
  68. paskia/frontend-build/auth/assets/restricted-D3AJx3_6.js +0 -1
  69. paskia-0.9.1.dist-info/RECORD +0 -60
  70. {paskia-0.9.1.dist-info → paskia-0.10.2.dist-info}/WHEEL +0 -0
  71. {paskia-0.9.1.dist-info → paskia-0.10.2.dist-info}/entry_points.txt +0 -0
paskia/_version.py CHANGED
@@ -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
paskia/bootstrap.py CHANGED
@@ -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
paskia/db/__init__.py CHANGED
@@ -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
  ]
paskia/db/background.py CHANGED
@@ -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():
paskia/db/jsonl.py CHANGED
@@ -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(
paskia/db/logging.py CHANGED
@@ -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)
paskia/db/operations.py CHANGED
@@ -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
  )
paskia/db/structs.py CHANGED
@@ -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"):
@@ -7,12 +7,12 @@ from urllib.parse import urlparse
7
7
 
8
8
  from fastapi_vue.hostutil import parse_endpoint
9
9
  from uvicorn import Config, Server
10
+ from uvicorn import run as uvicorn_run
10
11
 
11
12
  from paskia import globals as _globals
12
13
  from paskia.bootstrap import bootstrap_if_needed
13
14
  from paskia.config import PaskiaConfig
14
15
  from paskia.db.background import flush
15
- from paskia.fastapi import app as fastapi_app
16
16
  from paskia.fastapi import reset as reset_cmd
17
17
  from paskia.util import startupbox
18
18
  from paskia.util.hostutil import normalize_origin
@@ -21,10 +21,10 @@ DEFAULT_PORT = 4401
21
21
 
22
22
  EPILOG = """\
23
23
  Examples:
24
- paskia # localhost:4401
25
- paskia :8080 # All interfaces, port 8080
26
- paskia unix:/tmp/paskia.sock
27
- paskia reset [user] # Generate passkey reset link
24
+ paskia # localhost:4401
25
+ paskia -l :8080 # All interfaces, port 8080
26
+ paskia -l /tmp/paskia.sock # Unix socket
27
+ paskia reset [user] # Generate passkey reset link
28
28
  """
29
29
 
30
30
 
@@ -81,32 +81,40 @@ def main():
81
81
  epilog=EPILOG,
82
82
  )
83
83
 
84
- # Primary argument: either host:port or "reset" subcommand
84
+ # Subcommand for reset
85
85
  parser.add_argument(
86
- "hostport",
86
+ "command",
87
87
  nargs="?",
88
- help=(
89
- "Endpoint (default: localhost:4401). Forms: host[:port] | :port | "
90
- "[ipv6][:port] | ipv6 | unix:/path.sock | 'reset' for credential reset"
91
- ),
88
+ help="Command: 'reset' for credential reset, or omit to run server",
92
89
  )
93
90
  parser.add_argument(
94
91
  "reset_query",
95
92
  nargs="?",
96
93
  help="For 'reset' command: user UUID or substring of display name",
97
94
  )
95
+ parser.add_argument(
96
+ "-l",
97
+ "--listen",
98
+ metavar="LISTEN",
99
+ help=(
100
+ "Endpoint to listen on (default: localhost:4401). "
101
+ "Forms: host:port port :port [ipv6]:port unix:path /path.sock"
102
+ ),
103
+ )
98
104
  add_common_options(parser)
99
105
 
100
106
  args = parser.parse_args()
101
107
 
102
- # Detect "reset" subcommand (first positional is "reset")
103
- is_reset = args.hostport == "reset"
108
+ # Detect "reset" subcommand
109
+ is_reset = args.command == "reset"
104
110
 
105
111
  if is_reset:
106
112
  endpoints = []
107
113
  else:
114
+ if args.command is not None:
115
+ raise SystemExit(f"Unknown command: {args.command}")
108
116
  # Parse endpoint using fastapi_vue.hostutil
109
- endpoints = parse_endpoint(args.hostport, DEFAULT_PORT)
117
+ endpoints = parse_endpoint(args.listen, DEFAULT_PORT)
110
118
 
111
119
  # Extract host/port/uds from first endpoint for config display and site_url
112
120
  ep = endpoints[0] if endpoints else {}
@@ -188,7 +196,7 @@ def main():
188
196
  devmode = bool(os.environ.get("FASTAPI_VUE_FRONTEND_URL"))
189
197
 
190
198
  run_kwargs: dict = {
191
- "log_level": "info",
199
+ "log_level": "warning", # Suppress startup messages; we use custom logging
192
200
  "access_log": False, # We use custom AccessLogMiddleware instead
193
201
  }
194
202
 
@@ -199,8 +207,6 @@ def main():
199
207
  raise SystemExit(f"Dev mode requires localhost:4402, got {host}:{port}")
200
208
  run_kwargs["reload"] = True
201
209
  run_kwargs["reload_dirs"] = ["paskia"]
202
- # Suppress uvicorn startup messages in dev mode
203
- run_kwargs["log_level"] = "warning"
204
210
 
205
211
  async def async_main():
206
212
  await _globals.init(
@@ -220,10 +226,18 @@ def main():
220
226
  async with asyncio.TaskGroup() as tg:
221
227
  for ep in endpoints:
222
228
  tg.create_task(
223
- Server(Config(app=fastapi_app, **run_kwargs, **ep)).serve()
229
+ Server(
230
+ Config(app="paskia.fastapi:app", **run_kwargs, **ep)
231
+ ).serve()
224
232
  )
233
+ elif devmode:
234
+ # Use uvicorn.run for proper reload support (it handles subprocess spawning)
235
+ ep = endpoints[0]
236
+ uvicorn_run("paskia.fastapi:app", **run_kwargs, **ep)
225
237
  else:
226
- server = Server(Config(app=fastapi_app, **run_kwargs, **endpoints[0]))
238
+ server = Server(
239
+ Config(app="paskia.fastapi:app", **run_kwargs, **endpoints[0])
240
+ )
227
241
  await server.serve()
228
242
 
229
243
  try:
paskia/fastapi/admin.py CHANGED
@@ -127,7 +127,7 @@ async def admin_create_org(
127
127
  db.create_org(org, ctx=ctx)
128
128
  # Grant requested permissions to the new org
129
129
  for perm in permissions:
130
- db.add_permission_to_org(str(org.uuid), perm)
130
+ db.add_permission_to_org(str(org.uuid), perm, ctx=ctx)
131
131
 
132
132
  return {"uuid": str(org.uuid)}
133
133
 
@@ -706,7 +706,7 @@ async def admin_delete_user_session(
706
706
  if not target_session or target_session.user_uuid != user_uuid:
707
707
  raise HTTPException(status_code=404, detail="Session not found")
708
708
 
709
- db.delete_session(session_id, ctx=ctx)
709
+ db.delete_session(session_id, ctx=ctx, action="admin:delete_session")
710
710
 
711
711
  # Check if admin terminated their own session
712
712
  current_terminated = session_id == auth
paskia/fastapi/api.py CHANGED
@@ -78,7 +78,7 @@ async def validate_token(
78
78
  try:
79
79
  ctx = await authz.verify(
80
80
  auth,
81
- perm,
81
+ " ".join(perm).split(),
82
82
  host=request.headers.get("host"),
83
83
  max_age=max_age,
84
84
  )
@@ -94,6 +94,7 @@ async def validate_token(
94
94
  ip=request.client.host if request.client else "",
95
95
  user_agent=request.headers.get("user-agent") or "",
96
96
  expiry=expires(),
97
+ ctx=ctx,
97
98
  )
98
99
  session.set_session_cookie(response, auth)
99
100
  renewed = True
@@ -130,7 +131,10 @@ async def forward_authentication(
130
131
  """
131
132
  try:
132
133
  ctx = await authz.verify(
133
- auth, perm, host=request.headers.get("host"), max_age=max_age
134
+ auth,
135
+ " ".join(perm).split(),
136
+ host=request.headers.get("host"),
137
+ max_age=max_age,
134
138
  )
135
139
  # Build permission scopes for Remote-Groups header
136
140
  role_permissions = (
@@ -248,7 +252,7 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
248
252
  if not ctx:
249
253
  return {"message": "Already logged out"}
250
254
  with suppress(Exception):
251
- db.delete_session(auth, ctx=ctx)
255
+ db.delete_session(auth, ctx=ctx, action="logout")
252
256
  session.clear_session_cookie(response)
253
257
  return {"message": "Logged out successfully"}
254
258