paskia 0.8.1__tar.gz → 0.9.1__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 (71) hide show
  1. {paskia-0.8.1 → paskia-0.9.1}/PKG-INFO +1 -1
  2. {paskia-0.8.1 → paskia-0.9.1}/paskia/_version.py +2 -2
  3. {paskia-0.8.1 → paskia-0.9.1}/paskia/aaguid/__init__.py +5 -4
  4. paskia-0.9.1/paskia/authsession.py +47 -0
  5. paskia-0.9.1/paskia/bootstrap.py +123 -0
  6. {paskia-0.8.1 → paskia-0.9.1}/paskia/db/__init__.py +27 -55
  7. {paskia-0.8.1 → paskia-0.9.1}/paskia/db/background.py +20 -40
  8. paskia-0.9.1/paskia/db/jsonl.py +282 -0
  9. paskia-0.9.1/paskia/db/logging.py +233 -0
  10. paskia-0.9.1/paskia/db/migrations.py +33 -0
  11. paskia-0.9.1/paskia/db/operations.py +825 -0
  12. paskia-0.9.1/paskia/db/structs.py +462 -0
  13. {paskia-0.8.1 → paskia-0.9.1}/paskia/fastapi/__main__.py +25 -28
  14. {paskia-0.8.1 → paskia-0.9.1}/paskia/fastapi/admin.py +147 -329
  15. {paskia-0.8.1 → paskia-0.9.1}/paskia/fastapi/api.py +68 -110
  16. paskia-0.9.1/paskia/fastapi/logging.py +218 -0
  17. {paskia-0.8.1 → paskia-0.9.1}/paskia/fastapi/mainapp.py +25 -8
  18. {paskia-0.8.1 → paskia-0.9.1}/paskia/fastapi/remote.py +16 -39
  19. {paskia-0.8.1 → paskia-0.9.1}/paskia/fastapi/reset.py +27 -19
  20. paskia-0.9.1/paskia/fastapi/response.py +22 -0
  21. {paskia-0.8.1 → paskia-0.9.1}/paskia/fastapi/session.py +2 -2
  22. {paskia-0.8.1 → paskia-0.9.1}/paskia/fastapi/user.py +24 -30
  23. {paskia-0.8.1 → paskia-0.9.1}/paskia/fastapi/ws.py +25 -60
  24. paskia-0.9.1/paskia/fastapi/wschat.py +62 -0
  25. {paskia-0.8.1 → paskia-0.9.1}/paskia/fastapi/wsutil.py +15 -2
  26. {paskia-0.8.1 → paskia-0.9.1}/paskia/frontend-build/auth/admin/index.html +5 -5
  27. paskia-0.8.1/paskia/frontend-build/auth/assets/AccessDenied-Bc249ASC.css → paskia-0.9.1/paskia/frontend-build/auth/assets/AccessDenied-DPkUS8LZ.css +1 -1
  28. paskia-0.9.1/paskia/frontend-build/auth/assets/AccessDenied-Fmeb6EtF.js +8 -0
  29. paskia-0.8.1/paskia/frontend-build/auth/assets/RestrictedAuth-DgdJyscT.css → paskia-0.9.1/paskia/frontend-build/auth/assets/RestrictedAuth-CvR33_Z0.css +1 -1
  30. paskia-0.9.1/paskia/frontend-build/auth/assets/RestrictedAuth-DsJXicIw.js +1 -0
  31. paskia-0.8.1/paskia/frontend-build/auth/assets/_plugin-vue_export-helper-rKFEraYH.js → paskia-0.9.1/paskia/frontend-build/auth/assets/_plugin-vue_export-helper-nhjnO_bd.js +1 -1
  32. paskia-0.9.1/paskia/frontend-build/auth/assets/admin-CPE1pLMm.js +1 -0
  33. paskia-0.8.1/paskia/frontend-build/auth/assets/admin-BeNu48FR.css → paskia-0.9.1/paskia/frontend-build/auth/assets/admin-DzzjSg72.css +1 -1
  34. paskia-0.8.1/paskia/frontend-build/auth/assets/auth-BKX7shEe.css → paskia-0.9.1/paskia/frontend-build/auth/assets/auth-C7k64Wad.css +1 -1
  35. paskia-0.9.1/paskia/frontend-build/auth/assets/auth-YIZvPlW_.js +1 -0
  36. paskia-0.8.1/paskia/frontend-build/auth/assets/forward-Dzg-aE1C.js → paskia-0.9.1/paskia/frontend-build/auth/assets/forward-DmqVHZ7e.js +1 -1
  37. paskia-0.9.1/paskia/frontend-build/auth/assets/reset-Chtv69AT.css +1 -0
  38. paskia-0.9.1/paskia/frontend-build/auth/assets/reset-s20PATTN.js +1 -0
  39. paskia-0.8.1/paskia/frontend-build/auth/assets/restricted-C0IQufuH.js → paskia-0.9.1/paskia/frontend-build/auth/assets/restricted-D3AJx3_6.js +1 -1
  40. {paskia-0.8.1 → paskia-0.9.1}/paskia/frontend-build/auth/index.html +5 -5
  41. {paskia-0.8.1 → paskia-0.9.1}/paskia/frontend-build/auth/restricted/index.html +4 -4
  42. {paskia-0.8.1 → paskia-0.9.1}/paskia/frontend-build/int/forward/index.html +4 -4
  43. {paskia-0.8.1 → paskia-0.9.1}/paskia/frontend-build/int/reset/index.html +3 -3
  44. {paskia-0.8.1 → paskia-0.9.1}/paskia/globals.py +2 -2
  45. {paskia-0.8.1 → paskia-0.9.1}/paskia/migrate/__init__.py +67 -60
  46. {paskia-0.8.1 → paskia-0.9.1}/paskia/migrate/sql.py +94 -37
  47. {paskia-0.8.1 → paskia-0.9.1}/paskia/remoteauth.py +7 -8
  48. {paskia-0.8.1 → paskia-0.9.1}/paskia/sansio.py +6 -12
  49. {paskia-0.8.1 → paskia-0.9.1}/pyproject.toml +1 -5
  50. paskia-0.8.1/paskia/authsession.py +0 -75
  51. paskia-0.8.1/paskia/bootstrap.py +0 -195
  52. paskia-0.8.1/paskia/db/jsonl.py +0 -132
  53. paskia-0.8.1/paskia/db/operations.py +0 -1241
  54. paskia-0.8.1/paskia/db/structs.py +0 -148
  55. paskia-0.8.1/paskia/frontend-build/auth/assets/AccessDenied-aTdCvz9k.js +0 -8
  56. paskia-0.8.1/paskia/frontend-build/auth/assets/RestrictedAuth-BLMK7-nL.js +0 -1
  57. paskia-0.8.1/paskia/frontend-build/auth/assets/admin-tVs8oyLv.js +0 -1
  58. paskia-0.8.1/paskia/frontend-build/auth/assets/auth-Dk3q4pNS.js +0 -1
  59. paskia-0.8.1/paskia/frontend-build/auth/assets/reset-BWF4cWKR.css +0 -1
  60. paskia-0.8.1/paskia/frontend-build/auth/assets/reset-C_Td1_jn.js +0 -1
  61. {paskia-0.8.1 → paskia-0.9.1}/.gitignore +0 -0
  62. {paskia-0.8.1 → paskia-0.9.1}/README.md +0 -0
  63. {paskia-0.8.1 → paskia-0.9.1}/paskia/__init__.py +0 -0
  64. {paskia-0.8.1 → paskia-0.9.1}/paskia/aaguid/combined_aaguid.json +0 -0
  65. {paskia-0.8.1 → paskia-0.9.1}/paskia/config.py +0 -0
  66. {paskia-0.8.1 → paskia-0.9.1}/paskia/fastapi/__init__.py +0 -0
  67. {paskia-0.8.1 → paskia-0.9.1}/paskia/fastapi/auth_host.py +0 -0
  68. {paskia-0.8.1 → paskia-0.9.1}/paskia/fastapi/authz.py +0 -0
  69. {paskia-0.8.1 → paskia-0.9.1}/paskia/frontend-build/auth/assets/_plugin-vue_export-helper-BTzJAQlS.css +0 -0
  70. {paskia-0.8.1 → paskia-0.9.1}/paskia/frontend-build/auth/assets/helpers-DzjFIx78.js +0 -0
  71. {paskia-0.8.1 → paskia-0.9.1}/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.1
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.1'
32
+ __version_tuple__ = version_tuple = (0, 9, 1)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -10,6 +10,7 @@ This module provides functionality to:
10
10
  import json
