paskia 0.8.1__tar.gz → 0.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. {paskia-0.8.1 → paskia-0.9.0}/PKG-INFO +1 -1
  2. {paskia-0.8.1 → paskia-0.9.0}/paskia/_version.py +2 -2
  3. {paskia-0.8.1 → paskia-0.9.0}/paskia/authsession.py +14 -27
  4. paskia-0.9.0/paskia/bootstrap.py +123 -0
  5. {paskia-0.8.1 → paskia-0.9.0}/paskia/db/__init__.py +25 -51
  6. {paskia-0.8.1 → paskia-0.9.0}/paskia/db/background.py +17 -37
  7. paskia-0.9.0/paskia/db/jsonl.py +294 -0
  8. paskia-0.9.0/paskia/db/migrations.py +34 -0
  9. paskia-0.9.0/paskia/db/operations.py +918 -0
  10. paskia-0.9.0/paskia/db/structs.py +272 -0
  11. {paskia-0.8.1 → paskia-0.9.0}/paskia/fastapi/__main__.py +24 -28
  12. {paskia-0.8.1 → paskia-0.9.0}/paskia/fastapi/admin.py +101 -160
  13. {paskia-0.8.1 → paskia-0.9.0}/paskia/fastapi/api.py +47 -83
  14. {paskia-0.8.1 → paskia-0.9.0}/paskia/fastapi/mainapp.py +13 -6
  15. {paskia-0.8.1 → paskia-0.9.0}/paskia/fastapi/remote.py +16 -39
  16. {paskia-0.8.1 → paskia-0.9.0}/paskia/fastapi/reset.py +27 -17
  17. {paskia-0.8.1 → paskia-0.9.0}/paskia/fastapi/session.py +2 -2
  18. {paskia-0.8.1 → paskia-0.9.0}/paskia/fastapi/user.py +21 -27
  19. {paskia-0.8.1 → paskia-0.9.0}/paskia/fastapi/ws.py +27 -62
  20. paskia-0.9.0/paskia/fastapi/wschat.py +62 -0
  21. {paskia-0.8.1 → paskia-0.9.0}/paskia/frontend-build/auth/admin/index.html +5 -5
  22. paskia-0.8.1/paskia/frontend-build/auth/assets/AccessDenied-Bc249ASC.css → paskia-0.9.0/paskia/frontend-build/auth/assets/AccessDenied-DPkUS8LZ.css +1 -1
  23. paskia-0.9.0/paskia/frontend-build/auth/assets/AccessDenied-Fmeb6EtF.js +8 -0
  24. paskia-0.8.1/paskia/frontend-build/auth/assets/RestrictedAuth-DgdJyscT.css → paskia-0.9.0/paskia/frontend-build/auth/assets/RestrictedAuth-CvR33_Z0.css +1 -1
  25. paskia-0.9.0/paskia/frontend-build/auth/assets/RestrictedAuth-DsJXicIw.js +1 -0
  26. paskia-0.8.1/paskia/frontend-build/auth/assets/_plugin-vue_export-helper-rKFEraYH.js → paskia-0.9.0/paskia/frontend-build/auth/assets/_plugin-vue_export-helper-nhjnO_bd.js +1 -1
  27. paskia-0.9.0/paskia/frontend-build/auth/assets/admin-CPE1pLMm.js +1 -0
  28. paskia-0.8.1/paskia/frontend-build/auth/assets/admin-BeNu48FR.css → paskia-0.9.0/paskia/frontend-build/auth/assets/admin-DzzjSg72.css +1 -1
  29. paskia-0.8.1/paskia/frontend-build/auth/assets/auth-BKX7shEe.css → paskia-0.9.0/paskia/frontend-build/auth/assets/auth-C7k64Wad.css +1 -1
  30. paskia-0.9.0/paskia/frontend-build/auth/assets/auth-YIZvPlW_.js +1 -0
  31. paskia-0.8.1/paskia/frontend-build/auth/assets/forward-Dzg-aE1C.js → paskia-0.9.0/paskia/frontend-build/auth/assets/forward-DmqVHZ7e.js +1 -1
  32. paskia-0.9.0/paskia/frontend-build/auth/assets/reset-Chtv69AT.css +1 -0
  33. paskia-0.9.0/paskia/frontend-build/auth/assets/reset-s20PATTN.js +1 -0
  34. paskia-0.8.1/paskia/frontend-build/auth/assets/restricted-C0IQufuH.js → paskia-0.9.0/paskia/frontend-build/auth/assets/restricted-D3AJx3_6.js +1 -1
  35. {paskia-0.8.1 → paskia-0.9.0}/paskia/frontend-build/auth/index.html +5 -5
  36. {paskia-0.8.1 → paskia-0.9.0}/paskia/frontend-build/auth/restricted/index.html +4 -4
  37. {paskia-0.8.1 → paskia-0.9.0}/paskia/frontend-build/int/forward/index.html +4 -4
  38. {paskia-0.8.1 → paskia-0.9.0}/paskia/frontend-build/int/reset/index.html +3 -3
  39. {paskia-0.8.1 → paskia-0.9.0}/paskia/globals.py +2 -2
  40. {paskia-0.8.1 → paskia-0.9.0}/paskia/migrate/__init__.py +62 -55
  41. {paskia-0.8.1 → paskia-0.9.0}/paskia/migrate/sql.py +72 -22
  42. {paskia-0.8.1 → paskia-0.9.0}/paskia/remoteauth.py +1 -2
  43. {paskia-0.8.1 → paskia-0.9.0}/paskia/sansio.py +6 -12
  44. {paskia-0.8.1 → paskia-0.9.0}/pyproject.toml +1 -1
  45. paskia-0.8.1/paskia/bootstrap.py +0 -195
  46. paskia-0.8.1/paskia/db/jsonl.py +0 -132
  47. paskia-0.8.1/paskia/db/operations.py +0 -1241
  48. paskia-0.8.1/paskia/db/structs.py +0 -148
  49. paskia-0.8.1/paskia/frontend-build/auth/assets/AccessDenied-aTdCvz9k.js +0 -8
  50. paskia-0.8.1/paskia/frontend-build/auth/assets/RestrictedAuth-BLMK7-nL.js +0 -1
  51. paskia-0.8.1/paskia/frontend-build/auth/assets/admin-tVs8oyLv.js +0 -1
  52. paskia-0.8.1/paskia/frontend-build/auth/assets/auth-Dk3q4pNS.js +0 -1
  53. paskia-0.8.1/paskia/frontend-build/auth/assets/reset-BWF4cWKR.css +0 -1
  54. paskia-0.8.1/paskia/frontend-build/auth/assets/reset-C_Td1_jn.js +0 -1
  55. {paskia-0.8.1 → paskia-0.9.0}/.gitignore +0 -0
  56. {paskia-0.8.1 → paskia-0.9.0}/README.md +0 -0
  57. {paskia-0.8.1 → paskia-0.9.0}/paskia/__init__.py +0 -0
  58. {paskia-0.8.1 → paskia-0.9.0}/paskia/aaguid/__init__.py +0 -0
  59. {paskia-0.8.1 → paskia-0.9.0}/paskia/aaguid/combined_aaguid.json +0 -0
  60. {paskia-0.8.1 → paskia-0.9.0}/paskia/config.py +0 -0
  61. {paskia-0.8.1 → paskia-0.9.0}/paskia/fastapi/__init__.py +0 -0
  62. {paskia-0.8.1 → paskia-0.9.0}/paskia/fastapi/auth_host.py +0 -0
  63. {paskia-0.8.1 → paskia-0.9.0}/paskia/fastapi/authz.py +0 -0
  64. {paskia-0.8.1 → paskia-0.9.0}/paskia/fastapi/wsutil.py +0 -0
  65. {paskia-0.8.1 → paskia-0.9.0}/paskia/frontend-build/auth/assets/_plugin-vue_export-helper-BTzJAQlS.css +0 -0
  66. {paskia-0.8.1 → paskia-0.9.0}/paskia/frontend-build/auth/assets/helpers-DzjFIx78.js +0 -0
  67. {paskia-0.8.1 → paskia-0.9.0}/paskia/frontend-build/auth/assets/pow-2N9bxgAo.js +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: paskia
