paskia 0.8.1__py3-none-any.whl → 0.9.1__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 (58) hide show
  1. paskia/_version.py +2 -2
  2. paskia/aaguid/__init__.py +5 -4
  3. paskia/authsession.py +15 -43
  4. paskia/bootstrap.py +31 -103
  5. paskia/db/__init__.py +27 -55
  6. paskia/db/background.py +20 -40
  7. paskia/db/jsonl.py +196 -46
  8. paskia/db/logging.py +233 -0
  9. paskia/db/migrations.py +33 -0
  10. paskia/db/operations.py +409 -825
  11. paskia/db/structs.py +408 -94
  12. paskia/fastapi/__main__.py +25 -28
  13. paskia/fastapi/admin.py +147 -329
  14. paskia/fastapi/api.py +68 -110
  15. paskia/fastapi/logging.py +218 -0
  16. paskia/fastapi/mainapp.py +25 -8
  17. paskia/fastapi/remote.py +16 -39
  18. paskia/fastapi/reset.py +27 -19
  19. paskia/fastapi/response.py +22 -0
  20. paskia/fastapi/session.py +2 -2
  21. paskia/fastapi/user.py +24 -30
  22. paskia/fastapi/ws.py +25 -60
  23. paskia/fastapi/wschat.py +62 -0
  24. paskia/fastapi/wsutil.py +15 -2
  25. paskia/frontend-build/auth/admin/index.html +5 -5
  26. paskia/frontend-build/auth/assets/{AccessDenied-Bc249ASC.css → AccessDenied-DPkUS8LZ.css} +1 -1
  27. paskia/frontend-build/auth/assets/AccessDenied-Fmeb6EtF.js +8 -0
  28. paskia/frontend-build/auth/assets/{RestrictedAuth-DgdJyscT.css → RestrictedAuth-CvR33_Z0.css} +1 -1
  29. paskia/frontend-build/auth/assets/RestrictedAuth-DsJXicIw.js +1 -0
  30. paskia/frontend-build/auth/assets/{_plugin-vue_export-helper-rKFEraYH.js → _plugin-vue_export-helper-nhjnO_bd.js} +1 -1
  31. paskia/frontend-build/auth/assets/admin-CPE1pLMm.js +1 -0
  32. paskia/frontend-build/auth/assets/{admin-BeNu48FR.css → admin-DzzjSg72.css} +1 -1
  33. paskia/frontend-build/auth/assets/{auth-BKX7shEe.css → auth-C7k64Wad.css} +1 -1
  34. paskia/frontend-build/auth/assets/auth-YIZvPlW_.js +1 -0
  35. paskia/frontend-build/auth/assets/{forward-Dzg-aE1C.js → forward-DmqVHZ7e.js} +1 -1
  36. paskia/frontend-build/auth/assets/reset-Chtv69AT.css +1 -0
  37. paskia/frontend-build/auth/assets/reset-s20PATTN.js +1 -0
  38. paskia/frontend-build/auth/assets/{restricted-C0IQufuH.js → restricted-D3AJx3_6.js} +1 -1
  39. paskia/frontend-build/auth/index.html +5 -5
  40. paskia/frontend-build/auth/restricted/index.html +4 -4
  41. paskia/frontend-build/int/forward/index.html +4 -4
  42. paskia/frontend-build/int/reset/index.html +3 -3
  43. paskia/globals.py +2 -2
  44. paskia/migrate/__init__.py +67 -60
  45. paskia/migrate/sql.py +94 -37
  46. paskia/remoteauth.py +7 -8
  47. paskia/sansio.py +6 -12
  48. {paskia-0.8.1.dist-info → paskia-0.9.1.dist-info}/METADATA +1 -1
  49. paskia-0.9.1.dist-info/RECORD +60 -0
  50. paskia/frontend-build/auth/assets/AccessDenied-aTdCvz9k.js +0 -8
  51. paskia/frontend-build/auth/assets/RestrictedAuth-BLMK7-nL.js +0 -1
  52. paskia/frontend-build/auth/assets/admin-tVs8oyLv.js +0 -1
  53. paskia/frontend-build/auth/assets/auth-Dk3q4pNS.js +0 -1
  54. paskia/frontend-build/auth/assets/reset-BWF4cWKR.css +0 -1
  55. paskia/frontend-build/auth/assets/reset-C_Td1_jn.js +0 -1
  56. paskia-0.8.1.dist-info/RECORD +0 -55
  57. {paskia-0.8.1.dist-info → paskia-0.9.1.dist-info}/WHEEL +0 -0
  58. {paskia-0.8.1.dist-info → paskia-0.9.1.dist-info}/entry_points.txt +0 -0
