codexLB-up 1.25.0b10__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 (827) hide show
  1. app/__init__.py +10 -0
  2. app/admin_cli.py +494 -0
  3. app/cli.py +339 -0
  4. app/codex_sessions_retag.py +487 -0
  5. app/core/__init__.py +0 -0
  6. app/core/audit/__init__.py +0 -0
  7. app/core/audit/service.py +216 -0
  8. app/core/audit/types.py +94 -0
  9. app/core/auth/__init__.py +236 -0
  10. app/core/auth/api_key_cache.py +65 -0
  11. app/core/auth/dashboard_access.py +429 -0
  12. app/core/auth/dashboard_mode.py +171 -0
  13. app/core/auth/dashboard_session_ttl.py +74 -0
  14. app/core/auth/dashboard_users_cache.py +120 -0
  15. app/core/auth/dependencies.py +710 -0
  16. app/core/auth/external_identity.py +78 -0
  17. app/core/auth/guardian.py +374 -0
  18. app/core/auth/models.py +49 -0
  19. app/core/auth/providers/__init__.py +129 -0
  20. app/core/auth/providers/oidc.py +838 -0
  21. app/core/auth/providers/registry.py +118 -0
  22. app/core/auth/refresh.py +394 -0
  23. app/core/auth/step_up.py +117 -0
  24. app/core/auth/totp.py +81 -0
  25. app/core/balancer/__init__.py +71 -0
  26. app/core/balancer/logic.py +1612 -0
  27. app/core/balancer/rendezvous_hash.py +21 -0
  28. app/core/balancer/types.py +21 -0
  29. app/core/bootstrap.py +172 -0
  30. app/core/cache/invalidation.py +426 -0
  31. app/core/clients/__init__.py +0 -0
  32. app/core/clients/codex.py +996 -0
  33. app/core/clients/codex_version.py +187 -0
  34. app/core/clients/codex_version_snapshot.py +2 -0
  35. app/core/clients/files.py +534 -0
  36. app/core/clients/headers.py +29 -0
  37. app/core/clients/http.py +511 -0
  38. app/core/clients/model_fetcher.py +276 -0
  39. app/core/clients/native_egress.py +1304 -0
  40. app/core/clients/oauth.py +477 -0
  41. app/core/clients/proxy.py +6089 -0
  42. app/core/clients/proxy_websocket.py +1411 -0
  43. app/core/clients/rate_limit_reset_credits.py +412 -0
  44. app/core/clients/stream_errors.py +12 -0
  45. app/core/clients/thread_cache_identity.py +347 -0
  46. app/core/clients/usage.py +467 -0
  47. app/core/clock.py +170 -0
  48. app/core/config/__init__.py +0 -0
  49. app/core/config/background_jobs.py +73 -0
  50. app/core/config/context_window_overrides.py +166 -0
  51. app/core/config/dashboard_overrides.py +145 -0
  52. app/core/config/inheritable.py +47 -0
  53. app/core/config/key_fingerprint.py +115 -0
  54. app/core/config/settings.py +872 -0
  55. app/core/config/settings_cache.py +100 -0
  56. app/core/config/spool_retention.py +217 -0
  57. app/core/config/tiers.py +174 -0
  58. app/core/conversation_archive.py +568 -0
  59. app/core/crypto.py +37 -0
  60. app/core/draining.py +11 -0
  61. app/core/errors.py +365 -0
  62. app/core/exceptions.py +155 -0
  63. app/core/handlers/__init__.py +3 -0
  64. app/core/handlers/exceptions.py +478 -0
  65. app/core/http_protocol.py +176 -0
  66. app/core/http_protocol_httptools.py +204 -0
  67. app/core/ingress_limits.py +17 -0
  68. app/core/metadata_store.py +18 -0
  69. app/core/metrics/__init__.py +0 -0
  70. app/core/metrics/middleware.py +74 -0
  71. app/core/metrics/prometheus.py +604 -0
  72. app/core/middleware/__init__.py +25 -0
  73. app/core/middleware/api_firewall.py +111 -0
  74. app/core/middleware/app_version.py +37 -0
  75. app/core/middleware/dashboard_auth_proxy.py +55 -0
  76. app/core/middleware/dashboard_csrf.py +177 -0
  77. app/core/middleware/dashboard_gzip.py +41 -0
  78. app/core/middleware/dashboard_overrides.py +59 -0
  79. app/core/middleware/firewall_cache.py +91 -0
  80. app/core/middleware/inflight.py +126 -0
  81. app/core/middleware/multipart_content_encoding.py +129 -0
  82. app/core/middleware/path_rewrite.py +148 -0
  83. app/core/middleware/request_body_limit.py +196 -0
  84. app/core/middleware/request_decompression.py +209 -0
  85. app/core/middleware/request_id.py +72 -0
  86. app/core/middleware/required_capability_http.py +116 -0
  87. app/core/middleware/trusted_proxy_headers.py +36 -0
  88. app/core/multipart.py +237 -0
  89. app/core/multipart_fields.py +91 -0
  90. app/core/openai/__init__.py +0 -0
  91. app/core/openai/chat_requests.py +493 -0
  92. app/core/openai/chat_responses.py +931 -0
  93. app/core/openai/contracts.py +46 -0
  94. app/core/openai/exceptions.py +24 -0
  95. app/core/openai/host_models.py +14 -0
  96. app/core/openai/images.py +413 -0
  97. app/core/openai/message_coercion.py +376 -0
  98. app/core/openai/model_refresh_scheduler.py +423 -0
  99. app/core/openai/model_registry.py +997 -0
  100. app/core/openai/model_registry_store.py +548 -0
  101. app/core/openai/models.py +172 -0
  102. app/core/openai/parsing.py +97 -0
  103. app/core/openai/requests.py +1980 -0
  104. app/core/openai/strict_schema.py +199 -0
  105. app/core/openai/tool_call_safety.py +99 -0
  106. app/core/openai/v1_requests.py +187 -0
  107. app/core/plan_types.py +90 -0
  108. app/core/prestop.py +202 -0
  109. app/core/rate_limiter/__init__.py +0 -0
  110. app/core/rate_limiter/db_rate_limiter.py +215 -0
  111. app/core/request_locality.py +473 -0
  112. app/core/resilience/__init__.py +0 -0
  113. app/core/resilience/backpressure.py +57 -0
  114. app/core/resilience/bulkhead.py +202 -0
  115. app/core/resilience/circuit_breaker.py +150 -0
  116. app/core/resilience/degradation.py +55 -0
  117. app/core/resilience/loop_lag_monitor.py +60 -0
  118. app/core/resilience/memory_monitor.py +137 -0
  119. app/core/resilience/network_recovery.py +256 -0
  120. app/core/resilience/overload.py +98 -0
  121. app/core/resilience/toggles.py +114 -0
  122. app/core/retention/__init__.py +0 -0
  123. app/core/retention/job.py +240 -0
  124. app/core/retention/scheduler.py +69 -0
  125. app/core/runtime_logging.py +601 -0
  126. app/core/scheduling/__init__.py +0 -0
  127. app/core/scheduling/leader_election.py +993 -0
  128. app/core/scheduling/leader_election_handle.py +25 -0
  129. app/core/server.py +140 -0
  130. app/core/shutdown.py +326 -0
  131. app/core/socket_peer.py +22 -0
  132. app/core/startup.py +41 -0
  133. app/core/timeout_invariants.py +347 -0
  134. app/core/tracing/__init__.py +0 -0
  135. app/core/tracing/otel.py +207 -0
  136. app/core/types.py +6 -0
  137. app/core/upstream_proxy/__init__.py +13 -0
  138. app/core/upstream_proxy/cache.py +130 -0
  139. app/core/upstream_proxy/resolver.py +189 -0
  140. app/core/upstream_proxy/types.py +104 -0
  141. app/core/usage/__init__.py +407 -0
  142. app/core/usage/depletion.py +109 -0
  143. app/core/usage/live_hub.py +41 -0
  144. app/core/usage/live_snapshots.py +174 -0
  145. app/core/usage/logs.py +212 -0
  146. app/core/usage/metadata_scheduler.py +118 -0
  147. app/core/usage/models.py +54 -0
  148. app/core/usage/pricing.py +576 -0
  149. app/core/usage/pricing_catalog.py +241 -0
  150. app/core/usage/pricing_snapshot.json +821 -0
  151. app/core/usage/quota.py +133 -0
  152. app/core/usage/refresh_policy.py +21 -0
  153. app/core/usage/refresh_scheduler.py +713 -0
  154. app/core/usage/reset_credits_refresh_scheduler.py +435 -0
  155. app/core/usage/types.py +168 -0
  156. app/core/utils/__init__.py +0 -0
  157. app/core/utils/json_guards.py +18 -0
  158. app/core/utils/locks.py +33 -0
  159. app/core/utils/masking.py +20 -0
  160. app/core/utils/proxy_env.py +104 -0
  161. app/core/utils/request_id.py +65 -0
  162. app/core/utils/retry.py +77 -0
  163. app/core/utils/shared_future.py +172 -0
  164. app/core/utils/sse.py +286 -0
  165. app/core/utils/time.py +24 -0
  166. app/db/__init__.py +0 -0
  167. app/db/account_identity_lock.py +56 -0
  168. app/db/alembic/__init__.py +1 -0
  169. app/db/alembic/env.py +70 -0
  170. app/db/alembic/revision_ids.py +43 -0
  171. app/db/alembic/versions/20260213_000000_base_schema.py +248 -0
  172. app/db/alembic/versions/20260213_000100_normalize_account_plan_types.py +45 -0
  173. app/db/alembic/versions/20260213_000200_add_request_logs_reasoning_effort.py +45 -0
  174. app/db/alembic/versions/20260213_000300_add_accounts_reset_at.py +45 -0
  175. app/db/alembic/versions/20260213_000400_add_accounts_chatgpt_account_id.py +45 -0
  176. app/db/alembic/versions/20260213_000500_add_dashboard_settings.py +70 -0
  177. app/db/alembic/versions/20260213_000600_add_dashboard_settings_totp.py +64 -0
  178. app/db/alembic/versions/20260213_000700_add_dashboard_settings_password.py +59 -0
  179. app/db/alembic/versions/20260213_000800_add_api_keys.py +87 -0
  180. app/db/alembic/versions/20260214_000000_add_api_key_limits.py +143 -0
  181. app/db/alembic/versions/20260215_000000_add_idx_logs_requested_at.py +38 -0
  182. app/db/alembic/versions/20260218_000000_add_api_key_usage_reservations.py +117 -0
  183. app/db/alembic/versions/20260218_000100_add_import_without_overwrite_and_drop_accounts_email_unique.py +198 -0
  184. app/db/alembic/versions/20260225_000000_add_dashboard_settings_routing_strategy.py +53 -0
  185. app/db/alembic/versions/20260228_020000_align_api_key_limit_enum_types.py +104 -0
  186. app/db/alembic/versions/20260228_030000_add_api_firewall_allowlist.py +42 -0
  187. app/db/alembic/versions/20260306_000000_add_request_logs_service_tier.py +45 -0
  188. app/db/alembic/versions/20260307_000000_add_api_key_enforcement_fields.py +56 -0
  189. app/db/alembic/versions/20260308_000000_add_sqlite_performance_indexes.py +41 -0
  190. app/db/alembic/versions/20260309_000000_add_additional_usage_history.py +71 -0
  191. app/db/alembic/versions/20260309_000000_request_logs_nullable_account_id.py +28 -0
  192. app/db/alembic/versions/20260310_000000_add_request_logs_transport.py +45 -0
  193. app/db/alembic/versions/20260310_000000_fix_postgresql_enum_value_casing.py +79 -0
  194. app/db/alembic/versions/20260310_120000_add_sticky_session_kinds_and_affinity_ttl.py +110 -0
  195. app/db/alembic/versions/20260312_000000_add_additional_usage_quota_key.py +193 -0
  196. app/db/alembic/versions/20260312_000000_split_sticky_sessions_primary_key_by_kind.py +96 -0
  197. app/db/alembic/versions/20260312_010000_merge_additional_usage_and_sticky_session_heads.py +25 -0
  198. app/db/alembic/versions/20260312_020000_merge_request_logs_transport_and_feature_heads.py +25 -0
  199. app/db/alembic/versions/20260312_020000_merge_request_logs_transport_and_sticky_heads.py +25 -0
  200. app/db/alembic/versions/20260312_120000_add_dashboard_upstream_stream_transport.py +62 -0
  201. app/db/alembic/versions/20260313_024500_merge_request_log_transport_heads.py +27 -0
  202. app/db/alembic/versions/20260319_100937_increase_prompt_cache_ttl_to_1800s.py +73 -0
  203. app/db/alembic/versions/20260319_111500_merge_prompt_cache_ttl_and_upstream_transport_heads.py +25 -0
  204. app/db/alembic/versions/20260319_183000_normalize_sqlite_account_status_casing.py +48 -0
  205. app/db/alembic/versions/20260320_000000_add_request_log_requested_actual_tiers.py +51 -0
  206. app/db/alembic/versions/20260321_190000_tighten_dashboard_db_indexes.py +64 -0
  207. app/db/alembic/versions/20260321_210000_merge_request_log_tiers_and_dashboard_index_heads.py +25 -0
  208. app/db/alembic/versions/20260325_000000_add_request_log_cost.py +193 -0
  209. app/db/alembic/versions/20260328_000000_add_rate_limit_attempts.py +48 -0
  210. app/db/alembic/versions/20260328_120000_add_request_log_ttft.py +37 -0
  211. app/db/alembic/versions/20260328_130000_add_audit_logs_table.py +45 -0
  212. app/db/alembic/versions/20260328_140000_add_scheduler_leader_table.py +39 -0
  213. app/db/alembic/versions/20260330_000000_add_cache_locality_settings.py +69 -0
  214. app/db/alembic/versions/20260330_010000_merge_scheduler_leader_and_cache_locality_heads.py +25 -0
  215. app/db/alembic/versions/20260330_020000_add_bridge_ring_members.py +46 -0
  216. app/db/alembic/versions/20260401_000000_add_api_key_enforced_service_tier.py +52 -0
  217. app/db/alembic/versions/20260401_000000_add_cache_invalidation.py +41 -0
  218. app/db/alembic/versions/20260401_020000_merge_cache_invalidation_and_api_key_service_tier_heads.py +22 -0
  219. app/db/alembic/versions/20260402_000000_switch_dashboard_routing_default_to_capacity_weighted.py +66 -0
  220. app/db/alembic/versions/20260403_000000_add_credit_api_key_limit_values.py +44 -0
  221. app/db/alembic/versions/20260406_000000_add_api_key_account_assignments.py +57 -0
  222. app/db/alembic/versions/20260406_010000_add_api_key_assignment_scope_flag.py +75 -0
  223. app/db/alembic/versions/20260407_000000_add_bridge_gateway_safe_mode.py +51 -0
  224. app/db/alembic/versions/20260407_010000_merge_api_key_assignment_and_bridge_gateway_heads.py +25 -0
  225. app/db/alembic/versions/20260408_000000_switch_import_without_overwrite_default_to_true.py +73 -0
  226. app/db/alembic/versions/20260408_010000_merge_import_without_overwrite_and_assignment_heads.py +25 -0
  227. app/db/alembic/versions/20260409_000000_add_http_bridge_sessions.py +130 -0
  228. app/db/alembic/versions/20260409_000000_switch_sticky_threads_and_prefer_earlier_reset_defaults_to_true.py +89 -0
  229. app/db/alembic/versions/20260409_010000_add_dashboard_settings_bootstrap_token.py +57 -0
  230. app/db/alembic/versions/20260409_010000_merge_http_bridge_and_import_without_overwrite_heads.py +25 -0
  231. app/db/alembic/versions/20260409_020000_fix_http_bridge_last_seen_index.py +46 -0
  232. app/db/alembic/versions/20260410_000000_backfill_pristine_dashboard_settings_defaults.py +77 -0
  233. app/db/alembic/versions/20260410_030000_restore_import_without_overwrite_default_true.py +73 -0
  234. app/db/alembic/versions/20260410_040000_merge_dashboard_defaults_and_import_default_heads.py +25 -0
  235. app/db/alembic/versions/20260413_000000_add_accounts_blocked_at.py +44 -0
  236. app/db/alembic/versions/20260415_160000_add_automations_tables.py +189 -0
  237. app/db/alembic/versions/20260415_160000_add_request_logs_response_lookup_index.py +67 -0
  238. app/db/alembic/versions/20260417_000000_add_request_log_plan_type.py +44 -0
  239. app/db/alembic/versions/20260418_190000_add_automation_runs_cycle_key.py +87 -0
  240. app/db/alembic/versions/20260419_000000_add_automation_run_cycle_metadata.py +56 -0
  241. app/db/alembic/versions/20260419_020000_add_automation_run_cycles_snapshot_tables.py +342 -0
  242. app/db/alembic/versions/20260421_000000_add_dashboard_session_ttl_setting.py +51 -0
  243. app/db/alembic/versions/20260421_120000_merge_request_log_lookup_and_plan_type_heads.py +25 -0
  244. app/db/alembic/versions/20260421_130000_merge_automation_and_request_log_heads.py +25 -0
  245. app/db/alembic/versions/20260422_103000_normalize_legacy_automation_run_cycle_keys.py +648 -0
  246. app/db/alembic/versions/20260423_120000_add_api_key_limit_reset_at_index.py +43 -0
  247. app/db/alembic/versions/20260424_000000_merge_dashboard_session_ttl_and_request_log_heads.py +25 -0
  248. app/db/alembic/versions/20260426_000000_add_dashboard_relative_availability_settings.py +68 -0
  249. app/db/alembic/versions/20260427_130000_add_warmup_model_and_request_kind.py +75 -0
  250. app/db/alembic/versions/20260506_000000_add_account_routing_policy.py +37 -0
  251. app/db/alembic/versions/20260509_000000_add_api_key_traffic_class.py +37 -0
  252. app/db/alembic/versions/20260509_010000_add_additional_quota_routing_policies.py +37 -0
  253. app/db/alembic/versions/20260513_000000_add_accounts_alias.py +44 -0
  254. app/db/alembic/versions/20260513_000000_add_api_key_apply_to_codex_model.py +59 -0
  255. app/db/alembic/versions/20260514_000000_add_request_logs_api_key_time_index.py +43 -0
  256. app/db/alembic/versions/20260515_000000_add_prefer_earlier_reset_window.py +47 -0
  257. app/db/alembic/versions/20260515_000000_soft_delete_request_logs_on_account_delete.py +83 -0
  258. app/db/alembic/versions/20260515_020000_add_split_sticky_budget_thresholds.py +62 -0
  259. app/db/alembic/versions/20260516_000000_add_sqlite_hot_path_indexes.py +75 -0
  260. app/db/alembic/versions/20260517_000000_merge_sqlite_hot_path_and_request_log_delete_heads.py +24 -0
  261. app/db/alembic/versions/20260518_000000_add_http_bridge_durable_input_prefix.py +44 -0
  262. app/db/alembic/versions/20260518_010000_merge_http_bridge_and_sqlite_recovery_heads.py +25 -0
  263. app/db/alembic/versions/20260518_120000_add_dashboard_guest_access.py +57 -0
  264. app/db/alembic/versions/20260520_000000_merge_api_key_and_http_bridge_heads.py +26 -0
  265. app/db/alembic/versions/20260520_010000_add_request_logs_api_key_account_index.py +45 -0
  266. app/db/alembic/versions/20260520_010000_merge_automation_and_api_key_heads.py +26 -0
  267. app/db/alembic/versions/20260520_030000_add_quota_planner.py +194 -0
  268. app/db/alembic/versions/20260520_040000_merge_quota_planner_and_request_log_index_heads.py +24 -0
  269. app/db/alembic/versions/20260521_000000_add_account_security_work_authorized.py +46 -0
  270. app/db/alembic/versions/20260521_000000_merge_dashboard_guest_and_api_key_heads.py +26 -0
  271. app/db/alembic/versions/20260521_000000_merge_prefer_reset_window_head.py +26 -0
  272. app/db/alembic/versions/20260521_000000_merge_warmup_and_request_log_index_heads.py +25 -0
  273. app/db/alembic/versions/20260522_000000_add_limit_warmup_trigger.py +195 -0
  274. app/db/alembic/versions/20260524_000000_merge_accounts_alias_and_reset_window_heads.py +24 -0
  275. app/db/alembic/versions/20260525_000000_add_usage_raw_window_latest_index.py +31 -0
  276. app/db/alembic/versions/20260526_000000_add_request_log_failure_metadata.py +100 -0
  277. app/db/alembic/versions/20260531_000000_add_account_workspace_identity.py +39 -0
  278. app/db/alembic/versions/20260531_000000_add_single_account_routing.py +42 -0
  279. app/db/alembic/versions/20260601_000000_merge_relative_availability_and_usage_raw_heads.py +24 -0
  280. app/db/alembic/versions/20260601_010000_add_api_key_usage_sections.py +58 -0
  281. app/db/alembic/versions/20260601_010000_add_routing_policy_persistence.py +67 -0
  282. app/db/alembic/versions/20260601_010000_merge_security_work_and_relative_availability_heads.py +25 -0
  283. app/db/alembic/versions/20260601_010000_merge_split_sticky_and_relative_usage_heads.py +25 -0
  284. app/db/alembic/versions/20260601_010000_merge_warmup_and_relative_availability_heads.py +25 -0
  285. app/db/alembic/versions/20260601_020000_merge_additional_quota_routing_and_relative_availability_heads.py +25 -0
  286. app/db/alembic/versions/20260601_020000_merge_quota_planner_and_relative_availability_heads.py +25 -0
  287. app/db/alembic/versions/20260601_020000_merge_reset_window_and_relative_availability_heads.py +25 -0
  288. app/db/alembic/versions/20260601_020000_merge_routing_policy_and_split_threshold_heads.py +24 -0
  289. app/db/alembic/versions/20260601_020000_merge_warmup_and_request_log_failure_heads.py +25 -0
  290. app/db/alembic/versions/20260601_030000_merge_single_account_and_routing_quota_heads.py +29 -0
  291. app/db/alembic/versions/20260601_040000_merge_security_work_and_routing_consolidation_heads.py +25 -0
  292. app/db/alembic/versions/20260601_050000_merge_quota_planner_and_routing_consolidation_heads.py +25 -0
  293. app/db/alembic/versions/20260602_050000_add_upstream_proxy_routing.py +164 -0
  294. app/db/alembic/versions/20260602_060000_merge_account_workspace_and_failure_heads.py +25 -0
  295. app/db/alembic/versions/20260602_070000_add_request_log_useragent_fields.py +86 -0
  296. app/db/alembic/versions/20260602_070000_merge_quota_planner_and_account_workspace_heads.py +25 -0
  297. app/db/alembic/versions/20260602_080000_merge_upstream_proxy_and_quota_planner_heads.py +25 -0
  298. app/db/alembic/versions/20260602_080000_merge_useragent_and_upstream_proxy_heads.py +24 -0
  299. app/db/alembic/versions/20260603_000000_add_weekly_pace_working_days.py +52 -0
  300. app/db/alembic/versions/20260603_000000_free_account_monthly_window.py +60 -0
  301. app/db/alembic/versions/20260604_000000_add_reauth_required_account_status.py +159 -0
  302. app/db/alembic/versions/20260604_010000_merge_automations_and_reauth_status_heads.py +25 -0
  303. app/db/alembic/versions/20260604_010000_merge_dashboard_guest_and_reauth_status_heads.py +25 -0
  304. app/db/alembic/versions/20260604_010000_merge_useragent_and_reauth_heads.py +24 -0
  305. app/db/alembic/versions/20260607_000000_merge_weekly_monthly_useragent_heads.py +28 -0
  306. app/db/alembic/versions/20260608_000000_add_hide_upstream_quota_from_api_keys.py +50 -0
  307. app/db/alembic/versions/20260611_000000_add_request_log_archive_request_id.py +47 -0
  308. app/db/alembic/versions/20260611_000000_merge_automations_and_weekly_monthly_useragent_heads.py +26 -0
  309. app/db/alembic/versions/20260611_000000_merge_dashboard_guest_and_weekly_useragent_heads.py +26 -0
  310. app/db/alembic/versions/20260611_010000_add_request_log_client_ip.py +57 -0
  311. app/db/alembic/versions/20260611_020000_merge_request_log_archive_and_client_ip_heads.py +26 -0
  312. app/db/alembic/versions/20260611_030000_merge_dashboard_guest_and_request_log_client_ip_heads.py +26 -0
  313. app/db/alembic/versions/20260613_000000_add_accounts_codex_installation_id.py +57 -0
  314. app/db/alembic/versions/20260613_010000_merge_installation_id_and_dashboard_weekly_heads.py +26 -0
  315. app/db/alembic/versions/20260615_000000_merge_automations_and_dashboard_guest_heads.py +26 -0
  316. app/db/alembic/versions/20260623_000000_add_limit_warmup_exhausted_threshold.py +51 -0
  317. app/db/alembic/versions/20260626_000000_add_http_downstream_transport_policy.py +46 -0
  318. app/db/alembic/versions/20260626_010000_add_request_logs_upstream_transport.py +31 -0
  319. app/db/alembic/versions/20260629_000000_add_dashboard_query_hot_path_indexes.py +53 -0
  320. app/db/alembic/versions/20260630_000000_add_limit_warmup_staggered_idle.py +47 -0
  321. app/db/alembic/versions/20260630_000000_merge_request_log_client_ip_and_dashboard_hot_path_heads.py +26 -0
  322. app/db/alembic/versions/20260630_010000_merge_warmup_and_request_log_dashboard_heads.py +26 -0
  323. app/db/alembic/versions/20260630_020000_merge_automations_and_main_heads.py +26 -0
  324. app/db/alembic/versions/20260630_020000_merge_warmup_threshold_and_main_heads.py +26 -0
  325. app/db/alembic/versions/20260630_030000_add_automation_run_model_snapshot.py +59 -0
  326. app/db/alembic/versions/20260630_040000_merge_automation_snapshot_and_warmup_threshold_heads.py +26 -0
  327. app/db/alembic/versions/20260630_050000_add_automation_run_prompt_snapshot.py +52 -0
  328. app/db/alembic/versions/20260701_000000_add_weekly_pace_smoothing_minutes.py +51 -0
  329. app/db/alembic/versions/20260702_000000_add_model_sources.py +176 -0
  330. app/db/alembic/versions/20260705_000000_add_model_source_audio_transcriptions.py +50 -0
  331. app/db/alembic/versions/20260705_000000_harden_dashboard_session_ttl.py +62 -0
  332. app/db/alembic/versions/20260706_000000_add_model_source_audio_per_minute.py +43 -0
  333. app/db/alembic/versions/20260707_000000_merge_model_sources_and_session_ttl.py +24 -0
  334. app/db/alembic/versions/20260707_010000_merge_automations_and_model_sources_heads.py +24 -0
  335. app/db/alembic/versions/20260707_020000_add_automation_job_account_scope.py +125 -0
  336. app/db/alembic/versions/20260709_000000_add_ttft_phase_observability.py +66 -0
  337. app/db/alembic/versions/20260711_000000_add_dashboard_prohibit_fast_mode.py +42 -0
  338. app/db/alembic/versions/20260711_010000_add_account_chatgpt_user_id.py +40 -0
  339. app/db/alembic/versions/20260711_020000_add_dashboard_account_concurrency_caps.py +71 -0
  340. app/db/alembic/versions/20260711_030000_add_limit_warmup_idle_threshold.py +51 -0
  341. app/db/alembic/versions/20260712_010000_add_account_usage_rollups.py +56 -0
  342. app/db/alembic/versions/20260712_020000_add_api_key_usage_rollups.py +53 -0
  343. app/db/alembic/versions/20260713_020000_add_model_registry_snapshot.py +42 -0
  344. app/db/alembic/versions/20260713_040000_add_account_refresh_claims.py +42 -0
  345. app/db/alembic/versions/20260713_040000_add_replica_guardrails.py +64 -0
  346. app/db/alembic/versions/20260713_070000_add_reset_credit_redeem_tables.py +58 -0
  347. app/db/alembic/versions/20260714_000000_add_oauth_flow_states.py +63 -0
  348. app/db/alembic/versions/20260715_000000_add_request_log_queue_latency.py +44 -0
  349. app/db/alembic/versions/20260716_000000_add_oauth_device_flow_slots.py +41 -0
  350. app/db/alembic/versions/20260716_010000_add_dashboard_retention_settings.py +48 -0
  351. app/db/alembic/versions/20260716_010000_add_reset_credit_display_settings.py +73 -0
  352. app/db/alembic/versions/20260717_000000_add_http_bridge_retry_circuits.py +47 -0
  353. app/db/alembic/versions/20260717_000000_merge_retention_and_reset_credit_display_heads.py +26 -0
  354. app/db/alembic/versions/20260717_000000_optimize_dashboard_hot_path_indexes.py +162 -0
  355. app/db/alembic/versions/20260717_000001_merge_retry_circuits_and_dashboard_indexes.py +22 -0
  356. app/db/alembic/versions/20260720_000000_add_request_log_conversation_id.py +57 -0
  357. app/db/alembic/versions/20260722_000000_backfill_request_log_useragent_families.py +40 -0
  358. app/db/alembic/versions/20260724_000000_add_request_usage_time_rollups.py +176 -0
  359. app/db/alembic/versions/20260724_000000_merge_request_log_schema_heads.py +33 -0
  360. app/db/alembic/versions/20260725_000000_add_http_bridge_pending_tool_calls.py +43 -0
  361. app/db/alembic/versions/20260726_000000_add_account_plan_downgrade_observations.py +44 -0
  362. app/db/alembic/versions/20260726_000000_repair_request_usage_rollups_after_merge.py +37 -0
  363. app/db/alembic/versions/20260727_000000_add_sticky_session_continuity_abandoned_at.py +45 -0
  364. app/db/alembic/versions/20260728_000000_merge_pending_tool_calls_and_rollup_repair_heads.py +31 -0
  365. app/db/alembic/versions/20260730_000000_add_api_key_fair_share_threshold.py +44 -0
  366. app/db/alembic/versions/20260730_000000_add_http_bridge_recovery_attempts.py +60 -0
  367. app/db/alembic/versions/20260731_000000_add_capability_lineage_markers.py +38 -0
  368. app/db/alembic/versions/20260803_000000_merge_http_bridge_recovery_and_capability_lineage_heads.py +30 -0
  369. app/db/alembic/versions/20260804_000000_add_http_bridge_operations.py +63 -0
  370. app/db/alembic/versions/20260804_000001_add_global_http_bridge_operation_fingerprint.py +53 -0
  371. app/db/alembic/versions/20260804_230000_add_request_log_connection_request_kind.py +43 -0
  372. app/db/alembic/versions/20260805_000000_add_http_bridge_operation_spool.py +75 -0
  373. app/db/alembic/versions/20260805_000001_finalize_http_bridge_operation_spool.py +65 -0
  374. app/db/alembic/versions/20260806_000000_add_additional_usage_alias_probe_indexes.py +84 -0
  375. app/db/alembic/versions/20260806_000000_add_anonymous_telemetry.py +49 -0
  376. app/db/alembic/versions/20260806_010000_add_conversation_presence_rollup.py +72 -0
  377. app/db/alembic/versions/20260806_020000_add_usage_history_bulk_covering_indexes.py +99 -0
  378. app/db/alembic/versions/20260806_030000_add_api_key_allowed_reasoning_efforts.py +65 -0
  379. app/db/alembic/versions/20260806_120000_add_http_bridge_owner_process_epoch.py +57 -0
  380. app/db/alembic/versions/20260807_000000_merge_http_bridge_operation_spool_and_latest_main.py +24 -0
  381. app/db/alembic/versions/20260808_000000_tune_usage_history_autovacuum.py +54 -0
  382. app/db/alembic/versions/20260810_000000_add_http_bridge_recovery_dispatch_count.py +44 -0
  383. app/db/alembic/versions/20260811_000000_add_hourly_rollup_cancelled_count.py +92 -0
  384. app/db/alembic/versions/20260812_000000_merge_recovery_dispatch_and_hourly_cancelled_heads.py +25 -0
  385. app/db/alembic/versions/20260812_120000_add_sticky_abandonment_scope.py +43 -0
  386. app/db/alembic/versions/20260813_000000_add_file_account_pins.py +40 -0
  387. app/db/alembic/versions/20260816_000000_add_account_pending_deletion.py +81 -0
  388. app/db/alembic/versions/20260816_000000_add_model_source_embeddings.py +50 -0
  389. app/db/alembic/versions/20260821_000000_add_retry_circuit_admission_generation.py +49 -0
  390. app/db/alembic/versions/20260826_000000_add_http_bridge_event_chunks.py +107 -0
  391. app/db/alembic/versions/20260828_000000_add_accounts_chatgpt_identity_index.py +55 -0
  392. app/db/alembic/versions/20260830_000000_add_quota_warmup_claim_expiry.py +83 -0
  393. app/db/alembic/versions/20260908_000000_add_guest_session_generation.py +37 -0
  394. app/db/alembic/versions/20260908_000000_add_subscription_overflow.py +140 -0
  395. app/db/alembic/versions/20260908_000000_replace_upstream_stream_transport_default_sentinel.py +50 -0
  396. app/db/alembic/versions/20260908_020000_merge_overflow_transport_heads.py +27 -0
  397. app/db/alembic/versions/20260909_000000_add_dashboard_roles.py +72 -0
  398. app/db/alembic/versions/20260909_010000_add_dashboard_users.py +183 -0
  399. app/db/alembic/versions/20260909_020000_reproject_compat_admin_credentials.py +105 -0
  400. app/db/alembic/versions/20260909_030000_add_audit_actor_columns.py +103 -0
  401. app/db/alembic/versions/20260909_030000_dashboard_resilience_toggle_settings.py +63 -0
  402. app/db/alembic/versions/20260909_040000_add_dashboard_user_invites.py +54 -0
  403. app/db/alembic/versions/20260909_040000_dashboard_timeout_settings.py +68 -0
  404. app/db/alembic/versions/20260909_050000_dashboard_routing_overload_settings.py +65 -0
  405. app/db/alembic/versions/20260909_060000_add_report_rollup.py +74 -0
  406. app/db/alembic/versions/20260909_070000_automation_run_claim_budget.py +50 -0
  407. app/db/alembic/versions/20260909_080000_dashboard_stream_bridge_budgets.py +62 -0
  408. app/db/alembic/versions/20260909_090000_dashboard_background_job_toggles.py +63 -0
  409. app/db/alembic/versions/20260909_100000_dashboard_codex_prewarm.py +49 -0
  410. app/db/alembic/versions/20260909_110000_model_context_window_overrides.py +45 -0
  411. app/db/alembic/versions/20260909_120000_dashboard_conversation_archive.py +48 -0
  412. app/db/alembic/versions/20260909_130000_add_request_logs_live_facet_indexes.py +96 -0
  413. app/db/alembic/versions/20260910_000000_add_totp_required_for_admin_role.py +44 -0
  414. app/db/alembic/versions/20260910_000000_request_logs_missing_cost_index.py +52 -0
  415. app/db/alembic/versions/20260910_010000_add_dashboard_auth_providers.py +134 -0
  416. app/db/alembic/versions/20260910_010000_dashboard_spool_retention.py +52 -0
  417. app/db/alembic/versions/20260910_020000_add_dashboard_role_mappings.py +74 -0
  418. app/db/alembic/versions/20260910_143000_request_log_affinity.py +28 -0
  419. app/db/alembic/versions/20260910_180000_merge_affinity_guest_heads.py +17 -0
  420. app/db/alembic/versions/20260910_200000_merge_affinity_identity_heads.py +17 -0
  421. app/db/alembic/versions/20260910_220000_merge_affinity_invite_heads.py +17 -0
  422. app/db/alembic/versions/20260911_000000_model_source_pins_kind_expires_index.py +43 -0
  423. app/db/alembic/versions/20260911_010000_merge_pin_index_and_affinity_heads.py +17 -0
  424. app/db/alembic/versions/20260911_020000_add_http_bridge_terminal_append_phase.py +54 -0
  425. app/db/alembic/versions/20260911_030000_add_local_login_policy.py +48 -0
  426. app/db/alembic/versions/20260911_040000_add_thread_cache_identity_mode.py +48 -0
  427. app/db/alembic/versions/20260911_060000_add_bridge_session_continuity_abandonment.py +40 -0
  428. app/db/alembic/versions/20260912_000000_merge_thread_cache_and_bridge_retirement_heads.py +22 -0
  429. app/db/alembic/versions/20260912_010000_drop_legacy_dashboard_credentials.py +140 -0
  430. app/db/alembic/versions/20260913_000000_add_oidc_provider_flow.py +134 -0
  431. app/db/alembic/versions/20260914_000000_add_scim_tokens.py +78 -0
  432. app/db/alembic/versions/20260914_000000_drop_subscription_overflow_schema.py +132 -0
  433. app/db/alembic/versions/20260918_000000_merge_scim_and_overflow_heads.py +17 -0
  434. app/db/alembic/versions/__init__.py +1 -0
  435. app/db/backup.py +64 -0
  436. app/db/migrate.py +1249 -0
  437. app/db/migration_lock.py +160 -0
  438. app/db/migration_url.py +18 -0
  439. app/db/models.py +3100 -0
  440. app/db/recover.py +244 -0
  441. app/db/session.py +1295 -0
  442. app/db/snapshot.py +46 -0
  443. app/db/sqlite_lock_retry.py +190 -0
  444. app/db/sqlite_utils.py +549 -0
  445. app/dependencies.py +462 -0
  446. app/main.py +1240 -0
  447. app/modules/__init__.py +0 -0
  448. app/modules/accounts/__init__.py +0 -0
  449. app/modules/accounts/api.py +426 -0
  450. app/modules/accounts/auth_manager.py +1439 -0
  451. app/modules/accounts/background_repository.py +160 -0
  452. app/modules/accounts/deletion.py +540 -0
  453. app/modules/accounts/mappers.py +654 -0
  454. app/modules/accounts/refresh_claims.py +352 -0
  455. app/modules/accounts/repository.py +1679 -0
  456. app/modules/accounts/schemas.py +308 -0
  457. app/modules/accounts/service.py +809 -0
  458. app/modules/accounts/usage_rollup.py +486 -0
  459. app/modules/accounts/usage_rollup_scheduler.py +75 -0
  460. app/modules/accounts/usage_time_rollup.py +1037 -0
  461. app/modules/accounts/usage_time_rollup_read.py +522 -0
  462. app/modules/api_keys/__init__.py +1 -0
  463. app/modules/api_keys/api.py +344 -0
  464. app/modules/api_keys/last_used_coalescer.py +216 -0
  465. app/modules/api_keys/limit_windows.py +41 -0
  466. app/modules/api_keys/repository.py +1200 -0
  467. app/modules/api_keys/reset_scheduler.py +85 -0
  468. app/modules/api_keys/schemas.py +135 -0
  469. app/modules/api_keys/service.py +2123 -0
  470. app/modules/audit/__init__.py +0 -0
  471. app/modules/audit/api.py +88 -0
  472. app/modules/audit/repository.py +74 -0
  473. app/modules/audit/schemas.py +33 -0
  474. app/modules/audit/service.py +71 -0
  475. app/modules/auth_providers/__init__.py +0 -0
  476. app/modules/auth_providers/api.py +127 -0
  477. app/modules/auth_providers/config.py +343 -0
  478. app/modules/auth_providers/repository.py +101 -0
  479. app/modules/auth_providers/schemas.py +88 -0
  480. app/modules/auth_providers/seed.py +105 -0
  481. app/modules/auth_providers/service.py +289 -0
  482. app/modules/automations/__init__.py +1 -0
  483. app/modules/automations/api.py +369 -0
  484. app/modules/automations/repository.py +1896 -0
  485. app/modules/automations/scheduler.py +107 -0
  486. app/modules/automations/schemas.py +158 -0
  487. app/modules/automations/service.py +2548 -0
  488. app/modules/cache_isolation_probe/__init__.py +0 -0
  489. app/modules/cache_isolation_probe/api.py +188 -0
  490. app/modules/cache_isolation_probe/prefix.py +144 -0
  491. app/modules/cache_isolation_probe/schemas.py +94 -0
  492. app/modules/cache_isolation_probe/sender.py +229 -0
  493. app/modules/cache_isolation_probe/service.py +525 -0
  494. app/modules/conversation_archive/__init__.py +1 -0
  495. app/modules/conversation_archive/api.py +101 -0
  496. app/modules/conversation_archive/schemas.py +28 -0
  497. app/modules/conversation_archive/service.py +165 -0
  498. app/modules/dashboard/__init__.py +1 -0
  499. app/modules/dashboard/api.py +106 -0
  500. app/modules/dashboard/builders.py +107 -0
  501. app/modules/dashboard/repository.py +304 -0
  502. app/modules/dashboard/schemas.py +148 -0
  503. app/modules/dashboard/service.py +534 -0
  504. app/modules/dashboard/timeframes.py +19 -0
  505. app/modules/dashboard/weekly_pace.py +617 -0
  506. app/modules/dashboard_auth/__init__.py +1 -0
  507. app/modules/dashboard_auth/api.py +1344 -0
  508. app/modules/dashboard_auth/oidc_api.py +753 -0
  509. app/modules/dashboard_auth/oidc_flows.py +330 -0
  510. app/modules/dashboard_auth/repository.py +452 -0
  511. app/modules/dashboard_auth/schemas.py +232 -0
  512. app/modules/dashboard_auth/service.py +1384 -0
  513. app/modules/dashboard_roles/__init__.py +0 -0
  514. app/modules/dashboard_roles/api.py +64 -0
  515. app/modules/dashboard_roles/repository.py +54 -0
  516. app/modules/dashboard_roles/schemas.py +29 -0
  517. app/modules/dashboard_roles/seed.py +80 -0
  518. app/modules/dashboard_roles/service.py +142 -0
  519. app/modules/dashboard_users/__init__.py +0 -0
  520. app/modules/dashboard_users/api.py +278 -0
  521. app/modules/dashboard_users/break_glass.py +169 -0
  522. app/modules/dashboard_users/credentials.py +18 -0
  523. app/modules/dashboard_users/identity_resolver.py +665 -0
  524. app/modules/dashboard_users/repository.py +725 -0
  525. app/modules/dashboard_users/schemas.py +117 -0
  526. app/modules/dashboard_users/service.py +1054 -0
  527. app/modules/firewall/__init__.py +1 -0
  528. app/modules/firewall/api.py +78 -0
  529. app/modules/firewall/repository.py +51 -0
  530. app/modules/firewall/schemas.py +28 -0
  531. app/modules/firewall/service.py +93 -0
  532. app/modules/fleet/__init__.py +1 -0
  533. app/modules/fleet/api.py +177 -0
  534. app/modules/fleet/mappers.py +56 -0
  535. app/modules/fleet/observability.py +372 -0
  536. app/modules/fleet/schemas.py +119 -0
  537. app/modules/health/__init__.py +0 -0
  538. app/modules/health/api.py +246 -0
  539. app/modules/health/schemas.py +27 -0
  540. app/modules/limit_warmup/__init__.py +1 -0
  541. app/modules/limit_warmup/repository.py +159 -0
  542. app/modules/limit_warmup/service.py +1001 -0
  543. app/modules/model_sources/__init__.py +1 -0
  544. app/modules/model_sources/api.py +99 -0
  545. app/modules/model_sources/catalog.py +307 -0
  546. app/modules/model_sources/forwarding.py +1435 -0
  547. app/modules/model_sources/projection.py +55 -0
  548. app/modules/model_sources/repository.py +201 -0
  549. app/modules/model_sources/schemas.py +79 -0
  550. app/modules/model_sources/selection.py +166 -0
  551. app/modules/model_sources/service.py +236 -0
  552. app/modules/oauth/__init__.py +0 -0
  553. app/modules/oauth/api.py +101 -0
  554. app/modules/oauth/repository.py +238 -0
  555. app/modules/oauth/schemas.py +49 -0
  556. app/modules/oauth/service.py +1196 -0
  557. app/modules/oauth/templates/oauth_success.html +122 -0
  558. app/modules/proxy/__init__.py +0 -0
  559. app/modules/proxy/_load_balancer/__init__.py +1 -0
  560. app/modules/proxy/_load_balancer/error_rate.py +116 -0
  561. app/modules/proxy/_load_balancer/latency_cohort.py +154 -0
  562. app/modules/proxy/_load_balancer/model_eligibility.py +250 -0
  563. app/modules/proxy/_load_balancer/opportunistic_admission.py +350 -0
  564. app/modules/proxy/_load_balancer/overload_backoff.py +394 -0
  565. app/modules/proxy/_load_balancer/sticky_selection.py +2417 -0
  566. app/modules/proxy/_load_balancer/throughput_cohort.py +233 -0
  567. app/modules/proxy/_load_balancer/ttft_cohort.py +214 -0
  568. app/modules/proxy/_load_balancer/tunables.py +97 -0
  569. app/modules/proxy/_load_balancer/types.py +96 -0
  570. app/modules/proxy/_load_balancer/unbound_selection.py +534 -0
  571. app/modules/proxy/_service/__init__.py +6 -0
  572. app/modules/proxy/_service/api_key_usage.py +745 -0
  573. app/modules/proxy/_service/clock_budget.py +38 -0
  574. app/modules/proxy/_service/codex_control.py +611 -0
  575. app/modules/proxy/_service/compact.py +2211 -0
  576. app/modules/proxy/_service/file_ops.py +749 -0
  577. app/modules/proxy/_service/http_bridge/__init__.py +3 -0
  578. app/modules/proxy/_service/http_bridge/accepted_replay.py +331 -0
  579. app/modules/proxy/_service/http_bridge/account_sessions.py +57 -0
  580. app/modules/proxy/_service/http_bridge/activity.py +178 -0
  581. app/modules/proxy/_service/http_bridge/helpers.py +3959 -0
  582. app/modules/proxy/_service/http_bridge/mixin.py +2435 -0
  583. app/modules/proxy/_service/http_bridge/owner_forwarding.py +609 -0
  584. app/modules/proxy/_service/http_bridge/protocol.py +167 -0
  585. app/modules/proxy/_service/http_bridge/proxy_failover.py +58 -0
  586. app/modules/proxy/_service/http_bridge/quarantine.py +443 -0
  587. app/modules/proxy/_service/http_bridge/request_submit.py +4582 -0
  588. app/modules/proxy/_service/http_bridge/retry_circuit.py +2442 -0
  589. app/modules/proxy/_service/http_bridge/service_stubs.py +492 -0
  590. app/modules/proxy/_service/http_bridge/session_registry.py +778 -0
  591. app/modules/proxy/_service/http_bridge/streaming.py +5328 -0
  592. app/modules/proxy/_service/http_bridge/upstream_events.py +4538 -0
  593. app/modules/proxy/_service/observability.py +410 -0
  594. app/modules/proxy/_service/rate_limit.py +361 -0
  595. app/modules/proxy/_service/realtime_live.py +655 -0
  596. app/modules/proxy/_service/refresh.py +154 -0
  597. app/modules/proxy/_service/request_log.py +585 -0
  598. app/modules/proxy/_service/response_create.py +930 -0
  599. app/modules/proxy/_service/streaming/__init__.py +3 -0
  600. app/modules/proxy/_service/streaming/helpers.py +1317 -0
  601. app/modules/proxy/_service/streaming/mixin.py +1100 -0
  602. app/modules/proxy/_service/streaming/protocol.py +32 -0
  603. app/modules/proxy/_service/streaming/retry.py +3762 -0
  604. app/modules/proxy/_service/support.py +2122 -0
  605. app/modules/proxy/_service/transcribe.py +434 -0
  606. app/modules/proxy/_service/warmup.py +483 -0
  607. app/modules/proxy/_service/websocket/__init__.py +3 -0
  608. app/modules/proxy/_service/websocket/helpers.py +2360 -0
  609. app/modules/proxy/_service/websocket/mixin.py +7285 -0
  610. app/modules/proxy/_service/websocket/protocol.py +74 -0
  611. app/modules/proxy/account_cache.py +229 -0
  612. app/modules/proxy/account_eligibility.py +87 -0
  613. app/modules/proxy/additional_model_limits.py +40 -0
  614. app/modules/proxy/affinity.py +830 -0
  615. app/modules/proxy/affinity_observation.py +101 -0
  616. app/modules/proxy/api.py +10254 -0
  617. app/modules/proxy/api_key_usage.py +113 -0
  618. app/modules/proxy/cap_partitioning.py +280 -0
  619. app/modules/proxy/capability_lineage.py +65 -0
  620. app/modules/proxy/capability_lineage_repository.py +82 -0
  621. app/modules/proxy/capability_routing.py +253 -0
  622. app/modules/proxy/continuity.py +112 -0
  623. app/modules/proxy/downstream_delivery.py +277 -0
  624. app/modules/proxy/durable_bridge_coordinator.py +1217 -0
  625. app/modules/proxy/durable_bridge_repository.py +4516 -0
  626. app/modules/proxy/durable_bridge_runtime.py +9 -0
  627. app/modules/proxy/durable_bridge_transcript_codec.py +122 -0
  628. app/modules/proxy/fair_share.py +131 -0
  629. app/modules/proxy/file_pin_repository.py +210 -0
  630. app/modules/proxy/helpers.py +439 -0
  631. app/modules/proxy/http_bridge_event_batcher.py +613 -0
  632. app/modules/proxy/http_bridge_forwarding.py +797 -0
  633. app/modules/proxy/http_continuation.py +76 -0
  634. app/modules/proxy/images_observability.py +62 -0
  635. app/modules/proxy/images_service.py +1054 -0
  636. app/modules/proxy/load_balancer.py +3016 -0
  637. app/modules/proxy/rate_limit_cache.py +46 -0
  638. app/modules/proxy/replay_safety.py +1063 -0
  639. app/modules/proxy/repo_bundle.py +31 -0
  640. app/modules/proxy/request_policy.py +1054 -0
  641. app/modules/proxy/ring_membership.py +251 -0
  642. app/modules/proxy/schemas.py +345 -0
  643. app/modules/proxy/selection_errors.py +41 -0
  644. app/modules/proxy/service.py +2600 -0
  645. app/modules/proxy/source_admission.py +125 -0
  646. app/modules/proxy/source_dispatch.py +999 -0
  647. app/modules/proxy/sticky_repository.py +831 -0
  648. app/modules/proxy/thread_anchors.py +491 -0
  649. app/modules/proxy/tool_call_dedupe.py +802 -0
  650. app/modules/proxy/types.py +54 -0
  651. app/modules/proxy/work_admission.py +121 -0
  652. app/modules/quota_planner/__init__.py +1 -0
  653. app/modules/quota_planner/api.py +276 -0
  654. app/modules/quota_planner/logic.py +743 -0
  655. app/modules/quota_planner/repository.py +722 -0
  656. app/modules/quota_planner/scheduler.py +212 -0
  657. app/modules/quota_planner/schemas.py +102 -0
  658. app/modules/quota_planner/warmup.py +693 -0
  659. app/modules/rate_limit_reset_credits/__init__.py +0 -0
  660. app/modules/rate_limit_reset_credits/api.py +601 -0
  661. app/modules/rate_limit_reset_credits/redeem_coordination.py +274 -0
  662. app/modules/rate_limit_reset_credits/store.py +114 -0
  663. app/modules/reports/__init__.py +1 -0
  664. app/modules/reports/api.py +134 -0
  665. app/modules/reports/cache.py +74 -0
  666. app/modules/reports/filters.py +27 -0
  667. app/modules/reports/repository.py +525 -0
  668. app/modules/reports/rollup.py +150 -0
  669. app/modules/reports/rollup_read.py +148 -0
  670. app/modules/reports/schemas.py +123 -0
  671. app/modules/reports/service.py +287 -0
  672. app/modules/reports/thread_identity.py +256 -0
  673. app/modules/request_logs/__init__.py +0 -0
  674. app/modules/request_logs/api.py +224 -0
  675. app/modules/request_logs/cost_backfill.py +154 -0
  676. app/modules/request_logs/mappers.py +97 -0
  677. app/modules/request_logs/repository.py +1765 -0
  678. app/modules/request_logs/schemas.py +145 -0
  679. app/modules/request_logs/service.py +348 -0
  680. app/modules/role_mappings/__init__.py +0 -0
  681. app/modules/role_mappings/api.py +206 -0
  682. app/modules/role_mappings/matching.py +55 -0
  683. app/modules/role_mappings/repository.py +106 -0
  684. app/modules/role_mappings/schemas.py +67 -0
  685. app/modules/role_mappings/service.py +334 -0
  686. app/modules/runtime/__init__.py +1 -0
  687. app/modules/runtime/api.py +18 -0
  688. app/modules/runtime/schemas.py +14 -0
  689. app/modules/runtime/service.py +218 -0
  690. app/modules/scim/__init__.py +1 -0
  691. app/modules/scim/api.py +194 -0
  692. app/modules/scim/dependencies.py +112 -0
  693. app/modules/scim/errors.py +77 -0
  694. app/modules/scim/management_api.py +156 -0
  695. app/modules/scim/repository.py +212 -0
  696. app/modules/scim/schemas.py +145 -0
  697. app/modules/scim/service.py +786 -0
  698. app/modules/scim/tokens.py +43 -0
  699. app/modules/settings/__init__.py +1 -0
  700. app/modules/settings/api.py +1677 -0
  701. app/modules/settings/repository.py +606 -0
  702. app/modules/settings/schemas.py +496 -0
  703. app/modules/settings/service.py +769 -0
  704. app/modules/shared/__init__.py +0 -0
  705. app/modules/shared/schemas.py +22 -0
  706. app/modules/sticky_sessions/__init__.py +1 -0
  707. app/modules/sticky_sessions/api.py +118 -0
  708. app/modules/sticky_sessions/cleanup_scheduler.py +487 -0
  709. app/modules/sticky_sessions/schemas.py +78 -0
  710. app/modules/sticky_sessions/service.py +196 -0
  711. app/modules/telemetry/__init__.py +5 -0
  712. app/modules/telemetry/api.py +115 -0
  713. app/modules/telemetry/clients.py +62 -0
  714. app/modules/telemetry/consent.py +115 -0
  715. app/modules/telemetry/scheduler.py +85 -0
  716. app/modules/telemetry/schemas.py +163 -0
  717. app/modules/telemetry/sender.py +221 -0
  718. app/modules/telemetry/snapshot.py +490 -0
  719. app/modules/usage/__init__.py +0 -0
  720. app/modules/usage/additional_quota_keys.py +379 -0
  721. app/modules/usage/api.py +45 -0
  722. app/modules/usage/background_repository.py +147 -0
  723. app/modules/usage/builders.py +559 -0
  724. app/modules/usage/depletion_service.py +277 -0
  725. app/modules/usage/live_ingest.py +356 -0
  726. app/modules/usage/mappers.py +29 -0
  727. app/modules/usage/plan_downgrade_observations.py +488 -0
  728. app/modules/usage/repository.py +1890 -0
  729. app/modules/usage/schemas.py +68 -0
  730. app/modules/usage/service.py +102 -0
  731. app/modules/usage/updater.py +1488 -0
  732. app/static/assets/Trans-Cj-J9mVA.js +1 -0
  733. app/static/assets/access-page-DHzZiXZi.js +1 -0
  734. app/static/assets/account-cost-donut-DGhfA4U8.js +1 -0
  735. app/static/assets/account-identifiers-CxbdLshZ.js +1 -0
  736. app/static/assets/account-status-DIsU5sUS.js +1 -0
  737. app/static/assets/account-trend-chart-BptlfwwQ.js +1 -0
  738. app/static/assets/accounts-page-Bbai8ift.js +3 -0
  739. app/static/assets/api-Dwa7XxPe.js +1 -0
  740. app/static/assets/api-client-dugX1Pti.js +55 -0
  741. app/static/assets/api-key-create-dialog-DnRm0oYN.js +1 -0
  742. app/static/assets/api-key-created-dialog-B6jkDG9f.js +1 -0
  743. app/static/assets/api-key-edit-dialog-Ck3Bnc3V.js +1 -0
  744. app/static/assets/api-trend-chart-BchCw7Hj.js +1 -0
  745. app/static/assets/apis-page-BvXz9c6i.js +2 -0
  746. app/static/assets/arrow-up-Dqh_KzJo.js +1 -0
  747. app/static/assets/arrow-up-down-LBRgVDGY.js +1 -0
  748. app/static/assets/automations-page-DQgNAJOR.js +3 -0
  749. app/static/assets/badge-DqMb9es0.js +1 -0
  750. app/static/assets/blur-email-HEvKagG2.js +1 -0
  751. app/static/assets/button-BjVmmiGW.js +1 -0
  752. app/static/assets/checkbox-apsl9vzk.js +1 -0
  753. app/static/assets/chevron-left-1d_pHVa5.js +1 -0
  754. app/static/assets/circle-alert-BS0xd_o1.js +1 -0
  755. app/static/assets/clsx-DB0hHKMi.js +1 -0
  756. app/static/assets/colors-BBy8GEtr.js +1 -0
  757. app/static/assets/copy-button-D7nAvOKR.js +1 -0
  758. app/static/assets/cost-per-day-chart-YdqlkZVM.js +1 -0
  759. app/static/assets/createLucideIcon-DBLzLNh2.js +1 -0
  760. app/static/assets/dashboard-page-CSdhBy0f.js +2 -0
  761. app/static/assets/distribution-metric-format-yZTy1ZEu.js +1 -0
  762. app/static/assets/donut-chart-Bxc69cra.js +1 -0
  763. app/static/assets/download-kQteIcsB.js +1 -0
  764. app/static/assets/ellipsis-B_5OgIre.js +1 -0
  765. app/static/assets/empty-state-JcA7tc8m.js +1 -0
  766. app/static/assets/es2015-bMH8YKZT.js +41 -0
  767. app/static/assets/es6-B264ni6W.js +69 -0
  768. app/static/assets/external-link-UVAOgMQ0.js +1 -0
  769. app/static/assets/i18n-BWqZ9Ua2.js +1 -0
  770. app/static/assets/inbox-B_RM2s1J.js +1 -0
  771. app/static/assets/index-DIpd264Z.js +23 -0
  772. app/static/assets/index-DMeLKRJx.css +2 -0
  773. app/static/assets/inherit-badge-QhTYVaU6.js +1 -0
  774. app/static/assets/invite-dialog-0CYh0GiQ.js +1 -0
  775. app/static/assets/iso-Bpe5Ihbb.js +1 -0
  776. app/static/assets/label-CGaLmLNf.js +1 -0
  777. app/static/assets/lazy-recharts-Pv8p9ArM.js +2 -0
  778. app/static/assets/loading-overlay-CzKQfHLI.js +1 -0
  779. app/static/assets/mini-quota-bar-cGT_t8Ae.js +1 -0
  780. app/static/assets/model-distribution-donut-BKjAfT48.js +1 -0
  781. app/static/assets/model-source-multi-select-D2d06Vc7.js +1 -0
  782. app/static/assets/multi-select-filter-KIA5qU6Z.js +1 -0
  783. app/static/assets/oauth-dialog-XeaGGDjS.js +1 -0
  784. app/static/assets/pagination-controls-D1Ypd0RU.js +1 -0
  785. app/static/assets/payload-CIYnGZz9.js +1 -0
  786. app/static/assets/queue-wait-chart-VoV7Yg9L.js +1 -0
  787. app/static/assets/report-chart-card-BXDliwcc.js +1 -0
  788. app/static/assets/reports-page-CGCJeMHG.js +3 -0
  789. app/static/assets/reset-credit-confirm-dialog-QOifTnVB.js +1 -0
  790. app/static/assets/rolldown-runtime-hePW80VL.js +1 -0
  791. app/static/assets/search-HfhqwERt.js +1 -0
  792. app/static/assets/select-Cl33bz-g.js +1 -0
  793. app/static/assets/settings-page-mZj01HYl.js +2 -0
  794. app/static/assets/shim-CZwyM4Q7.js +1 -0
  795. app/static/assets/skeleton-C_EUc4Rp.js +1 -0
  796. app/static/assets/sparkline-chart-DdNNsddM.js +1 -0
  797. app/static/assets/status-badge-CHHA5X6c.js +1 -0
  798. app/static/assets/switch-CzxgokcM.js +1 -0
  799. app/static/assets/table-DXaNSQ0G.js +1 -0
  800. app/static/assets/time-to-first-token-chart--68L0u_v.js +1 -0
  801. app/static/assets/tokens-per-day-chart-Cs82BFuE.js +1 -0
  802. app/static/assets/tokens-per-second-chart-AD2dfOn6.js +1 -0
  803. app/static/assets/totp-settings-CRHtxQ7j.js +1 -0
  804. app/static/assets/trash-D3KyWr1h.js +1 -0
  805. app/static/assets/use-account-quota-display-BPmrTBTC.js +1 -0
  806. app/static/assets/use-accounts-PWlVGw3z.js +1 -0
  807. app/static/assets/use-auth-CuDlW3iC.js +1 -0
  808. app/static/assets/use-chart-colors-DIu6tClI.js +1 -0
  809. app/static/assets/use-model-sources-Bul24JBu.js +1 -0
  810. app/static/assets/use-models-DUCPUuZg.js +1 -0
  811. app/static/assets/use-reduced-motion-hAm5r6_d.js +1 -0
  812. app/static/assets/useTranslation--56nHhlH.js +1 -0
  813. app/static/assets/useragent-distribution-donut-Dup6sf_n.js +1 -0
  814. app/static/assets/users-uIC3TIlE.js +1 -0
  815. app/static/assets/vendor-query-_bHZuxEx.js +1 -0
  816. app/static/assets/vendor-react-BVL0YZPW.js +9 -0
  817. app/static/favicon.svg +10 -0
  818. app/static/fonts/GeistSans-Variable.woff2 +0 -0
  819. app/static/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
  820. app/static/fonts/jetbrains-mono-latin.woff2 +0 -0
  821. app/static/index.html +29 -0
  822. codexlb_up-1.25.0b10.dist-info/METADATA +124 -0
  823. codexlb_up-1.25.0b10.dist-info/RECORD +827 -0
  824. codexlb_up-1.25.0b10.dist-info/WHEEL +4 -0
  825. codexlb_up-1.25.0b10.dist-info/entry_points.txt +3 -0
  826. codexlb_up-1.25.0b10.dist-info/licenses/LICENSE +21 -0
  827. config/additional_quota_registry.json +11 -0