3
- Version: 0.8.1
3
+ Version: 0.9.0
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
@@ -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.8.1'
32
- __version_tuple__ = version_tuple = (0, 8, 1)
31
+ __version__ = version = '0.9.0'
32
+ __version_tuple__ = version_tuple = (0, 9, 0)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -9,13 +9,16 @@ independent of any web framework:
9
9
  """
10
10
 
11
11
  from datetime import datetime, timezone
12
+ from typing import TYPE_CHECKING
12
13
  from uuid import UUID
13
14
 
14
15
  from paskia import db
15
- from paskia.config import SESSION_LIFETIME
16
- from paskia.db import ResetToken, Session
16
+ from paskia.config import RESET_LIFETIME, SESSION_LIFETIME
17
17
  from paskia.util import hostutil
18
18
 
19
+ if TYPE_CHECKING:
20
+ from paskia.db import ResetToken
21
+
19
22
  EXPIRES = SESSION_LIFETIME
20
23
 
21
24
 
@@ -24,39 +27,21 @@ def expires() -> datetime:
24
27
 
25
28
 
26
29
  def reset_expires() -> datetime:
27
- from .config import RESET_LIFETIME
28
-
29
30
  return datetime.now(timezone.utc) + RESET_LIFETIME
30
31
 
31
32
 
32
- async def get_reset(token: str) -> ResetToken:
33
+ def get_reset(token: str) -> "ResetToken":
33
34
  """Validate a credential reset token."""
35
+
34
36
  record = db.get_reset_token(token)
35
37
  if record:
36
38
  return record
37
39
  raise ValueError("This authentication link is no longer valid.")
38
40
 
39
41
 
40
- async def get_session(token: str, host: str | None = None) -> Session:
41
- """Validate a session token and return session data if valid."""
42
- host = hostutil.normalize_host(host)
43
- if not host:
44
- raise ValueError("Invalid host")
45
- session = db.get_session(token)
46
- if session:
47
- if session.host is None:
48
- # First time binding: store exact host:port (or IPv6 form) now.
49
- db.set_session_host(session.key, host)
50
- session.host = host
51
- elif session.host != host:
52
- raise ValueError("Session host mismatch")
53
- return session
54
- raise ValueError("Your session has expired. Please sign in again!")
55
-
56
-
57
- async def refresh_session_token(token: str, *, ip: str, user_agent: str):
42
+ def refresh_session_token(token: str, *, ip: str, user_agent: str):
58
43
  """Refresh a session extending its expiry."""
59
- session_record = db.get_session(token)
44
+ session_record = db.data().sessions.get(token)
60
45
  if not session_record:
61
46
  raise ValueError("Session not found or expired")
62
47
  updated = db.update_session(
@@ -69,7 +54,9 @@ async def refresh_session_token(token: str, *, ip: str, user_agent: str):
69
54
  raise ValueError("Session not found or expired")
70
55
 
71
56
 
72
- async def delete_credential(credential_uuid: UUID, auth: str, host: str | None = None):
57
+ def delete_credential(credential_uuid: UUID, auth: str, host: str | None = None):
73
58
  """Delete a specific credential for the current user."""
74
- s = await get_session(auth, host=host)
75
- db.delete_credential(credential_uuid, s.user_uuid)
59
+ ctx = db.get_session_context(auth, hostutil.normalize_host(host))
60
+ if not ctx:
61
+ raise ValueError("Session expired")
62
+ db.delete_credential(credential_uuid, ctx.user.uuid)
@@ -0,0 +1,123 @@
1
+ """
2
+ Bootstrap module for passkey authentication system.
3
+
4
+ This module handles initial system setup when a new database is created,
5
+ including creating default admin user, organization, permissions, and
6
+ generating a reset link for initial admin setup.
7
+ """
8
+
9
+ import asyncio
10
+ import logging
11
+
12
+ from paskia import authsession, db, globals
13
+ from paskia.util import hostutil, passphrase
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # Shared log message template for admin reset links
18
+ ADMIN_RESET_MESSAGE = """\
19
+ %s
20
+
21
+ 👤 Admin %s
22
+ - Use this link to register a Passkey for the admin user!
23
+ """
24
+
25
+
26
+ def _log_reset_link(message: str, passphrase: str) -> str:
27
+ """Log a reset link message and return the URL."""
28
+ reset_link = hostutil.reset_link_url(passphrase)
29
+ logger.info(ADMIN_RESET_MESSAGE, message, reset_link)
30
+ return reset_link
31
+
32
+
33
+ async def bootstrap_system() -> None:
34
+ """
35
+ Bootstrap the entire system with default data.
36
+
37
+ Uses db.bootstrap() which performs all operations in a single transaction.
38
+ The transaction log will show a single "bootstrap" action with all changes.
39
+ """
40
+ # Call the single-transaction bootstrap function
41
+ reset_passphrase = db.bootstrap()
42
+
43
+ # Log the reset link (this is separate from the transaction log)
44
+ _log_reset_link("✅ Bootstrap completed!", reset_passphrase)
45
+
46
+
47
+ async def check_admin_credentials() -> bool:
48
+ """
49
+ Check if the admin user needs credentials and create a reset link if needed.
50
+
51
+ Returns:
52
+ bool: True if a reset link was created, False if admin already has credentials
53
+ """
54
+ try:
55
+ # Get permission organizations to find admin users
56
+ p = next(
57
+ (p for p in db.data().permissions.values() if p.scope == "auth:admin"), None
58
+ )
59
+ if not p or not p.orgs:
60
+ return False
61
+
62
+ # Get users from the first organization with admin permission
63
+ first_org_uuid = next(iter(p.orgs))
64
+ org_users = db.get_organization_users(first_org_uuid)
65
+ admin_users = [user for user, role in org_users if role == "Administration"]
66
+
67
+ if not admin_users:
68
+ return False
69
+
70
+ # Check first admin user for credentials
71
+ admin_user = admin_users[0]
72
+
73
+ if not db.get_user_credential_ids(admin_user.uuid):
74
+ # Admin exists but has no credentials, create reset link
75
+
76
+ token = passphrase.generate()
77
+ expiry = authsession.reset_expires()
78
+ db.create_reset_token(
79
+ user_uuid=admin_user.uuid,
80
+ passphrase=token,
81
+ expiry=expiry,
82
+ token_type="admin registration",
83
+ )
84
+ _log_reset_link("⚠️ Admin user has no credentials!", token)
85
+ return True
86
+
87
+ return False
88
+
89
+ except Exception:
90
+ return False
91
+
92
+
93
+ async def bootstrap_if_needed() -> bool:
94
+ """
95
+ Check if system needs bootstrapping and perform it if necessary.
96
+
97
+ Returns:
98
+ bool: True if bootstrapping was performed, False if system was already set up
99
+ """
100
+ # Check if the admin permission exists - if it does, system is already bootstrapped
101
+ if any(p.scope == "auth:admin" for p in db.data().permissions.values()):
102
+ # Permission exists, system is already bootstrapped
103
+ # Check if admin needs credentials (only for already-bootstrapped systems)
104
+ await check_admin_credentials()
105
+ return False
106
+
107
+ # No admin permission found, need to bootstrap
108
+ # Bootstrap creates the admin user AND the reset link, so no need to check credentials after
109
+ await bootstrap_system()
110
+ return True
111
+
112
+
113
+ # CLI interface
114
+ async def main():
115
+ """Main CLI entry point for bootstrapping."""
116
+ # Configure logging for CLI usage
117
+ logging.basicConfig(level=logging.INFO, format="%(message)s", force=True)
118
+
119
+ await globals.init()
120
+
121
+
122
+ if __name__ == "__main__":
123
+ asyncio.run(main())
@@ -1,7 +1,7 @@
1
1
  """