paskia/db/structs.py CHANGED
@@ -1,43 +1,223 @@
1
- from datetime import datetime
1
+ from __future__ import annotations
2
+
3
+ import secrets
4
+ from datetime import UTC, datetime
2
5
  from uuid import UUID
3
6
 
4
7
  import msgspec
8
+ import uuid7
9
+
10
+ from paskia import db
11
+ from paskia.util.hostutil import normalize_host
12
+
13
+ # Sentinel for uuid fields before they are set by create() or DB post init
14
+ _UUID_UNSET = UUID(int=0)
15
+
5
16
 
17
+ class Permission(msgspec.Struct, dict=True, omit_defaults=True):
18
+ """Permission data structure.
19
+
20
+ Mutable fields: scope, display_name, domain, orgs
21
+ Immutable fields: None (all fields can be updated via update_permission)
22
+ uuid is generated at creation.
23
+ """
6
24
 
7
- class Permission(msgspec.Struct, omit_defaults=True):
8
- uuid: UUID # UUID primary key
9
25
  scope: str # Permission scope identifier (e.g. "auth:admin", "myapp:write")
10
26
  display_name: str
11
27
  domain: str | None = None # If set, scopes permission to this domain
28
+ orgs: dict[UUID, bool] = {} # org_uuid -> True (which orgs can grant this)
12
29
 
30
+ def __post_init__(self):
31
+ if not hasattr(self, "uuid"):
32
+ self.uuid: UUID = _UUID_UNSET
33
+
34
+ @property
35
+ def org_set(self) -> set[UUID]:
36
+ """Get orgs that can grant this permission as a set."""
37
+ return set(self.orgs.keys())
38
+
39
+ @property
40
+ def orgs_list(self) -> list[Org]:
41
+ """Get list of Org objects that can grant this permission."""
42
+ return [
43
+ db.data().orgs[org_uuid]
44
+ for org_uuid in self.orgs.keys()
45
+ if org_uuid in db.data().orgs
46
+ ]
47
+
48
+ @classmethod
49
+ def create(
50
+ cls,
51
+ scope: str,
52
+ display_name: str,
53
+ domain: str | None = None,
54
+ ) -> Permission:
55
+ """Create a new Permission with auto-generated uuid7."""
56
+ perm = cls(
57
+ scope=scope,
58
+ display_name=display_name,
59
+ domain=domain,
60
+ )
61
+ perm.uuid = uuid7.create()
62
+ return perm
63
+
64
+
65
+ class Org(msgspec.Struct, dict=True):
66
+ """Organization data structure."""
13
67
 
14
- class Role(msgspec.Struct):
15
- uuid: UUID
16
- org_uuid: UUID
17
68
  display_name: str
18
- permissions: list[str] = [] # permission UUIDs this role grants
19
69
 
70
+ def __post_init__(self):
71
+ if not hasattr(self, "uuid"):
72
+ self.uuid: UUID = _UUID_UNSET
73
+
74
+ @property
75
+ def roles(self) -> list[Role]:
76
+ """Get all roles that belong to this organization."""
77
+ return [r for r in db.data().roles.values() if r.org_uuid == self.uuid]
20
78
 
