timeback-sdk 0.2.0__tar.gz → 0.2.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 (128) hide show
  1. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/.gitignore +3 -0
  2. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/PKG-INFO +1 -1
  3. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/pyproject.toml +1 -1
  4. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/activity/heartbeat_handler.py +52 -46
  5. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/activity/submit_handler.py +29 -36
  6. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/identity/handler.py +93 -71
  7. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/identity_only/handler.py +90 -65
  8. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/lessons/errors.py +4 -0
  9. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/user/verify.py +26 -27
  10. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/lib/utils.py +1 -0
  11. timeback_sdk-0.2.1/src/timeback/server/namespaces/lessons/next.py +51 -0
  12. timeback_sdk-0.2.1/tests/native/test_handler_exception_safety.py +560 -0
  13. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_lessons_handler_schema.py +11 -0
  14. timeback_sdk-0.2.0/src/timeback/server/namespaces/lessons/next.py +0 -38
  15. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/README.md +0 -0
  16. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/pytest.ini +0 -0
  17. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/__init__.py +0 -0
  18. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/py.typed +0 -0
  19. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/__init__.py +0 -0
  20. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/adapters/__init__.py +0 -0
  21. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/adapters/django.py +0 -0
  22. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/adapters/fastapi.py +0 -0
  23. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/__init__.py +0 -0
  24. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/activity/__init__.py +0 -0
  25. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/activity/attempts.py +0 -0
  26. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/activity/caliper.py +0 -0
  27. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/activity/completion.py +0 -0
  28. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/activity/progress.py +0 -0
  29. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/activity/resolve.py +0 -0
  30. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/activity/schema.py +0 -0
  31. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/activity/submit.py +0 -0
  32. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/factory.py +0 -0
  33. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/identity/__init__.py +0 -0
  34. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/identity_only/__init__.py +0 -0
  35. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/lessons/__init__.py +0 -0
  36. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/lessons/attempt_details_handler.py +0 -0
  37. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/lessons/attempts_handler.py +0 -0
  38. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/lessons/auth.py +0 -0
  39. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/lessons/complete_handler.py +0 -0
  40. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/lessons/list_handler.py +0 -0
  41. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/lessons/next_handler.py +0 -0
  42. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/lessons/schema.py +0 -0
  43. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/lessons/start_handler.py +0 -0
  44. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/lessons/submit_handler.py +0 -0
  45. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/lessons/types.py +0 -0
  46. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/user/__init__.py +0 -0
  47. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/user/enrollments.py +0 -0
  48. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/user/handler.py +0 -0
  49. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/handlers/user/profile.py +0 -0
  50. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/lib/__init__.py +0 -0
  51. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/lib/hooks.py +0 -0
  52. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/lib/logger.py +0 -0
  53. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/lib/oidc.py +0 -0
  54. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/lib/resolve.py +0 -0
  55. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/namespaces/__init__.py +0 -0
  56. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/namespaces/activity/__init__.py +0 -0
  57. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/namespaces/activity/record.py +0 -0
  58. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/namespaces/activity/schema.py +0 -0
  59. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/namespaces/lessons/__init__.py +0 -0
  60. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/namespaces/lessons/attempt_details.py +0 -0
  61. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/namespaces/lessons/attempts.py +0 -0
  62. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/namespaces/lessons/complete.py +0 -0
  63. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/namespaces/lessons/helpers.py +0 -0
  64. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/namespaces/lessons/list.py +0 -0
  65. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/namespaces/lessons/start.py +0 -0
  66. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/namespaces/lessons/submit.py +0 -0
  67. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/namespaces/lessons/types.py +0 -0
  68. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/namespaces/user/__init__.py +0 -0
  69. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/namespaces/user/get_profile.py +0 -0
  70. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/namespaces/user/verify.py +0 -0
  71. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/test_utils/__init__.py +0 -0
  72. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/test_utils/handler_helpers.py +0 -0
  73. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/test_utils/namespace_helpers.py +0 -0
  74. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/test_utils/namespace_stub_shapes.py +0 -0
  75. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/test_utils/recording_client.py +0 -0
  76. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/timeback.py +0 -0
  77. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/server/types.py +0 -0
  78. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/shared/__init__.py +0 -0
  79. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/shared/constants.py +0 -0
  80. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/src/timeback/shared/types.py +0 -0
  81. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_activity_record.py +0 -0
  82. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_handlers_activity_heartbeat.py +0 -0
  83. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_handlers_activity_submit.py +0 -0
  84. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_handlers_identity_callback.py +0 -0
  85. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_handlers_identity_only_callback.py +0 -0
  86. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_handlers_identity_only_signin.py +0 -0
  87. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_handlers_identity_only_signout.py +0 -0
  88. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_handlers_identity_signin.py +0 -0
  89. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_handlers_identity_signout.py +0 -0
  90. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_handlers_lessons_attempt_details.py +0 -0
  91. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_handlers_lessons_attempts.py +0 -0
  92. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_handlers_lessons_complete.py +0 -0
  93. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_handlers_lessons_list.py +0 -0
  94. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_handlers_lessons_next.py +0 -0
  95. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_handlers_lessons_start.py +0 -0
  96. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_handlers_lessons_submit.py +0 -0
  97. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_handlers_user_me.py +0 -0
  98. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_handlers_user_verify.py +0 -0
  99. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_lessons_attempt_details.py +0 -0
  100. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_lessons_attempts.py +0 -0
  101. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_lessons_complete.py +0 -0
  102. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_lessons_list.py +0 -0
  103. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_lessons_next.py +0 -0
  104. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_lessons_start.py +0 -0
  105. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_lessons_submit.py +0 -0
  106. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_user_get_profile.py +0 -0
  107. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/fixtures/test_user_verify.py +0 -0
  108. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_activity_caliper.py +0 -0
  109. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_activity_handler_schema.py +0 -0
  110. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_activity_progress.py +0 -0
  111. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_activity_record_schema.py +0 -0
  112. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_activity_record_submit_sends_one_or_two_events.py +0 -0
  113. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_canonical_activity_url.py +0 -0
  114. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_course_resolution.py +0 -0
  115. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_fastapi_adapter_response_annotation.py +0 -0
  116. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_handler_pct_complete_app.py +0 -0
  117. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_hooks.py +0 -0
  118. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_identity_handler.py +0 -0
  119. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_lesson_complete_result.py +0 -0
  120. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_lessons_list_course_ref.py +0 -0
  121. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_logger.py +0 -0
  122. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_namespace_test_utils.py +0 -0
  123. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_oidc.py +0 -0
  124. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_resolve.py +0 -0
  125. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_submit_handler_hook_integration.py +0 -0
  126. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_types.py +0 -0
  127. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_user_handler.py +0 -0
  128. {timeback_sdk-0.2.0 → timeback_sdk-0.2.1}/tests/native/test_utils.py +0 -0