2
2
  Database module for WebAuthn passkey authentication.
3
3
 
4
- Read: Access _db._data directly, use build_* to convert to public structs.
4
+ Read: Access data() directly, use build_* to convert to public structs.
5
5
  CTX: get_session_context(key) returns SessionContext with effective permissions.
6
6
  Write: Functions validate and commit, or raise ValueError.
7
7
 
@@ -9,7 +9,7 @@ Usage:
9
9
  from paskia import db
10
10
 
11
11
  # Read (after init)
12
- user_data = db._db._data.users[user_uuid]
12
+ user_data = db.data().users[user_uuid]
13
13
  user = db.build_user(user_uuid)
14
14
 
15
15
  # Context
@@ -19,6 +19,7 @@ Usage:
19
19
  db.create_user(user)
20
20
  """
21
21
 
22
+ import paskia.db.operations as operations
22
23
  from paskia.db.background import (
23
24
  start_background,
24
25
  start_cleanup,
@@ -26,59 +27,38 @@ from paskia.db.background import (
26
27
  stop_cleanup,
27
28
  )
28
29
  from paskia.db.operations import (
29
- DB,
30
- _db,
31
- add_permission_to_organization,
30
+ add_permission_to_org,
32
31
  add_permission_to_role,
33
- build_credential,
34
- build_org,
35
- build_permission,
36
- build_reset_token,
37
- build_role,
38
- build_session,
39
- build_user,
32
+ bootstrap,
40
33
  cleanup_expired,
41
34
  create_credential,
42
35
  create_credential_session,
43
- create_organization,
36
+ create_org,
44
37
  create_permission,
45
38
  create_reset_token,
46
39
  create_role,
47
40
  create_session,
48
41
  create_user,
49
42
  delete_credential,
50
- delete_organization,
43
+ delete_org,
51
44
  delete_permission,
52
45
  delete_reset_token,
53
46
  delete_role,
54
47
  delete_session,
55
48
  delete_sessions_for_user,
56
49
  delete_user,
57
- get_credential_by_id,
58
- get_credentials_by_user_uuid,
59
- get_organization,
60
50
  get_organization_users,
61
- get_permission,
62
- get_permission_by_scope,
63
- get_permission_organizations,
64
51
  get_reset_token,
65
- get_role,
66
- get_roles_by_organization,
67
- get_session,
68
52
  get_session_context,
69
- get_user_by_uuid,
53
+ get_user_credential_ids,
70
54
  get_user_organization,
71
55
  init,
72
- list_organizations,
73
- list_permissions,
74
- list_sessions_for_user,
75
56
  login,
76
- remove_permission_from_organization,
57
+ remove_permission_from_org,
77
58
  remove_permission_from_role,
78
- rename_permission,
79
59
  set_session_host,
80
60
  update_credential_sign_count,
81
- update_organization_name,
61
+ update_org_name,
82
62
  update_permission,
83
63
  update_role_name,
84
64
  update_session,
@@ -87,6 +67,7 @@ from paskia.db.operations import (
87
67
  update_user_role_in_organization,
88
68
  )
89
69
  from paskia.db.structs import (
70
+ DB,
90
71
  Credential,
91
72
  Org,
92
73
  Permission,
@@ -97,6 +78,12 @@ from paskia.db.structs import (
97
78
  User,
98
79
  )
99
80
 
81
+
82
+ def data() -> DB:
83
+ """Get the database instance for direct read access."""
84
+ return operations._db
85
+
86
+
100
87
  __all__ = [
101
88
  # Types
102
89
  "Credential",
@@ -109,7 +96,7 @@ __all__ = [
109
96
  "SessionContext",
110
97
  "User",
111
98
  # Instance
112
- "_db",
99
+ "data",
113
100
  "init",
114
101
  # Background
115
102
  "start_background",
@@ -118,44 +105,32 @@ __all__ = [
118
105
  "stop_cleanup",
119
106
  # Builders
120
107
  "build_credential",
121
- "build_org",
122
108
  "build_permission",
123
109
  "build_reset_token",
124
110
  "build_role",
125
111
  "build_session",
126
112
  "build_user",
127
113
  # Read ops
128
- "get_credential_by_id",
129
- "get_credentials_by_user_uuid",
130
- "get_organization",
131
114
  "get_organization_users",
132
- "get_permission",
133
- "get_permission_by_scope",
134
- "get_permission_organizations",
135
115
  "get_reset_token",
136
- "get_role",
137
- "get_roles_by_organization",
138
- "get_session",
139
116
  "get_session_context",
140
- "get_user_by_uuid",
117
+ "get_user_credential_ids",
141
118
  "get_user_organization",
142
- "list_organizations",
143
- "list_permissions",
144
- "list_sessions_for_user",
145
119
  # Write ops
146
- "add_permission_to_organization",
120
+ "add_permission_to_org",
147
121
  "add_permission_to_role",
122
+ "bootstrap",
148
123
  "cleanup_expired",
149
124
  "create_credential",
150
125
  "create_credential_session",
151
- "create_organization",
126
+ "create_org",
152
127
  "create_permission",
153
128
  "create_reset_token",
154
129
  "create_role",
155
130
  "create_session",
156
131
  "create_user",
157
132
  "delete_credential",
158
- "delete_organization",
133
+ "delete_org",
159
134
  "delete_permission",
160
135
  "delete_reset_token",
161
136
  "delete_role",
@@ -163,12 +138,11 @@ __all__ = [
163
138
  "delete_sessions_for_user",
164
139
  "delete_user",
165
140
  "login",
166
- "remove_permission_from_organization",
141
+ "remove_permission_from_org",
167
142
  "remove_permission_from_role",
168
- "rename_permission",
169
143
  "set_session_host",
170
144
  "update_credential_sign_count",
171
- "update_organization_name",
145
+ "update_org_name",
172
146
  "update_permission",
173
147
  "update_role_name",
174
148
  "update_session",
@@ -8,57 +8,29 @@ import asyncio
8
8
  import logging
9
9
  from datetime import datetime, timezone
10
10
 
11
- from paskia.db.jsonl import flush_changes
11
+ from paskia.db.operations import _store, cleanup_expired
12
12
 
13
- # Flush changes to disk every N seconds
14
- FLUSH_INTERVAL = 1
15
- # Cleanup expired items every N seconds (cheap when nothing to remove)
16
- CLEANUP_INTERVAL = 1
13
+ FLUSH_INTERVAL = 0.1 # Flush to disk
14
+ CLEANUP_INTERVAL = 1 # Expired item cleanup
17
15
 
18
16
 
19
17
  _logger = logging.getLogger(__name__)
20
18
  _background_task: asyncio.Task | None = None
21
19
 
22
20
 
23
- def cleanup() -> None:
24
- """Remove expired sessions and reset tokens from the database."""
25
- from paskia.db.operations import _db
26
-
27
- if _db is None or _db._data is None:
28
- return
29
-
30
- with _db.transaction("expiry"):
31
- current_time = datetime.now(timezone.utc)
32
-
33
- # Clean expired sessions
34
- to_delete_sessions = [
35
- k for k, s in _db._data.sessions.items() if s.expiry < current_time
36
- ]
37
- for k in to_delete_sessions:
38
- del _db._data.sessions[k]
39
-
40
- # Clean expired reset tokens
41
- to_delete_tokens = [
42
- k for k, t in _db._data.reset_tokens.items() if t.expiry < current_time
43
- ]
44
- for k in to_delete_tokens:
45
- del _db._data.reset_tokens[k]
46
-
47
-
48
21
  async def flush() -> None:
49
22
  """Write all pending database changes to disk."""
50
- from paskia.db.operations import _db
51
23
 
52
- if _db is None:
53
- _logger.warning("flush() called but _db is None")
24
+ if _store is None:
25
+ _logger.warning("flush() called but _store is None")
54
26
  return
55
- await flush_changes(_db.db_path, _db._pending_changes)
27
+ await _store.flush()
56
28
 
57
29
 
58
30
  async def _background_loop():
59
31
  """Background task that periodically flushes changes and cleans up."""
60
32
  # Run cleanup immediately on startup to clear old expired items
61
- cleanup()
33
+ cleanup_expired()
62
34
  await flush()
63
35
 
64
36
  last_cleanup = datetime.now(timezone.utc)
@@ -69,10 +41,10 @@ async def _background_loop():
69
41
  # Flush pending changes to disk
70
42
  await flush()
71
43
 
72
- # Run cleanup less frequently
44
+ # Run cleanup periodically
73
45
  now = datetime.now(timezone.utc)
74
46
  if (now - last_cleanup).total_seconds() >= CLEANUP_INTERVAL:
75
- cleanup()
47
+ cleanup_expired()
76
48
  await flush() # Flush cleanup changes
77
49
  last_cleanup = now
78
50
  except asyncio.CancelledError:
@@ -101,6 +73,14 @@ async def start_background():
101
73
  if loop is not task_loop:
102
74
  _logger.debug("Background task in different event loop, restarting")
103
75
  _background_task = None
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."
81
+ )
82
+ except RuntimeError:
83
+ raise # Re-raise RuntimeError from above
104
84
  except Exception as e:
105
85
  _logger.debug("Error checking background task loop: %s, restarting", e)
106
86
  _background_task = None