21
- class Org(msgspec.Struct):
22
- uuid: UUID
79
+ @property
80
+ def permissions(self) -> list[Permission]:
81
+ """Get all permissions that this organization can grant."""
82
+ return [p for p in db.data().permissions.values() if self.uuid in p.orgs]
83
+
84
+ @classmethod
85
+ def create(cls, display_name: str) -> Org:
86
+ """Create a new Org with auto-generated uuid7."""
87
+ org = cls(display_name=display_name)
88
+ org.uuid = uuid7.create()
89
+ return org
90
+
91
+
92
+ class Role(msgspec.Struct, dict=True, omit_defaults=True):
93
+ """Role data structure.
94
+
95
+ Mutable fields: display_name, permissions
96
+ Immutable fields: org_uuid (set at creation, never modified)
97
+ uuid is generated at creation.
98
+ """
99
+
100
+ org_uuid: UUID = msgspec.field(name="org")
23
101
  display_name: str
24
- permissions: list[str] = [] # permission UUIDs this org can grant
25
- roles: list[Role] = [] # roles belonging to this org
102
+ permissions: dict[UUID, bool] = {} # permission_uuid -> True
26
103
 
104
+ def __post_init__(self):
105
+ if not hasattr(self, "uuid"):
106
+ self.uuid: UUID = _UUID_UNSET
107
+
108
+ @property
109
+ def permission_set(self) -> set[UUID]:
110
+ """Get permissions as a set of UUIDs."""
111
+ return set(self.permissions.keys())
112
+
113
+ @property
114
+ def permissions_list(self) -> list[Permission]:
115
+ """Get list of Permission objects for this role."""
116
+ return [
117
+ db.data().permissions[perm_uuid]
118
+ for perm_uuid in self.permissions.keys()
119
+ if perm_uuid in db.data().permissions
120
+ ]
121
+
122
+ @property
123
+ def org(self) -> Org:
124
+ """Get the organization object this role belongs to."""
125
+ return db.data().orgs[self.org_uuid]
126
+
127
+ @property
128
+ def users(self) -> list[User]:
129
+ """Get all users that have this role."""
130
+ return [u for u in db.data().users.values() if u.role_uuid == self.uuid]
131
+
132
+ @classmethod
133
+ def create(
134
+ cls,
135
+ org: UUID | Org,
136
+ display_name: str,
137
+ permissions: set[UUID] | None = None,
138
+ ) -> Role:
139
+ """Create a new Role with auto-generated uuid7."""
140
+ org_uuid = org if isinstance(org, UUID) else org.uuid
141
+ role = cls(
142
+ org_uuid=org_uuid,
143
+ display_name=display_name,
144
+ permissions={p: True for p in (permissions or set())},
145
+ )
146
+ role.uuid = uuid7.create()
147
+ return role
148
+
149
+
150
+ class User(msgspec.Struct, dict=True):
151
+ """User data structure.
152
+
153
+ Mutable fields: display_name, role_uuid, last_seen, visits
154
+ Immutable fields: created_at (set at creation, never modified)
155
+ uuid is derived from created_at using uuid7.
156
+ """
27
157
 
28
- class User(msgspec.Struct):
29
- uuid: UUID
30
158
  display_name: str
31
- role_uuid: UUID
32
- created_at: datetime | None = None
159
+ role_uuid: UUID = msgspec.field(name="role")
160
+ created_at: datetime
33
161
  last_seen: datetime | None = None
34
162
  visits: int = 0
35
163
 