11
11
  from collections.abc import Iterable
12
12
  from importlib.resources import files
13
+ from uuid import UUID
13
14
 
14
15
  __ALL__ = ["AAGUID", "filter"]
15
16
 
@@ -18,15 +19,15 @@ AAGUID_FILE = files("paskia") / "aaguid" / "combined_aaguid.json"
18
19
  AAGUID: dict[str, dict] = json.loads(AAGUID_FILE.read_text(encoding="utf-8"))
19
20
 
20
21
 
21
- def filter(aaguids: Iterable[str]) -> dict[str, dict]:
22
+ def filter(aaguids: Iterable[UUID]) -> dict[str, dict]:
22
23
  """
23
24
  Get AAGUID information only for the provided set of AAGUIDs.
24
25
 
25
26
  Args:
26
- aaguids: Set of AAGUID strings that the user has credentials for
27
+ aaguids: Iterable of AAGUIDs (UUIDs) that the user has credentials for
27
28
 
28
29
  Returns:
29
- Dictionary mapping AAGUID to authenticator information for only
30
+ Dictionary mapping AAGUID string to authenticator information for only
30
31
  the AAGUIDs that the user has and that we have data for
31
32
  """
32
- return {aaguid: AAGUID[aaguid] for aaguid in aaguids if aaguid in AAGUID}
33
+ return {(s := str(a)): AAGUID[s] for a in aaguids if (s := str(a)) in AAGUID}
@@ -0,0 +1,47 @@
1
+ """
2
+ Core session management for WebAuthn authentication.
3
+
4
+ This module provides generic session management functionality that is
5
+ independent of any web framework:
6
+ - Session creation and validation
7
+ - Token handling and refresh
8
+ - Credential management
9
+ """
10
+
11
+ from datetime import UTC, datetime
12
+ from typing import TYPE_CHECKING
13
+ from uuid import UUID
14
+
15
+ from paskia import db
16
+ from paskia.config import RESET_LIFETIME, SESSION_LIFETIME
17
+ from paskia.util import hostutil
18
+
19
+ if TYPE_CHECKING:
20
+ from paskia.db import ResetToken
21
+
22
+ EXPIRES = SESSION_LIFETIME
23
+
24
+
25
+ def expires() -> datetime:
26
+ return datetime.now(UTC) + EXPIRES
27
+
28
+
29
+ def reset_expires() -> datetime:
30
+ return datetime.now(UTC) + RESET_LIFETIME
31
+
32
+
33
+ def get_reset(token: str) -> "ResetToken":
34
+ """Validate a credential reset token."""
35
+
36
+ record = db.get_reset_token(token)
37
+ if record:
38
+ return record
39
+ raise ValueError("This authentication link is no longer valid.")
40
+
41
+
42
+ def delete_credential(credential_uuid: UUID, auth: str, host: str | None = None):
43
+ """Delete a specific credential for the current user."""
44
+ ctx = db.data().session_ctx(auth, hostutil.normalize_host(host))
45
+ if not ctx:
46
+ raise ValueError("Session expired")
47
+ 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,24 +1,25 @@
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.
5
- CTX: get_session_context(key) returns SessionContext with effective permissions.
4
+ Read: Access data() directly, use build_* to convert to public structs.
5
+ CTX: data().session_ctx(key) returns SessionContext with effective permissions.
6
6
  Write: Functions validate and commit, or raise ValueError.