@@ -0,0 +1,1344 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import logging
5
+ from time import time
6
+
7
+ from fastapi import APIRouter, Body, Depends, Request, Response
8
+ from fastapi.responses import JSONResponse
9
+
10
+ from app.core.audit.service import AuditActor, AuditService, AuditTarget
11
+ from app.core.auth.dashboard_access import (
12
+ ADMIN_GRANTS,
13
+ GUEST_GRANTS,
14
+ DashboardPrincipal,
15
+ DashboardRole,
16
+ Permission,
17
+ PresetRoleSlug,
18
+ permission_strings,
19
+ )
20
+ from app.core.auth.dashboard_mode import (
21
+ DashboardAuthMode,
22
+ get_dashboard_request_auth,
23
+ password_management_enabled,
24
+ )
25
+ from app.core.auth.dashboard_session_ttl import (
26
+ resolve_admin_dashboard_session_ttl_seconds,
27
+ resolve_dashboard_session_ttl_seconds,
28
+ )
29
+ from app.core.auth.dashboard_users_cache import get_dashboard_users_cache
30
+ from app.core.auth.dependencies import (
31
+ ensure_dashboard_permission,
32
+ recorded_step_up,
33
+ require_dashboard_permission,
34
+ set_dashboard_error_format,
35
+ validate_dashboard_session,
36
+ )
37
+ from app.core.auth.external_identity import ExternalResolution, resolve_trusted_header_request
38
+ from app.core.auth.providers.registry import get_auth_provider_registry
39
+ from app.core.auth.step_up import STEP_UP_COOKIE, STEP_UP_UNAVAILABLE_MESSAGE, step_up_expires_at
40
+ from app.core.bootstrap import (
41
+ ensure_auto_bootstrap_token,
42
+ get_bootstrap_validation_status,
43
+ has_active_bootstrap_token,
44
+ log_bootstrap_token,
45
+ )
46
+ from app.core.config.settings import get_settings
47
+ from app.core.config.settings_cache import get_settings_cache
48
+ from app.core.exceptions import (
49
+ DashboardAuthError,
50
+ DashboardBadRequestError,
51
+ DashboardConflictError,
52
+ DashboardNotFoundError,
53
+ DashboardPermissionError,
54
+ DashboardRateLimitError,
55
+ DashboardValidationError,
56
+ )
57
+ from app.core.rate_limiter.db_rate_limiter import DatabaseRateLimiter
58
+ from app.core.request_locality import is_local_request
59
+ from app.db.models import DashboardUser
60
+ from app.dependencies import (
61
+ DashboardAuthContext,
62
+ DashboardUsersContext,
63
+ get_dashboard_auth_context,
64
+ get_dashboard_users_context,
65
+ )
66
+ from app.modules.dashboard_auth.oidc_flows import (
67
+ OIDC_PENDING_COOKIE,
68
+ clear_pending_marker,
69
+ get_oidc_pending_cookie_store,
70
+ )
71
+ from app.modules.dashboard_auth.schemas import (
72
+ DashboardAuthSessionResponse,
73
+ DashboardLoginHint,
74
+ DashboardMeResponse,
75
+ DashboardPendingArrival,
76
+ GuestLoginRequest,
77
+ GuestPasswordSetRequest,
78
+ InviteAcceptRequest,
79
+ InviteDescriptionResponse,
80
+ PasswordChangeRequest,
81
+ PasswordLoginRequest,
82
+ PasswordRemoveRequest,
83
+ PasswordSetupRequest,
84
+ StepUpRequest,
85
+ StepUpResponse,
86
+ TotpSetupConfirmRequest,
87
+ TotpSetupStartResponse,
88
+ TotpVerifyRequest,
89
+ )
90
+ from app.modules.dashboard_auth.service import (
91
+ DASHBOARD_SESSION_COOKIE,
92
+ DashboardSessionState,
93
+ GuestAccessDisabledError,
94
+ InvalidCredentialsError,
95
+ OtherUsersExistError,
96
+ PasswordAlreadyConfiguredError,
97
+ PasswordNotConfiguredError,
98
+ PasswordSessionRequiredError,
99
+ ResolvedUserSession,
100
+ SessionDescription,
101
+ StepUpUnavailableError,
102
+ TotpAlreadyConfiguredError,
103
+ TotpEnrollmentRequiredError,
104
+ TotpInvalidCodeError,
105
+ TotpInvalidSetupError,
106
+ TotpNotConfiguredError,
107
+ TotpVerificationRequiredError,
108
+ UsernameRequiredError,
109
+ assignable_role_ids,
110
+ get_dashboard_session_store,
111
+ get_guest_password_rate_limiter,
112
+ get_invite_accept_rate_limiter,
113
+ get_invite_accept_token_rate_limiter,
114
+ get_invite_lookup_rate_limiter,
115
+ get_login_failed_audit_rate_limiter,
116
+ get_password_address_rate_limiter,
117
+ get_password_rate_limiter,
118
+ get_step_up_cookie_store,
119
+ get_totp_rate_limiter,
120
+ hash_password,
121
+ is_local_password_session,
122
+ log_login_failed,
123
+ session_clock,
124
+ session_user,
125
+ step_up_state,
126
+ )
127
+ from app.modules.dashboard_roles.service import resolve_role_grants
128
+ from app.modules.dashboard_users.api import mapped_user_errors
129
+ from app.modules.dashboard_users.break_glass import (
130
+ LastBreakGlassProtectedError,
131
+ local_login_admits,
132
+ )
133
+ from app.modules.dashboard_users.credentials import CredentialRequiredError
134
+ from app.modules.dashboard_users.schemas import ProfileUpdateRequest
135
+ from app.modules.dashboard_users.service import InviteNotFoundError, UsernameLockedError, invite_token_hash
136
+
137
+ router = APIRouter(
138
+ prefix="/api/dashboard-auth",
139
+ tags=["dashboard"],
140
+ dependencies=[Depends(set_dashboard_error_format)],
141
+ )
142
+
143
+ logger = logging.getLogger(__name__)
144
+
145
+
146
+ def _client_host(request: Request) -> str | None:
147
+ return request.client.host if request.client else None
148
+
149
+
150
+ def _session_client_key(request: Request, *, prefix: str) -> str:
151
+ return f"{prefix}:{request.client.host if request.client else 'unknown'}"
152
+
153
+
154
+ def _password_login_address_key(request: Request) -> str:
155
+ """The coarse per-address failed-login budget (PLAN §4.4: ``password_login:{ip}``).
156
+
157
+ This is the endpoint's actual ceiling. The per-account bucket below is
158
+ deliberately local — a limit for one username at one address bars nothing
159
+ else — which on its own would let a single address mint an unlimited number
160
+ of buckets by inventing usernames, each of them costing a blocking
161
+ password-hash comparison. The two are added, never swapped.
162
+ """
163
+
164
+ return _session_client_key(request, prefix="password_login")
165
+
166
+
167
+ def _password_login_key(request: Request, username: str | None) -> str:
168
+ """The failed-login budget: one bucket per (username, address) pair.
169
+
170
+ Keyed on the *normalized* username so case variants share a budget, and
171
+ hashed so the rate-limit table never holds a username in the clear. The
172
+ pair is what makes a limit local: eight failures for one account from one
173
+ address bar neither that account elsewhere nor another account from the
174
+ same address.
175
+ """
176
+
177
+ digest = hashlib.sha256((username or "").encode("utf-8")).hexdigest()
178
+ return f"{_password_login_address_key(request)}:{digest}"
179
+
180
+
181
+ def _session_ttl_seconds(request: Request, user: DashboardUser, configured_ttl_seconds: int) -> int:
182
+ if user.role.slug == PresetRoleSlug.ADMIN.value:
183
+ return resolve_admin_dashboard_session_ttl_seconds(request, configured_ttl_seconds)
184
+ return resolve_dashboard_session_ttl_seconds(request, configured_ttl_seconds)
185
+
186
+
187
+ async def _create_user_session(
188
+ request: Request,
189
+ user: DashboardUser,
190
+ *,
191
+ totp_verified: bool,
192
+ auth_method: str,
193
+ max_ttl_seconds: int | None = None,
194
+ step_up_verified_at: int | None = None,
195
+ ) -> tuple[str, int]:
196
+ settings = await get_settings_cache().get()
197
+ ttl_seconds = _session_ttl_seconds(request, user, settings.dashboard_session_ttl_seconds)
198
+ if max_ttl_seconds is not None:
199
+ ttl_seconds = max(1, min(ttl_seconds, max_ttl_seconds))
200
+ session_id = get_dashboard_session_store().create_user_session(
201
+ user.id,
202
+ user.session_generation,
203
+ password_verified=True,
204
+ totp_verified=totp_verified,
205
+ ttl_seconds=ttl_seconds,
206
+ auth_method=auth_method,
207
+ step_up_verified_at=step_up_verified_at,
208
+ break_glass=user.is_break_glass,
209
+ )
210
+ return session_id, ttl_seconds
211
+
212
+
213
+ async def _create_guest_session(request: Request, *, guest_session_generation: int) -> tuple[str, int]:
214
+ settings = await get_settings_cache().get()
215
+ ttl_seconds = resolve_dashboard_session_ttl_seconds(request, settings.dashboard_session_ttl_seconds)
216
+ session_id = get_dashboard_session_store().create_guest_session(
217
+ ttl_seconds=ttl_seconds,
218
+ guest_session_generation=guest_session_generation,
219
+ )
220
+ return session_id, ttl_seconds
221
+
222
+
223
+ #: Fields that describe a signed-in account; stripped whenever the response is
224
+ #: served to a caller that is not authenticated as one.
225
+ _UNAUTHENTICATED_ACCOUNT_FIELDS: dict[str, object] = {
226
+ "user": None,
227
+ "access_summary": None,
228
+ "assignable_role_ids": [],
229
+ }
230
+
231
+
232
+ async def _refused_company_sign_in(request: Request, login: DashboardLoginHint | None) -> DashboardLoginHint | None:
233
+ """This browser's own refusal marker, projected onto the login hint, or ``None``.
234
+
235
+ The marker is read here and nowhere else, and only for a caller that holds
236
+ no session: what it carries is a lossy echo of an identity this very
237
+ browser presented a moment ago, so handing it back to that browser tells it
238
+ nothing it did not already know, and there is no request shape that gets it
239
+ for anybody else's address.
240
+
241
+ A marker naming a row that no longer exists yields nothing rather than a
242
+ guess. The label belongs to the row; an install that deleted it has no
243
+ label to give, and inventing one from the active providers would be the
244
+ screen asserting which provider refused somebody.
245
+ """
246
+
247
+ if login is None:
248
+ return None
249
+ marker = get_oidc_pending_cookie_store().get(request.cookies.get(OIDC_PENDING_COOKIE))
250
+ if marker is None:
251
+ return None
252
+ row = next((row for row in await get_auth_provider_registry().rows() if row.id == marker.provider_id), None)
253
+ if row is None:
254
+ return None
255
+ return login.model_copy(
256
+ update={
257
+ "pending_identity": True,
258
+ "pending_arrival": DashboardPendingArrival(provider=row.label, reference=marker.reference),
259
+ }
260
+ )
261
+
262
+
263
+ async def _decorate_session_response(
264
+ description: SessionDescription,
265
+ *,
266
+ request: Request,
267
+ context: DashboardAuthContext,
268
+ force_authenticated: bool = False,
269
+ ) -> DashboardAuthSessionResponse:
270
+ response, resolved = description.response, description.resolved
271
+ request_auth = get_dashboard_request_auth(request)
272
+ auth_mode = get_settings().dashboard_auth_mode
273
+ has_pwd = resolved is not None and resolved.state.password_verified
274
+ totp_pending = (
275
+ has_pwd and resolved is not None and response.totp_required_on_login and not resolved.state.totp_verified
276
+ )
277
+ fully_authorized = has_pwd and not totp_pending and response.password_required
278
+ # Behind a reverse proxy the cookie only counts as a fallback while the
279
+ # local login policy admits the account -- the same call the session gate
280
+ # makes, so ``password_session_active`` never advertises a fallback the
281
+ # gate would refuse.
282
+ fallback_admitted = resolved is None or (
283
+ # An OIDC session is not the local fallback (it is the very thing the
284
+ # policy closes the local door against), so it never reports one.
285
+ is_local_password_session(resolved.state)
286
+ and local_login_admits(resolved.user, (await get_settings_cache().get()).local_login_policy)
287
+ )
288
+ fallback_authorized = fully_authorized and fallback_admitted
289
+
290
+ if request_auth is None:
291
+ update: dict[str, object] = {
292
+ "auth_mode": auth_mode,
293
+ "password_management_enabled": password_management_enabled(auth_mode),
294
+ "password_session_active": (
295
+ fallback_authorized if auth_mode == DashboardAuthMode.TRUSTED_HEADER else fully_authorized
296
+ ),
297
+ }
298
+ # Without the header only a password session gets in. When no account
299
+ # holds a password (proxy-created accounts do not count) there is no
300
+ # form to show: the client renders the reverse-proxy notice.
301
+ if auth_mode == DashboardAuthMode.TRUSTED_HEADER and not fallback_authorized and not totp_pending:
302
+ local_password_users = (await get_dashboard_users_cache().local_auth_state()).active_local_password_users
303
+ if local_password_users == 0 or not response.password_required:
304
+ update["authenticated"] = False
305
+ update["password_required"] = False
306
+ update.update(_UNAUTHENTICATED_ACCOUNT_FIELDS)
307
+ # A company sign-in this install refused, described to the browser it
308
+ # refused -- and only while that browser has nothing better: a session
309
+ # is the answer to the same question and supersedes the marker.
310
+ if not bool(update.get("authenticated", response.authenticated)):
311
+ refused = await _refused_company_sign_in(request, response.login)
312
+ if refused is not None:
313
+ update["login"] = refused
314
+ return response.model_copy(update=update)
315
+
316
+ if request_auth.mode == DashboardAuthMode.TRUSTED_HEADER:
317
+ resolution = await resolve_trusted_header_request(request, request_auth)
318
+ if resolution is None:
319
+ # Header present but the provider is inactive: the dependency answers
320
+ # proxy_auth_required, so the session must not describe an admin.
321
+ return response.model_copy(
322
+ update={
323
+ "authenticated": False,
324
+ "password_required": False,
325
+ "auth_mode": DashboardAuthMode.TRUSTED_HEADER,
326
+ "password_session_active": fallback_authorized,
327
+ **_UNAUTHENTICATED_ACCOUNT_FIELDS,
328
+ }
329
+ )
330
+ if resolution.user is None and has_pwd and fallback_admitted:
331
+ # Refused identity but a password cookie rides along: describe that
332
+ # session (the break-glass admin stays reachable behind the proxy).
333
+ return response.model_copy(
334
+ update={
335
+ "auth_mode": DashboardAuthMode.TRUSTED_HEADER,
336
+ "password_management_enabled": True,
337
+ "password_session_active": fallback_authorized,
338
+ }
339
+ )
340
+ return await _trusted_header_session_response(
341
+ response, resolution, request=request, context=context, password_session_active=fallback_authorized
342
+ )
343
+
344
+ # Disabled auth: the implicit admin holds every permission.
345
+ return response.model_copy(
346
+ update={
347
+ "authenticated": force_authenticated or response.authenticated,
348
+ "totp_required_on_login": totp_pending,
349
+ "auth_mode": request_auth.mode,
350
+ "password_management_enabled": password_management_enabled(request_auth.mode),
351
+ "password_session_active": fully_authorized,
352
+ "role": DashboardRole.ADMIN,
353
+ "permissions": permission_strings(ADMIN_GRANTS),
354
+ "totp_enrollment_required": False,
355
+ "access_summary": await context.service.access_summary(),
356
+ "assignable_role_ids": assignable_role_ids(),
357
+ }
358
+ )
359
+
360
+
361
+ async def _trusted_header_session_response(
362
+ response: DashboardAuthSessionResponse,
363
+ resolution: ExternalResolution,
364
+ *,
365
+ request: Request,
366
+ context: DashboardAuthContext,
367
+ password_session_active: bool,
368
+ ) -> DashboardAuthSessionResponse:
369
+ """The session as the proxy-asserted account sees it, or the "not ready" state for a refused identity.
370
+
371
+ ``password_session_active`` reports whether a fallback password cookie also
372
+ rode along, so the settings page keeps gating password management on it.
373
+ The account's step-up rides in the step-up cookie (no session cookie exists).
374
+ """
375
+
376
+ user = resolution.user
377
+ if user is None:
378
+ return response.model_copy(
379
+ update={
380
+ "authenticated": False,
381
+ "auth_mode": DashboardAuthMode.TRUSTED_HEADER,
382
+ "totp_required_on_login": False,
383
+ "totp_configured": False,
384
+ "password_session_active": password_session_active,
385
+ "auth_method": None,
386
+ "totp_enrollment_required": False,
387
+ "login": response.login.model_copy(update={"pending_identity": True}) if response.login else None,
388
+ **_UNAUTHENTICATED_ACCOUNT_FIELDS,
389
+ }
390
+ )
391
+ grants = resolve_role_grants(user.role)
392
+ manages_users = Permission.USERS_MANAGE in grants
393
+ return response.model_copy(
394
+ update={
395
+ "authenticated": True,
396
+ # An account with an identity exists (this one), so sign-in is required
397
+ # even when the cached auth state predates its just-in-time creation.
398
+ "password_required": True,
399
+ "auth_mode": DashboardAuthMode.TRUSTED_HEADER,
400
+ "password_management_enabled": True,
401
+ "password_session_active": password_session_active,
402
+ "totp_required_on_login": False,
403
+ "totp_configured": user.totp_secret_encrypted is not None,
404
+ "role": DashboardRole.ADMIN,
405
+ "permissions": permission_strings(grants),
406
+ "user": session_user(user),
407
+ "auth_method": DashboardAuthMode.TRUSTED_HEADER.value,
408
+ "must_change_password": False,
409
+ "totp_enrollment_required": False,
410
+ "access_summary": await context.service.access_summary() if manages_users else None,
411
+ "assignable_role_ids": assignable_role_ids() if manages_users else [],
412
+ "step_up": await step_up_state(user, verified_at=recorded_step_up(request, user)),
413
+ }
414
+ )
415
+
416
+
417
+ def _guest_overrides() -> dict[str, object]:
418
+ return {
419
+ "bootstrap_required": False,
420
+ "bootstrap_token_configured": False,
421
+ "role": DashboardRole.GUEST,
422
+ "permissions": permission_strings(GUEST_GRANTS),
423
+ "guest_access_enabled": True,
424
+ "password_session_active": False,
425
+ "user": None,
426
+ "auth_method": None,
427
+ "must_change_password": False,
428
+ "totp_enrollment_required": False,
429
+ "access_summary": None,
430
+ "assignable_role_ids": [],
431
+ }
432
+
433
+
434
+ def _public_guest_response(response: DashboardAuthSessionResponse) -> DashboardAuthSessionResponse:
435
+ return response.model_copy(update={**_guest_overrides(), "authenticated": True, "guest_password_required": False})
436
+
437
+
438
+ def _guest_login_required_response(response: DashboardAuthSessionResponse) -> DashboardAuthSessionResponse:
439
+ return response.model_copy(update={**_guest_overrides(), "authenticated": False, "guest_password_required": True})
440
+
441
+
442
+ async def _require_password_session(request: Request, context: DashboardAuthContext) -> ResolvedUserSession:
443
+ try:
444
+ return await context.service.require_password_session(request.cookies.get(DASHBOARD_SESSION_COOKIE))
445
+ except PasswordSessionRequiredError as exc:
446
+ raise DashboardAuthError("Authentication is required") from exc
447
+
448
+
449
+ async def _resolve_account_session(request: Request, context: DashboardAuthContext) -> ResolvedUserSession:
450
+ """The signed-in account for the TOTP routes: the trusted-header account when the header is present,
451
+ else the password session — the same precedence as the session dependency, so the account that
452
+ enrols is the account the dashboard acts as.
453
+
454
+ A trusted-header account has no session cookie; it gets a stand-in state
455
+ (``password_verified=False``, ``auth_method=trusted_header``) so the same
456
+ routes can enrol its TOTP secret — the only step-up method such an account
457
+ can have without a local password.
458
+ """
459
+
460
+ header_account = await _header_account_session(request)
461
+ if header_account is not None:
462
+ return header_account
463
+ try:
464
+ return await context.service.require_password_session(request.cookies.get(DASHBOARD_SESSION_COOKIE))
465
+ except PasswordSessionRequiredError as exc:
466
+ raise DashboardAuthError(str(exc)) from exc
467
+
468
+
469
+ async def _header_account_session(request: Request) -> ResolvedUserSession | None:
470
+ request_auth = get_dashboard_request_auth(request)
471
+ if request_auth is None or request_auth.mode != DashboardAuthMode.TRUSTED_HEADER:
472
+ return None
473
+ resolution = await resolve_trusted_header_request(request, request_auth)
474
+ if resolution is None or resolution.user is None:
475
+ return None
476
+ now = session_clock()
477
+ state = DashboardSessionState(
478
+ expires_at=now,
479
+ issued_at=now,
480
+ kind="user",
481
+ user_id=resolution.user.id,
482
+ session_generation=resolution.user.session_generation,
483
+ auth_method=DashboardAuthMode.TRUSTED_HEADER.value,
484
+ )
485
+ return ResolvedUserSession(user=resolution.user, state=state)
486
+
487
+
488
+ def _is_header_account(resolved: ResolvedUserSession) -> bool:
489
+ return not resolved.state.password_verified
490
+
491
+
492
+ async def _require_management_session(
493
+ request: Request, context: DashboardAuthContext, *, allow_unenrolled: bool = False
494
+ ) -> ResolvedUserSession:
495
+ """A password session that also passed TOTP when the install requires it.
496
+
497
+ Password/TOTP management is refused outright in the auth-bypass modes. An
498
+ account that still has to enrol a TOTP secret is refused with 403 unless the
499
+ route is one of the self-service routes (``allow_unenrolled``).
500
+ """
501
+
502
+ _ensure_password_management_enabled(request)
503
+ try:
504
+ return await context.service.require_management_session(
505
+ request.cookies.get(DASHBOARD_SESSION_COOKIE), allow_unenrolled=allow_unenrolled
506
+ )
507
+ except PasswordSessionRequiredError as exc:
508
+ raise DashboardAuthError("Authentication is required") from exc
509
+ except TotpVerificationRequiredError as exc:
510
+ raise DashboardAuthError(str(exc), code="totp_required") from exc
511
+ except TotpEnrollmentRequiredError as exc:
512
+ raise _enrollment_required(exc) from exc
513
+
514
+
515
+ def _enrollment_required(exc: TotpEnrollmentRequiredError) -> DashboardPermissionError:
516
+ return DashboardPermissionError(str(exc), code="totp_enrollment_required")
517
+
518
+
519
+ def _ensure_password_management_enabled(request: Request) -> None:
520
+ request_auth = get_dashboard_request_auth(request)
521
+ if request_auth is not None and not password_management_enabled(request_auth.mode):
522
+ raise DashboardBadRequestError(
523
+ "Password and TOTP management is disabled while dashboard auth is bypassed",
524
+ code="password_management_disabled",
525
+ )
526
+
527
+
528
+ async def _invalidate_auth_caches() -> None:
529
+ await get_settings_cache().invalidate()
530
+ await get_dashboard_users_cache().invalidate()
531
+
532
+
533
+ # bcrypt enforces a hard 72-byte limit on the input password; anything longer
534
+ # raises ``ValueError`` from ``bcrypt.hashpw`` and surfaces as a 500 to the
535
+ # client. Validate the encoded length here so the API returns a clear 400.
536
+ _MAX_PASSWORD_BYTES = 72
537
+
538
+
539
+ def _validate_password_length(password: str) -> None:
540
+ if len(password) < 8:
541
+ raise DashboardValidationError("Password must be at least 8 characters")
542
+ if len(password.encode("utf-8")) > _MAX_PASSWORD_BYTES:
543
+ raise DashboardValidationError(
544
+ f"Password must be at most {_MAX_PASSWORD_BYTES} bytes when encoded as UTF-8. "
545
+ "Note that multi-byte characters (e.g. emoji, non-ASCII letters) count for more than one byte.",
546
+ code="password_too_long",
547
+ )
548
+
549
+
550
+ def _rate_limit_error(exc: DashboardRateLimitError, *, code: str) -> DashboardRateLimitError:
551
+ return DashboardRateLimitError(
552
+ f"Too many attempts. Try again in {exc.retry_after} seconds.",
553
+ retry_after=exc.retry_after,
554
+ code=code,
555
+ )
556
+
557
+
558
+ @router.get("/session", response_model=DashboardAuthSessionResponse)
559
+ async def get_dashboard_auth_session(
560
+ request: Request,
561
+ context: DashboardAuthContext = Depends(get_dashboard_auth_context),
562
+ ) -> DashboardAuthSessionResponse:
563
+ session_id = request.cookies.get(DASHBOARD_SESSION_COOKIE)
564
+ description = await context.service.describe_session(session_id)
565
+ decorated = await _decorate_session_response(
566
+ description, request=request, context=context, force_authenticated=True
567
+ )
568
+ if decorated.auth_mode != DashboardAuthMode.STANDARD:
569
+ return decorated
570
+ if decorated.password_required or is_local_request(request):
571
+ return decorated
572
+ current_settings = await context.repository.get_settings()
573
+ if current_settings.guest_access_enabled:
574
+ if current_settings.guest_password_hash is None:
575
+ return _public_guest_response(decorated)
576
+ if decorated.authenticated and decorated.role == DashboardRole.GUEST:
577
+ session_state = get_dashboard_session_store().get(session_id)
578
+ if (
579
+ session_state is not None
580
+ and session_state.is_guest
581
+ and session_state.guest_session_generation == current_settings.guest_session_generation
582
+ ):
583
+ return decorated
584
+ return _guest_login_required_response(decorated)
585
+ bootstrap_token_configured = await has_active_bootstrap_token()
586
+ return decorated.model_copy(
587
+ update={
588
+ "authenticated": False,
589
+ "bootstrap_required": True,
590
+ "bootstrap_token_configured": bootstrap_token_configured,
591
+ **_UNAUTHENTICATED_ACCOUNT_FIELDS,
592
+ }
593
+ )
594
+
595
+
596
+ @router.post("/password/setup", response_model=DashboardAuthSessionResponse)
597
+ async def setup_password(
598
+ request: Request,
599
+ payload: PasswordSetupRequest = Body(...),
600
+ context: DashboardAuthContext = Depends(get_dashboard_auth_context),
601
+ ) -> DashboardAuthSessionResponse | JSONResponse:
602
+ settings = get_settings()
603
+ auth_state = await context.repository.get_local_auth_state()
604
+ if settings.dashboard_auth_mode == DashboardAuthMode.DISABLED:
605
+ raise DashboardBadRequestError(
606
+ "Password management is disabled while dashboard auth is bypassed",
607
+ code="password_management_disabled",
608
+ )
609
+ if settings.dashboard_auth_mode == DashboardAuthMode.TRUSTED_HEADER:
610
+ # Behind the proxy the break-glass admin is created by an account that
611
+ # manages users (a header-resolved account or a password session), never
612
+ # by a bare request: without the header this is proxy_auth_required, with
613
+ # a refused identity identity_not_provisioned, as for every other route.
614
+ principal = await validate_dashboard_session(request)
615
+ ensure_dashboard_permission(principal, Permission.USERS_MANAGE)
616
+ elif not auth_state.requires_auth and not is_local_request(request):
617
+ submitted_bootstrap_token = (payload.bootstrap_token or "").strip()
618
+ validation_status = await get_bootstrap_validation_status(submitted_bootstrap_token)
619
+ if validation_status == "unavailable":
620
+ raise DashboardAuthError(
621
+ "Remote bootstrap is disabled until CODEX_LB_DASHBOARD_BOOTSTRAP_TOKEN is configured.",
622
+ code="bootstrap_unavailable",
623
+ )
624
+ if validation_status == "password_already_configured":
625
+ raise DashboardConflictError("Password is already configured", code="password_already_configured")
626
+ if validation_status != "valid":
627
+ raise DashboardAuthError("Invalid dashboard bootstrap token.", code="invalid_bootstrap_token")
628
+ password = payload.password.strip()
629
+ _validate_password_length(password)
630
+ try:
631
+ user = await context.service.setup_password(password)
632
+ except PasswordAlreadyConfiguredError as exc:
633
+ raise DashboardConflictError(str(exc), code="password_already_configured") from exc
634
+
635
+ await _invalidate_auth_caches()
636
+ return await _issue_user_session_response(request, context, user, totp_verified=False, auth_method="password")
637
+
638
+
639
+ async def _issue_user_session_response(
640
+ request: Request,
641
+ context: DashboardAuthContext,
642
+ user: DashboardUser,
643
+ *,
644
+ totp_verified: bool,
645
+ auth_method: str,
646
+ ) -> JSONResponse:
647
+ # Every caller just verified a password. That is a complete step-up only
648
+ # for an account without a TOTP secret; otherwise ``/totp/verify`` mints it.
649
+ session_id, session_ttl_seconds = await _create_user_session(
650
+ request,
651
+ user,
652
+ totp_verified=totp_verified,
653
+ auth_method=auth_method,
654
+ step_up_verified_at=session_clock() if user.totp_secret_encrypted is None else None,
655
+ )
656
+ response = await _decorate_session_response(
657
+ await context.service.describe_session(session_id), request=request, context=context
658
+ )
659
+ json_response = JSONResponse(status_code=200, content=response.model_dump(by_alias=True))
660
+ _set_session_cookie(json_response, session_id, request, max_age_seconds=session_ttl_seconds)
661
+ return json_response
662
+
663
+
664
+ @router.post("/guest/login", response_model=DashboardAuthSessionResponse)
665
+ async def login_guest(
666
+ request: Request,
667
+ payload: GuestLoginRequest | None = Body(default=None),
668
+ context: DashboardAuthContext = Depends(get_dashboard_auth_context),
669
+ ) -> DashboardAuthSessionResponse | JSONResponse:
670
+ settings = await get_settings_cache().get()
671
+ if not settings.guest_access_enabled:
672
+ raise DashboardBadRequestError("Guest access is disabled", code="guest_access_disabled")
673
+ if (
674
+ get_settings().dashboard_auth_mode == DashboardAuthMode.TRUSTED_HEADER
675
+ and get_dashboard_request_auth(request) is None
676
+ ):
677
+ raise DashboardAuthError("Reverse proxy authentication is required", code="proxy_auth_required")
678
+
679
+ limiter = get_guest_password_rate_limiter()
680
+ rate_key = _session_client_key(request, prefix="guest_login")
681
+ if settings.guest_password_hash is not None:
682
+ try:
683
+ await limiter.check_and_increment(rate_key, context.session)
684
+ except DashboardRateLimitError as exc:
685
+ raise _rate_limit_error(exc, code="guest_password_rate_limited") from exc
686
+
687
+ try:
688
+ verification = await context.service.verify_guest_password(
689
+ None if payload is None else payload.password,
690
+ actor_ip=_client_host(request),
691
+ )
692
+ except GuestAccessDisabledError as exc:
693
+ raise DashboardBadRequestError(str(exc), code="guest_access_disabled") from exc
694
+ except InvalidCredentialsError as exc:
695
+ raise DashboardAuthError(str(exc), code="invalid_credentials") from exc
696
+
697
+ await limiter.clear_for_key(rate_key, context.session)
698
+
699
+ # Stamp the generation of the very settings row the credential was checked
700
+ # against (one database read, not the 5 s cache and not a second read): a
701
+ # guest password enabled between check and mint must invalidate this cookie.
702
+ session_id, session_ttl_seconds = await _create_guest_session(
703
+ request, guest_session_generation=verification.guest_session_generation
704
+ )
705
+ response = await _decorate_session_response(
706
+ await context.service.describe_session(session_id), request=request, context=context
707
+ )
708
+ json_response = JSONResponse(status_code=200, content=response.model_dump(by_alias=True))
709
+ _set_session_cookie(json_response, session_id, request, max_age_seconds=session_ttl_seconds)
710
+ return json_response
711
+
712
+
713
+ @router.post("/password/login", response_model=DashboardAuthSessionResponse)
714
+ async def login_password(
715
+ request: Request,
716
+ payload: PasswordLoginRequest = Body(...),
717
+ context: DashboardAuthContext = Depends(get_dashboard_auth_context),
718
+ ) -> DashboardAuthSessionResponse | JSONResponse:
719
+ if get_settings().dashboard_auth_mode == DashboardAuthMode.DISABLED:
720
+ raise DashboardBadRequestError(
721
+ "Password login is disabled while dashboard auth is bypassed",
722
+ code="password_management_disabled",
723
+ )
724
+
725
+ # Target resolution happens before any limiter so a missing username on a
726
+ # multi-user install (422) and an unconfigured install (400) never spend budget.
727
+ try:
728
+ target = await context.service.resolve_login_target(payload.username)
729
+ except PasswordNotConfiguredError as exc:
730
+ raise DashboardBadRequestError(str(exc), code="password_not_configured") from exc
731
+ except UsernameRequiredError as exc:
732
+ await _audit_username_required(request, context)
733
+ raise DashboardValidationError(str(exc), code="username_required") from exc
734
+
735
+ # Two buckets, both spent by every attempt: the coarse per-address ceiling
736
+ # that bounds the endpoint, and the per-(address, username) budget that
737
+ # keeps one account's failures from barring another. No account is exempt
738
+ # from either. PLAN §4.4 exempted the emergency account from a limit it
739
+ # assumed was keyed on the username alone, which an attacker could exhaust
740
+ # from anywhere; both keys carry the client address, so an attacker
741
+ # hammering that username from their own address cannot touch the operator
742
+ # signing in from a different one. T13/T15 ("no remotely triggerable
743
+ # account lockout") therefore hold without an exemption -- and the
744
+ # exemption itself was an oracle, because whether the ninth failure for a
745
+ # username answers 429 or 401 named the emergency account to an
746
+ # unauthenticated caller.
747
+ address_limiter, address_key = get_password_address_rate_limiter(), _password_login_address_key(request)
748
+ limiter = get_password_rate_limiter()
749
+ rate_key = _password_login_key(request, target.username)
750
+ try:
751
+ await address_limiter.check_and_increment(address_key, context.session)
752
+ await limiter.check_and_increment(rate_key, context.session)
753
+ except DashboardRateLimitError as exc:
754
+ raise _rate_limit_error(exc, code="password_rate_limited") from exc
755
+
756
+ try:
757
+ user = await context.service.verify_user_password(target, payload.password, actor_ip=_client_host(request))
758
+ except InvalidCredentialsError as exc:
759
+ raise DashboardAuthError(str(exc), code="invalid_credentials") from exc
760
+
761
+ # Only the per-account bucket is cleared. The coarse address bucket is the
762
+ # endpoint's only ceiling on *how many* usernames one address may try, and
763
+ # clearing it on success would hand that ceiling to anyone holding a single
764
+ # valid account: seven guesses at someone else's username, one sign-in of
765
+ # their own, seven more, for as long as they like. It is left to expire on
766
+ # its own window instead, which the person who just signed in never notices
767
+ # (60/60 s against one sign-in).
768
+ await limiter.clear_for_key(rate_key, context.session)
769
+ # Only last_login_at changed; nothing on the auth path reads it, so the users
770
+ # cache stays warm.
771
+
772
+ return await _issue_user_session_response(request, context, user, totp_verified=False, auth_method="password")
773
+
774
+
775
+ async def _audit_username_required(request: Request, context: DashboardAuthContext) -> None:
776
+ """Audit a ``username_required`` refusal without letting it become an unbounded row source.
777
+
778
+ The refusal itself never spends password budget, so a client that is
779
+ already at the password limit gets no row, and a dedicated per-client
780
+ budget (same 8/60 s shape, own counter) bounds the rows a client can add
781
+ without ever touching the password limiter's counter.
782
+
783
+ The budget it reads is the per-address one, which is the bucket ordinary
784
+ logins from this client actually increment; a per-username key would be a
785
+ counter nothing ever advances, so the guard would let every refusal
786
+ through.
787
+ """
788
+
789
+ try:
790
+ await get_password_address_rate_limiter().check(_password_login_address_key(request), context.session)
791
+ await get_login_failed_audit_rate_limiter().check_and_increment(
792
+ _session_client_key(request, prefix="login_failed_audit"), context.session
793
+ )
794
+ except DashboardRateLimitError:
795
+ return
796
+ log_login_failed(_client_host(request), "password", "username_required")
797
+
798
+
799
+ @router.post("/password/change")
800
+ async def change_password(
801
+ request: Request,
802
+ payload: PasswordChangeRequest = Body(...),
803
+ context: DashboardAuthContext = Depends(get_dashboard_auth_context),
804
+ ) -> JSONResponse:
805
+ resolved = await _require_management_session(request, context, allow_unenrolled=True)
806
+
807
+ new_password = payload.new_password.strip()
808
+ _validate_password_length(new_password)
809
+
810
+ try:
811
+ await context.service.change_password(
812
+ resolved.user,
813
+ payload.current_password,
814
+ new_password,
815
+ actor_ip=_client_host(request),
816
+ auth_method=resolved.state.auth_method,
817
+ )
818
+ except PasswordNotConfiguredError as exc:
819
+ raise DashboardBadRequestError(str(exc), code="password_not_configured") from exc
820
+ except InvalidCredentialsError as exc:
821
+ raise DashboardAuthError(str(exc), code="invalid_credentials") from exc
822
+
823
+ await _invalidate_auth_caches()
824
+ # Every other device is logged out by the generation bump; this caller stays
825
+ # signed in through a fresh cookie that keeps the old session's remaining life.
826
+ user = await context.repository.get_user_by_id(resolved.user.id)
827
+ if user is None:
828
+ raise DashboardAuthError("Authentication is required")
829
+ remaining = max(1, resolved.state.expires_at - int(time()))
830
+ session_id, session_ttl_seconds = await _create_user_session(
831
+ request,
832
+ user,
833
+ totp_verified=resolved.state.totp_verified,
834
+ auth_method=resolved.state.auth_method or "password",
835
+ max_ttl_seconds=remaining,
836
+ step_up_verified_at=resolved.state.step_up_verified_at,
837
+ )
838
+ response = JSONResponse(status_code=200, content={"status": "ok"})
839
+ _set_session_cookie(response, session_id, request, max_age_seconds=session_ttl_seconds)
840
+ return response
841
+
842
+
843
+ @router.post("/guest/password")
844
+ async def set_guest_password(
845
+ payload: GuestPasswordSetRequest = Body(...),
846
+ context: DashboardAuthContext = Depends(get_dashboard_auth_context),
847
+ _principal: DashboardPrincipal = Depends(require_dashboard_permission(Permission.SECURITY_WRITE)),
848
+ ) -> JSONResponse:
849
+ password = payload.password.strip()
850
+ _validate_password_length(password)
851
+ await context.service.set_guest_password(password)
852
+ await get_settings_cache().invalidate()
853
+ return JSONResponse(status_code=200, content={"status": "ok"})
854
+
855
+
856
+ @router.delete("/guest/password")
857
+ async def remove_guest_password(
858
+ context: DashboardAuthContext = Depends(get_dashboard_auth_context),
859
+ _principal: DashboardPrincipal = Depends(require_dashboard_permission(Permission.SECURITY_WRITE)),
860
+ ) -> JSONResponse:
861
+ await context.service.clear_guest_password()
862
+ await get_settings_cache().invalidate()
863
+ return JSONResponse(status_code=200, content={"status": "ok"})
864
+
865
+
866
+ @router.post("/guest/logout-all")
867
+ async def revoke_guest_sessions(
868
+ request: Request,
869
+ context: DashboardAuthContext = Depends(get_dashboard_auth_context),
870
+ principal: DashboardPrincipal = Depends(require_dashboard_permission(Permission.SECURITY_WRITE)),
871
+ ) -> JSONResponse:
872
+ """Invalidate every outstanding guest session without touching guest settings."""
873
+
874
+ await context.service.revoke_guest_sessions()
875
+ await get_settings_cache().invalidate()
876
+ AuditService.log_async(
877
+ "guest_sessions_revoked",
878
+ actor_ip=_client_host(request),
879
+ actor=AuditActor.from_principal(principal),
880
+ target=AuditTarget("settings", "guest_access"),
881
+ )
882
+ return JSONResponse(status_code=200, content={"status": "ok"})
883
+
884
+
885
+ @router.delete("/password")
886
+ async def remove_password(
887
+ request: Request,
888
+ payload: PasswordRemoveRequest = Body(...),
889
+ context: DashboardAuthContext = Depends(get_dashboard_auth_context),
890
+ ) -> JSONResponse:
891
+ resolved = await _require_management_session(request, context)
892
+
893
+ try:
894
+ await context.service.remove_password(
895
+ resolved.user,
896
+ payload.password,
897
+ actor_ip=_client_host(request),
898
+ auth_method=resolved.state.auth_method,
899
+ )
900
+ except PasswordNotConfiguredError as exc:
901
+ raise DashboardBadRequestError(str(exc), code="password_not_configured") from exc
902
+ except InvalidCredentialsError as exc:
903
+ raise DashboardAuthError(str(exc), code="invalid_credentials") from exc
904
+ except OtherUsersExistError as exc:
905
+ raise DashboardConflictError(str(exc), code="other_users_exist") from exc
906
+ except CredentialRequiredError as exc:
907
+ raise DashboardConflictError(str(exc), code="credential_required") from exc
908
+ except LastBreakGlassProtectedError as exc:
909
+ raise DashboardConflictError(str(exc), code="last_break_glass_protected") from exc
910
+
911
+ await _invalidate_auth_caches()
912
+ bootstrap_token = await ensure_auto_bootstrap_token()
913
+ if bootstrap_token:
914
+ log_bootstrap_token(logger, bootstrap_token, reason="password-removed")
915
+ response = JSONResponse(status_code=200, content={"status": "ok"})
916
+ response.delete_cookie(key=DASHBOARD_SESSION_COOKIE, path="/")
917
+ return response
918
+
919
+
920
+ @router.post("/logout-all")
921
+ async def logout_everywhere(
922
+ request: Request,
923
+ context: DashboardAuthContext = Depends(get_dashboard_auth_context),
924
+ ) -> JSONResponse:
925
+ """Revoke every session of the signed-in account, including this one."""
926
+
927
+ resolved = await _require_password_session(request, context)
928
+ await context.service.revoke_user_sessions(
929
+ resolved.user, actor_ip=_client_host(request), auth_method=resolved.state.auth_method
930
+ )
931
+ await get_dashboard_users_cache().invalidate()
932
+ response = JSONResponse(status_code=200, content={"status": "ok"})
933
+ response.delete_cookie(key=DASHBOARD_SESSION_COOKIE, path="/")
934
+ return response
935
+
936
+
937
+ @router.get("/me", response_model=DashboardMeResponse)
938
+ async def get_me(
939
+ request: Request,
940
+ context: DashboardAuthContext = Depends(get_dashboard_auth_context),
941
+ ) -> DashboardMeResponse:
942
+ """The signed-in account. Guests and implicit admins have no account and get 401."""
943
+
944
+ try:
945
+ return await context.service.me(request.cookies.get(DASHBOARD_SESSION_COOKIE))
946
+ except PasswordSessionRequiredError as exc:
947
+ raise DashboardAuthError("A user account session is required", code="user_account_required") from exc
948
+ except TotpVerificationRequiredError as exc:
949
+ raise DashboardAuthError(str(exc), code="totp_required") from exc
950
+ except TotpEnrollmentRequiredError as exc:
951
+ raise _enrollment_required(exc) from exc
952
+
953
+
954
+ @router.patch("/me", response_model=DashboardMeResponse)
955
+ async def update_me(
956
+ request: Request,
957
+ payload: ProfileUpdateRequest = Body(...),
958
+ context: DashboardAuthContext = Depends(get_dashboard_auth_context),
959
+ users: DashboardUsersContext = Depends(get_dashboard_users_context),
960
+ ) -> DashboardMeResponse:
961
+ """Self-service profile edit (display name, e-mail) for the signed-in account."""
962
+
963
+ resolved = await _require_management_session(request, context, allow_unenrolled=True)
964
+ with mapped_user_errors():
965
+ await users.service.update_profile(resolved.user, payload, actor_ip=_client_host(request))
966
+ return await context.service.me(request.cookies.get(DASHBOARD_SESSION_COOKIE))
967
+
968
+
969
+ @router.get("/invite/{token}", response_model=InviteDescriptionResponse)
970
+ async def describe_invite(
971
+ token: str,
972
+ request: Request,
973
+ context: DashboardAuthContext = Depends(get_dashboard_auth_context),
974
+ users: DashboardUsersContext = Depends(get_dashboard_users_context),
975
+ ) -> InviteDescriptionResponse:
976
+ """Unauthenticated: what the acceptance screen shows. Every invalid token is the same 404."""
977
+
978
+ limiter = get_invite_lookup_rate_limiter()
979
+ try:
980
+ await limiter.check_and_increment(_session_client_key(request, prefix="invite_lookup"), context.session)
981
+ except DashboardRateLimitError as exc:
982
+ raise _rate_limit_error(exc, code="invite_rate_limited") from exc
983
+ try:
984
+ description = await users.service.describe_invite(token)
985
+ except InviteNotFoundError as exc:
986
+ raise DashboardNotFoundError(str(exc), code="invite_not_found") from exc
987
+ return InviteDescriptionResponse(
988
+ role_name=description.role_name,
989
+ inviter_display_name=description.inviter_display_name,
990
+ suggested_username=description.suggested_username,
991
+ username_locked=description.username_locked,
992
+ expires_at=description.expires_at,
993
+ )
994
+
995
+
996
+ @router.post("/invite/accept", response_model=DashboardAuthSessionResponse)
997
+ async def accept_invite(
998
+ request: Request,
999
+ payload: InviteAcceptRequest = Body(...),
1000
+ context: DashboardAuthContext = Depends(get_dashboard_auth_context),
1001
+ users: DashboardUsersContext = Depends(get_dashboard_users_context),
1002
+ ) -> DashboardAuthSessionResponse | JSONResponse:
1003
+ """Unauthenticated: set the invited account's password, activate it, and sign it in."""
1004
+
1005
+ if get_settings().dashboard_auth_mode == DashboardAuthMode.DISABLED:
1006
+ raise DashboardBadRequestError(
1007
+ "Invites cannot be accepted while dashboard auth is bypassed", code="password_management_disabled"
1008
+ )
1009
+ if await context.service.resolve_user_session(request.cookies.get(DASHBOARD_SESSION_COOKIE)) is not None:
1010
+ raise DashboardConflictError("Sign out before accepting an invite", code="already_signed_in")
1011
+ password = payload.password.strip()
1012
+ _validate_password_length(password)
1013
+
1014
+ try:
1015
+ await get_invite_accept_rate_limiter().check_and_increment(
1016
+ _session_client_key(request, prefix="invite_accept"), context.session
1017
+ )
1018
+ await get_invite_accept_token_rate_limiter().check_and_increment(
1019
+ f"invite_accept_token:{invite_token_hash(payload.token).hex()}", context.session
1020
+ )
1021
+ except DashboardRateLimitError as exc:
1022
+ raise _rate_limit_error(exc, code="invite_rate_limited") from exc
1023
+
1024
+ try:
1025
+ with mapped_user_errors():
1026
+ user = await users.service.accept_invite(
1027
+ payload.token,
1028
+ username=payload.username,
1029
+ password_hash=hash_password(password),
1030
+ display_name=payload.display_name,
1031
+ actor_ip=_client_host(request),
1032
+ )
1033
+ except InviteNotFoundError as exc:
1034
+ raise DashboardNotFoundError(str(exc), code="invite_not_found") from exc
1035
+ except UsernameLockedError as exc:
1036
+ raise DashboardValidationError(str(exc), code="username_locked") from exc
1037
+
1038
+ await _invalidate_auth_caches()
1039
+ return await _issue_user_session_response(request, context, user, totp_verified=False, auth_method="password")
1040
+
1041
+
1042
+ @router.post("/totp/setup/start", response_model=TotpSetupStartResponse)
1043
+ async def start_totp_setup(
1044
+ request: Request,
1045
+ context: DashboardAuthContext = Depends(get_dashboard_auth_context),
1046
+ ) -> TotpSetupStartResponse:
1047
+ _ensure_password_management_enabled(request)
1048
+ resolved = await _resolve_account_session(request, context)
1049
+ session_id = request.cookies.get(DASHBOARD_SESSION_COOKIE)
1050
+ try:
1051
+ return await context.service.start_totp_setup(session_id=session_id, resolved=resolved)
1052
+ except PasswordSessionRequiredError as exc:
1053
+ raise DashboardAuthError(str(exc)) from exc
1054
+ except TotpAlreadyConfiguredError as exc:
1055
+ raise DashboardBadRequestError(str(exc), code="invalid_totp_setup") from exc
1056
+
1057
+
1058
+ @router.post("/totp/setup/confirm")
1059
+ async def confirm_totp_setup(
1060
+ request: Request,
1061
+ payload: TotpSetupConfirmRequest = Body(...),
1062
+ context: DashboardAuthContext = Depends(get_dashboard_auth_context),
1063
+ ) -> JSONResponse:
1064
+ _ensure_password_management_enabled(request)
1065
+ resolved = await _resolve_account_session(request, context)
1066
+
1067
+ limiter = get_totp_rate_limiter()
1068
+ rate_key = _session_client_key(request, prefix="totp_setup_confirm")
1069
+ try:
1070
+ await limiter.check_and_increment(rate_key, context.session)
1071
+ except DashboardRateLimitError as exc:
1072
+ raise _rate_limit_error(exc, code="totp_rate_limited") from exc
1073
+
1074
+ try:
1075
+ session_id = request.cookies.get(DASHBOARD_SESSION_COOKIE)
1076
+ await context.service.confirm_totp_setup(
1077
+ session_id=session_id,
1078
+ secret=payload.secret,
1079
+ code=payload.code,
1080
+ actor_ip=_client_host(request),
1081
+ resolved=resolved,
1082
+ )
1083
+ except PasswordSessionRequiredError as exc:
1084
+ raise DashboardAuthError(str(exc)) from exc
1085
+ except TotpInvalidCodeError as exc:
1086
+ raise DashboardBadRequestError(str(exc), code="invalid_totp_code") from exc
1087
+ except TotpInvalidSetupError as exc:
1088
+ raise DashboardBadRequestError(str(exc), code="invalid_totp_setup") from exc
1089
+ except TotpAlreadyConfiguredError as exc:
1090
+ raise DashboardBadRequestError(str(exc), code="invalid_totp_setup") from exc
1091
+
1092
+ await limiter.clear_for_key(rate_key, context.session)
1093
+ await _invalidate_auth_caches()
1094
+ # The enrolment itself does not mint a TOTP-verified session: whenever the
1095
+ # new secret makes the second factor mandatory the account presents it
1096
+ # once through ``/totp/verify``, which is the shape the enrolment gate
1097
+ # already has for the global toggle.
1098
+ return JSONResponse(status_code=200, content={"status": "ok"})
1099
+
1100
+
1101
+ @router.post("/totp/verify", response_model=DashboardAuthSessionResponse)
1102
+ async def verify_totp(
1103
+ request: Request,
1104
+ payload: TotpVerifyRequest = Body(...),
1105
+ context: DashboardAuthContext = Depends(get_dashboard_auth_context),
1106
+ ) -> DashboardAuthSessionResponse | JSONResponse:
1107
+ _ensure_password_management_enabled(request)
1108
+ limiter = get_totp_rate_limiter()
1109
+ rate_key = _session_client_key(request, prefix="totp_verify")
1110
+ current_session_id = request.cookies.get(DASHBOARD_SESSION_COOKIE)
1111
+ resolved = await _resolve_account_session(request, context)
1112
+ try:
1113
+ await limiter.check_and_increment(rate_key, context.session)
1114
+ except DashboardRateLimitError as exc:
1115
+ raise _rate_limit_error(exc, code="totp_rate_limited") from exc
1116
+ if _is_header_account(resolved):
1117
+ # No session cookie to upgrade: a valid code is a step-up for the header account.
1118
+ return await _step_up_header_account(request, context, resolved.user, limiter, rate_key, code=payload.code)
1119
+ try:
1120
+ configured_ttl_seconds = (await get_settings_cache().get()).dashboard_session_ttl_seconds
1121
+ session_ttl_seconds = _session_ttl_seconds(request, resolved.user, configured_ttl_seconds)
1122
+ session_id, applied_ttl_seconds = await context.service.verify_totp(
1123
+ session_id=current_session_id,
1124
+ code=payload.code,
1125
+ ttl_seconds=session_ttl_seconds,
1126
+ actor_ip=_client_host(request),
1127
+ )
1128
+ except PasswordSessionRequiredError as exc:
1129
+ raise DashboardAuthError(str(exc)) from exc
1130
+ except TotpInvalidCodeError as exc:
1131
+ raise DashboardBadRequestError(str(exc), code="invalid_totp_code") from exc
1132
+ except TotpNotConfiguredError as exc:
1133
+ raise DashboardBadRequestError(str(exc), code="invalid_totp_code") from exc
1134
+
1135
+ await limiter.clear_for_key(rate_key, context.session)
1136
+ await get_dashboard_users_cache().invalidate()
1137
+ response = await _decorate_session_response(
1138
+ await context.service.describe_session(session_id), request=request, context=context
1139
+ )
1140
+ json_response = JSONResponse(status_code=200, content=response.model_dump(by_alias=True))
1141
+ _set_session_cookie(json_response, session_id, request, max_age_seconds=applied_ttl_seconds)
1142
+ return json_response
1143
+
1144
+
1145
+ @router.post("/totp/disable")
1146
+ async def disable_totp(
1147
+ request: Request,
1148
+ payload: TotpVerifyRequest = Body(...),
1149
+ context: DashboardAuthContext = Depends(get_dashboard_auth_context),
1150
+ ) -> JSONResponse:
1151
+ _ensure_password_management_enabled(request)
1152
+ limiter = get_totp_rate_limiter()
1153
+ rate_key = _session_client_key(request, prefix="totp_disable")
1154
+ session_id = request.cookies.get(DASHBOARD_SESSION_COOKIE)
1155
+ # A header account has no TOTP-verified cookie; its valid code is the verification.
1156
+ header_account = await _header_account_session(request)
1157
+ if header_account is None:
1158
+ try:
1159
+ await context.service.ensure_totp_verified_session(session_id)
1160
+ except PasswordSessionRequiredError as exc:
1161
+ raise DashboardAuthError(str(exc)) from exc
1162
+ except TotpEnrollmentRequiredError as exc:
1163
+ raise _enrollment_required(exc) from exc
1164
+ try:
1165
+ await limiter.check_and_increment(rate_key, context.session)
1166
+ except DashboardRateLimitError as exc:
1167
+ raise _rate_limit_error(exc, code="totp_rate_limited") from exc
1168
+ try:
1169
+ user = await context.service.disable_totp(
1170
+ session_id=session_id,
1171
+ code=payload.code,
1172
+ actor_ip=_client_host(request),
1173
+ resolved=header_account,
1174
+ )
1175
+ except PasswordSessionRequiredError as exc:
1176
+ raise DashboardAuthError(str(exc)) from exc
1177
+ except TotpInvalidCodeError as exc:
1178
+ raise DashboardBadRequestError(str(exc), code="invalid_totp_code") from exc
1179
+ except TotpNotConfiguredError as exc:
1180
+ raise DashboardBadRequestError(str(exc), code="invalid_totp_code") from exc
1181
+ except LastBreakGlassProtectedError as exc:
1182
+ raise DashboardConflictError(str(exc), code="last_break_glass_protected") from exc
1183
+
1184
+ await limiter.clear_for_key(rate_key, context.session)
1185
+ await _invalidate_auth_caches()
1186
+ # Disabling is allowed even when TOTP was the account's only step-up
1187
+ # method; the client is told so it can point at enrolling again.
1188
+ return JSONResponse(status_code=200, content={"status": "ok", "stepUpAvailable": user.password_hash is not None})
1189
+
1190
+
1191
+ @router.post("/step-up", response_model=StepUpResponse)
1192
+ async def step_up(
1193
+ request: Request,
1194
+ payload: StepUpRequest = Body(...),
1195
+ context: DashboardAuthContext = Depends(get_dashboard_auth_context),
1196
+ ) -> JSONResponse:
1197
+ """Re-verify the signed-in account for sensitive changes (PLAN §5 H5).
1198
+
1199
+ Password accounts present their password (and their TOTP code when they
1200
+ have a secret); provider-only accounts present their TOTP code. Success
1201
+ stamps ``su`` into a re-issued session cookie, or — for a trusted-header
1202
+ account, which has no session cookie — into the short-lived step-up cookie.
1203
+ """
1204
+
1205
+ principal = await validate_dashboard_session(request)
1206
+ if principal.user_id is None:
1207
+ raise DashboardAuthError("A user account session is required", code="user_account_required")
1208
+ user = await context.repository.get_user_by_id(principal.user_id)
1209
+ if user is None:
1210
+ raise DashboardAuthError("Authentication is required")
1211
+ limiter = get_password_rate_limiter()
1212
+ rate_key = _session_client_key(request, prefix="step_up")
1213
+ try:
1214
+ await limiter.check_and_increment(rate_key, context.session)
1215
+ except DashboardRateLimitError as exc:
1216
+ raise _rate_limit_error(exc, code="step_up_rate_limited") from exc
1217
+ try:
1218
+ await context.service.verify_step_up(
1219
+ user,
1220
+ password=payload.password,
1221
+ code=payload.code,
1222
+ actor_ip=_client_host(request),
1223
+ auth_method=principal.auth_method,
1224
+ )
1225
+ except StepUpUnavailableError as exc:
1226
+ raise DashboardPermissionError(STEP_UP_UNAVAILABLE_MESSAGE, code="step_up_unavailable") from exc
1227
+ except InvalidCredentialsError as exc:
1228
+ raise DashboardAuthError(str(exc), code="invalid_credentials") from exc
1229
+ await limiter.clear_for_key(rate_key, context.session)
1230
+ await get_dashboard_users_cache().invalidate()
1231
+
1232
+ verified_at = session_clock()
1233
+ response = _step_up_response(verified_at)
1234
+ await record_step_up_on(response, request, user, verified_at=verified_at)
1235
+ return response
1236
+
1237
+
1238
+ async def record_step_up_on(response: Response, request: Request, user: DashboardUser, *, verified_at: int) -> None:
1239
+ """Write a completed step-up onto ``response``, by whichever of the two paths fits.
1240
+
1241
+ A cookie session carries the proof in its own ``su`` claim; a principal
1242
+ that has no session cookie (a trusted-header account) carries it in the
1243
+ generation-bound step-up cookie. Shared with the OIDC step-up completion so
1244
+ that flow mints the proof through these same two paths instead of adding a
1245
+ third: one function, one set of cookie attributes, one lifetime.
1246
+ """
1247
+
1248
+ state = get_dashboard_session_store().get(request.cookies.get(DASHBOARD_SESSION_COOKIE))
1249
+ if state is not None and state.is_user and state.user_id == user.id and state.password_verified:
1250
+ # Keep the session exactly as it was (method, TOTP step, remaining life); only ``su`` changes.
1251
+ session_id, session_ttl_seconds = await _create_user_session(
1252
+ request,
1253
+ user,
1254
+ totp_verified=state.totp_verified,
1255
+ auth_method=state.auth_method or "password",
1256
+ max_ttl_seconds=max(1, state.expires_at - verified_at),
1257
+ step_up_verified_at=verified_at,
1258
+ )
1259
+ _set_session_cookie(response, session_id, request, max_age_seconds=session_ttl_seconds)
1260
+ else:
1261
+ _set_step_up_cookie(response, user, request, verified_at=verified_at)
1262
+
1263
+
1264
+ async def _step_up_header_account(
1265
+ request: Request,
1266
+ context: DashboardAuthContext,
1267
+ user: DashboardUser,
1268
+ limiter: DatabaseRateLimiter,
1269
+ rate_key: str,
1270
+ *,
1271
+ code: str,
1272
+ ) -> JSONResponse:
1273
+ """``/totp/verify`` for a trusted-header account: the code proves the account and mints the step-up cookie."""
1274
+
1275
+ try:
1276
+ await context.service.verify_step_up(
1277
+ user,
1278
+ password=None,
1279
+ code=code,
1280
+ actor_ip=_client_host(request),
1281
+ auth_method=DashboardAuthMode.TRUSTED_HEADER.value,
1282
+ )
1283
+ except (InvalidCredentialsError, StepUpUnavailableError) as exc:
1284
+ raise DashboardBadRequestError("Invalid TOTP code", code="invalid_totp_code") from exc
1285
+ await limiter.clear_for_key(rate_key, context.session)
1286
+ verified_at = session_clock()
1287
+ response = await _decorate_session_response(
1288
+ await context.service.describe_session(None), request=request, context=context
1289
+ )
1290
+ json_response = JSONResponse(status_code=200, content=response.model_dump(by_alias=True))
1291
+ _set_step_up_cookie(json_response, user, request, verified_at=verified_at)
1292
+ return json_response
1293
+
1294
+
1295
+ def _step_up_response(verified_at: int) -> JSONResponse:
1296
+ body = StepUpResponse(verified_at=verified_at, expires_at=step_up_expires_at(verified_at))
1297
+ return JSONResponse(status_code=200, content=body.model_dump(by_alias=True))
1298
+
1299
+
1300
+ def _set_step_up_cookie(response: Response, user: DashboardUser, request: Request, *, verified_at: int) -> None:
1301
+ response.set_cookie(
1302
+ key=STEP_UP_COOKIE,
1303
+ value=get_step_up_cookie_store().create(
1304
+ user.id, session_generation=user.session_generation, verified_at=verified_at
1305
+ ),
1306
+ httponly=True,
1307
+ secure=request.url.scheme == "https",
1308
+ samesite="lax",
1309
+ max_age=step_up_expires_at(verified_at) - verified_at,
1310
+ path="/",
1311
+ )
1312
+
1313
+
1314
+ @router.post("/logout")
1315
+ async def logout_dashboard(
1316
+ request: Request,
1317
+ context: DashboardAuthContext = Depends(get_dashboard_auth_context),
1318
+ ) -> JSONResponse:
1319
+ session_id = request.cookies.get(DASHBOARD_SESSION_COOKIE)
1320
+ context.service.logout(session_id)
1321
+ response = JSONResponse(status_code=200, content={"status": "ok"})
1322
+ response.delete_cookie(key=DASHBOARD_SESSION_COOKIE, path="/")
1323
+ # The pending screen's Logout is how a refused person leaves it, so the
1324
+ # refusal marker goes with the session; otherwise the screen comes back.
1325
+ clear_pending_marker(response)
1326
+ return response
1327
+
1328
+
1329
+ def _set_session_cookie(response: Response, session_id: str, request: Request, *, max_age_seconds: int) -> None:
1330
+ response.set_cookie(
1331
+ key=DASHBOARD_SESSION_COOKIE,
1332
+ value=session_id,
1333
+ httponly=True,
1334
+ secure=request.url.scheme == "https",
1335
+ samesite="lax",
1336
+ max_age=max_age_seconds,
1337
+ path="/",
1338
+ )
1339
+
1340
+
1341
+ # The OIDC sign-in routes are written in their own module but mount on *this*
1342
+ # router: one prefix, one error format, one set of middleware exemptions, no
1343
+ # second ``include_router``. Imported last, when ``router`` exists.
1344
+ from app.modules.dashboard_auth import oidc_api as _oidc_api # noqa: E402,F401