164
+ def __post_init__(self):
165
+ if not hasattr(self, "uuid"):
166
+ self.uuid: UUID = _UUID_UNSET
167
+
168
+ @property
169
+ def role(self) -> Role:
170
+ """Get the role object this user has."""
171
+ return db.data().roles[self.role_uuid]
172
+
173
+ @property
174
+ def org(self) -> Org:
175
+ """Get the organization this user belongs to (via role)."""
176
+ return self.role.org
177
+
178
+ @property
179
+ def credentials(self) -> list[Credential]:
180
+ """Get all credentials for this user."""
181
+ return [c for c in db.data().credentials.values() if c.user_uuid == self.uuid]
182
+
183
+ @property
184
+ def sessions(self) -> list[Session]:
185
+ """Get all sessions for this user."""
186
+ return [s for s in db.data().sessions.values() if s.user_uuid == self.uuid]
187
+
188
+ @property
189
+ def reset_tokens(self) -> list[ResetToken]:
190
+ """Get all reset tokens for this user."""
191
+ return [t for t in db.data().reset_tokens.values() if t.user_uuid == self.uuid]
192
+
193
+ @classmethod
194
+ def create(
195
+ cls,
196
+ display_name: str,
197
+ role: UUID | Role,
198
+ created_at: datetime | None = None,
199
+ ) -> User:
200
+ """Create a new User with auto-generated uuid7."""
201
+ role_uuid = role if isinstance(role, UUID) else role.uuid
202
+ user = cls(
203
+ display_name=display_name,
204
+ role_uuid=role_uuid,
205
+ created_at=created_at or datetime.now(UTC),
206
+ )
207
+ user.uuid = uuid7.create(user.created_at)
208
+ return user
209
+
210
+
211
+ class Credential(msgspec.Struct, dict=True):
212
+ """Credential (passkey) data structure.
213
+
214
+ Mutable fields: sign_count, last_used, last_verified
215
+ Immutable fields: credential_id, user, aaguid, public_key, created_at
216
+ uuid is derived from created_at using uuid7.
217
+ """
36
218
 
37
- class Credential(msgspec.Struct):
38
- uuid: UUID
39
219
  credential_id: bytes # Long binary ID from the authenticator
40
- user_uuid: UUID
220
+ user_uuid: UUID = msgspec.field(name="user")
41
221
  aaguid: UUID
42
222
  public_key: bytes
43
223
  sign_count: int
@@ -45,16 +225,78 @@ class Credential(msgspec.Struct):
45
225
  last_used: datetime | None = None
46
226
  last_verified: datetime | None = None
47
227
 
48
-
49
- class Session(msgspec.Struct):
50
- key: str
51
- user_uuid: UUID
52
- credential_uuid: UUID
53
- host: str | None
54
- ip: str | None
55
- user_agent: str | None
228
+ def __post_init__(self):
229
+ if not hasattr(self, "uuid"):
230
+ self.uuid: UUID = _UUID_UNSET
231
+
232
+ @property
233
+ def user(self) -> User:
234
+ """Get the User object for this credential."""
235
+ return db.data().users[self.user_uuid]
236
+
237
+ @property
238
+ def sessions(self) -> list[Session]:
239
+ """Get all sessions using this credential."""
240
+ return [
241
+ s for s in db.data().sessions.values() if s.credential_uuid == self.uuid
242
+ ]
243
+
244
+ @classmethod
245
+ def create(
246
+ cls,
247
+ credential_id: bytes,
248
+ user: UUID | User,
249
+ aaguid: UUID,
250
+ public_key: bytes,
251
+ sign_count: int,
252
+ created_at: datetime | None = None,
253
+ ) -> Credential:
254
+ """Create a new Credential with auto-generated uuid7."""
255
+ user_uuid = user if isinstance(user, UUID) else user.uuid
256
+ now = created_at or datetime.now(UTC)
257
+ cred = cls(
258
+ credential_id=credential_id,
259
+ user_uuid=user_uuid,
260
+ aaguid=aaguid,
261
+ public_key=public_key,
262
+ sign_count=sign_count,
263
+ created_at=now,
264
+ last_used=now,
265
+ last_verified=now,
266
+ )
267
+ cred.uuid = uuid7.create(now)
268
+ return cred
269
+
270
+
271
+ class Session(msgspec.Struct, dict=True):
272
+ """Session data structure.
273
+
274
+ Mutable fields: expiry (updated on session refresh)
275
+ Immutable fields: user_uuid, credential_uuid, host, ip, user_agent
276
+ key is stored in the dict key, not in the struct.
277
+ """
278
+
279
+ user_uuid: UUID = msgspec.field(name="user")
280
+ credential_uuid: UUID = msgspec.field(name="credential")
281
+ host: str
282
+ ip: str
283
+ user_agent: str
56
284
  expiry: datetime