7
7
 
8
8
  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
16
- ctx = db.get_session_context(session_key)
16
+ ctx = db.data().session_ctx(session_key)
17
17
 
18
18
  # Write
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,37 @@ 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
- get_session_context,
69
- get_user_by_uuid,
52
+ get_user_credential_ids,
70
53
  get_user_organization,
71
54
  init,
72
- list_organizations,
73
- list_permissions,
74
- list_sessions_for_user,
75
55
  login,
76
- remove_permission_from_organization,
56
+ remove_permission_from_org,
77
57
  remove_permission_from_role,
78
- rename_permission,
79
58
  set_session_host,
80
59
  update_credential_sign_count,
81
- update_organization_name,
60
+ update_org_name,
82
61
  update_permission,
83
62
  update_role_name,
84
63
  update_session,
@@ -87,6 +66,7 @@ from paskia.db.operations import (
87
66
  update_user_role_in_organization,
88
67
  )
89
68
  from paskia.db.structs import (
69
+ DB,
90
70
  Credential,
91
71
  Org,
92
72
  Permission,
@@ -97,6 +77,12 @@ from paskia.db.structs import (
97
77
  User,
98
78
  )
99
79
 
80
+
81
+ def data() -> DB:
82
+ """Get the database instance for direct read access."""
83
+ return operations._db
84
+
85
+
100
86
  __all__ = [
101
87
  # Types
102
88
  "Credential",
@@ -109,7 +95,7 @@ __all__ = [
109
95
  "SessionContext",
110
96
  "User",
111
97
  # Instance
112
- "_db",
98
+ "data",
113
99
  "init",
114
100
  # Background
115
101
  "start_background",
@@ -118,44 +104,31 @@ __all__ = [
118
104
  "stop_cleanup",
119
105
  # Builders
120
106
  "build_credential",
121
- "build_org",
122
107
  "build_permission",
123
108
  "build_reset_token",
124
109
  "build_role",
125
110
  "build_session",
126
111
  "build_user",
127
112
  # Read ops
128
- "get_credential_by_id",
129
- "get_credentials_by_user_uuid",
130
- "get_organization",
131
113
  "get_organization_users",
132
- "get_permission",
133
- "get_permission_by_scope",
134
- "get_permission_organizations",
135
114
  "get_reset_token",
136
- "get_role",
137
- "get_roles_by_organization",
138
- "get_session",
139
- "get_session_context",
140
- "get_user_by_uuid",
115
+ "get_user_credential_ids",
141
116
  "get_user_organization",
142
- "list_organizations",
143
- "list_permissions",
144
- "list_sessions_for_user",
145
117
  # Write ops
146
- "add_permission_to_organization",
118
+ "add_permission_to_org",
147
119
  "add_permission_to_role",
120
+ "bootstrap",
148
121
  "cleanup_expired",
149
122
  "create_credential",
150
123
  "create_credential_session",
151
- "create_organization",
124
+ "create_org",
152
125
  "create_permission",
153
126
  "create_reset_token",
154
127
  "create_role",
155
128
  "create_session",
156
129
  "create_user",
157
130
  "delete_credential",
158
- "delete_organization",
131
+ "delete_org",
159
132
  "delete_permission",
160
133
  "delete_reset_token",
161
134
  "delete_role",
@@ -163,12 +136,11 @@ __all__ = [
163
136
  "delete_sessions_for_user",
164
137
  "delete_user",
165
138
  "login",
166
- "remove_permission_from_organization",
139
+ "remove_permission_from_org",
167
140
  "remove_permission_from_role",
168
- "rename_permission",
169
141
  "set_session_host",
170
142
  "update_credential_sign_count",
171
- "update_organization_name",
143
+ "update_org_name",
172
144
  "update_permission",
173
145
  "update_role_name",
174
146
  "update_session",
@@ -6,62 +6,34 @@ Periodically flushes pending changes to disk and cleans up expired items.
6
6
 
7
7
  import asyncio
8
8
  import logging
9
- from datetime import datetime, timezone
9
+ from datetime import UTC, datetime
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
- last_cleanup = datetime.now(timezone.utc)
36
+ last_cleanup = datetime.now(UTC)
65
37
 
66
38
  while True:
67
39
  try:
@@ -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
73
- now = datetime.now(timezone.utc)
44
+ # Run cleanup periodically
45
+ now = datetime.now(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