@@ -1,3 +1,6 @@
1
+ # ai
2
+ .claude
3
+
1
4
  # Python
2
5
  __pycache__/
3
6
  *.py[cod]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: timeback-sdk
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Timeback SDK for Python - adapters for FastAPI, Django, and more
5
5
  Project-URL: Homepage, https://developer.timeback.com
6
6
  Project-URL: Documentation, https://docs.timeback.com
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "timeback-sdk"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "Timeback SDK for Python - adapters for FastAPI, Django, and more"
5
5
  readme = { file = "README.md", content-type = "text/markdown" }
6
6
  requires-python = ">=3.12"
@@ -106,53 +106,59 @@ def create_heartbeat_handler(
106
106
  api_env = map_env_for_api(env)
107
107
 
108
108
  async def handler(request: Request) -> Response:
109
- # ═══════════════════════════════════════════════════════════════════
110
- # Step 1: Authenticate
111
- # ═══════════════════════════════════════════════════════════════════
112
- user_info = await get_activity_user_info(identity, request)
113
- if not user_info:
114
- return error_response("UNAUTHORIZED", "Unauthorized", 401)
115
-
116
- # ═══════════════════════════════════════════════════════════════════
117
- # Step 2: Validate request
118
- # ═══════════════════════════════════════════════════════════════════
119
- body = await request.json()
120
- result = validate_heartbeat_request(body, app_config)
121
-
122
- if isinstance(result, ValidationError):
123
- return result.response
124
-
125
- payload = result.payload
126
- course_config = result.course
127
- sensor = result.sensor
128
-
129
- # ═══════════════════════════════════════════════════════════════════
130
- # Step 3: Check for duplicate (idempotency)
131
- # ═══════════════════════════════════════════════════════════════════
132
- window_start_ms = _parse_iso_to_ms(payload.started_at)
133
- window_key = f"{payload.run_id}:{window_start_ms}"
134
-
135
- if _is_duplicate(window_key):
136
- log.debug(
137
- "Duplicate heartbeat, skipping: runId=%s, windowKey=%s", payload.run_id, window_key
138
- )
139
- return JSONResponse({"duplicate": True})
140
-
141
- # ═══════════════════════════════════════════════════════════════════
142
- # Step 4: Verify course is synced
143
- # ═══════════════════════════════════════════════════════════════════
144
- synced_course_id = (course_config.get("ids") or {}).get(api_env)
145
- if not isinstance(synced_course_id, str) or not synced_course_id:
146
- err = MissingSyncedCourseIdError(course_config, api_env)
147
- log.warning("Course not synced: %s, env=%s", err.course, err.env)
148
- return error_response("MISSING_SYNCED_COURSE_ID", str(err), 503)
149
-
150
- # ═══════════════════════════════════════════════════════════════════
151
- # Step 5: Resolve user and send event
152
- # ═══════════════════════════════════════════════════════════════════
153
- client = get_client()
154
-
155
109
  try:
110
+ # ═══════════════════════════════════════════════════════════════
111
+ # Step 1: Authenticate
112
+ # ═══════════════════════════════════════════════════════════════
113
+ user_info = await get_activity_user_info(identity, request)
114
+ if not user_info:
115
+ return error_response("UNAUTHORIZED", "Unauthorized", 401)
116
+
117
+ # ═══════════════════════════════════════════════════════════════
118
+ # Step 2: Validate request
119
+ # ═══════════════════════════════════════════════════════════════
120
+ try:
121
+ body = await request.json()
122
+ except Exception:
123
+ return error_response("INVALID_PAYLOAD", "Malformed JSON in request body", 400)
124
+
125
+ result = validate_heartbeat_request(body, app_config)
126
+
127
+ if isinstance(result, ValidationError):
128
+ return result.response
129
+
130
+ payload = result.payload
131
+ course_config = result.course
132
+ sensor = result.sensor
133
+
134
+ # ═══════════════════════════════════════════════════════════════
135
+ # Step 3: Check for duplicate (idempotency)
136
+ # ═══════════════════════════════════════════════════════════════
137
+ window_start_ms = _parse_iso_to_ms(payload.started_at)
138
+ window_key = f"{payload.run_id}:{window_start_ms}"
139
+
140
+ if _is_duplicate(window_key):
141
+ log.debug(
142
+ "Duplicate heartbeat, skipping: runId=%s, windowKey=%s",
143
+ payload.run_id,
144
+ window_key,
145
+ )
146
+ return JSONResponse({"duplicate": True})
147
+
148
+ # ═══════════════════════════════════════════════════════════════
149
+ # Step 4: Verify course is synced
150
+ # ═══════════════════════════════════════════════════════════════
151
+ synced_course_id = (course_config.get("ids") or {}).get(api_env)
152
+ if not isinstance(synced_course_id, str) or not synced_course_id:
153
+ err = MissingSyncedCourseIdError(course_config, api_env)
154
+ log.warning("Course not synced: %s, env=%s", err.course, err.env)
155
+ return error_response("MISSING_SYNCED_COURSE_ID", str(err), 503)
156
+
157
+ # ═══════════════════════════════════════════════════════════════
158
+ # Step 5: Resolve user and send event
159
+ # ═══════════════════════════════════════════════════════════════
160
+ client = get_client()
161
+
156
162
  timeback_id = (
157
163
  user_info.timeback_id
158
164
  if user_info.timeback_id
@@ -117,28 +117,32 @@ def create_submit_handler(
117
117
  api_env = map_env_for_api(env)
118
118
 
119
119
  async def handler(request: Request) -> Response:
120
- # ═══════════════════════════════════════════════════════════════════
121
- # Step 1: Authenticate
122
- # ═══════════════════════════════════════════════════════════════════
123
- user_info = await get_activity_user_info(identity, request)
124
- if not user_info:
125
- return error_response("UNAUTHORIZED", "Unauthorized", 401)
126
-
127
- # ═══════════════════════════════════════════════════════════════════
128
- # Step 2: Validate request
129
- # ═══════════════════════════════════════════════════════════════════
130
- body = await request.json()
131
- result = validate_submit_request(body, app_config)
132
-
133
- if isinstance(result, ValidationError):
134
- return result.response
135
-
136
- payload = result.payload
137
-
138
- # ═══════════════════════════════════════════════════════════════════
139
- # Step 3: Delegate to record_completion
140
- # ═══════════════════════════════════════════════════════════════════
141
120
  try:
121
+ # ═══════════════════════════════════════════════════════════════
122
+ # Step 1: Authenticate
123
+ # ═══════════════════════════════════════════════════════════════
124
+ user_info = await get_activity_user_info(identity, request)
125
+ if not user_info:
126
+ return error_response("UNAUTHORIZED", "Unauthorized", 401)
127
+
128
+ # ═══════════════════════════════════════════════════════════════
129
+ # Step 2: Validate request
130
+ # ═══════════════════════════════════════════════════════════════
131
+ try:
132
+ body = await request.json()
133
+ except Exception:
134
+ return error_response("INVALID_PAYLOAD", "Malformed JSON in request body", 400)
135
+
136
+ result = validate_submit_request(body, app_config)
137
+
138
+ if isinstance(result, ValidationError):
139
+ return result.response
140
+
141
+ payload = result.payload
142
+
143
+ # ═══════════════════════════════════════════════════════════════
144
+ # Step 3: Delegate to record_completion
145
+ # ═══════════════════════════════════════════════════════════════
142
146
  completion_result = await record_completion(
143
147
  user_info=user_info,
144
148
  body=body,
@@ -193,23 +197,12 @@ def create_submit_handler(
193
197
  return error_response("INVALID_SENSOR_URL", str(e), 500)
194
198
 
195
199
  except CaliperValidationError as e:
196
- selector_desc = format_course_selector(payload.course)
197
- log.error(
198
- "Caliper validation error: course=%s, id=%s, error=%s",
199
- selector_desc,
200
- payload.id,
201
- str(e),
202
- )
200
+ log.error("Caliper validation error: %s", str(e))
203
201
  return error_response("INTERNAL_ERROR", str(e), 502, details=e.response)
204
202
 
205
203
  except Exception as e:
206
- selector_desc = format_course_selector(payload.course)
207
- log.error(
208
- "Failed to submit activity: course=%s, id=%s, error=%s",
209
- selector_desc,
210
- payload.id,
211
- str(e),
212
- )
213
- return error_response("INTERNAL_ERROR", str(e), 502)
204
+ message = str(e) if str(e) else "Unknown error"
205
+ log.error("Failed to submit activity: %s", message)
206
+ return error_response("INTERNAL_ERROR", message, 502)
214
207
 
215
208
  return handler
@@ -85,74 +85,90 @@ def create_identity_handlers(
85
85
  )
86
86
 
87
87
  async def sso_signin(request: Request) -> Response:
88
- # Build callback URL
89
- callback_url = sso_config.redirect_uri
90
- if not callback_url:
91
- base = str(request.base_url).rstrip("/")
92
- # Derive from current path: /api/timeback/identity/signin -> /api/timeback/identity/callback
93
- path = str(request.url.path)
94
- callback_path = path.replace("/signin", "/callback")
95
- callback_url = f"{base}{callback_path}"
96
-
97
- sso_log.debug(
98
- "SSO sign-in initiated, env=%s, issuer=%s, client_id=%s",
99
- env,
100
- issuer,
101
- sso_config.client_id,
102
- )
103
-
104
- # Build state - default to {} (state always decodes to object)
105
- state_data: dict | None = {}
106
- if sso_config.build_state:
107
- ctx = BuildStateContext(
108
- request=request,
109
- url=str(request.url),
88
+ try:
89
+ # Build callback URL
90
+ callback_url = sso_config.redirect_uri
91
+ if not callback_url:
92
+ base = str(request.base_url).rstrip("/")
93
+ path = str(request.url.path)
94
+ callback_path = path.replace("/signin", "/callback")
95
+ callback_url = f"{base}{callback_path}"
96
+
97
+ sso_log.debug(
98
+ "SSO sign-in initiated, env=%s, issuer=%s, client_id=%s",
99
+ env,
100
+ issuer,
101
+ sso_config.client_id,
110
102
  )
111
- state_data = sso_config.build_state(ctx)
112
-
113
- # Generate auth URL
114
- auth_url = await oidc.get_authorization_url(redirect_uri=callback_url, state=state_data)
115
- return RedirectResponse(url=auth_url, status_code=302)
116
-
117
- async def sso_callback(request: Request) -> Response:
118
- code = request.query_params.get("code")
119
- state_param = request.query_params.get("state")
120
- error = request.query_params.get("error")
121
103
 
122
- sso_log.debug("Received callback from IdP, has_code=%s, error=%s", bool(code), error)
123
-
124
- # Parse state early for error context
125
- state = oidc.decode_state(state_param) if state_param else None
104
+ # Build state - default to {} (state always decodes to object)
105
+ state_data: dict | None = {}
106
+ if sso_config.build_state:
107
+ ctx = BuildStateContext(
108
+ request=request,
109
+ url=str(request.url),
110
+ )
111
+ state_data = sso_config.build_state(ctx)
126
112
 
127
- if error:
128
- error_desc = request.query_params.get("error_description")
129
- sso_log.error("IdP returned error: %s, description=%s", error, error_desc)
113
+ # Generate auth URL
114
+ auth_url = await oidc.get_authorization_url(redirect_uri=callback_url, state=state_data)
115
+ return RedirectResponse(url=auth_url, status_code=302)
130
116
 
131
- error_ctx = CallbackErrorContext(
132
- error=Exception(error_desc or error),
133
- error_code=error,
134
- state=state,
135
- request=request,
136
- )
137
- if sso_config.on_callback_error:
138
- result = sso_config.on_callback_error(error_ctx)
139
- return await result if inspect.isawaitable(result) else result
140
- return JSONResponse({"error": error}, status_code=400)
117
+ except Exception as e:
118
+ sso_log.error("SSO sign-in failed: %s", str(e))
119
+ return JSONResponse({"error": "SSO sign-in failed"}, status_code=500)
141
120
 
142
- if not code:
143
- sso_log.error("Missing authorization code in callback")
144
- error_ctx = CallbackErrorContext(
145
- error=Exception("Missing authorization code"),
146
- error_code="missing_code",
147
- state=state,
148
- request=request,
149
- )
150
- if sso_config.on_callback_error:
151
- result = sso_config.on_callback_error(error_ctx)
152
- return await result if inspect.isawaitable(result) else result
153
- return JSONResponse({"error": "Missing authorization code"}, status_code=400)
121
+ async def sso_callback(request: Request) -> Response:
122
+ state: dict | None = None
154
123
 
155
124
  try:
125
+ code = request.query_params.get("code")
126
+ state_param = request.query_params.get("state")
127
+ error = request.query_params.get("error")
128
+
129
+ sso_log.debug("Received callback from IdP, has_code=%s, error=%s", bool(code), error)
130
+
131
+ # Parse state early for error context
132
+ try:
133
+ state = oidc.decode_state(state_param) if state_param else None
134
+ except Exception as decode_err:
135
+ sso_log.warning("Failed to decode state param: %s", str(decode_err))
136
+ state = None
137
+
138
+ if error:
139
+ error_desc = request.query_params.get("error_description")
140
+ sso_log.error("IdP returned error: %s, description=%s", error, error_desc)
141
+
142
+ error_ctx = CallbackErrorContext(
143
+ error=Exception(error_desc or error),
144
+ error_code=error,
145
+ state=state,
146
+ request=request,
147
+ )
148
+ if sso_config.on_callback_error:
149
+ try:
150
+ result = sso_config.on_callback_error(error_ctx)
151
+ return await result if inspect.isawaitable(result) else result
152
+ except Exception as cb_err:
153
+ sso_log.error("on_callback_error raised: %s", str(cb_err))
154
+ return JSONResponse({"error": error}, status_code=400)
155
+
156
+ if not code:
157
+ sso_log.error("Missing authorization code in callback")
158
+ error_ctx = CallbackErrorContext(
159
+ error=Exception("Missing authorization code"),
160
+ error_code="missing_code",
161
+ state=state,
162
+ request=request,
163
+ )
164
+ if sso_config.on_callback_error:
165
+ try:
166
+ result = sso_config.on_callback_error(error_ctx)
167
+ return await result if inspect.isawaitable(result) else result
168
+ except Exception as cb_err:
169
+ sso_log.error("on_callback_error raised: %s", str(cb_err))
170
+ return JSONResponse({"error": "Missing authorization code"}, status_code=400)
171
+
156
172
  # Build callback URL (same as signin)
157
173
  callback_url = sso_config.redirect_uri
158
174
  if not callback_url:
@@ -193,8 +209,12 @@ def create_identity_handlers(
193
209
  )
194
210
 
195
211
  if sso_config.on_callback_success:
196
- result = sso_config.on_callback_success(success_ctx)
197
- return await result if inspect.isawaitable(result) else result
212
+ try:
213
+ result = sso_config.on_callback_success(success_ctx)
214
+ return await result if inspect.isawaitable(result) else result
215
+ except Exception as cb_err:
216
+ sso_log.error("on_callback_success raised: %s", str(cb_err))
217
+ return JSONResponse({"error": "Callback handler failed"}, status_code=500)
198
218
 
199
219
  return RedirectResponse(url="/", status_code=302)
200
220
 
@@ -207,13 +227,12 @@ def create_identity_handlers(
207
227
  request=request,
208
228
  )
209
229
  if sso_config.on_callback_error:
210
- result = sso_config.on_callback_error(error_ctx)
211
- return await result if inspect.isawaitable(result) else result
230
+ try:
231
+ result = sso_config.on_callback_error(error_ctx)
232
+ return await result if inspect.isawaitable(result) else result
233
+ except Exception as cb_err:
234
+ sso_log.error("on_callback_error raised: %s", str(cb_err))
212
235
 
213
- # Map resolution errors consistently with other handlers:
214
- # - ambiguous -> 409
215
- # - not_found -> 404
216
- # - missing_email / lookup_failed -> 502 (upstream dependency / identity provider mismatch)
217
236
  if e.code == "timeback_user_ambiguous":
218
237
  return JSONResponse({"error": str(e)}, status_code=409)
219
238
  if e.code == "timeback_user_not_found":
@@ -229,8 +248,11 @@ def create_identity_handlers(
229
248
  request=request,
230
249
  )
231
250
  if sso_config.on_callback_error:
232
- result = sso_config.on_callback_error(error_ctx)
233
- return await result if inspect.isawaitable(result) else result
251
+ try:
252
+ result = sso_config.on_callback_error(error_ctx)
253
+ return await result if inspect.isawaitable(result) else result
254
+ except Exception as cb_err:
255
+ sso_log.error("on_callback_error raised: %s", str(cb_err))
234
256
  return JSONResponse({"error": str(e)}, status_code=500)
235
257
 
236
258
  def sso_signout() -> Response:
@@ -81,76 +81,94 @@ def create_identity_only_handlers(
81
81
  )
82
82
 
83
83
  async def identity_only_signin(request: Request) -> Response:
84
- # Build callback URL
85
- callback_url = identity.redirect_uri
86
- if not callback_url:
87
- base = str(request.base_url).rstrip("/")
88
- # Derive from current path: /api/timeback/identity/signin -> /api/timeback/identity/callback
89
- path = str(request.url.path)
90
- callback_path = path.replace("/signin", "/callback")
91
- callback_url = f"{base}{callback_path}"
92
-
93
- sso_log.debug(
94
- "SSO sign-in initiated (identity-only), env=%s, issuer=%s, client_id=%s",
95
- env,
96
- issuer,
97
- identity.client_id,
98
- )
99
-
100
- # Build state - default to {} (state always decodes to object)
101
- state_data: dict | None = {}
102
- if identity.build_state:
103
- ctx = BuildStateContext(
104
- request=request,
105
- url=str(request.url),
84
+ try:
85
+ # Build callback URL
86
+ callback_url = identity.redirect_uri
87
+ if not callback_url:
88
+ base = str(request.base_url).rstrip("/")
89
+ path = str(request.url.path)
90
+ callback_path = path.replace("/signin", "/callback")
91
+ callback_url = f"{base}{callback_path}"
92
+
93
+ sso_log.debug(
94
+ "SSO sign-in initiated (identity-only), env=%s, issuer=%s, client_id=%s",
95
+ env,
96
+ issuer,
97
+ identity.client_id,
106
98
  )
107
- state_data = identity.build_state(ctx)
108
-
109
- # Generate auth URL
110
- auth_url = await oidc.get_authorization_url(redirect_uri=callback_url, state=state_data)
111
- return RedirectResponse(url=auth_url, status_code=302)
112
99
 
113
- async def identity_only_callback(request: Request) -> Response:
114
- code = request.query_params.get("code")
115
- state_param = request.query_params.get("state")
116
- error = request.query_params.get("error")
100
+ # Build state - default to {} (state always decodes to object)
101
+ state_data: dict | None = {}
102
+ if identity.build_state:
103
+ ctx = BuildStateContext(
104
+ request=request,
105
+ url=str(request.url),
106
+ )
107
+ state_data = identity.build_state(ctx)
117
108
 
118
- sso_log.debug(
119
- "Received callback from IdP (identity-only), has_code=%s, error=%s", bool(code), error
120
- )
109
+ # Generate auth URL
110
+ auth_url = await oidc.get_authorization_url(redirect_uri=callback_url, state=state_data)
111
+ return RedirectResponse(url=auth_url, status_code=302)
121
112
 
122
- # Parse state early for error context
123
- state = oidc.decode_state(state_param) if state_param else None
113
+ except Exception as e:
114
+ sso_log.error("SSO sign-in failed (identity-only): %s", str(e))
115
+ return JSONResponse({"error": "SSO sign-in failed"}, status_code=500)
124
116
 
125
- if error:
126
- error_desc = request.query_params.get("error_description")
127
- sso_log.error("IdP returned error: %s, description=%s", error, error_desc)
117
+ async def identity_only_callback(request: Request) -> Response:
118
+ state: dict | None = None
128
119
 
129
- error_ctx = CallbackErrorContext(
130
- error=Exception(error_desc or error),
131
- error_code=error,
132
- state=state,
133
- request=request,
120
+ try:
121
+ code = request.query_params.get("code")
122
+ state_param = request.query_params.get("state")
123
+ error = request.query_params.get("error")
124
+
125
+ sso_log.debug(
126
+ "Received callback from IdP (identity-only), has_code=%s, error=%s",
127
+ bool(code),
128
+ error,
134
129
  )
135
- if identity.on_callback_error:
136
- result = identity.on_callback_error(error_ctx)
137
- return await result if inspect.isawaitable(result) else result
138
- return JSONResponse({"error": error}, status_code=400)
139
130
 
140
- if not code:
141
- sso_log.error("Missing authorization code in callback")
142
- error_ctx = CallbackErrorContext(
143
- error=Exception("Missing authorization code"),
144
- error_code="missing_code",
145
- state=state,
146
- request=request,
147
- )
148
- if identity.on_callback_error:
149
- result = identity.on_callback_error(error_ctx)
150
- return await result if inspect.isawaitable(result) else result
151
- return JSONResponse({"error": "Missing authorization code"}, status_code=400)
131
+ # Parse state early for error context
132
+ try:
133
+ state = oidc.decode_state(state_param) if state_param else None
134
+ except Exception as decode_err:
135
+ sso_log.warning("Failed to decode state param: %s", str(decode_err))
136
+ state = None
137
+
138
+ if error:
139
+ error_desc = request.query_params.get("error_description")
140
+ sso_log.error("IdP returned error: %s, description=%s", error, error_desc)
141
+
142
+ error_ctx = CallbackErrorContext(
143
+ error=Exception(error_desc or error),
144
+ error_code=error,
145
+ state=state,
146
+ request=request,
147
+ )
148
+ if identity.on_callback_error:
149
+ try:
150
+ result = identity.on_callback_error(error_ctx)
151
+ return await result if inspect.isawaitable(result) else result
152
+ except Exception as cb_err:
153
+ sso_log.error("on_callback_error raised: %s", str(cb_err))
154
+ return JSONResponse({"error": error}, status_code=400)
155
+
156
+ if not code:
157
+ sso_log.error("Missing authorization code in callback")
158
+ error_ctx = CallbackErrorContext(
159
+ error=Exception("Missing authorization code"),
160
+ error_code="missing_code",
161
+ state=state,
162
+ request=request,
163
+ )
164
+ if identity.on_callback_error:
165
+ try:
166
+ result = identity.on_callback_error(error_ctx)
167
+ return await result if inspect.isawaitable(result) else result
168
+ except Exception as cb_err:
169
+ sso_log.error("on_callback_error raised: %s", str(cb_err))
170
+ return JSONResponse({"error": "Missing authorization code"}, status_code=400)
152
171
 
153
- try:
154
172
  # Build callback URL (same as signin)
155
173
  callback_url = identity.redirect_uri
156
174
  if not callback_url:
@@ -175,8 +193,12 @@ def create_identity_only_handlers(
175
193
  )
176
194
 
177
195
  if identity.on_callback_success:
178
- result = identity.on_callback_success(success_ctx)
179
- return await result if inspect.isawaitable(result) else result
196
+ try:
197
+ result = identity.on_callback_success(success_ctx)
198
+ return await result if inspect.isawaitable(result) else result
199
+ except Exception as cb_err:
200
+ sso_log.error("on_callback_success raised: %s", str(cb_err))
201
+ return JSONResponse({"error": "Callback handler failed"}, status_code=500)
180
202
 
181
203
  return RedirectResponse(url="/", status_code=302)
182
204
 
@@ -189,8 +211,11 @@ def create_identity_only_handlers(
189
211
  request=request,
190
212
  )
191
213
  if identity.on_callback_error:
192
- result = identity.on_callback_error(error_ctx)
193
- return await result if inspect.isawaitable(result) else result
214
+ try:
215
+ result = identity.on_callback_error(error_ctx)
216
+ return await result if inspect.isawaitable(result) else result
217
+ except Exception as cb_err:
218
+ sso_log.error("on_callback_error raised: %s", str(cb_err))
194
219
  return JSONResponse({"error": str(e)}, status_code=500)
195
220
 
196
221
  def identity_only_signout() -> Response:
@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING
7
7
  from ...lib.logger import create_scoped_logger
8
8
  from ...lib.resolve import TimebackUserResolutionError, resolve_status_for_user_resolution_error
9
9
  from ...lib.utils import error_response
10
+ from ...namespaces.lessons.next import LessonTypeMismatchError
10
11
 
11
12
  if TYPE_CHECKING:
12
13
  from starlette.responses import Response
@@ -31,6 +32,9 @@ def to_lesson_error_response(error: BaseException, message: str) -> Response:
31
32
  resolve_status_for_user_resolution_error(error),
32
33
  )
33
34
 
35
+ if isinstance(error, LessonTypeMismatchError):
36
+ return error_response("LESSON_TYPE_MISMATCH", str(error), 400)
37
+
34
38
  detail = str(error) if isinstance(error, Exception) else "Unknown error"
35
39
  log.error("%s: %s", message, detail)
36
40
  return error_response("INTERNAL_ERROR", detail, 500)