57
285
 
286
+ def __post_init__(self):
287
+ if not hasattr(self, "key"):
288
+ self.key: str = ""
289
+
290
+ @property
291
+ def user(self) -> User:
292
+ """Get the User object for this session."""
293
+ return db.data().users[self.user_uuid]
294
+
295
+ @property
296
+ def credential(self) -> Credential:
297
+ """Get the Credential object for this session."""
298
+ return db.data().credentials[self.credential_uuid]
299
+
58
300
  def metadata(self) -> dict:
59
301
  """Return session metadata for backwards compatibility."""
60
302
  return {
@@ -63,86 +305,158 @@ class Session(msgspec.Struct):
63
305
  "expiry": self.expiry.isoformat(),
64
306
  }
65
307
 
66
-
67
- class ResetToken(msgspec.Struct):
68
- key: bytes
69
- user_uuid: UUID
308
+ @classmethod
309
+ def create(
310
+ cls,
311
+ user: UUID | User,
312
+ credential: UUID | Credential,
313
+ host: str,
314
+ ip: str,
315
+ user_agent: str,
316
+ expiry: datetime,
317
+ ) -> Session:
318
+ """Create a new Session with auto-generated key."""
319
+ user_uuid = user if isinstance(user, UUID) else user.uuid
320
+ credential_uuid = (
321
+ credential if isinstance(credential, UUID) else credential.uuid
322
+ )
323
+ session = cls(
324
+ user_uuid=user_uuid,
325
+ credential_uuid=credential_uuid,
326
+ host=host,
327
+ ip=ip,
328
+ user_agent=user_agent,
329
+ expiry=expiry,
330
+ )
331
+ session.key = secrets.token_urlsafe(12)
332
+ return session
333
+
334
+
335
+ class ResetToken(msgspec.Struct, dict=True):
336
+ """Reset/device-addition token data structure.
337
+
338
+ Immutable fields: All fields (tokens are created and deleted, never modified)
339
+ key is stored in the dict key, not in the struct.
340
+ """
341
+
342
+ user_uuid: UUID = msgspec.field(name="user")
70
343
  expiry: datetime
71
344
  token_type: str
72
345
 
346
+ def __post_init__(self):
347
+ if not hasattr(self, "key"):
348
+ self.key: bytes = b""
349
+
350
+ @property
351
+ def user(self) -> User:
352
+ """Get the User object for this reset token."""
353
+ return db.data().users[self.user_uuid]
354
+
73
355
 
74
356
  class SessionContext(msgspec.Struct):
75
357
  session: Session
76
358
  user: User
77
359
  org: Org
78
360
  role: Role
79
- credential: Credential | None = None
80
- permissions: list[Permission] | None = None
361
+ credential: Credential
362
+ permissions: list[Permission] = []
81
363
 
82
364
 
83
365
  # -------------------------------------------------------------------------
84
- # Internal storage types (different structure for efficient storage)
366
+ # Database storage structure
85
367
  # -------------------------------------------------------------------------
86
368
 
87
369
 
88
- class _PermissionData(msgspec.Struct, omit_defaults=True):
89
- scope: str # Permission scope identifier
90
- display_name: str
91
- domain: str | None = None
92
- orgs: dict[UUID, bool] = {} # org_uuid -> True (which orgs can grant this)
93
-
94
-
95
- class _OrgData(msgspec.Struct):
96
- display_name: str
97
- created_at: datetime | None = None
98
-
99
-
100
- class _RoleData(msgspec.Struct):
101
- org: UUID
102
- display_name: str
103
- permissions: dict[UUID, bool] = {} # permission_uuid -> True
104
-
105
-
106
- class _UserData(msgspec.Struct):
107
- display_name: str
108
- role: UUID
109
- created_at: datetime
110
- last_seen: datetime | None
111
- visits: int
112
-
113
-
114
- class _CredentialData(msgspec.Struct):
115
- credential_id: bytes
116
- user: UUID
117
- aaguid: UUID
118
- public_key: bytes
119
- sign_count: int
120
- created_at: datetime
121
- last_used: datetime | None
122
- last_verified: datetime | None
123
-
124
-
125
- class _SessionData(msgspec.Struct):
126
- user: UUID
127
- credential: UUID
128
- host: str | None
129
- ip: str | None
130
- user_agent: str | None
131
- expiry: datetime
132
-
133
-
134
- class _ResetTokenData(msgspec.Struct):
135
- user: UUID
136
- expiry: datetime
137
- token_type: str
138
-
139
-
140
- class _DatabaseData(msgspec.Struct, omit_defaults=True):
141
- permissions: dict[UUID, _PermissionData]
142
- orgs: dict[UUID, _OrgData]
143
- roles: dict[UUID, _RoleData]
144
- users: dict[UUID, _UserData]
145
- credentials: dict[UUID, _CredentialData]
146
- sessions: dict[str, _SessionData]
147
- reset_tokens: dict[bytes, _ResetTokenData]
148
- v: int = 0
370
+ class DB(msgspec.Struct, dict=True, omit_defaults=False):
371
+ """In-memory database. Access fields directly for reads."""
372
+
373
+ permissions: dict[UUID, Permission] = {}
374
+ orgs: dict[UUID, Org] = {}
375
+ roles: dict[UUID, Role] = {}
376
+ users: dict[UUID, User] = {}
377
+ credentials: dict[UUID, Credential] = {}
378
+ sessions: dict[str, Session] = {}
379
+ reset_tokens: dict[bytes, ResetToken] = {}
380
+
381
+ def __post_init__(self):
382
+ # Store reference for persistence (not serialized)
383
+ self._store = None
384
+ # Set the key fields on all stored objects
385
+ for uuid, perm in self.permissions.items():
386
+ perm.uuid = uuid
387
+ for uuid, org in self.orgs.items():
388
+ org.uuid = uuid
389
+ for uuid, role in self.roles.items():
390
+ role.uuid = uuid
391
+ for uuid, user in self.users.items():
392
+ user.uuid = uuid
393
+ for uuid, cred in self.credentials.items():
394
+ cred.uuid = uuid
395
+ for key, session in self.sessions.items():
396
+ session.key = key
397
+ for key, token in self.reset_tokens.items():
398
+ token.key = key
399
+
400
+ def transaction(self, action, ctx=None, *, user=None):
401
+ """Wrap writes in transaction. Delegates to JsonlStore."""
402
+ return self._store.transaction(action, ctx, user=user)
403
+
404
+ def session_ctx(
405
+ self, session_key: str, host: str | None = None
406
+ ) -> SessionContext | None:
407
+ """Get full session context with effective permissions.
408
+
409
+ Args:
410
+ session_key: The session key string
411
+ host: Optional host for binding/validation and domain-scoped permissions
412
+
413
+ Returns:
414
+ SessionContext if valid, None if session not found, expired, or host mismatch
415
+ """
416
+ try:
417
+ s = self.sessions[session_key]
418
+ except KeyError:
419
+ return None
420
+
421
+ # Validate host matches (sessions are always created with a host)
422
+ if s.host != host:
423
+ # Session bound to different host
424
+ return None
425
+
426
+ try:
427
+ user = s.user
428
+ role = user.role
429
+ org = role.org
430
+ credential = s.credential
431
+ except KeyError:
432
+ return None
433
+
434
+ # Effective permissions: role's permissions that the org can grant
435
+ # Also filter by domain if host is provided
436
+ org_perm_uuids = {p.uuid for p in org.permissions}
437
+ normalized_host = normalize_host(host)
438
+ host_without_port = (
439
+ normalized_host.rsplit(":", 1)[0] if normalized_host else None
440
+ )
441
+
442
+ effective_perms = []
443
+ for perm_uuid in role.permission_set:
444
+ if perm_uuid not in org_perm_uuids:
445
+ continue
446
+ try:
447
+ p = self.permissions[perm_uuid]
448
+ except KeyError:
449
+ continue
450
+ # Check domain restriction
451
+ if p.domain is not None and p.domain != host_without_port:
452
+ continue
453
+ effective_perms.append(p)
454
+
455
+ return SessionContext(
456
+ session=s,
457
+ user=user,
458
+ org=org,
459
+ role=role,
460
+ credential=credential,
461
+ permissions=effective_perms,
462
+ )
@@ -5,13 +5,13 @@ import logging
5
5
  import os
6
6
  from urllib.parse import urlparse
7
7
 
8
- import uvicorn
9
8
  from fastapi_vue.hostutil import parse_endpoint
10
9
  from uvicorn import Config, Server
11
10
 
12
11
  from paskia import globals as _globals
13
12
  from paskia.bootstrap import bootstrap_if_needed
14
13
  from paskia.config import PaskiaConfig
14
+ from paskia.db.background import flush
15
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
@@ -183,32 +183,13 @@ def main():
183
183
  }
184
184
  os.environ["PASKIA_CONFIG"] = json.dumps(config_json)
185
185
 
186
- # Initialize globals (without bootstrap yet)
187
- asyncio.run(
188
- _globals.init(
189
- rp_id=config.rp_id,
190
- rp_name=config.rp_name,
191
- origins=config.origins,
192
- bootstrap=False,
193
- )
194
- )
195
-
196
- # Print startup configuration
197
186
  startupbox.print_startup_config(config)
198
187
 
199
- # Bootstrap after startup box is printed
200
- asyncio.run(bootstrap_if_needed())
201
-
202
- # Handle reset command (no server start)
203
- if is_reset:
204
- exit_code = reset_cmd.run(args.reset_query)
205
- raise SystemExit(exit_code)
206
-
207
- # Dev mode: enable reload when FASTAPI_VUE_FRONTEND_URL is set
208
188
  devmode = bool(os.environ.get("FASTAPI_VUE_FRONTEND_URL"))
209
189
 
210
190
  run_kwargs: dict = {
211
191
  "log_level": "info",
192
+ "access_log": False, # We use custom AccessLogMiddleware instead
212
193
  }
213
194
 
214
195
  if devmode:
@@ -221,18 +202,34 @@ def main():
221
202
  # Suppress uvicorn startup messages in dev mode
222
203
  run_kwargs["log_level"] = "warning"
223
204
 
224
- if len(endpoints) > 1:
225
- # Run separate servers for multiple endpoints (e.g. IPv4 + IPv6)
226
- async def serve_all():
205
+ async def async_main():
206
+ await _globals.init(
207
+ rp_id=config.rp_id,
208
+ rp_name=config.rp_name,
209
+ origins=config.origins,
210
+ bootstrap=False,
211
+ )
212
+ await bootstrap_if_needed()
213
+ await flush()
214
+
215
+ if is_reset:
216
+ exit_code = reset_cmd.run(args.reset_query)
217
+ raise SystemExit(exit_code)
218
+
219
+ if len(endpoints) > 1:
227
220
  async with asyncio.TaskGroup() as tg:
228
221
  for ep in endpoints:
229
222
  tg.create_task(
230
223
  Server(Config(app=fastapi_app, **run_kwargs, **ep)).serve()
231
224
  )
232
-
233
- asyncio.run(serve_all())
234
- else:
235
- uvicorn.run("paskia.fastapi:app", **run_kwargs, **endpoints[0])
225
+ else:
226
+ server = Server(Config(app=fastapi_app, **run_kwargs, **endpoints[0]))
227
+ await server.serve()
228
+
229
+ try:
230
+ asyncio.run(async_main())
231
+ except KeyboardInterrupt:
232
+ pass
236
233
 
237
234
 
238
235
  if __name__ == "__main__":