Coverage for src/local_deep_research/web/routers/settings.py: 96%

1575 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1""" 

2Settings Routes Module 

3 

4This module handles all settings-related HTTP endpoints for the application. 

5 

6CHECKBOX HANDLING PATTERN: 

7-------------------------- 

8This module supports TWO submission modes to handle checkboxes correctly: 

9 

10**MODE 1: AJAX/JSON Submission (Primary - /save_all_settings)** 

11- JavaScript intercepts form submission with e.preventDefault() 

12- Checkbox values read directly from DOM via checkbox.checked 

13- Data sent as JSON: {"setting.key": true/false} 

14- Hidden fallback inputs are managed but NOT used in this mode 

15- Provides better UX with instant feedback and validation 

16 

17**MODE 2: Traditional POST Submission (Fallback - /save_settings)** 

18- Used when JavaScript is disabled (accessibility/no-JS environments) 

19- Browser submits form data naturally via request.form 

20- Hidden fallback pattern CRITICAL here: 

21 * Checked checkbox: Submits checkbox value, hidden input disabled 

22 * Unchecked checkbox: Submits hidden input value "false" 

23- Ensures unchecked checkboxes are captured (HTML limitation workaround) 

24 

25**Implementation Details:** 

261. Each checkbox has `data-hidden-fallback` attribute → hidden input ID 

272. checkbox_handler.js manages hidden input disabled state 

283. AJAX mode: settings.js reads checkbox.checked directly (lines 2233-2240) 

294. POST mode: Flask reads request.form including enabled hidden inputs 

305. Both modes use convert_setting_value() for consistent boolean conversion 

31 

32**Why Both Patterns?** 

33- AJAX: Better UX, immediate validation, no page reload 

34- Traditional POST: Accessibility, progressive enhancement, JavaScript-free operation 

35- Hidden inputs: Only meaningful for traditional POST, ignored in AJAX mode 

36 

37This dual-mode approach ensures the app works for all users while providing 

38optimal experience when JavaScript is available. 

39""" 

40 

41from fastapi import APIRouter, Depends, HTTPException, Request 

42from fastapi.responses import JSONResponse, RedirectResponse 

43from ..dependencies.auth import ( 

44 require_auth, 

45) 

46from ..dependencies.rate_limit import ( 

47 SETTINGS_RATE_LIMIT, 

48 _user_key, 

49 limiter, 

50 settings_limit, 

51) 

52from ..dependencies.threadpool import run_db_sync, run_model_discovery_sync 

53from ..template_config import templates 

54from .notes import _log_value_preview 

55 

56import asyncio 

57import json 

58import math 

59import platform 

60import threading 

61import time 

62from types import SimpleNamespace 

63from typing import Any, Optional, Tuple, Annotated 

64from datetime import UTC, datetime, timedelta, timezone 

65 

66import requests 

67 

68from loguru import logger 

69from sqlalchemy.orm import Session 

70 

71from ...config.constants import DEFAULT_OLLAMA_URL 

72from ...config.paths import ( 

73 get_custom_data_directory, 

74 get_data_directory, 

75 get_encrypted_database_path, 

76) 

77from ...constants import DEFAULT_SEARCH_TOOL 

78from ...database.models.rate_limiting import RateLimitEstimate 

79from ...database.models.settings import Setting, SettingType 

80from ...database.session_context import get_user_db_session 

81from ...database.encrypted_db import db_manager 

82from ...database.sqlcipher_utils import retry_on_db_lock 

83from ...llm.providers.base import normalize_provider 

84from ...security.egress.policy import ( 

85 DEFAULT_EGRESS_SCOPE, 

86 Decision, 

87 EgressContext, 

88 EgressScope, 

89 PolicyDeniedError, 

90 coerce_policy_bool, 

91 context_from_snapshot, 

92 effective_scope_for_display, 

93 evaluate_llm_endpoint, 

94 parse_user_egress_scope, 

95 resolve_run_primary_engine, 

96 unprotected_egress_allowed, 

97) 

98from ...security.egress.validators import ( 

99 first_egress_validation_error, 

100) 

101from ...utilities.db_utils import get_settings_manager 

102from ...utilities.type_utils import to_positive_int, unwrap_setting 

103from ...utilities.url_utils import normalize_url 

104 

105from ...settings.exceptions import SettingsValueError 

106from ...settings.manager import ( 

107 UI_ELEMENT_TO_SETTING_TYPE, 

108 _infer_ui_element, 

109 check_env_setting, 

110 get_typed_setting_value, 

111 is_operator_only_setting, 

112 is_valid_setting_key, 

113 parse_boolean, 

114) 

115from ..services.settings_service import ( 

116 create_or_update_setting, 

117 invalidate_settings_caches, 

118 reschedule_document_jobs_if_needed, 

119 reschedule_zotero_jobs_if_needed, 

120 set_setting, 

121) 

122 

123from ...security import safe_get 

124 

125# ``_is_empty_value`` is deliberately imported private (#6201): it is HALF 

126# of the predicate ``redact_value`` gates masking on -- the other half is 

127# ``is_sensitive_setting(key, ...)`` (``data_sanitizer.py``), which the 

128# write-back merge below does NOT consult. The merge must restore a masked 

129# leaf from at least the set of stored values that got masked in the first 

130# place -- a merge rule NARROWER than the mask rule's emptiness half turns 

131# an untouched GET-then-PUT round-trip into an unsatisfiable 400. Sharing 

132# this one predicate instead of re-deriving it keeps that emptiness half in 

133# lockstep; it does NOT make the merge equivalent to the mask rule as a 

134# whole, since the merge restores on the submitted leaf being the sentinel 

135# regardless of whether that leaf's own sub-key is sensitive -- e.g. a 

136# stored ``{"port": 19530}`` is never masked (``port`` is not sensitive), 

137# but a submitted ``{"port": "[REDACTED]"}`` over it is still restored to 

138# ``19530``. Narrowing ``DEFAULT_SENSITIVE_KEYS`` / ``is_sensitive_setting`` 

139# therefore does NOT narrow what this merge will restore. 

140from ...security.data_sanitizer import DataSanitizer, _is_empty_value 

141from ..warning_checks import calculate_warnings 

142from ..dependencies.json_body import json_body_error, read_json_dict 

143 

144# Create the router for settings 

145router = APIRouter(prefix="/settings", tags=["settings"]) 

146 

147# NOTE: Routes use username (not .get()) intentionally. 

148# Depends(require_auth) guarantees the key exists; direct access fails 

149# fast if the dependency is ever removed. 

150 

151# Settings with dynamically populated options (excluded from validation) 

152DYNAMIC_SETTINGS = ["llm.provider", "llm.model", "search.tool"] 

153 

154# Settings whose changes trigger a warning recalculation. 

155WARNING_AFFECTING_KEYS = frozenset( 

156 [ 

157 "llm.provider", 

158 "search.tool", 

159 "search.iterations", 

160 "search.questions_per_iteration", 

161 "llm.local_context_window_size", 

162 "llm.context_window_unrestricted", 

163 "llm.context_window_size", 

164 "policy.egress_scope", 

165 "llm.require_local_endpoint", 

166 "embeddings.require_local", 

167 ] 

168) 

169 

170 

171def _shape_egress_scope_setting(key: str, metadata: Any) -> Any: 

172 """Gate the operator-disabled "unprotected" escape hatch out of the 

173 egress-scope setting's ``options`` and normalise its displayed 

174 ``value`` wherever ``policy.egress_scope`` metadata is served to the 

175 settings UI. 

176 

177 Ports the option-filtering / display-normalisation half of main's 

178 ``web/routes/settings_routes.py::_shape_egress_scope_metadata`` 

179 (87537d9ec, "fix(security): operator-gate unprotected egress and 

180 harden policy-sensitive consumers", #5148). Without this, the select's 

181 ``options`` list (sourced from ``defaults/default_settings.json``, 

182 which unconditionally lists "unprotected") is served to the browser 

183 as-is, so the settings dashboard would offer the escape hatch even 

184 when the operator never set ``LDR_POLICY_ALLOW_UNPROTECTED_EGRESS``. 

185 

186 NOTE: main's ``_shape_egress_scope_metadata`` also overlays an env-var 

187 lock onto ``value``/``editable`` for the setting (a presentation cue 

188 for *any* environment-overridden setting). That is a separate, much 

189 broader feature — this branch's ``SettingsManager.get_all_settings()`` 

190 (see ``settings/manager.py``, ~line 977) already overlays 

191 ``LDR_*`` env values onto ``value``/``editable`` uniformly for every 

192 setting key, including ``policy.egress_scope`` — so it is intentionally 

193 NOT duplicated here; doing so would just re-apply the same override a 

194 second time. 

195 """ 

196 if key != "policy.egress_scope" or not isinstance(metadata, dict): 

197 return metadata 

198 shaped = dict(metadata) 

199 options = shaped.get("options") 

200 if isinstance(options, list) and not unprotected_egress_allowed(): 

201 shaped["options"] = [ 

202 option 

203 for option in options 

204 if str(option.get("value") if isinstance(option, dict) else option) 

205 .strip() 

206 .lower() 

207 != EgressScope.UNPROTECTED.value 

208 ] 

209 shaped["value"] = effective_scope_for_display(shaped.get("value")) 

210 return shaped 

211 

212 

213def _shape_pdf_storage_mode_setting(key: str, metadata: Any) -> Any: 

214 """Hide the operator-gated unencrypted 'filesystem' PDF storage option. 

215 

216 Ports main's ``_shape_pdf_storage_mode_metadata`` 

217 (web/routes/settings_routes.py, fb49985aa, "operator-gate unprotected 

218 egress and harden policy-sensitive consumers", #5148). Mirrors 

219 ``_shape_egress_scope_setting``: when the operator gate 

220 ``research_library.allow_filesystem_pdf_storage`` is off (the default), 

221 the ``filesystem`` choice is stripped from the 

222 ``research_library.pdf_storage_mode`` options served to the settings UI, 

223 leaving the encrypted ``database`` default and ``none``. 

224 Consumption-site coercion (``resolve_pdf_storage_mode``) is the actual 

225 protection; this just keeps a disabled option out of the dropdown so a 

226 user doesn't pick a value that is silently coerced away later. 

227 """ 

228 if key != "research_library.pdf_storage_mode" or not isinstance( 

229 metadata, dict 

230 ): 

231 return metadata 

232 options = metadata.get("options") 

233 if not isinstance(options, list): 233 ↛ 234line 233 didn't jump to line 234 because the condition on line 233 was never true

234 return metadata 

235 # Lazy import to avoid pulling the research_library package in at 

236 # settings-router module load time. 

237 from ...research_library.services.pdf_storage_manager import ( 

238 filesystem_pdf_storage_allowed, 

239 ) 

240 

241 if filesystem_pdf_storage_allowed(): 

242 return metadata 

243 shaped = dict(metadata) 

244 shaped["options"] = [ 

245 option 

246 for option in options 

247 if str(option.get("value") if isinstance(option, dict) else option) 

248 .strip() 

249 .lower() 

250 != "filesystem" 

251 ] 

252 return shaped 

253 

254 

255def _apply_env_override( 

256 settings_manager, key: str, value: Any, editable: bool 

257) -> Tuple[Any, bool]: 

258 """Overlay the LDR_* env-var value/editable state onto a single-key 

259 lookup. 

260 

261 Reuses ``SettingsManager.get_all_settings()`` — the exact overlay 

262 ``GET /settings/api`` and ``GET /settings/api/bulk`` already apply via 

263 ``check_env_setting``/``get_typed_setting_value`` (see 

264 ``settings/manager.py``, ~line 977) — instead of reimplementing that 

265 logic here. Without this, ``GET /settings/api/{key}`` (single-key) 

266 returned the stale DB value with ``editable=True`` for a setting an 

267 operator had pinned via an LDR_* env var, while the bulk endpoints 

268 correctly reported the effective value and ``editable=False`` for the 

269 same key. This is a read/display fix only: writes to an env-locked 

270 setting were already rejected server-side by 

271 ``SettingsManager._is_environment_locked`` regardless of this bug. 

272 """ 

273 effective = settings_manager.get_all_settings().get(key) 

274 if effective is None: 

275 return value, editable 

276 return ( 

277 effective.get("value", value), 

278 effective.get("editable", editable), 

279 ) 

280 

281 

282def _filter_editable_settings(form_data: dict, db_session: Session) -> dict: 

283 """Remove operator-locked and non-editable keys from *form_data* in place. 

284 

285 Operator-locked means an ``LDR_*`` environment variable pins the key 

286 (``check_env_setting``). ``SettingsManager.set_setting`` refuses those 

287 writes anyway, but it reports the refusal as a plain ``False``, which 

288 the bulk write paths count as a *failed* save — so any ``LDR_*`` 

289 variable made every no-JS form POST flash "Saved with N setting(s) 

290 failing" even though every editable key saved fine. Main dropped them 

291 here instead; this restores that (#5978). 

292 

293 Returns a dict of *all* ``{key: Setting}`` records from the database 

294 so callers can reuse it for further validation (e.g. egress-policy checks). 

295 """ 

296 all_db_settings = { 

297 setting.key: setting for setting in db_session.query(Setting).all() 

298 } 

299 

300 non_editable_keys = [ 

301 key 

302 for key in form_data.keys() 

303 if is_operator_only_setting(key) 

304 or check_env_setting(key) is not None 

305 or (key in all_db_settings and not all_db_settings[key].editable) 

306 ] 

307 if non_editable_keys: 

308 logger.bind(policy_audit=True).warning( 

309 "Skipping operator-locked or non-editable settings: {}", 

310 non_editable_keys, 

311 ) 

312 for key in non_editable_keys: 

313 del form_data[key] 

314 

315 return all_db_settings 

316 

317 

318def _resolve_model_discovery_policy( 

319 username: str, 

320) -> tuple[EgressContext, dict[str, Any]]: 

321 """Resolve the current egress policy before model cache or network access. 

322 

323 Ported from main's ``web/routes/settings_routes.py`` (87537d9ec, 

324 "fix(security): operator-gate unprotected egress and harden 

325 policy-sensitive consumers", #5148); the Flask module was deleted by 

326 the FastAPI migration and this gate came across as a failing test only. 

327 

328 Fails CLOSED: any settings failure (or a non-dict snapshot) raises 

329 ``PolicyDeniedError`` instead of falling back to "allow". The previous 

330 ``_model_list_local_only`` helper returned False (allow) on error, so a 

331 settings outage silently downgraded a local-only user to "list every 

332 cloud provider" — which reads their stored API key and sends it to the 

333 provider's model-listing endpoint. 

334 """ 

335 settings_snapshot = None 

336 try: 

337 with get_user_db_session(username) as db_session: 

338 if db_session: 338 ↛ 354line 338 didn't jump to line 354

339 settings_manager = get_settings_manager(db_session, username) 

340 settings_snapshot = settings_manager.get_settings_snapshot( 

341 strict=True 

342 ) 

343 except PolicyDeniedError: 

344 raise 

345 except Exception: 

346 logger.bind(policy_audit=True).warning( 

347 "available-model policy settings unavailable" 

348 ) 

349 raise PolicyDeniedError( 

350 Decision(False, "settings_unavailable"), 

351 target="available_models", 

352 ) from None 

353 

354 if not isinstance(settings_snapshot, dict): 354 ↛ 355line 354 didn't jump to line 355 because the condition on line 354 was never true

355 raise PolicyDeniedError( 

356 Decision(False, "settings_unavailable"), 

357 target="available_models", 

358 ) 

359 

360 scope_raw = check_env_setting("policy.egress_scope") 

361 if scope_raw is None: 

362 scope_raw = settings_snapshot.get( 

363 "policy.egress_scope", DEFAULT_EGRESS_SCOPE 

364 ) 

365 if str(scope_raw).strip().lower() == EgressScope.BOTH.value: 365 ↛ 366line 365 didn't jump to line 366 because the condition on line 365 was never true

366 scope_raw = EgressScope.ADAPTIVE.value 

367 parse_user_egress_scope(scope_raw) 

368 primary = resolve_run_primary_engine( 

369 settings_snapshot, default=DEFAULT_SEARCH_TOOL 

370 ) 

371 return ( 

372 context_from_snapshot( 

373 settings_snapshot, 

374 primary, 

375 username=username, 

376 ), 

377 settings_snapshot, 

378 ) 

379 

380 

381def _model_discovery_provider_decision( 

382 provider: str, 

383 policy_context: EgressContext, 

384 settings_snapshot: dict[str, Any], 

385) -> Decision: 

386 """Return the policy decision for a provider's configured endpoint.""" 

387 if not policy_context.require_local_llm: 

388 return Decision(True, "no_local_requirement") 

389 decision = evaluate_llm_endpoint( 

390 normalize_provider(provider), 

391 policy_context, 

392 settings_snapshot=settings_snapshot, 

393 ) 

394 if not decision.allowed: 

395 logger.bind(policy_audit=True).info( 

396 "available-model provider denied by egress policy", 

397 provider=normalize_provider(provider), 

398 reason=decision.reason, 

399 ) 

400 return decision 

401 

402 

403def _model_discovery_provider_allowed( 

404 provider: str, 

405 policy_context: EgressContext, 

406 settings_snapshot: dict[str, Any], 

407) -> bool: 

408 """Return whether a provider's configured endpoint is allowed to list models.""" 

409 return _model_discovery_provider_decision( 

410 provider, policy_context, settings_snapshot 

411 ).allowed 

412 

413 

414# Maps the policy's internal denial reason onto a short phrase describing 

415# what the user would have to change. Anything unmapped falls back to the 

416# control clause alone rather than leaking a raw reason token into the UI. 

417_PROVIDER_DENIAL_CAUSES = { 

418 "provider_cloud_only": "cloud-only provider", 

419 "provider_url_unset": "no endpoint URL configured", 

420 "url_malformed": "endpoint URL is malformed", 

421 "provider_remote": "endpoint URL is not local", 

422} 

423 

424 

425def _configured_egress_scope( 

426 settings_snapshot: dict[str, Any] | None, 

427) -> EgressScope | None: 

428 """Return the scope the user actually selected, or None if unknown. 

429 

430 ``EgressContext.scope`` is the RESOLVED scope: ``context_from_snapshot`` 

431 runs ``_resolve_adaptive_scope``, so the default ``adaptive`` selection 

432 is stored as ``PRIVATE_ONLY`` whenever the primary search engine is 

433 local. Naming that resolved value in the UI sends the user to a control 

434 they never touched — their Egress Scope select still reads "Adaptive". 

435 The configured value survives only on the settings snapshot, so read it 

436 back from there, env-first — the same precedence 

437 ``_resolve_model_discovery_policy`` and ``context_from_snapshot`` use 

438 for this setting. 

439 """ 

440 raw = check_env_setting("policy.egress_scope") 

441 if raw is None: 

442 if not isinstance(settings_snapshot, dict): 442 ↛ 443line 442 didn't jump to line 443 because the condition on line 442 was never true

443 return None 

444 raw = unwrap_setting( 

445 settings_snapshot.get("policy.egress_scope", DEFAULT_EGRESS_SCOPE) 

446 ) 

447 # Retired `both` is coerced to the protective default, as every other 

448 # reader of this setting does (see context_from_snapshot). 

449 if str(raw).strip().lower() == EgressScope.BOTH.value: 449 ↛ 450line 449 didn't jump to line 450 because the condition on line 449 was never true

450 raw = EgressScope.ADAPTIVE.value 

451 try: 

452 return parse_user_egress_scope(raw, disabled_unprotected="adaptive") 

453 except PolicyDeniedError: 

454 return None 

455 

456 

457def _provider_disabled_reason( 

458 decision: Decision, 

459 policy_context: EgressContext, 

460 settings_snapshot: dict[str, Any] | None, 

461) -> str: 

462 """Build the user-facing reason a provider is greyed out in the dropdown. 

463 

464 ``require_local_llm`` has three possible origins and the UI has to name 

465 the right one, or the user goes hunting for a control that won't help: 

466 an operator env lock, the ``private_only`` egress scope (which forces 

467 the flag on at ``context_from_snapshot``), or the user's own 

468 "Require Local LLM Endpoint" checkbox. 

469 

470 The scope arm has a fourth case hiding inside it: ``private_only`` may be 

471 what ``adaptive`` RESOLVED to rather than what the user chose, so the 

472 message is built from ``settings_snapshot`` — the same snapshot the 

473 context was built from — and names the selection the user can actually 

474 see. The parameter is required rather than defaulted: a caller with no 

475 snapshot must say so with an explicit ``None`` (falling back to the 

476 resolved scope) instead of silently getting the wrong control named. 

477 """ 

478 env_value = check_env_setting("llm.require_local_endpoint") 

479 # Truthiness must match what the policy enforces, not HTML-checkbox 

480 # semantics: parse_boolean() calls "enabled" / "T" / "y" True, while 

481 # context_from_snapshot() computes require_local_llm with the policy's 

482 # own {"true","1","yes","on"} vocabulary. The looser parser reported a 

483 # server lock that was not actually in force. 

484 env_locked = env_value is not None and coerce_policy_bool(env_value) 

485 

486 notes = [] 

487 if not env_locked and policy_context.scope == EgressScope.PRIVATE_ONLY: 

488 if _configured_egress_scope(settings_snapshot) == EgressScope.ADAPTIVE: 

489 control = 'Blocked by egress scope "Adaptive"' 

490 notes.append( 

491 "resolved to Private only from your primary search engine" 

492 ) 

493 else: 

494 control = 'Blocked by egress scope "Private only"' 

495 else: 

496 control = 'Blocked by "Require Local LLM Endpoint"' 

497 

498 cause = _PROVIDER_DENIAL_CAUSES.get(decision.reason) 

499 if cause: 

500 notes.append(cause) 

501 if env_locked: 

502 notes.append("locked by the server") 

503 return f"{control} ({'; '.join(notes)})" if notes else control 

504 

505 

506def _provider_options_for_snapshot(options, policy_context, policy_snapshot): 

507 """Describe provider availability without fetching models or credentials.""" 

508 provider_options = [] 

509 for option in options: 

510 entry = dict(option) 

511 decision = _model_discovery_provider_decision( 

512 option["value"], policy_context, policy_snapshot 

513 ) 

514 entry["disabled"] = not decision.allowed 

515 entry["disabled_reason"] = ( 

516 _provider_disabled_reason(decision, policy_context, policy_snapshot) 

517 if not decision.allowed 

518 else None 

519 ) 

520 provider_options.append(entry) 

521 return provider_options 

522 

523 

524def _get_setting_from_session(key: str | None, username: str, default=None): 

525 """Helper to get a setting using the current session context. 

526 

527 A ``None`` key returns ``default``. ``SettingsManager.get_setting`` 

528 treats ``key=None`` as "return all settings"; this route helper fetches 

529 a single named setting and must not inherit that bulk-read semantic. 

530 Without the guard, callers iterating providers that declare 

531 ``api_key_setting = None`` (LM Studio, Llama.cpp) would receive a dict 

532 of every setting — leaking other providers' API keys. 

533 """ 

534 if key is None: 

535 return default 

536 with get_user_db_session(username) as db_session: 

537 if db_session: 

538 settings_manager = get_settings_manager(db_session, username) 

539 return settings_manager.get_setting(key, default) 

540 return default 

541 

542 

543def _model_discovery_settings_unchanged(db_session, fetched_settings): 

544 """Compare discovery inputs with fresh rows in the cache write transaction.""" 

545 if not fetched_settings: 

546 return True 

547 rows = ( 

548 db_session.query(Setting) 

549 .filter(Setting.key.in_(fetched_settings)) 

550 .populate_existing() 

551 .all() 

552 ) 

553 settings = {row.key: row for row in rows} 

554 for key, fetched_value in fetched_settings.items(): 

555 row = settings.get(key) 

556 current_value = get_typed_setting_value( 

557 key, 

558 row.value if row is not None else None, 

559 str(row.ui_element) if row is not None else "text", 

560 default="", 

561 check_env=True, 

562 ) 

563 if current_value != fetched_value: 

564 return False 

565 return True 

566 

567 

568def validate_setting( 

569 setting: Setting, value: Any 

570) -> Tuple[bool, Optional[str]]: 

571 """ 

572 Validate a setting value based on its type and constraints. 

573 

574 Args: 

575 setting: The Setting object to validate against 

576 value: The value to validate 

577 

578 Returns: 

579 Tuple of (is_valid, error_message) 

580 """ 

581 if setting.key == "search.fetch.summary_input_max_length": 

582 try: 

583 to_positive_int(value) 

584 except ValueError: 

585 return False, "Value must be a positive integer" 

586 

587 # Validate the exact summary integer before generic number conversion 

588 # or the blank-input handling for other optional numeric settings. 

589 raw_value = value 

590 numeric_ui = setting.ui_element in ("number", "slider", "range") 

591 # Blank form inputs explicitly clear optional numeric settings; they are 

592 # normalized to None on write, not used as a fallback for bad numbers. 

593 if numeric_ui and ( 

594 raw_value is None 

595 or (isinstance(raw_value, str) and not raw_value.strip()) 

596 ): 

597 return True, None 

598 try: 

599 value = get_typed_setting_value( 

600 key=str(setting.key), 

601 value=value, 

602 ui_element=str(setting.ui_element), 

603 default=None, 

604 check_env=False, 

605 ) 

606 except SettingsValueError as error: 

607 if numeric_ui: 

608 return False, "Value must be a number" 

609 if setting.ui_element == "checkbox": 609 ↛ 610line 609 didn't jump to line 610 because the condition on line 609 was never true

610 return False, "Value must be a boolean" 

611 return False, error.reason 

612 

613 # Validate based on UI element type 

614 if setting.ui_element == "checkbox": 

615 # After conversion, should be boolean 

616 if not isinstance(value, bool): 

617 return False, "Value must be a boolean" 

618 

619 elif setting.ui_element in ("number", "slider", "range"): 

620 # After conversion, should be numeric 

621 if ( 

622 isinstance(raw_value, bool) 

623 or isinstance(value, bool) 

624 or not isinstance(value, (int, float)) 

625 or (isinstance(value, float) and not math.isfinite(value)) 

626 ): 

627 return False, "Value must be a number" 

628 

629 # Check min/max constraints if defined 

630 if setting.min_value is not None and value < setting.min_value: 

631 return False, f"Value must be at least {setting.min_value}" 

632 if setting.max_value is not None and value > setting.max_value: 

633 return False, f"Value must be at most {setting.max_value}" 

634 

635 elif setting.ui_element == "select": 

636 # Check if value is in the allowed options 

637 if setting.options: 

638 # Skip options validation for dynamically populated dropdowns 

639 if setting.key not in DYNAMIC_SETTINGS: 

640 allowed_values = [ 

641 opt.get("value") if isinstance(opt, dict) else opt 

642 for opt in list(setting.options) # type: ignore[arg-type] 

643 ] 

644 if value not in allowed_values: 

645 return ( 

646 False, 

647 f"Value must be one of: {', '.join(str(v) for v in allowed_values)}", 

648 ) 

649 

650 # All checks passed 

651 return True, None 

652 

653 

654def _strict_read_rejection(key: str, value: Any, ui_element: Any) -> str | None: 

655 """Return why a strict settings read would reject this row, or None. 

656 

657 A write must not store a row that every effective read of the account 

658 then refuses with ``SettingsValueError``. This applies the read's own 

659 rules: the type must be supported and the value must convert to it. 

660 Pass the value and the ui_element exactly as they will be stored. The 

661 reason never contains the value. 

662 """ 

663 try: 

664 get_typed_setting_value( 

665 key=key, 

666 value=value, 

667 ui_element=ui_element, 

668 default=None, 

669 check_env=False, 

670 ) 

671 except SettingsValueError as error: 

672 return error.reason 

673 return None 

674 

675 

676def coerce_setting_for_write(key: str, value: Any, ui_element: str) -> Any: 

677 """Coerce an incoming value to the correct type before writing to the DB. 

678 

679 All web routes that save settings should use this function to ensure 

680 consistent type conversion. 

681 

682 No JSON pre-parsing (``json.loads``) is needed here because: 

683 - ``get_typed_setting_value`` already parses JSON strings internally 

684 via ``_parse_json_value`` (for ``ui_element="json"``) and 

685 ``_parse_multiselect`` (for ``ui_element="multiselect"``). 

686 - For JSON API endpoints, ``await request.json()`` already delivers 

687 dicts/lists as native Python objects. 

688 - For ``ui_element="text"``, pre-parsing would corrupt data: a JSON 

689 string like ``'{"k": "v"}'`` would become a dict, then ``str()`` 

690 would produce ``"{'k': 'v'}"`` (Python repr, not valid JSON). 

691 """ 

692 if key == "search.fetch.summary_input_max_length": 

693 try: 

694 return to_positive_int(value) 

695 except ValueError: 

696 # Preserve invalid input for validate_setting to reject, rather 

697 # than losing a fractional part or treating it as optional/null. 

698 return value 

699 

700 # check_env=False: we are persisting a user-supplied value, not reading 

701 # from an environment variable override. check_env=True (the default) 

702 # would silently replace the user's value with an env var, which is 

703 # incorrect on the write path. 

704 numeric_ui = ui_element in ("number", "slider", "range") 

705 if numeric_ui and isinstance(value, str) and not value.strip(): 

706 return None 

707 if numeric_ui and isinstance(value, bool): 

708 # bool is an int subclass, so float(True) would otherwise become 1 and 

709 # erase the evidence validate_setting needs to reject type confusion. 

710 return value 

711 

712 try: 

713 coerced = get_typed_setting_value( 

714 key=key, 

715 value=value, 

716 ui_element=ui_element, 

717 default=None, 

718 check_env=False, 

719 ) 

720 except SettingsValueError: 

721 # Preserve the submitted input so validate_setting can return the 

722 # route's normal validation error. Never replace it with None/default, 

723 # which would turn a malformed value into an accepted optional value. 

724 return value 

725 # Canonicalise the egress scope on the write path: the select options 

726 # are exact lowercase strings, so a value arriving with surrounding 

727 # whitespace or different casing (" STRICT ") would otherwise be 

728 # rejected by validate_setting instead of being stored as "strict". 

729 if key == "policy.egress_scope" and isinstance(coerced, str): 

730 return coerced.strip().lower() 

731 return coerced 

732 

733 

734# Namespace validation for new setting creation via the web API (ported 

735# from main's Flask settings_routes). Keys starting with any ALLOWED 

736# prefix may be created; any prefix in BLOCKED takes precedence and is 

737# rejected even if it also matches an allowed prefix. Existing keys 

738# (updates) bypass this check — it only applies to creation of new DB 

739# rows through the three write routes. 

740ALLOWED_SETTING_PREFIXES = frozenset( 

741 { 

742 "app.", 

743 "backup.", 

744 "benchmark.", 

745 "chat.", 

746 "database.", 

747 "document_scheduler.", 

748 "embeddings.", 

749 "focused_iteration.", 

750 "general.", 

751 "langgraph_agent.", 

752 "llm.", 

753 "local_search_", 

754 "news.", 

755 "notifications.", 

756 "rag.", 

757 "rate_limiting.", 

758 "report.", 

759 "research_library.", 

760 "search.", 

761 "ui.", 

762 "web.", 

763 "zotero.", 

764 } 

765) 

766BLOCKED_SETTING_PREFIXES = frozenset( 

767 { 

768 "auth.", 

769 "bootstrap.", 

770 "db_config.", 

771 "security.", 

772 "server.", 

773 "testing.", 

774 } 

775) 

776 

777 

778# Recursion cap for EVERY container helper below -- the sentinel/depth 

779# scanners, the merge, the positional-rebinding gate 

780# (``_non_sentinel_fields_match``) and the type-aware equality it bottoms 

781# out in (``_values_equal``). A helper in this family without the cap is 

782# a 500 waiting to happen even when the others have it, because 

783# ``_container_embeds_sentinel`` answers True unconditionally past its own 

784# cap and therefore KEEPS the callers walking a too-deep value (#6201 

785# round 7). json.loads accepts 

786# JSON nested roughly 900 levels deep, but these helpers recurse once per 

787# nesting level and hit Python's own call-stack RecursionError somewhere 

788# between depth ~499 and ~600 (exact threshold depends on what else is on 

789# the stack) -- well short of what a malicious/malformed payload can hand 

790# them. A real setting's JSON value is never more than a handful of levels 

791# deep, so a cap far below the crash point (and far above any legitimate 

792# value) lets a too-deep payload be rejected cleanly by the callers below 

793# instead of surfacing as an opaque 500 through the routes' broad 

794# ``except Exception``. 

795_MAX_CONTAINER_DEPTH = 64 

796 

797 

798def _container_embeds_sentinel( 

799 value: object, redaction_text: str, _depth: int = 0 

800) -> bool: 

801 """True when a dict/list contains the redaction sentinel as a substring 

802 of ANY string leaf, exact-leaf-match or partially spliced into one. 

803 

804 Recursion counterpart of the ``str`` guards' ``redaction_text in value`` 

805 check, used for a container value under a setting that only matches the 

806 broadened suffix arm (see ``_force_redact_strings``, which is what 

807 produces such a container on the read side: every non-empty string leaf 

808 becomes the bare sentinel, regardless of its own sub-key name). bool/int/ 

809 None leaves are never masked and so can never embed it. 

810 

811 Past ``_MAX_CONTAINER_DEPTH`` levels this conservatively returns True 

812 (see ``_MAX_CONTAINER_DEPTH``'s comment) rather than recursing further: 

813 a value nested that deep cannot be proven safe, and every caller here 

814 treats "embeds the sentinel" as the signal to reject the write with a 

815 400, which is the outcome we want for a pathological payload too. 

816 """ 

817 if _depth > _MAX_CONTAINER_DEPTH: 

818 return True 

819 if isinstance(value, str): 

820 return redaction_text in value 

821 if isinstance(value, dict): 

822 return any( 

823 _container_embeds_sentinel(sub_val, redaction_text, _depth + 1) 

824 for sub_val in value.values() 

825 ) 

826 if isinstance(value, list): 

827 return any( 

828 _container_embeds_sentinel(item, redaction_text, _depth + 1) 

829 for item in value 

830 ) 

831 return False 

832 

833 

834def _container_exceeds_max_depth(value: object, _depth: int = 0) -> bool: 

835 """True when *value* nests a dict/list deeper than 

836 ``_MAX_CONTAINER_DEPTH``, independent of whether it contains the 

837 redaction sentinel anywhere. 

838 

839 Checked ahead of the sentinel guards so a too-deep payload with NO 

840 sentinel in it gets its own error instead of 

841 ``_redaction_sentinel_error``'s "contains the redaction placeholder" 

842 message. ``_container_embeds_sentinel`` and ``_merge_redacted_container`` 

843 both conservatively treat anything past the cap as "embeds it" / 

844 "unresolvable" regardless of actual content -- the right call for 

845 deciding to reject a payload that can't be proven safe, but the wrong 

846 reason to report when nothing in it actually matched the sentinel. 

847 """ 

848 if _depth > _MAX_CONTAINER_DEPTH: 

849 return True 

850 if isinstance(value, dict): 

851 return any( 

852 _container_exceeds_max_depth(sub_val, _depth + 1) 

853 for sub_val in value.values() 

854 ) 

855 if isinstance(value, list): 

856 return any( 

857 _container_exceeds_max_depth(item, _depth + 1) for item in value 

858 ) 

859 return False 

860 

861 

862def _container_too_deep_error() -> str: 

863 """Explain the 400 raised when a dict/list value nests deeper than 

864 ``_MAX_CONTAINER_DEPTH`` -- a distinct condition from embedding the 

865 redaction sentinel and reported with its own message (see 

866 ``_container_exceeds_max_depth``), instead of the misleading 

867 "contains the redaction placeholder" wording a too-deep payload with 

868 no sentinel in it used to get by reusing ``_redaction_sentinel_error``. 

869 """ 

870 return ( 

871 "Value is nested too deeply to validate " 

872 f"(max {_MAX_CONTAINER_DEPTH} levels) — flatten it before saving." 

873 ) 

874 

875 

876def _container_rejection_error(ui_element: str | None, value: object) -> str: 

877 """Pick the right 400 message for a rejected dict/list write: the 

878 depth-cap message when *value* nests past ``_MAX_CONTAINER_DEPTH`` 

879 (the cap itself is why it was rejected, whether or not it happens to 

880 also contain the sentinel), otherwise the usual 

881 redaction-sentinel message. 

882 """ 

883 if isinstance(value, (dict, list)) and _container_exceeds_max_depth(value): 

884 return _container_too_deep_error() 

885 return _redaction_sentinel_error(ui_element) 

886 

887 

888def _values_equal(a: object, b: object, _depth: int = 0) -> bool: 

889 """Type-aware equality for comparing a submitted/merged value against 

890 its stored counterpart. 

891 

892 Plain ``==`` treats ``1 == True`` as equal (``bool`` is an ``int`` 

893 subclass), so a checkbox submitted as ``1`` over a stored ``True`` -- 

894 or ``{"enabled": 1}`` over a stored ``{"enabled": True}`` -- would 

895 look byte-for-byte identical to 

896 ``_resolve_container_secret_write``'s no-op check and silently 

897 discard a genuine type change instead of persisting it (#6201). 

898 ``bool`` is therefore checked FIRST, before the numeric case, because 

899 it IS an ``int``; dict/list are walked structurally rather than 

900 compared with their own ``==`` so a type mismatch nested inside a 

901 container is caught too, not just at the top level. 

902 

903 ``int`` vs ``float`` is deliberately NOT a difference. This predicate 

904 also decides ``_non_sentinel_fields_match``, the gate that must see 

905 every visible field of a list item unchanged before a masked secret 

906 is restored by position -- and requiring ``type(a) is type(b)`` there 

907 made an ordinary browser round-trip UNSATISFIABLE. A stored 

908 ``[{"url": "a", "api_key": ..., "timeout": 30.0}, {...}]`` is served 

909 to the client with both keys masked; JavaScript has no ``30.0`` (its 

910 only number type is a double that serializes as ``30``), and 

911 ``settings.js`` additionally ``parseInt``s a decimal-free numeric 

912 before submitting. The client therefore CANNOT echo back a value 

913 ``type(a) is type(b)`` accepts, so every edit to a >=2-item list 

914 carrying a masked secret 400'd with "contains the redaction 

915 placeholder" no matter what the user did (#6201 round 5). Comparing 

916 numerics by value costs nothing the guard actually relies on: an 

917 attacker cannot use "same number, other numeric type" to disguise a 

918 reorder, because two list entries that differ ONLY in whether a 

919 number is stored as ``30`` or ``30.0`` are indistinguishable to any 

920 JSON client to begin with. 

921 

922 Depth-capped like the other container walkers (see 

923 ``_MAX_CONTAINER_DEPTH``): past the cap this answers "not equal" 

924 rather than recursing into a stack overflow. That is the fail-closed 

925 answer for both callers -- ``_non_sentinel_fields_match`` treats it as 

926 "cannot prove this position is unchanged" (so a positional secret 

927 restore is refused) and ``_resolve_container_secret_write`` treats it 

928 as "not a no-op" (so nothing is silently swallowed). Without the cap a 

929 ~7 KB payload nested ~1000 levels deep against a stored value of the 

930 same shape raised an uncaught ``RecursionError`` that the write routes' 

931 broad ``except Exception`` turned into a 500 (#6201 round 7). 

932 """ 

933 if _depth > _MAX_CONTAINER_DEPTH: 

934 return False 

935 if isinstance(a, dict) or isinstance(b, dict): 

936 if not (isinstance(a, dict) and isinstance(b, dict)): 936 ↛ 937line 936 didn't jump to line 937 because the condition on line 936 was never true

937 return False 

938 if a.keys() != b.keys(): 

939 return False 

940 return all(_values_equal(a[k], b[k], _depth + 1) for k in a) 

941 if isinstance(a, list) or isinstance(b, list): 

942 if not (isinstance(a, list) and isinstance(b, list)): 942 ↛ 943line 942 didn't jump to line 943 because the condition on line 942 was never true

943 return False 

944 if len(a) != len(b): 944 ↛ 945line 944 didn't jump to line 945 because the condition on line 944 was never true

945 return False 

946 return all(_values_equal(x, y, _depth + 1) for x, y in zip(a, b)) 

947 # bool BEFORE the numeric case: `bool` is an `int`, so a plain `==` 

948 # here would call `1` unchanged from a stored `True`. 

949 if isinstance(a, bool) or isinstance(b, bool): 

950 return type(a) is type(b) and a == b 

951 # Neither side is a bool by now, so `int`/`float` compare by VALUE: 

952 # `30 == 30.0` is the same number, and a JSON client has no way to 

953 # tell the two apart (see the docstring). 

954 return a == b 

955 

956 

957def _non_sentinel_fields_match( 

958 value: object, 

959 stored_value: object, 

960 redaction_text: str, 

961 _depth: int = 0, 

962) -> bool: 

963 """True when every leaf of *value* that is NOT the redaction sentinel 

964 equals ``stored_value``'s leaf at the same path -- i.e. nothing 

965 visible changed except the masked leaf(s). 

966 

967 Used ONLY to gate positional secret restoration inside a LIST (see the 

968 list branch of ``_merge_redacted_container``): a same-length 

969 permutation of a list of dicts is indistinguishable from an in-place 

970 edit by position alone -- ``zip()`` pairs each submitted item with 

971 whatever stored item shares its index, not with the item it actually 

972 came from. Given stored ``[{url:a,api_key:K1},{url:b,api_key:K2}]``, a 

973 client that reorders the two entries and resubmits both with their 

974 (masked) ``api_key`` untouched would otherwise have K1 silently 

975 rebound to ``b`` and K2 to ``a`` -- restoring the WRONG secret under 

976 each url, not the one it actually belonged to (#6201). Requiring every 

977 visible field at that index to match before trusting the position 

978 catches this: a genuine in-place edit leaves everything but the 

979 secret leaf(s) unchanged, so it still matches; a reorder does not. 

980 

981 This deliberately does NOT extend the "edit a sibling next to a 

982 restored secret" allowance ``_merge_redacted_container`` documents for 

983 a single dict/object -- that allowance is safe there because there is 

984 only one candidate object to restore into. Inside a list, the same 

985 allowance would make a sibling edit indistinguishable from a reorder, 

986 which is exactly the hole this closes. The accepted consequence: an 

987 ordinary edit to a NON-secret sibling field of a list item that also 

988 carries an untouched masked secret at the same index is rejected 

989 along with a genuine reorder -- the caller must split that into two 

990 writes (clear/replace the secret first, or edit the sibling via a 

991 request that doesn't also carry the sentinel) rather than combining 

992 them in one PUT. 

993 

994 Depth-capped like every other container walker here (see 

995 ``_MAX_CONTAINER_DEPTH``). Past the cap this answers False -- "nothing 

996 proves this position still holds the same entry" -- which makes the 

997 caller reject the item rather than positionally rebind a secret into 

998 a container it could not finish inspecting. This helper and 

999 ``_values_equal`` were the last two in the merge path without a cap, 

1000 and ``_merge_redacted_container``'s list branch called this one with 

1001 no depth at all: a ~7 KB list whose first item nested ~1000 levels 

1002 deep against a stored list of the same shape raised an uncaught 

1003 ``RecursionError`` and surfaced as a 500 through the write routes' 

1004 broad ``except Exception`` -- the exact outcome ``_MAX_CONTAINER_DEPTH`` 

1005 exists to prevent (#6201 round 7). Note the depth cap alone does not 

1006 end the walk: ``_container_embeds_sentinel`` answers True 

1007 unconditionally past its own cap, so this gate is still consulted for 

1008 a too-deep item and must terminate on its own. 

1009 """ 

1010 if _depth > _MAX_CONTAINER_DEPTH: 

1011 return False 

1012 if isinstance(value, str) and value == redaction_text: 

1013 return True 

1014 if isinstance(value, dict): 

1015 if not isinstance(stored_value, dict): 1015 ↛ 1016line 1015 didn't jump to line 1016 because the condition on line 1015 was never true

1016 return False 

1017 # The submitted item's visible keys must exactly match the stored 

1018 # item's keys, not just be a subset of them. A subset check lets a 

1019 # client RENAME or DROP a visible field (e.g. resubmit ``url`` as 

1020 # ``endpoint``, or omit it) to make an otherwise-mismatched item 

1021 # look like a match: the renamed/added key isn't in ``stored_value`` 

1022 # so a subset walk would skip it, and the dropped key is simply 

1023 # never visited from ``value.items()``. Either way position gets 

1024 # trusted with nothing left to disprove it -- the exact bypass this 

1025 # function exists to close (#6201 round 3 follow-up). Requiring 

1026 # equal key sets also matches this module's own rule that adding a 

1027 # field to a list item alongside a masked secret must be split into 

1028 # two writes, rather than silently letting the extra field ride 

1029 # along as if it always existed at this position. 

1030 if value.keys() != stored_value.keys(): 

1031 return False 

1032 for sub_key, sub_val in value.items(): 

1033 if not _non_sentinel_fields_match( 

1034 sub_val, stored_value[sub_key], redaction_text, _depth + 1 

1035 ): 

1036 return False 

1037 return True 

1038 if isinstance(value, list): 1038 ↛ 1039line 1038 didn't jump to line 1039 because the condition on line 1038 was never true

1039 if not isinstance(stored_value, list) or len(stored_value) != len( 

1040 value 

1041 ): 

1042 return False 

1043 return all( 

1044 _non_sentinel_fields_match( 

1045 sub_val, sub_stored, redaction_text, _depth + 1 

1046 ) 

1047 for sub_val, sub_stored in zip(value, stored_value) 

1048 ) 

1049 return _values_equal(value, stored_value, _depth) 

1050 

1051 

1052def _merge_redacted_container( 

1053 value: object, stored_value: object, redaction_text: str, _depth: int = 0 

1054) -> Tuple[object, bool]: 

1055 """Merge a dict/list write against its stored counterpart, restoring 

1056 the real secret under every leaf that is EXACTLY the redaction 

1057 sentinel while keeping every other leaf's submitted value as-is -- so 

1058 editing a non-secret sibling (or leaving a DIFFERENT secret leaf 

1059 untouched) is saved instead of the whole container being rejected. 

1060 

1061 ``redact_value`` (``security/data_sanitizer.py``) masks a container by 

1062 SUB-KEY: in a MIXED container -- e.g. ``{"api_key": "sk-...", 

1063 "description": "My engine", "requires_api_key": true}`` -- only the 

1064 sensitive-named leaf becomes the sentinel; ordinary siblings stay 

1065 plain. A caller that GETs, edits ``description``, and PUTs the result 

1066 back therefore submits a container where exactly one leaf is the 

1067 sentinel and the rest are ordinary values -- not the "every maskable 

1068 leaf is the sentinel" shape round 5's fix required of the WHOLE 

1069 container before treating it as anything but a corrupted edit. 

1070 Rejecting that outright turns a legitimate edit into an unsatisfiable 

1071 400: the client can never construct a request this endpoint will 

1072 accept, because it cannot see the real secret to resubmit it and any 

1073 masked-leaf resubmission was treated as "embeds the sentinel". This 

1074 function resolves it leaf-by-leaf instead of all-or-nothing. 

1075 

1076 ACCEPTED RISK, DELIBERATE (post-#6201 review, item a): this leaf-by- 

1077 leaf merge means a caller who has never seen the real secret can still 

1078 edit a NON-secret sibling of it in the SAME write -- e.g. change 

1079 ``search.engine.web.<engine>.url`` while ``api_key`` stays 

1080 ``[REDACTED]`` -- and the merge restores the real key under the NEW 

1081 url. A round of this feature's own review (round 5) rejected mixed 

1082 containers outright specifically to prevent this; this round reverses 

1083 that call because the outright rejection made the endpoint 

1084 unsatisfiable for the overwhelmingly common case (any GET-then-edit of 

1085 a mixed container at all, secret untouched). This is a real, 

1086 consciously-accepted trade rather than an oversight: egress/SSRF 

1087 validation for registered settings (``security/egress/validators.py``) 

1088 keys off FIXED, known setting names, not the contents of arbitrary 

1089 user-defined JSON, so this does not by itself hand a caller a new way 

1090 to bypass egress policy for a setting the validators inspect. It DOES 

1091 mean a masked secret can end up silently repointed at a different 

1092 destination than the one it was originally saved under, without the 

1093 caller ever needing to know the secret's value. If that trade 

1094 ever needs tightening, the narrowing applied to LIST items below (see 

1095 the list branch's docstring section) -- requiring every visible field 

1096 at a position to match before trusting a positional secret restore -- 

1097 is the template: the same "verify nothing else changed" check could be 

1098 applied to a bare dict's siblings too, at the cost of reintroducing 

1099 the unsatisfiable-400 problem this round fixed. 

1100 

1101 Returns ``(merged_value, rejected)``. ``rejected`` is True when a leaf 

1102 cannot be resolved safely: 

1103 

1104 * the leaf equals the sentinel exactly, but ``stored_value`` is 

1105 itself empty (absent, ``""``, ``" "``, ``None``, ``[]`` or ``{}``) 

1106 at that path -- there is nothing to "keep", so persisting it would 

1107 write the literal placeholder text as the value. This checks only 

1108 ``stored_value``'s emptiness, the same half of ``redact_value``'s 

1109 gate that ``_is_empty_value`` implements -- it does NOT also check 

1110 that the leaf's own sub-key is sensitive, so the restore set is a 

1111 SUPERSET of the mask set: a stored ``{"port": 19530}`` is never 

1112 masked on read (``port`` is not sensitive), but a submitted 

1113 ``{"port": "[REDACTED]"}`` over it still restores ``19530`` here. 

1114 Narrowing ``DEFAULT_SENSITIVE_KEYS`` / ``is_sensitive_setting`` 

1115 therefore does not narrow what this merge will restore; 

1116 * the leaf merely CONTAINS the sentinel as a substring without being 

1117 an exact match (e.g. a stale client's ``"[REDACTED],new-value"``) 

1118 -- an edit, but a corrupted one that would splice the placeholder 

1119 text into the stored value, the same corrupted-edit case the 

1120 scalar arm of ``_embeds_redaction_sentinel`` rejects. 

1121 

1122 A list is walked positionally only when the submitted and stored lists 

1123 are the same length; a length change means there is no reliable 

1124 correspondence between items, so any sentinel occurring in it can't be 

1125 resolved and the whole list is rejected the same way an orphaned leaf 

1126 is (matching the "can't confirm, so treat as unresolvable" stance the 

1127 other container helpers take). A dict key absent from ``stored_value`` 

1128 is not itself an error -- it is an ordinary new field unless its own 

1129 value happens to be the sentinel, which the orphan case above catches. 

1130 

1131 Equal length is NOT sufficient on its own to trust a list's positions, 

1132 though: a same-length PERMUTATION of a list of dicts is invisible to a 

1133 plain ``zip()`` walk. Given stored ``[{url:a,api_key:K1}, 

1134 {url:b,api_key:K2}]``, a client that swaps the two entries and 

1135 resubmits both with their ``api_key`` still masked would otherwise get 

1136 K1 silently restored under ``b`` and K2 under ``a`` -- the wrong 

1137 secret rebound to the wrong entry, not a rejection (#6201). Before 

1138 restoring a sentinel-bearing dict/list item positionally, this walks 

1139 it through ``_non_sentinel_fields_match`` first: every field at that 

1140 index NOT equal to the sentinel must match the stored item at the same 

1141 index, proving nothing but the masked leaf(s) changed there. A 

1142 mismatch means the position can't be trusted to identify the same 

1143 real-world entry the secret was read from, so that item's sentinel 

1144 leaves are treated as unresolvable (rejected) rather than restored 

1145 into what may be a different entry. This intentionally narrows the 

1146 "edit a sibling next to a restored secret" allowance documented above 

1147 to the single-object case -- inside a list it is indistinguishable 

1148 from a reorder, so it isn't extended there. 

1149 

1150 EXCEPTION: a single-element list (``len(value) == 1``) skips this 

1151 sibling-match gate entirely. The gate exists solely to rule out a 

1152 same-length reorder, and a list of one has no other position for its 

1153 one item to have come from or gone to -- there is no reorder for a 

1154 sibling edit to be confused with, so the "edit a sibling next to a 

1155 restored secret" allowance the single-object dict case gets above 

1156 applies here too. 

1157 """ 

1158 # NOTE (fragility, deliberate): past the cap this does NOT reject on its 

1159 # own -- it returns whatever ``_container_embeds_sentinel`` says, and that 

1160 # helper's own cap makes it answer True unconditionally past this depth. 

1161 # So the clean 400 a too-deep payload gets today is produced by that 

1162 # helper's fail-closed default, not by this line. The composition is 

1163 # correct (verified at 65/900/3000 levels), but it means a future edit 

1164 # that makes ``_container_embeds_sentinel`` return a real answer past the 

1165 # cap -- or drops its cap because "the callers already cap" -- would 

1166 # silently turn a too-deep payload into an ACCEPTED merge here rather 

1167 # than a rejection, with no test naming this line. Anything that touches 

1168 # either cap must re-check this pair together; ``return value, True`` is 

1169 # the equivalent that does not depend on the other helper, kept as a 

1170 # call rather than a literal only so the two stay visibly coupled. 

1171 if _depth > _MAX_CONTAINER_DEPTH: 

1172 return value, _container_embeds_sentinel(value, redaction_text, _depth) 

1173 

1174 if isinstance(value, dict): 

1175 stored_dict = stored_value if isinstance(stored_value, dict) else {} 

1176 merged: dict = {} 

1177 rejected = False 

1178 for sub_key, sub_val in value.items(): 

1179 merged_val, sub_rejected = _merge_redacted_container( 

1180 sub_val, stored_dict.get(sub_key), redaction_text, _depth + 1 

1181 ) 

1182 merged[sub_key] = merged_val 

1183 rejected = rejected or sub_rejected 

1184 return merged, rejected 

1185 

1186 if isinstance(value, list): 

1187 if isinstance(stored_value, list) and len(stored_value) == len(value): 

1188 merged_list = [] 

1189 rejected = False 

1190 for sub_val, sub_stored in zip(value, stored_value): 

1191 # A single-element list has exactly one possible position 

1192 # for its one item -- there is no OTHER index a reorder 

1193 # could have moved it from or to, so the "position can't 

1194 # be trusted" rationale below (which exists to catch a 

1195 # same-length reorder) cannot apply. Skip the sibling- 

1196 # match gate entirely for len == 1 rather than rejecting 

1197 # an ordinary sibling edit next to a masked secret that 

1198 # has no reorder to be confused with. 

1199 if ( 

1200 len(value) != 1 

1201 and _container_embeds_sentinel( 

1202 sub_val, redaction_text, _depth + 1 

1203 ) 

1204 and not _non_sentinel_fields_match( 

1205 sub_val, sub_stored, redaction_text, _depth + 1 

1206 ) 

1207 ): 

1208 # A visible (non-sentinel) field at this index differs 

1209 # from the stored item at the same index -- position 

1210 # cannot be trusted to identify the same entry the 

1211 # secret leaf was read from (e.g. a same-length 

1212 # reorder). Reject rather than positionally rebind the 

1213 # secret to a possibly different entry (#6201). 

1214 merged_list.append(sub_val) 

1215 rejected = True 

1216 continue 

1217 merged_val, sub_rejected = _merge_redacted_container( 

1218 sub_val, sub_stored, redaction_text, _depth + 1 

1219 ) 

1220 merged_list.append(merged_val) 

1221 rejected = rejected or sub_rejected 

1222 return merged_list, rejected 

1223 return value, _container_embeds_sentinel(value, redaction_text, _depth) 

1224 

1225 if isinstance(value, str): 

1226 if value == redaction_text: 

1227 # Restore from ANY non-empty stored value, not just a string 

1228 # (#6201/#6213 item c). ``redact_value`` masks a nested leaf to 

1229 # the bare sentinel for any non-empty value whose sub-key is an 

1230 # exact ``DEFAULT_SENSITIVE_KEYS`` match -- a list, dict, int or 

1231 # bool included -- so requiring ``isinstance(stored_value, str)`` 

1232 # here made the write-back rule STRICTER than the masking rule: 

1233 # for all 18 exact sensitive leaf names, a stored list/dict/int/ 

1234 # bool leaf was masked on GET and then refused on PUT, an 

1235 # unsatisfiable 400 the client could never construct a passing 

1236 # request for (it cannot see the value the GET collapsed to one 

1237 # string), and on ``save_all_settings`` a rejection rolls back 

1238 # the WHOLE batch. Mirroring ``_is_empty_value`` -- the exact 

1239 # gate ``redact_value`` masks on -- makes the round-trip a no-op 

1240 # again. Every other rejection is unchanged: an empty stored 

1241 # value ("", " ", None, [], {}) was never masked, so a sentinel 

1242 # over it is still an orphan (below); a missing sub-key arrives 

1243 # here as ``None`` from ``stored_dict.get(...)`` and is still 

1244 # rejected; and a sentinel EMBEDDED in a longer string is still 

1245 # the corrupted-edit case. 

1246 if not _is_empty_value(stored_value): 

1247 return stored_value, False 

1248 return value, True 

1249 if redaction_text in value: 

1250 return value, True 

1251 return value, False 

1252 

1253 return value, False 

1254 

1255 

1256def _resolve_container_secret_write( 

1257 value: object, stored_value: object, redaction_text: str 

1258) -> Tuple[object, bool, bool]: 

1259 """Decide the outcome of a dict/list write that may carry the 

1260 redaction sentinel. 

1261 

1262 Returns ``(effective_value, is_noop, is_rejected)``: 

1263 

1264 * No sentinel anywhere in *value* -- pass it through untouched 

1265 (``is_noop=False``, ``is_rejected=False``); there is nothing for 

1266 this guard to do with an ordinary container write. 

1267 * The sentinel is present but there is no ``stored_value`` to 

1268 restore a masked leaf from -- rejected, same as before this only 

1269 merges against a row that actually exists. 

1270 * The sentinel is present and every occurrence resolves against 

1271 ``stored_value`` (see ``_merge_redacted_container``) -- the merged 

1272 container is the value to persist. If it is byte-for-byte 

1273 identical to what's already stored, the write is a no-op (covers 

1274 both the fully-masked round-trip and a partially-masked one where 

1275 nothing actually changed); otherwise it is a genuine edit and the 

1276 caller must persist the MERGED value, not the raw submitted 

1277 *value*, which still carries the sentinel on its untouched leaves. 

1278 * Any leaf cannot be resolved (see ``_merge_redacted_container``) -- 

1279 rejected. 

1280 """ 

1281 if not _container_embeds_sentinel(value, redaction_text): 

1282 return value, False, False 

1283 if stored_value is None: 

1284 return value, False, True 

1285 merged, rejected = _merge_redacted_container( 

1286 value, stored_value, redaction_text 

1287 ) 

1288 if rejected: 

1289 return value, False, True 

1290 # Type-aware equality (#6201): plain ``==`` would call 

1291 # ``{"enabled": 1}`` unchanged from a stored ``{"enabled": True}`` and 

1292 # silently discard the type change as a false no-op. 

1293 if _values_equal(merged, stored_value): 

1294 return merged, True, False 

1295 return merged, False, False 

1296 

1297 

1298def _coerced_for_guard( 

1299 key: str, ui_element: str | None, value: object 

1300) -> object: 

1301 """Return *value* as the sentinel guards should see it, coerced to its 

1302 stored shape when a JSON-STRING encoding would otherwise hide a masked 

1303 container from them. 

1304 

1305 ``PUT /settings/api/<key>`` and both bulk-save routes accept a "json" 

1306 ui_element's value either as a native dict/list -- caught by the 

1307 container arm of ``_is_secret_empty_noop`` / ``_embeds_redaction_sentinel`` 

1308 -- or as its JSON-STRING encoding, which ``coerce_setting_for_write`` -> 

1309 ``get_typed_setting_value`` -> ``_parse_json_value`` 

1310 (``settings/manager.py``) decodes into the same dict, but only AFTER 

1311 every guard call site runs its checks. A masked container submitted in 

1312 string form therefore sails past the container arm (which only fires 

1313 on an actual ``dict``/``list``) and falls through to the string arm, 

1314 which is gated on the OUTER key's sensitivity -- exactly the 

1315 "non-sensitive outer key with a sensitive nested leaf" case the 

1316 container arm exists to close. Coercing here, before the guards run, 

1317 closes that gap the same way the create path's registered-default 

1318 branch already does (it coerces before its own guard call). 

1319 

1320 Only a ``str`` is re-coerced, and the coerced result is used only if 

1321 it actually becomes a dict/list: an already-dict/list *value* is 

1322 already handled correctly by the container arm as-is, and running it 

1323 back through a non-container ``ui_element`` (e.g. "text": ``str(value)``) 

1324 would destroy that shape instead of revealing one. This return value 

1325 is used ONLY to decide what the guards see and, when a sentinel is 

1326 found, to compute the merged replacement value -- callers must keep 

1327 persisting their own *value* (or that merge result) unchanged in every 

1328 other case, never this function's return value directly, so an 

1329 ordinary JSON-string write (no sentinel involved) is unaffected. 

1330 

1331 A row's ``ui_element`` can itself disagree with its actual stored 

1332 shape -- e.g. a ``PUT``-created key with no ``ui_element`` defaults to 

1333 "text" (``models/settings.py``) even when a caller stores a dict/list 

1334 value under it. For "text"/"textarea"/"password"/"select", the 

1335 ui_element-based coercion above NEVER becomes a dict/list (their 

1336 target type IS ``str``), so a masked container submitted in 

1337 JSON-STRING form under one of THOSE elements would still sail past 

1338 the container arm even after the coercion above (#6201). When the 

1339 ui_element-based attempt didn't produce a container, this also tries 

1340 a plain JSON decode of the same string independent of ``ui_element`` 

1341 entirely -- the row's ACTUAL stored shape decides whether this is a 

1342 container write in disguise, not its declared type. Quiet on failure 

1343 (unlike ``settings/manager.py``'s ``_parse_json_value``, which logs a 

1344 warning on every non-JSON string): an ordinary "text" setting holding 

1345 an ordinary non-JSON string is the overwhelmingly common case here and 

1346 must not spam the log. 

1347 

1348 The BARE sentinel (*value* exactly equal to the redaction text) is 

1349 special-cased to skip coercion entirely and pass through unchanged. 

1350 ``coerce_setting_for_write("multiselect", "[REDACTED]", ...)`` -> 

1351 ``_parse_multiselect`` (``settings/manager.py``) sees a string starting 

1352 with "[" that fails ``json.loads`` (the sentinel isn't valid JSON) and 

1353 falls back to its comma-separated split, which -- since there is no 

1354 comma -- wraps the whole string into a ONE-ELEMENT LIST, 

1355 ``["[REDACTED]"]``. That list then looks like a malformed/rejected 

1356 container write (a length-mismatch against a stored list of any other 

1357 size) instead of the ordinary round-tripped sentinel it actually is, 

1358 turning what used to be a 200 no-op into a 400 for a sensitive 

1359 ``multiselect`` setting (#6201 item 6). The exact-match scalar arm of 

1360 ``_is_secret_empty_noop`` / ``_embeds_redaction_sentinel`` handles a 

1361 bare-string sentinel for a SENSITIVE key under every ui_element (both 

1362 scalar arms are ``is_sensitive_setting``-gated -- a bare sentinel 

1363 submitted for a NON-sensitive container row still persists verbatim 

1364 over the stored container, which those arms do not cover), so there is 

1365 nothing this coercion needs to reveal for that case. 

1366 

1367 A SPLICED sentinel on a ``multiselect`` -- e.g. ``"[REDACTED],new"`` 

1368 from a stale client that rendered the mask and had a value appended -- 

1369 must skip the coercion for the same reason, and this one is a 

1370 data-loss bug rather than a usability one. ``_parse_multiselect``'s 

1371 comma-split fallback fires again (the whole string still isn't valid 

1372 JSON) and turns the SPLICED scalar into ``["[REDACTED]", "new"]``, 

1373 whose first item is now an EXACT sentinel match. The container arm 

1374 then merges it positionally against a stored list of the same length 

1375 and persists ``["A", "new"]`` with a 200 -- silently DESTROYING the 

1376 stored ``"B"`` -- so the "contains the sentinel but is not equal to it 

1377 => hard 400" rule that ``_merge_redacted_container`` documents (and 

1378 that the scalar arm enforces, as the base branch did here) never fires 

1379 at all. Splitting a spliced scalar into a list manufactures the exact 

1380 match out of a corrupted edit, so malformed JSON carrying the sentinel 

1381 is handed to the guards AS A STRING for this ui_element; the scalar arm 

1382 of ``_embeds_redaction_sentinel`` then rejects it with the usual 400 

1383 (#6201 round 7). Only ``multiselect`` needs this: ``json`` decodes 

1384 with ``json.loads`` alone, which never invents list structure out of a 

1385 non-JSON string. Valid JSON containers under ``multiselect`` must still 

1386 be decoded before the guards run, so nested masks restore the stored 

1387 secrets instead of being persisted literally. 

1388 """ 

1389 if not isinstance(value, str) or not ui_element: 

1390 return value 

1391 if value == DataSanitizer.REDACTION_TEXT: 

1392 return value 

1393 # ``multiselect`` is the one ui_element whose coercion SPLITS a string 

1394 # on commas, so a spliced sentinel ("[REDACTED],new") comes back as a 

1395 # list whose first item is an exact sentinel match -- laundering a 

1396 # corrupted edit into a merge that silently drops the stored items the 

1397 # client did not resend. Keep it a string so the scalar arm can reject 

1398 # it (see the docstring). 

1399 if ui_element == "multiselect" and DataSanitizer.REDACTION_TEXT in value: 

1400 # Strict JSON reveals real containers without the comma-split 

1401 # fallback manufacturing a container from a corrupted scalar. 

1402 try: 

1403 parsed = json.loads(value) 

1404 except (json.JSONDecodeError, ValueError, RecursionError): 

1405 return value 

1406 return parsed if isinstance(parsed, (dict, list)) else value 

1407 # Only "json" and "multiselect" can actually decode a string into a 

1408 # dict/list via ``coerce_setting_for_write`` -- every other 

1409 # ui_element's converter (``float``, ``bool``, ``str``, ...) can never 

1410 # produce one. Calling it for those elements anyway would run 

1411 # ``coerce_setting_for_write`` a SECOND time on the exact same 

1412 # (key, value, ui_element) once the caller coerces again for the 

1413 # actual persisted write below (every string write on an existing 

1414 # setting would otherwise pay for this twice): a failing conversion 

1415 # (e.g. "abc" on a "number" row) would log its warning twice, and for 

1416 # a "number"/"range" row this ran ``float(x)`` on the RAW submission 

1417 # BEFORE the sentinel guards below get a chance to run. That used to 

1418 # log the raw typed submission at WARNING on conversion failure -- 

1419 # exactly what the guards below exist to prevent from happening 

1420 # unchecked. ``get_typed_setting_value`` no longer logs a setting 

1421 # VALUE at all (#6201 round 6), so the disclosure half of this 

1422 # rationale is now closed at the converter; the duplicate-work and 

1423 # duplicate-warning halves stand on their own. 

1424 # Skipping the call here for non-container-capable elements removes 

1425 # that channel entirely; those elements still fall through to the 

1426 # ui_element-independent JSON-shape check below, which is what 

1427 # actually matters for detecting a container submitted under a 

1428 # mismatched ui_element (see the docstring). 

1429 if ui_element in ("json", "multiselect"): 

1430 coerced = coerce_setting_for_write( 

1431 key=key, value=value, ui_element=ui_element 

1432 ) 

1433 if isinstance(coerced, (dict, list)): 

1434 return coerced 

1435 stripped = value.strip() 

1436 if stripped and stripped[0] in "{[": 

1437 try: 

1438 parsed = json.loads(stripped) 

1439 except (json.JSONDecodeError, ValueError, RecursionError): 

1440 return value 

1441 if isinstance(parsed, (dict, list)): 1441 ↛ 1443line 1441 didn't jump to line 1443 because the condition on line 1441 was always true

1442 return parsed 

1443 return value 

1444 

1445 

1446def _reconciled_container_value_for_write( 

1447 raw_value: object, guard_value: object, stored_value: object 

1448) -> Tuple[object, bool]: 

1449 """Return the value to actually PERSIST for a write that has already 

1450 passed both sentinel guards (``_is_secret_empty_noop`` returned False 

1451 and ``_embeds_redaction_sentinel`` returned False for *guard_value*). 

1452 

1453 Returns ``(value_to_persist, is_reconciled_container)``. 

1454 

1455 For a scalar, or a dict/list that embeds no sentinel at all, this is 

1456 ``(raw_value, False)`` -- including when *guard_value* differs from 

1457 *raw_value* (e.g. *guard_value* is the JSON-string-decoded form 

1458 ``_coerced_for_guard`` produced purely to run the guards): an ordinary 

1459 write, with no security-relevant transformation, must never be altered 

1460 by this reconciliation step, or every other special-case handler 

1461 downstream (checkbox coercion, the corrupted-value repair block) would 

1462 see a different shape than it does today. 

1463 

1464 Only when *guard_value* is a dict/list that DOES embed the sentinel 

1465 does this return ``(effective, True)``, where ``effective`` is the 

1466 MERGED container from ``_resolve_container_secret_write`` -- masked 

1467 leaves restored to their stored secret, edited leaves as submitted -- 

1468 instead of *raw_value*, which still carries "[REDACTED]" on every 

1469 untouched masked leaf and would otherwise be persisted verbatim. 

1470 

1471 ``is_reconciled_container=True`` is the caller's signal to persist 

1472 ``effective`` AS-IS, bypassing ``coerce_setting_for_write``. That 

1473 coercion exists to turn a submitted SCALAR into the row's declared 

1474 Python type (e.g. ``"5" -> 5`` for a number setting); every 

1475 ui_element whose declared type is ``str`` -- "text", "textarea", 

1476 "password", "select" -- would otherwise run a MERGED CONTAINER 

1477 through ``str()``, flattening it to its Python repr and persisting a 

1478 real, just-restored secret as a plain, non-redacted string under a 

1479 key ``redact_value``/``is_sensitive_setting`` has no reason to treat 

1480 as sensitive (#6201: this is exactly how the original fix for this 

1481 function turned a data-loss bug into a disclosure one). A row's 

1482 ``ui_element`` can disagree with its actual stored shape -- e.g. a 

1483 legacy row or a ``PUT``-created key with explicit ``ui_element="text"`` 

1484 can hold a dict, even though new dict values now infer "json" -- so the 

1485 submitted VALUE's shape, once reconciliation has happened, is what 

1486 decides whether coercion is safe to run, not the row's declared type. 

1487 ``coerce_setting_for_write``'s "json"/"multiselect" converters already 

1488 pass a dict/list through unchanged, so skipping the call entirely for 

1489 a reconciled container changes nothing for those two ui_elements -- 

1490 only "text"/"textarea"/"password"/"select" (whose converter is 

1491 ``str``) and "number"/"checkbox"/"range"/"slider" (whose converter 

1492 would silently discard the container's structure) are protected by 

1493 this. 

1494 """ 

1495 if not isinstance(guard_value, (dict, list)): 

1496 return raw_value, False 

1497 if not _container_embeds_sentinel( 

1498 guard_value, DataSanitizer.REDACTION_TEXT 

1499 ): 

1500 return raw_value, False 

1501 # NOTE (cross-helper coupling, deliberate but latent -- read before 

1502 # editing either side). ``_is_rejected`` is DISCARDED here. When 

1503 # ``_resolve_container_secret_write`` rejects -- an orphan sentinel 

1504 # (a masked leaf with nothing in the stored row to restore it from), a 

1505 # spliced "[REDACTED],x" leaf, a length-changed list, an untrusted 

1506 # list position -- ``effective`` is the RAW submission, still carrying 

1507 # the literal placeholder text, and this returns it with 

1508 # ``is_reconciled_container=True``. Every caller reads that flag as 

1509 # "persist as-is, skipping BOTH ``coerce_setting_for_write`` and 

1510 # ``validate_setting``", so a rejected write would store "[REDACTED]" 

1511 # as the credential with both checks bypassed. 

1512 # 

1513 # That is unreachable today, and only for one reason: all three call 

1514 # sites run ``_embeds_redaction_sentinel`` FIRST, on the SAME 

1515 # ``guard_value``, and it 400s on exactly the conditions that set 

1516 # ``rejected`` here (it calls ``_resolve_container_secret_write`` with 

1517 # the same arguments and returns its ``is_rejected``). By the time 

1518 # this function runs, ``rejected`` is therefore always False. This is 

1519 # the second undocumented coupling in this file (the first is 

1520 # ``_merge_redacted_container``'s depth-cap NOTE) and is recorded 

1521 # rather than removed because propagating the rejection would change 

1522 # this helper's return arity at three call sites with three different 

1523 # error conventions (a 400 JSONResponse, a per-key failed_count skip, 

1524 # and a flash+302). If a caller is ever added that does NOT run 

1525 # ``_embeds_redaction_sentinel`` on the same guard value first, or if 

1526 # that guard's rejection conditions are narrowed, this becomes a live 

1527 # fail-open and the rejection MUST be propagated instead. 

1528 effective, _is_noop, _is_rejected = _resolve_container_secret_write( 

1529 guard_value, stored_value, DataSanitizer.REDACTION_TEXT 

1530 ) 

1531 return effective, True 

1532 

1533 

1534def _is_secret_empty_noop( 

1535 key: str, 

1536 ui_element: str | None, 

1537 value: object, 

1538 stored_value: object = None, 

1539) -> bool: 

1540 """True when a secret write must be ignored: the redaction sentinel 

1541 for any sensitive setting, or an empty string for password inputs 

1542 (which render blank, so an untouched field must not wipe the secret). 

1543 

1544 The ``ui_element == "password"`` narrowing matters: 

1545 ``notifications.service_url`` is a sensitive setting on a ``textarea`` 

1546 that renders blank once redacted, but its blankness is advertised (the 

1547 placeholder names the explicit clear gesture) rather than a stand-in 

1548 for an unknown value, so an empty write there is a deliberate "clear 

1549 it" gesture and must reach the database (#5960). 

1550 

1551 A dict/list value goes through the container arm, delegated to 

1552 ``_resolve_container_secret_write`` / ``_merge_redacted_container``: 

1553 ``redact_value`` can mask a container's string leaves via 

1554 ``_force_redact_strings`` or by SUB-KEY rather than replacing the whole 

1555 value with the bare sentinel (see its docstring), so the exact-string 

1556 check above never sees a container's round-tripped sentinel. Without 

1557 this arm, a GET-then-save-untouched container -- e.g. ``{"uri": 

1558 "[REDACTED]", "token": "[REDACTED]", "port": 19530}`` -- would sail past 

1559 every guard here (all are ``isinstance(value, str)``-gated) and persist 

1560 the sentinel over the real credential. 

1561 

1562 The merge is per-LEAF, not all-or-nothing: every leaf that is exactly 

1563 the sentinel is checked against ``stored_value`` at the same path, and 

1564 every other leaf (an edited sibling, a non-string port/toggle) is kept 

1565 as submitted. The write is a no-op only when that merge comes out 

1566 byte-for-byte identical to what's already stored -- i.e. nothing about 

1567 the container actually changed. A container that merges to something 

1568 DIFFERENT from what's stored is a genuine edit (handled by 

1569 ``_embeds_redaction_sentinel``'s reject path only if a leaf can't be 

1570 resolved; otherwise it is written, see the update-route call sites, 

1571 which persist the merged value rather than this function's raw 

1572 argument). If the caller has no stored value to compare against 

1573 (``stored_value is None``, e.g. no prior row), a sentinel-bearing 

1574 container can never be verified as an untouched round-trip and is 

1575 therefore never treated as this kind of no-op. 

1576 

1577 The dict/list arm runs BEFORE the ``is_sensitive_setting`` gate below, 

1578 unlike the string arm: ``redact_value`` recurses into a container by 

1579 SUB-KEY, so a setting whose OUTER key is not itself sensitive (e.g. 

1580 ``llm.model_kwargs``) can still have a sensitive-named leaf inside it 

1581 (``stop_token``, ``page_token``) masked to the sentinel on the way out. 

1582 Gating the container check on the outer key's sensitivity -- as this 

1583 function used to -- means that sentinel round-trips straight back into 

1584 the leaf on the very next save, since no guard ever looked at it. The 

1585 literal string "[REDACTED]" is not a value any real setting leaf would 

1586 hold, sensitive or not, so scanning every dict/list write for it 

1587 unconditionally closes that gap without needing to re-derive which 

1588 nested leaf name is "sensitive" here. 

1589 """ 

1590 if isinstance(value, (dict, list)): 

1591 _effective, is_noop, _is_rejected = _resolve_container_secret_write( 

1592 value, stored_value, DataSanitizer.REDACTION_TEXT 

1593 ) 

1594 return is_noop 

1595 if not DataSanitizer.is_sensitive_setting(key, ui_element): 

1596 return False 

1597 if isinstance(value, str): 1597 ↛ 1601line 1597 didn't jump to line 1601 because the condition on line 1597 was always true

1598 return value == DataSanitizer.REDACTION_TEXT or ( 

1599 value == "" and ui_element == "password" 

1600 ) 

1601 return False 

1602 

1603 

1604def _redaction_sentinel_error(ui_element: str | None) -> str: 

1605 """Explain the 400 raised for a value that embeds the redaction sentinel. 

1606 

1607 Shared by every write route so the message is identical wherever it 

1608 fires, but the "submit an empty value to clear it" hint only holds for 

1609 non-password sensitive settings such as the ``notifications.service_url`` 

1610 textarea. ``_is_secret_empty_noop`` makes an empty write to a 

1611 ``password`` input a deliberate no-op (those fields render blank, so an 

1612 untouched form must not wipe the secret), and 

1613 ``_embeds_redaction_sentinel`` gates on sensitivity alone, so a 

1614 password-backed setting can reach this error through a direct API call 

1615 even though the UI cannot produce it. Advertising the empty-value 

1616 escape there would tell the caller to do something that provably 

1617 cannot work, so the hint is dropped for password inputs. 

1618 """ 

1619 message = ( 

1620 "Value contains the redaction placeholder " 

1621 f"{DataSanitizer.REDACTION_TEXT!r}. The stored value is hidden, so " 

1622 "it cannot be edited in place — retype the whole value" 

1623 ) 

1624 if ui_element == "password": 

1625 return ( 

1626 f"{message}. To clear a password setting, clear the source " 

1627 "environment variable or use settings import." 

1628 ) 

1629 return f"{message}, or submit an empty value to clear it." 

1630 

1631 

1632def _embeds_redaction_sentinel( 

1633 key: str, 

1634 ui_element: str | None, 

1635 value: object, 

1636 stored_value: object = None, 

1637) -> bool: 

1638 """True when a submitted sensitive value *embeds* the redaction sentinel. 

1639 

1640 ``_is_secret_empty_noop`` covers the exact sentinel: an untouched field 

1641 round-tripped from a settings API read, which is benign and silently 

1642 ignored. This covers the *edited* field. Password inputs render blank, 

1643 so they cannot produce this, but ``notifications.service_url`` is a 

1644 sensitive setting on a ``textarea`` (the first non-password sensitive 

1645 setting in the codebase), and editing its comma-separated URL list is 

1646 the normal workflow. A stale client that rendered the sentinel yields 

1647 values like ``"[REDACTED],discord://webhook/tok"``, which is not an 

1648 exact match and would otherwise persist verbatim and silently break 

1649 every notification. No legitimate secret contains the sentinel, so this 

1650 is a hard 400 rather than a no-op: unlike the exact-match case the user 

1651 made an edit, and silently dropping it would look like a successful save. 

1652 

1653 A dict/list value takes the container arm, delegated to 

1654 ``_resolve_container_secret_write`` / ``_merge_redacted_container``. 

1655 This is a REJECT only, never a silent partial write: a container is 

1656 rejected when 

1657 

1658 1. a leaf equals the sentinel exactly but ``stored_value`` is itself 

1659 empty (a newly-introduced key, a structural mismatch, or no 

1660 ``stored_value`` at all) -- there is nothing to "keep", so 

1661 persisting it would write the literal placeholder text. This is 

1662 only the emptiness half of the check ``redact_value`` masks on; 

1663 the leaf's own sub-key sensitivity is never consulted here, so a 

1664 sentinel restores from ANY non-empty stored value at that path, 

1665 not only one that was actually masked (a stored ``{"port": 

1666 19530}`` is never masked on read, but a submitted ``{"port": 

1667 "[REDACTED]"}`` over it still restores ``19530``). Narrowing 

1668 ``DEFAULT_SENSITIVE_KEYS`` / ``is_sensitive_setting`` therefore 

1669 does not narrow what this merge will restore; or 

1670 2. a leaf merely CONTAINS the sentinel as a substring without being 

1671 an exact match (e.g. a stale client's rendered 

1672 "[REDACTED],new-value") -- an edit, but a corrupted one that 

1673 would splice the placeholder text into the stored value, same as 

1674 the plain string case below. 

1675 

1676 A container where every sentinel-bearing leaf DOES resolve against 

1677 ``stored_value`` is NOT rejected here, even when other, non-masked 

1678 leaves were edited or a masked leaf's non-string sibling changed: 

1679 ``redact_value`` masks a container by SUB-KEY, so a MIXED container -- 

1680 e.g. ``{"api_key": "[REDACTED]", "description": "My engine", 

1681 "requires_api_key": true}`` -- has only its sensitive leaf masked, and 

1682 an untouched GET-then-PUT round-trip of it, or an edit to 

1683 ``description``, must not become an unsatisfiable 400 the client can 

1684 never construct a passing request for (round 5 required EVERY maskable 

1685 leaf be exactly the sentinel, rejecting exactly this shape). Merging 

1686 the resolvable case instead of rejecting it is what lets 

1687 ``_is_secret_empty_noop`` recognize the true no-op and the 

1688 update-route call sites persist the resolved edit -- see 

1689 ``_reconciled_container_value_for_write``. 

1690 

1691 The dict/list arm runs BEFORE the ``is_sensitive_setting`` gate below, 

1692 for the same reason ``_is_secret_empty_noop`` does: ``redact_value`` 

1693 masks a container by SUB-KEY, so an outer key that is not itself 

1694 sensitive (``llm.model_kwargs``) can still carry a masked leaf 

1695 (``stop_token``) that this function must catch on write-back. No real 

1696 setting leaf ever legitimately holds the literal string 

1697 "[REDACTED]", so checking every dict/list write for it, regardless of 

1698 the outer key, cannot reject a value nothing would ever submit. 

1699 """ 

1700 if isinstance(value, (dict, list)): 

1701 _effective, _is_noop, is_rejected = _resolve_container_secret_write( 

1702 value, stored_value, DataSanitizer.REDACTION_TEXT 

1703 ) 

1704 return is_rejected 

1705 if not DataSanitizer.is_sensitive_setting(key, ui_element): 

1706 return False 

1707 if isinstance(value, str): 

1708 return ( 

1709 DataSanitizer.REDACTION_TEXT in value 

1710 and value != DataSanitizer.REDACTION_TEXT 

1711 ) 

1712 return False 

1713 

1714 

1715def _embeds_sentinel_on_create( 

1716 key: str, ui_element: object, value: object 

1717) -> bool: 

1718 """Sentinel check for the CREATE paths. 

1719 

1720 Creation has no prior value, so the sentinel cannot mean "keep the 

1721 stored secret" the way it does on the update path — every occurrence 

1722 of it, exact match included, is a corrupted client value that would be 

1723 stored verbatim as the credential. A dict/list value gets the same 

1724 treatment via ``_container_embeds_sentinel``: there is no "pure 

1725 round-trip" exemption here (unlike ``_is_secret_empty_noop`` on the 

1726 update path) because creation has no prior stored value for an untouched 

1727 field to round-trip from. 

1728 

1729 The dict/list check runs regardless of the outer key's sensitivity, for 

1730 the same reason as the update-path guards: a container's sensitive leaf 

1731 can be named differently than its outer key, and no legitimate value 

1732 ever contains the literal sentinel string. 

1733 """ 

1734 if isinstance(value, (dict, list)): 

1735 return _container_embeds_sentinel(value, DataSanitizer.REDACTION_TEXT) 

1736 if not DataSanitizer.is_sensitive_setting( 

1737 key, ui_element if isinstance(ui_element, str) else None 

1738 ): 

1739 return False 

1740 if isinstance(value, str): 

1741 return DataSanitizer.REDACTION_TEXT in value 

1742 return False 

1743 

1744 

1745def _is_allowed_new_setting_key(key: str) -> bool: 

1746 """Return True if *key* is permitted to be created via the web API.""" 

1747 # Reject malformed keys (blank, trailing/leading dot, empty ".." segment, 

1748 # stray whitespace) before the namespace check — a trailing-dot key such 

1749 # as ``local_search_chunk_size.`` otherwise passes the prefix allow-list 

1750 # and corrupts prefix lookups (see #4840). 

1751 if not is_valid_setting_key(key): 

1752 return False 

1753 key = key.lower() 

1754 for prefix in BLOCKED_SETTING_PREFIXES: 

1755 if key.startswith(prefix): 

1756 return False 

1757 for prefix in ALLOWED_SETTING_PREFIXES: 

1758 if key.startswith(prefix): 

1759 return True 

1760 return False 

1761 

1762 

1763def _new_key_rejection_reason(key) -> str: 

1764 """Explain why ``_is_allowed_new_setting_key`` rejected *key*. 

1765 

1766 Distinguishes a malformed key (bad syntax) from an allowed-namespace 

1767 violation so an API consumer with, say, a trailing-dot key (#4840) is 

1768 pointed at the real problem instead of being told it's a namespace issue. 

1769 """ 

1770 if not is_valid_setting_key(key): 

1771 return f"Setting key is malformed: {key!r}" 

1772 return f"Creating settings under this namespace is not allowed: {key}" 

1773 

1774 

1775@router.get("/") 

1776def settings_page( 

1777 request: Request, username: Annotated[str, Depends(require_auth)] 

1778): 

1779 """Main settings dashboard with links to specialized config pages""" 

1780 return templates.TemplateResponse( 

1781 request=request, 

1782 name="settings_dashboard.html", 

1783 context={"request": request}, 

1784 ) 

1785 

1786 

1787def _save_all_settings_sync(form_data: dict, username: str): 

1788 """Synchronous body of save_all_settings. 

1789 

1790 Separated so the `async def` handler can offload the entire 

1791 SQLAlchemy + validation + re-read block to a thread via 

1792 asyncio.to_thread, freeing the event loop during a bulk save. 

1793 """ 

1794 try: 

1795 from ...security.data_sanitizer import DataSanitizer 

1796 

1797 with get_user_db_session(username) as db_session: 

1798 settings_manager = get_settings_manager(db_session, username) 

1799 if settings_manager.settings_locked: 

1800 return JSONResponse( 

1801 { 

1802 "status": "error", 

1803 "message": "Settings are locked and cannot be changed", 

1804 }, 

1805 status_code=403, 

1806 ) 

1807 

1808 # Track validation errors 

1809 validation_errors = [] 

1810 settings_by_type: dict[str, Any] = {} 

1811 

1812 # Track changes for logging 

1813 updated_settings = [] 

1814 created_settings = [] 

1815 

1816 # Store original values for better messaging 

1817 original_values = {} 

1818 

1819 # Fetch all settings and remove non-editable keys 

1820 all_db_settings = _filter_editable_settings(form_data, db_session) 

1821 

1822 # Reject public hostnames being added to the local-hosts allowlist, 

1823 # and inherently-public engines being added to the trusted-engines 

1824 # list. 

1825 _hosts_err = first_egress_validation_error( 

1826 form_data, all_db_settings 

1827 ) 

1828 if _hosts_err is not None: 

1829 validation_errors.append(_hosts_err) 

1830 return JSONResponse( 

1831 { 

1832 "status": "error", 

1833 "message": "Validation errors", 

1834 "errors": validation_errors, 

1835 }, 

1836 status_code=400, 

1837 ) 

1838 

1839 # Update each setting 

1840 for key, value in form_data.items(): 

1841 # Skip corrupted keys or empty strings as keys 

1842 if not key or not isinstance(key, str) or key.strip() == "": 

1843 continue 

1844 

1845 # Get the setting metadata from pre-fetched dict 

1846 current_setting = all_db_settings.get(key) 

1847 

1848 if key == "search.fetch.summary_input_max_length": 

1849 try: 

1850 value = to_positive_int(value) 

1851 except ValueError: 

1852 validation_errors.append( 

1853 { 

1854 "key": key, 

1855 "name": "Fetch Summary Input Max Length", 

1856 "error": "Value must be a positive integer", 

1857 } 

1858 ) 

1859 continue 

1860 

1861 # The sentinel guards below must see a JSON-STRING-encoded 

1862 # container coerced to its real dict/list shape, or a 

1863 # masked container submitted in that form (a supported 

1864 # input shape for a "json" ui_element) sails past the 

1865 # container arm entirely -- see ``_coerced_for_guard``. 

1866 _guard_value = ( 

1867 _coerced_for_guard( 

1868 current_setting.key, current_setting.ui_element, value 

1869 ) 

1870 if current_setting 

1871 else value 

1872 ) 

1873 

1874 # SAFETY NET: the redaction sentinel is never a "clear the 

1875 # value" request, and neither is an empty string on a 

1876 # password input. Reasons: 

1877 # 1. The form templates (Jinja2 + JS-rendered) deliberately 

1878 # render password inputs empty so the saved value never 

1879 # enters the HTML source. A user who blurs the field 

1880 # without typing must not wipe their stored API key. 

1881 # 2. /settings/api redacts secret values to the redaction 

1882 # sentinel ("[REDACTED]"). A stale browser tab could 

1883 # submit it back — that must be idempotent on the DB, or 

1884 # the round-trip would persist the literal sentinel over 

1885 # the real secret. 

1886 # 3. Defense-in-depth against direct cURL or automation 

1887 # mistakes that POST an empty/sentinel value. 

1888 # The empty-string half is narrowed to password inputs: 

1889 # non-password sensitive controls (the 

1890 # notifications.service_url textarea) render their real value 

1891 # and must stay clearable (#5960). To unset a password 

1892 # setting, clear the source env var or use settings import. 

1893 if current_setting and _is_secret_empty_noop( 

1894 key, 

1895 current_setting.ui_element, 

1896 _guard_value, 

1897 current_setting.value, 

1898 ): 

1899 logger.debug( 

1900 f"Skipping empty secret write for {key} (no-op)" 

1901 ) 

1902 continue 

1903 

1904 # A value that merely *embeds* the sentinel is a corrupted 

1905 # edit, not an untouched round-trip: reject it loudly instead 

1906 # of persisting a secret with "[REDACTED]" spliced into it 

1907 # (#5947). 

1908 if current_setting and _embeds_redaction_sentinel( 

1909 key, 

1910 current_setting.ui_element, 

1911 _guard_value, 

1912 current_setting.value, 

1913 ): 

1914 logger.warning( 

1915 "Rejected redaction-sentinel value for {!r} via " 

1916 "save_all_settings (user={!r})", 

1917 key, 

1918 username, 

1919 ) 

1920 validation_errors.append( 

1921 { 

1922 "key": key, 

1923 "name": current_setting.name, 

1924 "error": _container_rejection_error( 

1925 current_setting.ui_element, _guard_value 

1926 ), 

1927 } 

1928 ) 

1929 continue 

1930 

1931 # EARLY VALIDATION: Convert checkbox values BEFORE any other processing 

1932 # This prevents incorrect triggering of corrupted value detection 

1933 if current_setting and current_setting.ui_element == "checkbox": 

1934 if not isinstance(value, bool): 

1935 # Never interpolate a settings VALUE into a log 

1936 # line -- same rule as ``manager.py``'s converter 

1937 # (#6201). A checkbox row can hold an arbitrary 

1938 # submitted value here (that is exactly why this 

1939 # branch exists), and a stale client or a 

1940 # mis-registered ui_element can put a credential or 

1941 # a secret-bearing container in it. The key, the 

1942 # type name and the length identify the bad 

1943 # submission without disclosing it. 

1944 logger.debug( 

1945 "Converting checkbox {} from {} (len={}) to bool", 

1946 key, 

1947 type(value).__name__, 

1948 len(value) 

1949 if isinstance(value, (str, list, dict, tuple)) 

1950 else "n/a", 

1951 ) 

1952 value = parse_boolean(value) 

1953 form_data[key] = ( 

1954 value # Update the form_data with converted value 

1955 ) 

1956 

1957 # Store original value for messaging 

1958 if current_setting: 

1959 original_values[key] = current_setting.value 

1960 

1961 # Determine setting type and category 

1962 if key.startswith("llm."): 

1963 setting_type = SettingType.LLM 

1964 category = "llm_general" 

1965 if ( 

1966 "temperature" in key 

1967 or "max_tokens" in key 

1968 or "batch" in key 

1969 or "layers" in key 

1970 ): 

1971 category = "llm_parameters" 

1972 elif key.startswith("search."): 

1973 setting_type = SettingType.SEARCH 

1974 category = "search_general" 

1975 if ( 

1976 "iterations" in key 

1977 or "results" in key 

1978 or "region" in key 

1979 or "questions" in key 

1980 or "section" in key 

1981 ): 

1982 category = "search_parameters" 

1983 elif key.startswith("report."): 

1984 setting_type = SettingType.REPORT 

1985 category = "report_parameters" 

1986 elif key.startswith("database."): 

1987 setting_type = SettingType.DATABASE 

1988 category = "database_parameters" 

1989 elif key.startswith("app."): 

1990 setting_type = SettingType.APP 

1991 category = "app_interface" 

1992 elif key.startswith("chat."): 1992 ↛ 1993line 1992 didn't jump to line 1993 because the condition on line 1992 was never true

1993 setting_type = SettingType.CHAT 

1994 category = "chat" 

1995 else: 

1996 setting_type = None 

1997 category = None 

1998 

1999 # Special handling for corrupted or empty values 

2000 if value == "[object Object]" or ( 

2001 isinstance(value, str) 

2002 and value.strip() in ["{}", "[]", "{", "["] 

2003 ): 

2004 if key.startswith("report."): 

2005 value = {} 

2006 else: 

2007 # Use default or null for other types 

2008 if key == "llm.model": 

2009 # Repair defaults must match main (#3348): an empty 

2010 # model lets the provider pick, "ollama" keeps a 

2011 # local-only install local instead of silently 

2012 # switching it to a cloud provider, and "auto" is 

2013 # NOT a registered engine — the factory fails 

2014 # closed on it, so a repaired install could no 

2015 # longer search at all. 

2016 value = "" 

2017 elif key == "llm.provider": 

2018 value = "ollama" 

2019 elif key == "search.tool": 

2020 value = DEFAULT_SEARCH_TOOL 

2021 elif key in ["app.theme", "app.default_theme"]: 

2022 # Must stay a value the theme registry actually 

2023 # serves; "dark" was reset here for a long time 

2024 # after the registry stopped having it. 

2025 value = "system" 

2026 else: 

2027 value = None 

2028 

2029 logger.warning( 

2030 "Corrected corrupted value for {}: {}", 

2031 _log_value_preview(key), 

2032 value, 

2033 ) 

2034 # NOTE: No JSON pre-parsing is done here. After the 

2035 # corruption replacement above, values are Python dicts 

2036 # (e.g. {}), hardcoded strings, or None — none are JSON 

2037 # strings that need parsing. Type conversion below via 

2038 # coerce_setting_for_write() handles everything; that 

2039 # function delegates to get_typed_setting_value() which 

2040 # already parses JSON internally for "json" and 

2041 # "multiselect" ui_elements. 

2042 

2043 if current_setting: 

2044 # If the guards above let this through because a masked 

2045 # container's sentinel leaves resolved against the 

2046 # stored row (see ``_reconciled_container_value_for_write``), 

2047 # persist the MERGED value -- secrets restored, edits 

2048 # applied -- not the raw submission, which still carries 

2049 # "[REDACTED]" on every untouched masked leaf. An 

2050 # ordinary write (no sentinel involved) is returned 

2051 # unchanged, so this has no effect on it. 

2052 value, _is_reconciled_container = ( 

2053 _reconciled_container_value_for_write( 

2054 value, _guard_value, current_setting.value 

2055 ) 

2056 ) 

2057 

2058 # Coerce to correct Python type (e.g. str "5" → int 5 

2059 # for number settings, str "true" → bool for checkboxes). 

2060 # A reconciled secret-bearing container is persisted 

2061 # exactly as merged instead: coercing it through a 

2062 # str-typed ui_element ("text"/"textarea"/"select") 

2063 # would flatten it to its Python repr, persisting a 

2064 # just-restored secret as a plain string (#6201). See 

2065 # ``_reconciled_container_value_for_write``'s docstring. 

2066 converted_value = ( 

2067 value 

2068 if _is_reconciled_container 

2069 else coerce_setting_for_write( 

2070 key=current_setting.key, 

2071 value=value, 

2072 ui_element=current_setting.ui_element, 

2073 ) 

2074 ) 

2075 

2076 # Validate the setting. A reconciled secret-bearing 

2077 # container must never reach ``validate_setting``, and 

2078 # the load-bearing reason is TYPE REJECTION rather than 

2079 # logging: for a "number"/"range" row the value flows 

2080 # into ``get_typed_setting_value``'s bare ``float(x)``, 

2081 # which fails, so the row is reported "Value must be a 

2082 # number" and the merged edit -- with the secret just 

2083 # restored into it -- is lost. That same converter also 

2084 # used to log the raw value at WARNING; that half is 

2085 # closed at the converter as of #6201 round 6 and no 

2086 # longer justifies the skip on its own (see 

2087 # ``_coerced_for_guard``, whose comment says the same). 

2088 # Only a user-constructed row (dict/list value on a 

2089 # non-container ui_element) can reach this; skipping 

2090 # validation is safe for the secret itself because the 

2091 # merge already resolved the container against the 

2092 # stored row. ACCEPTED LOSS: it does also skip the 

2093 # options-membership / min-max / checkbox-type checks 

2094 # for such a row, so e.g. a "select" row already 

2095 # holding a container accepts a merged container with 

2096 # no options check. 

2097 is_valid, error_message = ( 

2098 (True, None) 

2099 if _is_reconciled_container 

2100 else validate_setting(current_setting, converted_value) 

2101 ) 

2102 

2103 if is_valid: 

2104 # Save WITHOUT committing — one final commit runs 

2105 # below after the validation pass, so a later 

2106 # validation error rolls back every preceding 

2107 # write instead of leaving the DB half-saved. 

2108 success = set_setting( 

2109 key, 

2110 converted_value, 

2111 commit=False, 

2112 db_session=db_session, 

2113 ) 

2114 if success: 

2115 updated_settings.append(key) 

2116 else: 

2117 db_session.rollback() 

2118 logger.error("Failed to persist setting {}", key) 

2119 return JSONResponse( 

2120 { 

2121 "status": "error", 

2122 "message": "Failed to save setting.", 

2123 }, 

2124 status_code=500, 

2125 ) 

2126 

2127 # Track settings by type for exporting 

2128 if current_setting.type not in settings_by_type: 

2129 settings_by_type[current_setting.type] = [] 

2130 settings_by_type[current_setting.type].append( 

2131 current_setting 

2132 ) 

2133 else: 

2134 # Add to validation errors 

2135 validation_errors.append( 

2136 { 

2137 "key": key, 

2138 "name": current_setting.name, 

2139 "error": error_message, 

2140 } 

2141 ) 

2142 else: 

2143 # Namespace validation: reject new keys outside allowed 

2144 # prefixes (existing keys above bypass — updates only). 

2145 if not _is_allowed_new_setting_key(key): 

2146 logger.warning( 

2147 "Security: Rejected setting outside allowed " 

2148 "namespaces: {} (user={!r})", 

2149 _log_value_preview(key), 

2150 username, 

2151 ) 

2152 validation_errors.append( 

2153 { 

2154 "key": key, 

2155 "name": key, 

2156 "error": _new_key_rejection_reason(key), 

2157 } 

2158 ) 

2159 continue 

2160 

2161 # Creation has no prior value, so the sentinel cannot 

2162 # mean "keep the stored secret" — every occurrence of it, 

2163 # exact match included, would be stored verbatim as the 

2164 # credential. ui_element is not yet known here, so 

2165 # sensitivity is decided by the key's leaf name (#5947). 

2166 if _embeds_sentinel_on_create(key, None, value): 

2167 logger.warning( 

2168 "Rejected redaction-sentinel value for new key " 

2169 "{} via save_all_settings (user={!r})", 

2170 _log_value_preview(key), 

2171 username, 

2172 ) 

2173 validation_errors.append( 

2174 { 

2175 "key": key, 

2176 "name": key.split(".")[-1] 

2177 .replace("_", " ") 

2178 .title(), 

2179 "error": _container_rejection_error( 

2180 None, value 

2181 ), 

2182 } 

2183 ) 

2184 continue 

2185 

2186 # Create a new setting 

2187 new_setting = { 

2188 "key": key, 

2189 "value": value, 

2190 "type": setting_type.value.lower() 

2191 if setting_type is not None 

2192 else "app", 

2193 "name": key.split(".")[-1].replace("_", " ").title(), 

2194 "description": f"Setting for {key}", 

2195 "category": category, 

2196 "ui_element": "text", # Default UI element 

2197 } 

2198 

2199 # Determine better UI element based on value type 

2200 if isinstance(value, bool): 

2201 new_setting["ui_element"] = "checkbox" 

2202 elif isinstance(value, (int, float)) and not isinstance( 

2203 value, bool 

2204 ): 

2205 new_setting["ui_element"] = "number" 

2206 elif isinstance(value, (dict, list)): 

2207 new_setting["ui_element"] = "textarea" 

2208 

2209 # A JSON body can carry NaN, Infinity or an integer too 

2210 # large for a float, which the number type rejects on 

2211 # read. 

2212 rejection = _strict_read_rejection( 

2213 key, value, new_setting["ui_element"] 

2214 ) 

2215 if rejection is not None: 

2216 validation_errors.append( 

2217 { 

2218 "key": key, 

2219 "name": new_setting["name"], 

2220 "error": rejection, 

2221 } 

2222 ) 

2223 continue 

2224 

2225 # Create the setting without committing yet — see above 

2226 db_setting = create_or_update_setting( 

2227 new_setting, commit=False, db_session=db_session 

2228 ) 

2229 

2230 if db_setting: 

2231 created_settings.append(key) 

2232 # Track settings by type for exporting 

2233 if db_setting.type not in settings_by_type: 2233 ↛ 2235line 2233 didn't jump to line 2235 because the condition on line 2233 was always true

2234 settings_by_type[db_setting.type] = [] 

2235 settings_by_type[db_setting.type].append(db_setting) 

2236 else: 

2237 validation_errors.append( 

2238 { 

2239 "key": key, 

2240 "name": new_setting["name"], 

2241 "error": "Failed to create setting", 

2242 } 

2243 ) 

2244 

2245 # Report validation errors if any — roll back the whole batch 

2246 # so a partial save isn't visible to the next request. 

2247 if validation_errors: 

2248 db_session.rollback() 

2249 return JSONResponse( 

2250 { 

2251 "status": "error", 

2252 "message": "Validation errors", 

2253 "errors": validation_errors, 

2254 }, 

2255 status_code=400, 

2256 ) 

2257 

2258 # Prepare the effective response before committing. An invalid 

2259 # environment override must refuse the batch before any edit or 

2260 # post-commit effect becomes visible. 

2261 all_settings = {} 

2262 for setting in db_session.query(Setting).all(): 

2263 # Convert enum to string if present 

2264 setting_type = setting.type 

2265 if hasattr(setting_type, "value"): 

2266 setting_type = setting_type.value 

2267 

2268 all_settings[setting.key] = { 

2269 "value": setting.value, 

2270 "name": setting.name, 

2271 "description": setting.description, 

2272 "type": setting_type, 

2273 "category": setting.category, 

2274 "ui_element": setting.ui_element, 

2275 "editable": setting.editable, 

2276 "options": setting.options, 

2277 "visible": setting.visible, 

2278 "min_value": setting.min_value, 

2279 "max_value": setting.max_value, 

2280 "step": setting.step, 

2281 } 

2282 

2283 # Overlay operator-locked (LDR_*) settings onto the echo, the 

2284 # way SettingsManager.get_all_settings() already does for 

2285 # GET /settings/api. The settings form re-renders from this 

2286 # payload, so without the overlay an env-pinned field comes back 

2287 # showing the stale DB value and marked editable until a full 

2288 # page reload (#5978). Port of main's 

2289 # _shape_effective_setting_metadata. 

2290 for _echo_key, _echo_meta in all_settings.items(): 

2291 if is_operator_only_setting(_echo_key): 

2292 _echo_meta["editable"] = False 

2293 if check_env_setting(_echo_key) is None: 

2294 continue 

2295 _echo_meta["value"] = get_typed_setting_value( 

2296 key=_echo_key, 

2297 value=_echo_meta.get("value"), 

2298 ui_element=str(_echo_meta.get("ui_element", "text")), 

2299 default=_echo_meta.get("value"), 

2300 check_env=True, 

2301 ) 

2302 _echo_meta["editable"] = False 

2303 

2304 # The echo above covers only keys with a saved row. An override on 

2305 # a declared (default) key without a row is part of the same 

2306 # effective configuration, so it must refuse the batch too rather 

2307 # than surface only on the next typed read. 

2308 _defaults = settings_manager.default_settings 

2309 for _default_key, _default_meta in _defaults.items(): 

2310 if ( 

2311 _default_key in all_settings 

2312 or check_env_setting(_default_key) is None 

2313 ): 

2314 continue 

2315 get_typed_setting_value( 

2316 key=_default_key, 

2317 value=_default_meta.get("value"), 

2318 ui_element=str(_default_meta.get("ui_element", "text")), 

2319 default=_default_meta.get("value"), 

2320 check_env=True, 

2321 ) 

2322 

2323 # Operator-gate the "unprotected" escape hatch out of the 

2324 # egress-scope select's options unless explicitly enabled, and 

2325 # normalise the displayed value (#5148 / 87537d9ec). Without 

2326 # this, a save-and-refresh cycle would re-render the dropdown 

2327 # from this echoed payload with the escape hatch back on offer. 

2328 if "policy.egress_scope" in all_settings: 

2329 all_settings["policy.egress_scope"] = ( 

2330 _shape_egress_scope_setting( 

2331 "policy.egress_scope", 

2332 all_settings["policy.egress_scope"], 

2333 ) 

2334 ) 

2335 

2336 # Hide the operator-gated unencrypted "filesystem" PDF-storage 

2337 # option from a save-and-refresh echo, same rationale as the 

2338 # egress-scope shaping above (#5148 / fb49985aa). 

2339 if "research_library.pdf_storage_mode" in all_settings: 

2340 all_settings["research_library.pdf_storage_mode"] = ( 

2341 _shape_pdf_storage_mode_setting( 

2342 "research_library.pdf_storage_mode", 

2343 all_settings["research_library.pdf_storage_mode"], 

2344 ) 

2345 ) 

2346 

2347 # Customize the success message based on what changed 

2348 success_message = "" 

2349 if len(updated_settings) == 1: 

2350 # For a single update, provide more specific info about what changed 

2351 key = updated_settings[0] 

2352 # Reuse the already-fetched setting from our pre-fetched dict 

2353 updated_setting = all_db_settings.get(key) 

2354 name = ( 

2355 updated_setting.name 

2356 if updated_setting 

2357 else key.split(".")[-1].replace("_", " ").title() 

2358 ) 

2359 

2360 # Format the message 

2361 if key in original_values: 2361 ↛ 2379line 2361 didn't jump to line 2379 because the condition on line 2361 was always true

2362 # Get original value but comment out if not used 

2363 # old_value = original_values[key] 

2364 new_value = ( 

2365 updated_setting.value if updated_setting else None 

2366 ) 

2367 

2368 # If it's a boolean, use "enabled/disabled" language 

2369 if isinstance(new_value, bool): 

2370 state = "enabled" if new_value else "disabled" 

2371 success_message = f"{name} {state}" 

2372 else: 

2373 # For non-boolean values 

2374 if isinstance(new_value, (dict, list)): 

2375 success_message = f"{name} updated" 

2376 else: 

2377 success_message = f"{name} updated" 

2378 else: 

2379 success_message = f"{name} updated" 

2380 else: 

2381 # Multiple settings or generic message 

2382 success_message = f"Settings saved successfully ({len(updated_settings)} updated, {len(created_settings)} created)" 

2383 

2384 # Check if any warning-affecting settings were changed and include 

2385 # warnings. Redact secret values in the echoed settings so a POST 

2386 # response never ships plaintext API keys back to the browser — 

2387 # matching the redaction the GET /settings/api endpoint applies. 

2388 response_data = { 

2389 "status": "success", 

2390 "message": success_message, 

2391 "updated": updated_settings, 

2392 "created": created_settings, 

2393 "settings": DataSanitizer.redact_settings_snapshot( 

2394 all_settings 

2395 ), 

2396 } 

2397 

2398 # The writes and response both validated: commit atomically. 

2399 db_session.commit() 

2400 changed_settings = updated_settings + created_settings 

2401 # The batch is committed from here on. A settings error raised by 

2402 # a post-commit effect must not be reported as a refused save. 

2403 try: 

2404 if changed_settings: 

2405 settings_manager.emit_settings_changed_after_commit( 

2406 changed_settings 

2407 ) 

2408 

2409 invalidate_settings_caches(username) 

2410 reschedule_document_jobs_if_needed(username, changed_settings) 

2411 reschedule_zotero_jobs_if_needed(username, changed_settings) 

2412 

2413 # Check if any warning-affecting settings were changed 

2414 if any( 

2415 key in WARNING_AFFECTING_KEYS for key in changed_settings 

2416 ): 

2417 warnings = calculate_warnings(username=username) 

2418 response_data["warnings"] = warnings 

2419 logger.info( 

2420 f"Bulk settings update affected warning keys, calculated {len(warnings)} warnings" 

2421 ) 

2422 except SettingsValueError as error: 

2423 _log_post_save_settings_error(error) 

2424 

2425 return response_data 

2426 

2427 except SettingsValueError as error: 

2428 return _settings_value_error_response( 

2429 "settings for bulk save", error, bulk_save=True 

2430 ) 

2431 except Exception: 

2432 logger.exception("Error saving settings") 

2433 return JSONResponse( 

2434 { 

2435 "status": "error", 

2436 "message": "An internal error occurred while saving settings.", 

2437 }, 

2438 status_code=500, 

2439 ) 

2440 

2441 

2442@router.post("/save_all_settings") 

2443@settings_limit 

2444async def save_all_settings( 

2445 request: Request, 

2446 username: Annotated[str, Depends(require_auth)], 

2447): 

2448 """Handle saving all settings at once from the unified settings page. 

2449 

2450 Thin async wrapper that reads the JSON body, then offloads the 

2451 entire sync save to a thread so we don't block the event loop. 

2452 """ 

2453 form_data, body_error = await read_json_dict( 

2454 request, "status", "No settings data provided" 

2455 ) 

2456 if body_error is not None: 

2457 return body_error 

2458 if not form_data: 

2459 return JSONResponse( 

2460 {"status": "error", "message": "No settings data provided"}, 

2461 status_code=400, 

2462 ) 

2463 return await run_db_sync(_save_all_settings_sync, form_data, username) 

2464 

2465 

2466@router.post("/reset_to_defaults") 

2467@settings_limit 

2468def reset_to_defaults( 

2469 request: Request, 

2470 username: Annotated[str, Depends(require_auth)], 

2471): 

2472 """Reset all settings to their default values. 

2473 

2474 Preserves API keys and other password-type settings — overwriting 

2475 these with empty defaults from the bundled JSON would silently 

2476 erase the user's credentials and force them to re-enter every key. 

2477 """ 

2478 try: 

2479 with get_user_db_session(username) as db_session: 

2480 settings_manager = get_settings_manager(db_session, username) 

2481 

2482 # Settings lock (app.lock_settings) -- ported from main's 

2483 # reset_to_defaults (#5659, "enforce the settings lock on delete, 

2484 # import and reset"). SettingsManager already refuses when 

2485 # locked, so the write cannot happen either way; this repeats 

2486 # the check at the route so a locked instance answers 403 

2487 # rather than 200 with nothing written, which is what main's 

2488 # own comment gives as the reason. Without it the merge that 

2489 # brought #5659 in would have silently dropped the fix, since 

2490 # it landed in a Flask file this migration deletes. 

2491 if settings_manager.settings_locked: 

2492 return JSONResponse( 

2493 { 

2494 "status": "error", 

2495 "message": "Settings are locked and cannot be reset", 

2496 }, 

2497 status_code=403, 

2498 ) 

2499 

2500 # Snapshot password/api-key settings before reset so we can 

2501 # restore them. We use the DB row's ui_element to detect 

2502 # password fields rather than a name pattern. 

2503 preserved: dict[str, Any] = {} 

2504 password_rows = ( 

2505 db_session.query(Setting) 

2506 .filter(Setting.ui_element == "password") 

2507 .all() 

2508 ) 

2509 for row in password_rows: 

2510 if row.value not in (None, ""): 

2511 preserved[row.key] = row.value 

2512 

2513 # preserve_environment_locked=True is load-bearing and was lost in 

2514 # the port: it defaults to False, and import_settings writes rows 

2515 # in BULK rather than through the _is_environment_locked-guarded 

2516 # setters, so without it a reset overwrites the stored rows of 

2517 # settings an operator has locked via LDR_* env vars — and drops 

2518 # the policy_audit warning that records the attempt. The damage is 

2519 # latent while the env var is set (reads prefer env) and surfaces 

2520 # the moment the operator removes it. 

2521 settings_manager.load_from_defaults_file( 

2522 preserve_environment_locked=True 

2523 ) 

2524 reset_setting_keys = tuple(settings_manager.default_settings) 

2525 

2526 # Restore preserved values 

2527 for key, value in preserved.items(): 

2528 try: 

2529 settings_manager.set_setting(key, value, commit=False) 

2530 except Exception: 

2531 logger.exception( 

2532 f"Could not restore preserved setting {key}" 

2533 ) 

2534 if preserved: 

2535 db_session.commit() 

2536 

2537 logger.info( 

2538 "Successfully reset settings to defaults " 

2539 f"(preserved {len(preserved)} password-type setting(s))" 

2540 ) 

2541 invalidate_settings_caches(username) 

2542 reschedule_document_jobs_if_needed(username, reset_setting_keys) 

2543 reschedule_zotero_jobs_if_needed(username, reset_setting_keys) 

2544 

2545 except Exception: 

2546 logger.exception("Error importing default settings") 

2547 return JSONResponse( 

2548 { 

2549 "status": "error", 

2550 "message": "Failed to reset settings to defaults", 

2551 }, 

2552 status_code=500, 

2553 ) 

2554 

2555 return { 

2556 "status": "success", 

2557 "message": "All settings have been reset to default values", 

2558 } 

2559 

2560 

2561def _save_settings_sync(form_data: dict, username: str) -> dict: 

2562 """Sync helper for save_settings — runs the bulk setting writes. 

2563 

2564 Returns an outcome dict so the caller can flash user-visible feedback 

2565 on the no-JS fallback path (``ok``, ``policy_error``, ``failed``, 

2566 ``rejected``) — Flask's flash()-based feedback was otherwise lost in 

2567 the migration, leaving the no-JS form a silent redirect regardless of 

2568 success, partial rejection, or failure. 

2569 """ 

2570 with get_user_db_session(username) as db_session: 

2571 settings_manager = get_settings_manager(db_session, username) 

2572 

2573 # Fetch all settings and remove non-editable keys 

2574 all_db_settings = _filter_editable_settings(form_data, db_session) 

2575 

2576 # Egress-policy validators — the JSON route (save_all_settings) runs 

2577 # these; the POST fallback must too, or a JS-disabled client could 

2578 # whitelist a public hostname as "local", which the JSON route does 

2579 # not permit. 

2580 _policy_err = first_egress_validation_error(form_data, all_db_settings) 

2581 if _policy_err is not None: 2581 ↛ 2582line 2581 didn't jump to line 2582 because the condition on line 2581 was never true

2582 logger.warning( 

2583 "Rejected settings POST: {}", 

2584 _log_value_preview( 

2585 _policy_err.get("error", "Invalid policy setting") 

2586 ), 

2587 ) 

2588 return { 

2589 "ok": False, 

2590 "policy_error": _policy_err.get( 

2591 "error", "Invalid policy setting" 

2592 ), 

2593 "failed": 0, 

2594 "rejected": 0, 

2595 } 

2596 

2597 failed_count = 0 

2598 rejected_count = 0 

2599 changed_settings: list[str] = [] 

2600 sentinel_rejected_ui_elements: list[str | None] = [] 

2601 # Parallel to sentinel_rejected_ui_elements: whether that specific 

2602 # rejection was the depth cap rather than an actual sentinel, so the 

2603 # aggregated flash message below doesn't misreport a too-deep 

2604 # payload as "contains the redaction placeholder" (see 

2605 # ``_container_too_deep_error``). 

2606 sentinel_rejected_too_deep: list[bool] = [] 

2607 for key, value in form_data.items(): 

2608 try: 

2609 db_setting = all_db_settings.get(key) 

2610 

2611 if key == "search.fetch.summary_input_max_length": 

2612 try: 

2613 value = to_positive_int(value) 

2614 except ValueError: 

2615 failed_count += 1 

2616 continue 

2617 

2618 # Namespace validation: reject new keys outside allowed 

2619 # prefixes. Existing keys (updates) bypass this check — it 

2620 # only applies to creation of brand-new rows through this 

2621 # form-POST route. 

2622 if db_setting is None and not _is_allowed_new_setting_key(key): 

2623 logger.warning( 

2624 "Security: Rejected setting outside allowed " 

2625 "namespaces: {} (user={!r})", 

2626 _log_value_preview(key), 

2627 username, 

2628 ) 

2629 rejected_count += 1 

2630 continue 

2631 

2632 # Creation has no prior value, so the sentinel cannot mean 

2633 # "keep the stored secret" — every occurrence is a 

2634 # corrupted client value that would be stored verbatim as 

2635 # the credential. This is the one write path that never 

2636 # had this check wired in (#5947 added it to 

2637 # save_all_settings and api_update_setting, but not here); 

2638 # ui_element is unknown at creation time, so sensitivity is 

2639 # decided by the key's leaf name alone, same as the other 

2640 # create paths. 

2641 if db_setting is None and _embeds_sentinel_on_create( 

2642 key, None, value 

2643 ): 

2644 logger.warning( 

2645 "Rejected redaction-sentinel value for new key " 

2646 "{} via save_settings (user={!r})", 

2647 _log_value_preview(key), 

2648 username, 

2649 ) 

2650 sentinel_rejected_ui_elements.append(None) 

2651 sentinel_rejected_too_deep.append( 

2652 isinstance(value, (dict, list)) 

2653 and _container_exceeds_max_depth(value) 

2654 ) 

2655 continue 

2656 

2657 # See ``_coerced_for_guard``: the sentinel guards must see a 

2658 # JSON-string-encoded container coerced to its real 

2659 # dict/list shape, or a masked container submitted in that 

2660 # form sails past the container arm entirely. 

2661 _guard_value = ( 

2662 _coerced_for_guard( 

2663 db_setting.key, db_setting.ui_element, value 

2664 ) 

2665 if db_setting 

2666 else value 

2667 ) 

2668 

2669 # SAFETY NET: the redaction sentinel is a no-op for every 

2670 # sensitive setting, and an empty string is a no-op only for 

2671 # password inputs — never "clear my key". The no-JS form 

2672 # renders password inputs empty (and GET redacts them to the 

2673 # sentinel), so a plain form submit must not wipe the stored 

2674 # secret. Non-password sensitive controls (the 

2675 # notifications.service_url textarea) stay clearable (#5960). 

2676 # Matches the guards in save_all_settings + 

2677 # api_update_setting. 

2678 if db_setting and _is_secret_empty_noop( 

2679 key, db_setting.ui_element, _guard_value, db_setting.value 

2680 ): 

2681 logger.debug( 

2682 f"Skipping empty secret write for {key} via " 

2683 "save_settings (no-op)" 

2684 ) 

2685 continue 

2686 

2687 # An embedded sentinel is a corrupted edit, not a round-trip. 

2688 # This route reports per-setting outcomes by flash message, 

2689 # so collect it rather than saving a secret with "[REDACTED]" 

2690 # spliced into it (#5947). 

2691 if db_setting and _embeds_redaction_sentinel( 

2692 key, db_setting.ui_element, _guard_value, db_setting.value 

2693 ): 

2694 logger.warning( 

2695 "Rejected redaction-sentinel value for {!r} via " 

2696 "save_settings (user={!r})", 

2697 key, 

2698 username, 

2699 ) 

2700 sentinel_rejected_ui_elements.append(db_setting.ui_element) 

2701 sentinel_rejected_too_deep.append( 

2702 isinstance(_guard_value, (dict, list)) 

2703 and _container_exceeds_max_depth(_guard_value) 

2704 ) 

2705 continue 

2706 

2707 if db_setting: 

2708 # An HTML form cannot express None. A <select> whose 

2709 # options include a null value (e.g. "All Time" for 

2710 # search.engine.web.serper.default_params.time_period) 

2711 # posts back "" for that choice, and "" is not in the 

2712 # allowed values, so validate_setting below rejects a 

2713 # value the user never changed. The JSON route is 

2714 # unaffected because it sends a real null. 

2715 # 

2716 # Only "" is mapped, and only when null is genuinely an 

2717 # allowed option — so this cannot turn a typo into a 

2718 # silent null on a select that does not permit one. 

2719 if ( 

2720 value == "" 

2721 and db_setting.ui_element == "select" 

2722 and db_setting.options 

2723 and any( 

2724 (opt.get("value") if isinstance(opt, dict) else opt) 

2725 is None 

2726 for opt in list(db_setting.options) 

2727 ) 

2728 ): 

2729 value = None 

2730 

2731 # Persist the MERGED value when a masked container's 

2732 # sentinel leaves resolved against the stored row 

2733 # (see ``_reconciled_container_value_for_write``); an 

2734 # ordinary write is returned unchanged. 

2735 value, _is_reconciled_container = ( 

2736 _reconciled_container_value_for_write( 

2737 value, _guard_value, db_setting.value 

2738 ) 

2739 ) 

2740 

2741 # A reconciled secret-bearing container is persisted 

2742 # exactly as merged: coercing it through a str-typed 

2743 # ui_element would flatten it to its Python repr and 

2744 # disclose a just-restored secret as a plain string 

2745 # (#6201). See _reconciled_container_value_for_write. 

2746 if not _is_reconciled_container: 

2747 value = coerce_setting_for_write( 

2748 key=db_setting.key, 

2749 value=value, 

2750 ui_element=db_setting.ui_element, 

2751 ) 

2752 

2753 # Validate against the setting's constraints (options 

2754 # membership, numeric bounds, checkbox type) — the same 

2755 # check save_all_settings (~line 655) and 

2756 # api_update_setting (~line 3125) run. Main added this 

2757 # exact call to the Flask route this function replaces 

2758 # (fb49985aa8: "this JS-disabled POST fallback wrote 

2759 # values unchecked"); the FastAPI rewrite dropped it. 

2760 # Best-effort semantics preserved: an invalid key is 

2761 # skipped and counted in failed_count, the rest of the 

2762 # batch still validates, saves, and commits. 

2763 # 

2764 # A reconciled secret-bearing container skips 

2765 # ``validate_setting`` the same way it skips coercion 

2766 # above. The load-bearing reason is TYPE REJECTION, not 

2767 # logging: ``validate_setting`` runs the value through 

2768 # ``get_typed_setting_value`` for the row's DECLARED 

2769 # ui_element, so a dict/list on a "number"/"range" row 

2770 # comes back "Value must be a number" and the merged 

2771 # edit -- with the secret just restored into it -- is 

2772 # lost. Its other former reason (that same path logged 

2773 # the raw, secret-bearing value at WARNING) is closed 

2774 # at the converter itself as of #6201 round 6, so it no 

2775 # longer justifies anything on its own; 

2776 # ``_coerced_for_guard``'s comment already says this. 

2777 # ACCEPTED LOSS: skipping validation also skips the 

2778 # options-membership / min-max / checkbox-type checks, 

2779 # so e.g. a "select" row that already holds a container 

2780 # accepts a merged container with no options check. 

2781 # Bounded to rows already holding a container under a 

2782 # scalar ui_element -- a user-constructed shape -- and 

2783 # the merge itself already resolved every leaf against 

2784 # the stored row (#6201). 

2785 if value is not None and not _is_reconciled_container: 

2786 is_valid, error_message = validate_setting( 

2787 db_setting, value 

2788 ) 

2789 if not is_valid: 

2790 logger.warning( 

2791 f"Validation failed for setting {key}: " 

2792 f"{error_message}" 

2793 ) 

2794 failed_count += 1 

2795 continue 

2796 

2797 if not settings_manager.set_setting(key, value, commit=False): 

2798 failed_count += 1 

2799 logger.warning( 

2800 "Failed to save setting {}", _log_value_preview(key) 

2801 ) 

2802 else: 

2803 changed_settings.append(key) 

2804 except Exception: 

2805 logger.exception( 

2806 "Error saving setting {}", _log_value_preview(key) 

2807 ) 

2808 failed_count += 1 

2809 

2810 if rejected_count: 

2811 logger.warning( 

2812 f"Rejected {rejected_count} new setting(s) outside " 

2813 "allowed namespaces" 

2814 ) 

2815 

2816 try: 

2817 db_session.commit() 

2818 invalidate_settings_caches(username) 

2819 reschedule_document_jobs_if_needed(username, changed_settings) 

2820 reschedule_zotero_jobs_if_needed(username, changed_settings) 

2821 except Exception: 

2822 db_session.rollback() 

2823 logger.exception("Failed to commit settings") 

2824 return { 

2825 "ok": False, 

2826 "policy_error": None, 

2827 "failed": failed_count + 1, 

2828 "rejected": rejected_count, 

2829 "sentinel_rejected": sentinel_rejected_ui_elements, 

2830 "sentinel_rejected_too_deep": sentinel_rejected_too_deep, 

2831 } 

2832 

2833 return { 

2834 "ok": failed_count == 0 and not sentinel_rejected_ui_elements, 

2835 "policy_error": None, 

2836 "failed": failed_count, 

2837 "rejected": rejected_count, 

2838 "sentinel_rejected": sentinel_rejected_ui_elements, 

2839 "sentinel_rejected_too_deep": sentinel_rejected_too_deep, 

2840 } 

2841 

2842 

2843@router.post("/save_settings") 

2844@settings_limit 

2845async def save_settings( 

2846 request: Request, 

2847 username: Annotated[str, Depends(require_auth)], 

2848): 

2849 """Save all settings from the form using POST method — fallback when 

2850 JavaScript is disabled. Body work offloaded to threadpool.""" 

2851 from ..dependencies.flash import flash 

2852 

2853 outcome = None 

2854 try: 

2855 form_data = dict(await request.form()) 

2856 form_data.pop("csrf_token", None) 

2857 outcome = await run_db_sync(_save_settings_sync, form_data, username) 

2858 except Exception: 

2859 logger.exception("Error in save_settings") 

2860 

2861 # Give the no-JS user visible feedback via a flash message (rendered on 

2862 # the /settings/ page) instead of a silent redirect. Flask flashed the 

2863 # same success/error; the migration had dropped it. 

2864 if outcome is None: 

2865 flash(request, "Failed to save settings. Please try again.", "error") 

2866 elif outcome.get("policy_error"): 2866 ↛ 2867line 2866 didn't jump to line 2867 because the condition on line 2866 was never true

2867 flash(request, outcome["policy_error"], "error") 

2868 elif outcome.get("sentinel_rejected"): 

2869 # Drop the empty-value hint if any rejected setting is a password 

2870 # input, where an empty write is a no-op rather than a clear (see 

2871 # ``_redaction_sentinel_error``). 

2872 _rejected_uis = outcome["sentinel_rejected"] 

2873 _too_deep = outcome.get("sentinel_rejected_too_deep") or [] 

2874 _hint_ui = ( 

2875 "password" 

2876 if any(ui == "password" for ui in _rejected_uis) 

2877 else None 

2878 ) 

2879 # Every rejection in this batch was the depth cap, not an actual 

2880 # sentinel -- say so instead of claiming the value "contains the 

2881 # redaction placeholder" (see ``_container_too_deep_error``). A 

2882 # MIXED batch (some too-deep, some an actual sentinel) states 

2883 # both reasons instead of only the sentinel wording, which used 

2884 # to silently drop the too-deep explanation for whichever 

2885 # settings in the batch were rejected for that reason instead. 

2886 if _too_deep and all(_too_deep): 2886 ↛ 2887line 2886 didn't jump to line 2887 because the condition on line 2886 was never true

2887 _message = _container_too_deep_error() 

2888 elif _too_deep and any(_too_deep): 2888 ↛ 2889line 2888 didn't jump to line 2889 because the condition on line 2888 was never true

2889 _message = ( 

2890 _redaction_sentinel_error(_hint_ui) 

2891 + " (Some of these were instead rejected for nesting too " 

2892 "deeply to validate — flatten those before saving.)" 

2893 ) 

2894 else: 

2895 _message = _redaction_sentinel_error(_hint_ui) 

2896 flash( 

2897 request, 

2898 f"Rejected {len(_rejected_uis)} settings: " + _message, 

2899 "error", 

2900 ) 

2901 elif outcome.get("failed"): 

2902 flash( 

2903 request, 

2904 f"Saved with {outcome['failed']} setting(s) failing. " 

2905 "Check the values and try again.", 

2906 "warning", 

2907 ) 

2908 elif outcome.get("rejected"): 

2909 flash( 

2910 request, 

2911 f"Settings saved; {outcome['rejected']} unrecognized key(s) " 

2912 "were ignored.", 

2913 "warning", 

2914 ) 

2915 else: 

2916 flash(request, "Settings saved.", "success") 

2917 

2918 return RedirectResponse(url="/settings/", status_code=302) 

2919 

2920 

2921def _log_post_save_settings_error(error: SettingsValueError) -> None: 

2922 """Log a settings error raised after a bulk save already committed. 

2923 

2924 ``error.diagnostic`` holds only the key, its source and a fixed reason, 

2925 never the value. 

2926 """ 

2927 logger.error( 

2928 "Settings saved, but a post-save step could not read settings: {}", 

2929 error.diagnostic, 

2930 ) 

2931 

2932 

2933def _settings_value_error_response( 

2934 resource: str, error: SettingsValueError, *, bulk_save: bool = False 

2935) -> JSONResponse: 

2936 """Return the diagnostic for an invalid configured setting. 

2937 

2938 The upgrade guide tells operators to locate the setting from this 

2939 message, so it replaces the generic error. ``error.diagnostic`` holds 

2940 only the key, its source and a fixed reason, never the value. 

2941 Bulk saves retain their existing status/message response fields. 

2942 """ 

2943 logger.error("Could not load {}: {}", resource, error.diagnostic) 

2944 body = ( 

2945 {"status": "error", "message": error.diagnostic} 

2946 if bulk_save 

2947 else {"error": error.diagnostic} 

2948 ) 

2949 return JSONResponse(body, status_code=500) 

2950 

2951 

2952# API Routes 

2953@router.get("/api") 

2954def api_get_all_settings( 

2955 request: Request, 

2956 username: Annotated[str, Depends(require_auth)], 

2957): 

2958 """Get all settings""" 

2959 try: 

2960 # Get query parameters 

2961 category = request.query_params.get("category") 

2962 

2963 with get_user_db_session(username) as db_session: 

2964 settings_manager = get_settings_manager(db_session, username) 

2965 

2966 # Get settings 

2967 settings = settings_manager.get_all_settings() 

2968 

2969 # Filter by category if requested 

2970 if category: 

2971 filtered_settings = {} 

2972 # Need to get all setting details to check category 

2973 db_settings = db_session.query(Setting).all() 

2974 category_keys = [ 

2975 s.key for s in db_settings if s.category == category 

2976 ] 

2977 

2978 # Filter settings by keys 

2979 filtered_settings = { 

2980 key: value 

2981 for key, value in settings.items() 

2982 if key in category_keys 

2983 } 

2984 

2985 settings = filtered_settings 

2986 

2987 # Operator-gate the "unprotected" escape hatch out of the 

2988 # egress-scope select's options unless explicitly enabled, and 

2989 # normalise the displayed value (#5148 / 87537d9ec). 

2990 if "policy.egress_scope" in settings: 

2991 settings["policy.egress_scope"] = _shape_egress_scope_setting( 

2992 "policy.egress_scope", settings["policy.egress_scope"] 

2993 ) 

2994 

2995 # Hide the operator-gated unencrypted "filesystem" PDF-storage 

2996 # option, same rationale as the egress-scope shaping above 

2997 # (#5148 / fb49985aa). 

2998 if "research_library.pdf_storage_mode" in settings: 

2999 settings["research_library.pdf_storage_mode"] = ( 

3000 _shape_pdf_storage_mode_setting( 

3001 "research_library.pdf_storage_mode", 

3002 settings["research_library.pdf_storage_mode"], 

3003 ) 

3004 ) 

3005 

3006 # Redact secret values (API keys, passwords, OAuth tokens) — main 

3007 # does this (DataSanitizer.redact_settings_snapshot) as defense in 

3008 # depth: this JSON gets cached by clients, logged by proxies, and 

3009 # pasted into bug reports. Safe to round-trip: the save path treats 

3010 # the '[REDACTED]' sentinel as a no-op (see the write-back guards in 

3011 # _save_*_settings_sync), so the dashboard re-saving a redacted dump 

3012 # never overwrites stored credentials, and settings.js seeds the 

3013 # password baselines from this sentinel (PR #3947). 

3014 from ...security.data_sanitizer import DataSanitizer 

3015 

3016 settings = DataSanitizer.redact_settings_snapshot(settings) 

3017 return {"status": "success", "settings": settings} 

3018 except SettingsValueError as error: 

3019 return _settings_value_error_response("settings", error) 

3020 except Exception: 

3021 logger.exception("Error getting settings") 

3022 return JSONResponse( 

3023 {"error": "Failed to retrieve settings"}, status_code=500 

3024 ) 

3025 

3026 

3027@router.post("/api/import") 

3028@settings_limit 

3029def api_import_settings( 

3030 request: Request, 

3031 username: Annotated[str, Depends(require_auth)], 

3032): 

3033 """Import settings from defaults file""" 

3034 try: 

3035 with get_user_db_session(username) as db_session: 

3036 settings_manager = get_settings_manager(db_session, username) 

3037 

3038 # Settings lock (app.lock_settings) -- ported from main's 

3039 # api_import_settings (#5659, "enforce the settings lock on delete, 

3040 # import and reset"). SettingsManager already refuses when 

3041 # locked, so the write cannot happen either way; this repeats 

3042 # the check at the route so a locked instance answers 403 

3043 # rather than 200 with nothing written, which is what main's 

3044 # own comment gives as the reason. Without it the merge that 

3045 # brought #5659 in would have silently dropped the fix, since 

3046 # it landed in a Flask file this migration deletes. 

3047 if settings_manager.settings_locked: 

3048 return JSONResponse( 

3049 {"error": "Settings are locked and cannot be imported"}, 

3050 status_code=403, 

3051 ) 

3052 

3053 # See the identical call in reset_to_defaults: this flag is 

3054 # required so a bulk import cannot overwrite env-locked settings, 

3055 # and it defaults to False. 

3056 settings_manager.load_from_defaults_file( 

3057 preserve_environment_locked=True 

3058 ) 

3059 imported_setting_keys = tuple(settings_manager.default_settings) 

3060 

3061 invalidate_settings_caches(username) 

3062 reschedule_document_jobs_if_needed(username, imported_setting_keys) 

3063 reschedule_zotero_jobs_if_needed(username, imported_setting_keys) 

3064 return {"message": "Settings imported successfully"} 

3065 except Exception: 

3066 logger.exception("Error importing settings") 

3067 return JSONResponse( 

3068 {"error": "Failed to import settings"}, status_code=500 

3069 ) 

3070 

3071 

3072@router.get("/api/categories") 

3073def api_get_categories( 

3074 request: Request, 

3075 username: Annotated[str, Depends(require_auth)], 

3076): 

3077 """Get all setting categories""" 

3078 try: 

3079 with get_user_db_session(username) as db_session: 

3080 # Get all distinct categories 

3081 categories = db_session.query(Setting.category).distinct().all() 

3082 category_list = [c[0] for c in categories if c[0] is not None] 

3083 

3084 return {"categories": category_list} 

3085 except Exception: 

3086 logger.exception("Error getting categories") 

3087 return JSONResponse( 

3088 {"error": "Failed to retrieve settings"}, status_code=500 

3089 ) 

3090 

3091 

3092@router.get("/api/types") 

3093def api_get_types( 

3094 request: Request, username: Annotated[str, Depends(require_auth)] 

3095): 

3096 """Get all setting types""" 

3097 try: 

3098 # Get all setting types 

3099 types = [t.value for t in SettingType] 

3100 return {"types": types} 

3101 except Exception: 

3102 logger.exception("Error getting types") 

3103 return JSONResponse( 

3104 {"error": "Failed to retrieve settings"}, status_code=500 

3105 ) 

3106 

3107 

3108@router.get("/api/ui_elements") 

3109def api_get_ui_elements( 

3110 request: Request, username: Annotated[str, Depends(require_auth)] 

3111): 

3112 """Get all UI element types""" 

3113 try: 

3114 # The types a settings read can convert. A new setting with any 

3115 # other ui_element is rejected on write. 

3116 ui_elements = list(UI_ELEMENT_TO_SETTING_TYPE) 

3117 

3118 return {"ui_elements": ui_elements} 

3119 except Exception: 

3120 logger.exception("Error getting UI elements") 

3121 return JSONResponse( 

3122 {"error": "Failed to retrieve settings"}, status_code=500 

3123 ) 

3124 

3125 

3126@router.get("/api/available-models") 

3127@router.post("/api/available-models") 

3128async def api_get_available_models( 

3129 request: Request, username: Annotated[str, Depends(require_auth)] 

3130): 

3131 """Read cached models on GET; discover or refresh only on POST. 

3132 

3133 ``GET`` returns only the policy-filtered model cache: a cold, expired 

3134 or filtered cache answers with an empty model list, and a ``GET`` never 

3135 performs provider-specific credential lookups, contacts a provider or 

3136 writes the cache. It still loads the strict settings snapshot for policy. 

3137 Discovery calls providers with the user's stored API keys and rewrites 

3138 their cached ``ProviderModel`` rows, so it is a state change accepted 

3139 only on the CSRF-protected ``POST``: 

3140 

3141 * a ``POST`` without a refresh query returns the fresh cached rows and 

3142 discovers every provider only on a cache miss; 

3143 * ``POST ?force_refresh=true`` bypasses and replaces the whole cache; 

3144 * ``POST ?refresh_provider=<p>`` re-discovers that provider (or every 

3145 provider, when the cache has no usable rows). A target takes 

3146 precedence over ``force_refresh=true``, so a targeted refresh is never 

3147 widened into a full one. 

3148 

3149 A ``GET`` carrying ``refresh_provider`` or ``force_refresh=true`` 

3150 answers 405 (``Allow: POST``) without touching the cache or any 

3151 provider. 

3152 

3153 Discovery runs on its own executor and does not continue the provider 

3154 loop after cancellation. 

3155 """ 

3156 # Parse as bool — a raw string (non-empty) is always truthy, so the old 

3157 # code always took the force-refresh path, deleting every cached model 

3158 # on every request. 

3159 force_refresh_requested = ( 

3160 request.query_params.get("force_refresh", "false").lower() == "true" 

3161 ) 

3162 # Refused here, on the event loop, so a 405 never waits for a worker. 

3163 if request.method != "POST" and ( 

3164 force_refresh_requested or request.query_params.get("refresh_provider") 

3165 ): 

3166 raise HTTPException( 

3167 status_code=405, 

3168 detail="Use POST to refresh models", 

3169 headers={"Allow": "POST"}, 

3170 ) 

3171 cancelled = threading.Event() 

3172 try: 

3173 # Cache-only reads must not queue behind slow provider discovery. 

3174 if request.method != "POST": 

3175 return await run_db_sync( 

3176 _get_available_models, request, username, cancelled 

3177 ) 

3178 # Discovery can wait out one provider timeout after another. Keep 

3179 # those calls off the workers used by authenticated DB requests. 

3180 return await run_model_discovery_sync( 

3181 _get_available_models, request, username, cancelled 

3182 ) 

3183 finally: 

3184 # Canceling an awaiting task cannot interrupt synchronous SDK I/O. 

3185 # The worker must stop after its current provider returns, before 

3186 # starting another request or replacing the user's cached models. 

3187 cancelled.set() 

3188 

3189 

3190def _get_available_models( 

3191 request: Request, username: str, cancelled: threading.Event 

3192): 

3193 """Run discovery and DB access on the worker that owns their cleanup.""" 

3194 

3195 def check_cancelled(): 

3196 if cancelled.is_set(): 

3197 raise asyncio.CancelledError 

3198 

3199 check_cancelled() 

3200 # Only the CSRF-protected POST may discover providers or write the model 

3201 # cache. Any other method reads the policy-filtered cache and stops there. 

3202 discovery_allowed = request.method == "POST" 

3203 endpoint_start = time.perf_counter() 

3204 try: 

3205 # request comes from FastAPI parameter 

3206 from ...database.models.providers import ProviderModel 

3207 

3208 # Optional targeted refresh: re-discover ONLY this provider and 

3209 # merge the result into the cached catalog (used after a provider 

3210 # API key is saved — the plain cache read would keep that 

3211 # provider's stale/empty list for up to 24h, and a full refresh 

3212 # re-probes every provider serially). POST-only: a GET carrying it 

3213 # was refused with 405 above. 

3214 refresh_provider = ( 

3215 normalize_provider( 

3216 request.query_params.get("refresh_provider", "") or "" 

3217 ) 

3218 if discovery_allowed 

3219 else None 

3220 ) 

3221 

3222 # The forced (full) refresh: POST ?force_refresh=true without a 

3223 # target. A POST without the flag stays cache-first (it discovers 

3224 # only on a cache miss), and a target takes precedence over the 

3225 # flag, so a targeted POST stays targeted. 

3226 force_refresh = ( 

3227 discovery_allowed 

3228 and not refresh_provider 

3229 and request.query_params.get("force_refresh", "false").lower() 

3230 == "true" 

3231 ) 

3232 

3233 # Resolve the egress policy BEFORE the model cache is read or any 

3234 # provider is discovered/contacted. Fails closed (PolicyDeniedError). 

3235 policy_context, policy_snapshot = _resolve_model_discovery_policy( 

3236 username 

3237 ) 

3238 

3239 # Get all auto-discovered providers. Every provider is advertised to 

3240 # the UI; the egress policy marks blocked ones as disabled (with a 

3241 # reason) instead of removing them. Removing them silently made the 

3242 # "Model Provider" dropdown look short after configuring a cloud API 

3243 # key, leading users to assume the key wasn't picked up — and left 

3244 # the front-end code that renders the reason 

3245 # (static/js/components/custom_dropdown.js, research.js, settings.js) 

3246 # unreachable. The per-provider MODELS list below is still filtered 

3247 # so we never actually call a blocked provider (#5922 / #5662). 

3248 from ...llm.providers import get_discovered_provider_options 

3249 

3250 options = list(get_discovered_provider_options()) 

3251 provider_options = _provider_options_for_snapshot( 

3252 options, policy_context, policy_snapshot 

3253 ) 

3254 # Grading uses its own endpoint override. Keep this copied snapshot 

3255 # out of research model discovery, credential reads and model caches. 

3256 # 

3257 # The override is read back from the SAME snapshot the policy context 

3258 # was built from, not through a second settings read: the snapshot 

3259 # already carries the LDR_* overlay for this key, so the two agree 

3260 # (no TOCTOU between classifying the endpoint and reading it), and it 

3261 # costs one fewer get_user_db_session per request. The route's own 

3262 # deny-before-credential-read ordering is unaffected either way: 

3263 # _resolve_model_discovery_policy above has already opened a session 

3264 # and read the whole snapshot before anything here runs, and the 

3265 # per-provider api_key_setting reads still happen only after the 

3266 # denial point. 

3267 benchmark_snapshot = dict(policy_snapshot) 

3268 benchmark_endpoint = unwrap_setting( 

3269 policy_snapshot.get( 

3270 "benchmark.evaluation.endpoint_url", 

3271 "https://openrouter.ai/api/v1", 

3272 ) 

3273 ) 

3274 # Match get_llm's explicit endpoint-override normalization. 

3275 benchmark_snapshot["llm.openai_endpoint.url"] = ( 

3276 benchmark_endpoint.strip() 

3277 if isinstance(benchmark_endpoint, str) 

3278 else benchmark_endpoint 

3279 ) 

3280 benchmark_provider_options = _provider_options_for_snapshot( 

3281 options, policy_context, benchmark_snapshot 

3282 ) 

3283 

3284 # Add remaining hardcoded providers (complex local providers not yet migrated) 

3285 

3286 # Available models by provider 

3287 providers: dict[str, Any] = {} 

3288 

3289 # Cached rows of a targeted refresh's provider, set aside when the 

3290 # target's cache entries are dropped for re-fetch. Restored to the 

3291 # response if the re-fetch fails, so a transient upstream error 

3292 # can't blank the provider's catalog. 

3293 stale_target_rows: list | None = None 

3294 # Whether the cache read produced ANY rows (before the targeted 

3295 # refresh pops its provider). Distinct from a post-pop empty 

3296 # ``providers`` dict, which just means the cache held only the 

3297 # target — not that it is cold. 

3298 cache_had_rows = False 

3299 

3300 # Check database cache first (unless force_refresh is True) 

3301 if not force_refresh: 

3302 try: 

3303 # Define cache expiration (24 hours) 

3304 cache_expiry = datetime.now(UTC) - timedelta(hours=24) 

3305 

3306 # Get cached models from database 

3307 with get_user_db_session(username) as db_session: 

3308 cached_models = ( 

3309 db_session.query(ProviderModel) 

3310 .filter(ProviderModel.last_updated > cache_expiry) 

3311 .all() 

3312 ) 

3313 

3314 if cached_models: 

3315 logger.info( 

3316 f"Found {len(cached_models)} cached models in database" 

3317 ) 

3318 

3319 # Group models by provider 

3320 for model in cached_models: 

3321 if ( 

3322 policy_context.require_local_llm 

3323 and not _model_discovery_provider_allowed( 

3324 model.provider, policy_context, policy_snapshot 

3325 ) 

3326 ): 

3327 continue 

3328 provider_key = ( 

3329 f"{normalize_provider(model.provider)}_models" 

3330 ) 

3331 if provider_key not in providers: 3331 ↛ 3334line 3331 didn't jump to line 3334 because the condition on line 3331 was always true

3332 providers[provider_key] = [] 

3333 

3334 providers[provider_key].append( 

3335 { 

3336 "value": model.model_key, 

3337 "label": model.model_label, 

3338 "provider": model.provider.upper(), 

3339 } 

3340 ) 

3341 

3342 # If we have cached data for all providers, return it — 

3343 # unless a targeted refresh was requested for a provider 

3344 # whose rows are stale or absent (e.g. right after its 

3345 # API key was saved): drop just that provider's cached 

3346 # entries and fall through to a single-provider fetch. 

3347 cache_had_rows = bool(providers) 

3348 if providers and refresh_provider: 

3349 # nosemgrep: semgrep.rules.sql-string-concatenation, reason: Dictionary key only 

3350 stale_key = f"{refresh_provider}_models" 

3351 # Drop the target's cached entries (if any) and 

3352 # fall through to a single-provider re-fetch. Keep 

3353 # them aside for restoration if the re-fetch fails. 

3354 stale_target_rows = providers.pop(stale_key, None) 

3355 elif providers: 

3356 _log_available_models_duration( 

3357 endpoint_start, cache_hit=True 

3358 ) 

3359 logger.info("Returning cached models from database") 

3360 return { 

3361 "provider_options": provider_options, 

3362 "benchmark_provider_options": benchmark_provider_options, 

3363 "providers": providers, 

3364 } 

3365 

3366 except PolicyDeniedError: 

3367 raise 

3368 except Exception: 

3369 logger.warning("Error reading cached models from database") 

3370 if not discovery_allowed: 3370 ↛ 3377line 3370 didn't jump to line 3377 because the condition on line 3370 was always true

3371 raise 

3372 # A CSRF-protected POST may recover by fetching fresh data. 

3373 

3374 # Even an ordinary cache miss can populate or replace persisted rows. 

3375 # GET must never reach model listing, credential lookup or cache writes. 

3376 # Return the same response shape for cold, expired or filtered caches. 

3377 if not discovery_allowed: 

3378 _log_available_models_duration(endpoint_start, cache_hit=False) 

3379 return { 

3380 "provider_options": provider_options, 

3381 "benchmark_provider_options": benchmark_provider_options, 

3382 "providers": providers, 

3383 } 

3384 

3385 if refresh_provider and not cache_had_rows: 

3386 # Cold cache (fresh install, or every row older than 24h): a 

3387 # targeted refresh would return a one-provider catalog that 

3388 # the client would cache as the whole world. Fall back to a 

3389 # full discovery — the plain cold-cache path does exactly that. 

3390 logger.info( 

3391 "Targeted refresh with an empty model cache: " 

3392 "falling back to full discovery" 

3393 ) 

3394 refresh_provider = None 

3395 

3396 # Ollama / OpenAI / Anthropic model listing is handled by the 

3397 # auto-discovery loop below (their provider classes' list_models_for_api), 

3398 # leaving one provider-class fetch path. Those classes own base-URL 

3399 # validation and authentication, and the router does no duplicate network 

3400 # work. The removed Ollama probe used safe_get's request-time SSRF checks; 

3401 # its problem here was duplicate fetching, not missing validation. 

3402 

3403 # Fetch models from auto-discovered providers 

3404 from ...llm.providers import discover_providers 

3405 

3406 discovered_providers = discover_providers() 

3407 

3408 # If the effective egress posture is local-only, the user (or policy) 

3409 # has opted into local-only inference, so don't list cloud providers 

3410 # (OpenRouter, Google, XAI, IonOS, OpenAI, Anthropic, ...). We still 

3411 # list the local providers via their provider classes — which validate 

3412 # the URL (SSRF) and support auth headers — by filtering the discovered 

3413 # set rather than skipping discovery entirely. The filter classifies 

3414 # each provider's configured ENDPOINT (evaluate_llm_endpoint), so a 

3415 # nominally-local provider pointed at a public URL is dropped too; a 

3416 # static LOCAL_PROVIDERS name check would have kept it. 

3417 if policy_context.require_local_llm: 

3418 all_discovered_providers = discovered_providers 

3419 discovered_providers = { 

3420 key: info 

3421 for key, info in all_discovered_providers.items() 

3422 if _model_discovery_provider_allowed( 

3423 key, policy_context, policy_snapshot 

3424 ) 

3425 } 

3426 logger.bind(policy_audit=True).info( 

3427 "local-only egress posture: limiting discovered model lists " 

3428 "to endpoint-approved local providers", 

3429 kept=list(discovered_providers.keys()), 

3430 skipped=[ 

3431 key 

3432 for key in all_discovered_providers 

3433 if key not in discovered_providers 

3434 ], 

3435 ) 

3436 

3437 if refresh_provider and not force_refresh: 

3438 # Targeted refresh: only the named provider is re-fetched (and 

3439 # only if it survived the policy filter above). Unknown 

3440 # provider names degrade to a plain cache read (nothing to 

3441 # re-fetch) — the merged `providers` dict below still returns 

3442 # the other providers' cached rows. 

3443 discovered_providers = { 

3444 key: info 

3445 for key, info in discovered_providers.items() 

3446 if key.lower() == refresh_provider 

3447 } 

3448 

3449 targeted_fetch_failed = False 

3450 fetched_provider_settings = {} 

3451 for provider_key, provider_info in discovered_providers.items(): 

3452 check_cancelled() 

3453 provider_models = [] 

3454 try: 

3455 logger.info( 

3456 f"Fetching models from {provider_info.provider_name}" 

3457 ) 

3458 

3459 # Get the provider class 

3460 provider_class = provider_info.provider_class 

3461 

3462 # Get API key if configured 

3463 api_key = _get_setting_from_session( 

3464 provider_class.api_key_setting, username, "" 

3465 ) 

3466 

3467 # Get base URL if provider has configurable URL 

3468 provider_base_url: str | None = None 

3469 url_setting = getattr(provider_class, "url_setting", None) 

3470 if url_setting: 

3471 provider_base_url = _get_setting_from_session( 

3472 url_setting, username, "" 

3473 ) 

3474 

3475 fetched_settings = {} 

3476 if provider_class.api_key_setting is not None: 

3477 fetched_settings[provider_class.api_key_setting] = api_key 

3478 if url_setting: 

3479 fetched_settings[url_setting] = provider_base_url 

3480 fetched_provider_settings[ 

3481 f"{normalize_provider(provider_key)}_models" 

3482 ] = fetched_settings 

3483 

3484 if policy_context.require_local_llm: 

3485 provider_snapshot = dict(policy_snapshot) 

3486 if url_setting: 

3487 provider_snapshot[url_setting] = provider_base_url 

3488 if not _model_discovery_provider_allowed( 3488 ↛ 3491line 3488 didn't jump to line 3491 because the condition on line 3488 was never true

3489 provider_key, policy_context, provider_snapshot 

3490 ): 

3491 continue 

3492 

3493 # Use the provider's list_models_for_api method. This is the 

3494 # single remaining cloud fetch path (OpenAI's models.list() 

3495 # among them), so it carries PR #3483's provider-fetch timer. 

3496 provider_fetch_start = time.perf_counter() 

3497 models = provider_class.list_models_for_api( 

3498 api_key, provider_base_url 

3499 ) 

3500 provider_fetch_ms = ( 

3501 time.perf_counter() - provider_fetch_start 

3502 ) * 1000 

3503 if provider_fetch_ms > 1000: 

3504 logger.info( 

3505 f"{provider_key} list_models_for_api took " 

3506 f"{provider_fetch_ms:.0f}ms" 

3507 ) 

3508 else: 

3509 logger.debug( 

3510 f"{provider_key} list_models_for_api took " 

3511 f"{provider_fetch_ms:.0f}ms" 

3512 ) 

3513 

3514 # Format models for the API response 

3515 for model in models: 

3516 provider_models.append( 

3517 { 

3518 "value": model["value"], 

3519 "label": model[ 

3520 "label" 

3521 ], # Use provider's label as-is 

3522 "provider": provider_key, 

3523 } 

3524 ) 

3525 

3526 logger.info( 

3527 f"Successfully fetched {len(provider_models)} models from {provider_info.provider_name}" 

3528 ) 

3529 

3530 except PolicyDeniedError: 

3531 raise 

3532 except Exception: 

3533 logger.exception( 

3534 f"Error getting {provider_info.provider_name} models" 

3535 ) 

3536 if ( 

3537 refresh_provider 

3538 and provider_key.lower() == refresh_provider 

3539 ): 

3540 # The targeted provider's re-fetch failed: don't let a 

3541 # transient upstream error blank its catalog — the 

3542 # cached rows set aside earlier are restored below and 

3543 # the DB rows are left untouched. 

3544 targeted_fetch_failed = True 

3545 

3546 # Set models in providers dict using lowercase key 

3547 providers[f"{normalize_provider(provider_key)}_models"] = ( 

3548 provider_models 

3549 ) 

3550 logger.info( 

3551 f"Final {provider_key} models count: {len(provider_models)}" 

3552 ) 

3553 

3554 # Also check after the final provider, including failed fetches. 

3555 check_cancelled() 

3556 

3557 # Restore the targeted provider's cached rows when its re-fetch 

3558 # failed (response keeps the last-known-good catalog). 

3559 if targeted_fetch_failed and stale_target_rows is not None: 

3560 providers[f"{refresh_provider}_models"] = stale_target_rows 

3561 

3562 # ``list_models_for_api`` converts every upstream failure into an 

3563 # empty list (its catch-all never raises), so the exception path 

3564 # above cannot fire for real network errors. Treat "targeted 

3565 # refresh produced zero models while non-empty cached rows were 

3566 # set aside" as a failed probe too: keep the last-known-good 

3567 # catalog and skip the cache write, so a transient outage during 

3568 # a key re-save cannot blank the provider's cached models. 

3569 if ( 

3570 refresh_provider 

3571 and not force_refresh 

3572 and stale_target_rows 

3573 and not providers.get(f"{refresh_provider}_models") 

3574 ): 

3575 targeted_fetch_failed = True 

3576 providers[f"{refresh_provider}_models"] = stale_target_rows 

3577 

3578 # Save fetched models to database cache. A targeted refresh only 

3579 # persists the refreshed provider's rows — other providers keep 

3580 # their cached rows and TTLs untouched. A failed targeted re-fetch 

3581 # persists nothing (the cached rows stay valid). 

3582 providers_to_save = providers 

3583 if refresh_provider and not force_refresh: 

3584 # nosemgrep: semgrep.rules.sql-string-concatenation, reason: Dictionary key only 

3585 target_key = f"{refresh_provider}_models" 

3586 providers_to_save = ( 

3587 {} 

3588 if targeted_fetch_failed 

3589 else { 

3590 key: models 

3591 for key, models in providers.items() 

3592 if key == target_key 

3593 } 

3594 ) 

3595 if force_refresh or providers_to_save: 

3596 # We fetched fresh data, save it to database 

3597 with get_user_db_session(username) as db_session: 

3598 try: 

3599 # Take SQLite's writer lock only AFTER all network calls. 

3600 # Checking settings and writing models under the same lock 

3601 # prevents a key/URL change (including in another worker) 

3602 # from racing between the check and the cache replacement. 

3603 db_session.connection().exec_driver_sql("BEGIN IMMEDIATE") 

3604 current_providers_to_save = { 

3605 key: models 

3606 for key, models in providers_to_save.items() 

3607 if _model_discovery_settings_unchanged( 

3608 db_session, fetched_provider_settings.get(key, {}) 

3609 ) 

3610 } 

3611 superseded = len(current_providers_to_save) != len( 

3612 providers_to_save 

3613 ) 

3614 providers_to_save = current_providers_to_save 

3615 # A superseded full refresh must also leave the newer 

3616 # provider's rows intact instead of clearing the catalog. 

3617 if force_refresh and not superseded: 

3618 # When force refresh, clear ALL cached models to remove any stale data 

3619 # from old code versions or deleted providers 

3620 deleted_count = db_session.query(ProviderModel).delete() 

3621 logger.info( 

3622 f"Force refresh: cleared all {deleted_count} cached models" 

3623 ) 

3624 else: 

3625 # Clear old cache entries only for providers we're updating 

3626 for provider_key in providers_to_save: 

3627 provider_name = provider_key.replace( 

3628 "_models", "" 

3629 ).upper() 

3630 db_session.query(ProviderModel).filter( 

3631 ProviderModel.provider == provider_name 

3632 ).delete() 

3633 

3634 # Insert new models. A targeted refresh stamps its 

3635 # rows with the oldest ``last_updated`` among the other 

3636 # providers' fresh cached rows so the whole catalog 

3637 # still expires at once: the cache read returns any 

3638 # fresh rows as the complete catalog, so rows that 

3639 # outlived the others would hide every other 

3640 # provider's models until they expired too. Read under 

3641 # the writer lock, so a full refresh that committed 

3642 # after this request's cache read is taken into 

3643 # account. 

3644 row_time = datetime.now(UTC) 

3645 if refresh_provider and not force_refresh: 

3646 other_row_times = [ 

3647 model.last_updated 

3648 for model in db_session.query(ProviderModel) 

3649 .filter( 

3650 ProviderModel.last_updated 

3651 > row_time - timedelta(hours=24) 

3652 ) 

3653 .all() 

3654 if normalize_provider(model.provider) 

3655 != refresh_provider 

3656 and isinstance(model.last_updated, datetime) 

3657 ] 

3658 if other_row_times: 

3659 row_time = min( 

3660 row_time, 

3661 *( 

3662 t if t.tzinfo else t.replace(tzinfo=UTC) 

3663 for t in other_row_times 

3664 ), 

3665 ) 

3666 for provider_key, models in providers_to_save.items(): 

3667 provider_name = provider_key.replace( 

3668 "_models", "" 

3669 ).upper() 

3670 for model in models: 

3671 if ( 3671 ↛ 3670line 3671 didn't jump to line 3670 because the condition on line 3671 was always true

3672 isinstance(model, dict) 

3673 and "value" in model 

3674 and "label" in model 

3675 ): 

3676 new_model = ProviderModel( 

3677 provider=provider_name, 

3678 model_key=model["value"], 

3679 model_label=model["label"], 

3680 last_updated=row_time, 

3681 ) 

3682 db_session.add(new_model) 

3683 

3684 db_session.commit() 

3685 logger.info("Successfully cached models to database") 

3686 

3687 except Exception: 

3688 logger.exception("Error saving models to database cache") 

3689 db_session.rollback() 

3690 

3691 # Return all options 

3692 _log_available_models_duration(endpoint_start, cache_hit=False) 

3693 return { 

3694 "provider_options": provider_options, 

3695 "benchmark_provider_options": benchmark_provider_options, 

3696 "providers": providers, 

3697 } 

3698 

3699 except PolicyDeniedError as exc: 

3700 reason = exc.decision.reason 

3701 status_code = 503 if reason == "settings_unavailable" else 400 

3702 logger.bind(policy_audit=True).warning( 

3703 "available-model discovery denied by egress policy", reason=reason 

3704 ) 

3705 _log_available_models_duration( 

3706 endpoint_start, cache_hit=False, error=True 

3707 ) 

3708 return JSONResponse( 

3709 { 

3710 "status": "error", 

3711 "message": f"Egress policy refused this request: {reason}", 

3712 }, 

3713 status_code=status_code, 

3714 ) 

3715 except Exception: 

3716 logger.exception("Error getting available models") 

3717 _log_available_models_duration( 

3718 endpoint_start, cache_hit=False, error=True 

3719 ) 

3720 return JSONResponse( 

3721 { 

3722 "status": "error", 

3723 "message": "Failed to retrieve available models", 

3724 }, 

3725 status_code=500, 

3726 ) 

3727 

3728 

3729def _log_available_models_duration( 

3730 start: float, cache_hit: bool, error: bool = False 

3731) -> None: 

3732 """Log /api/available-models endpoint duration. 

3733 

3734 Uses INFO when the endpoint took > 1s (indicating a real provider fetch 

3735 latency worth flagging), DEBUG otherwise. This is the likely culprit for 

3736 Path C (LLM provider timeout masquerading as backend hang) in the 

3737 login-hang investigation (PR #3483 / #5961). 

3738 """ 

3739 elapsed_ms = (time.perf_counter() - start) * 1000 

3740 path = ( 

3741 "error" 

3742 if error 

3743 else ("cache hit" if cache_hit else "full provider fetch") 

3744 ) 

3745 if elapsed_ms > 1000: 

3746 logger.info(f"/api/available-models ({path}) took {elapsed_ms:.0f}ms") 

3747 else: 

3748 logger.debug(f"/api/available-models ({path}) took {elapsed_ms:.0f}ms") 

3749 

3750 

3751def _get_engine_icon_and_category( 

3752 engine_data: dict, engine_class=None 

3753) -> tuple: 

3754 """ 

3755 Get icon emoji and category label for a search engine based on its attributes. 

3756 

3757 Args: 

3758 engine_data: Engine configuration dictionary 

3759 engine_class: Optional loaded engine class to check attributes 

3760 

3761 Returns: 

3762 Tuple of (icon, category) strings 

3763 """ 

3764 # Check attributes from either the class or the engine data 

3765 if engine_class: 

3766 is_scientific = getattr(engine_class, "is_scientific", False) 

3767 is_generic = getattr(engine_class, "is_generic", False) 

3768 is_local = getattr(engine_class, "is_local", False) 

3769 is_news = getattr(engine_class, "is_news", False) 

3770 is_code = getattr(engine_class, "is_code", False) 

3771 else: 

3772 is_scientific = engine_data.get("is_scientific", False) 

3773 is_generic = engine_data.get("is_generic", False) 

3774 is_local = engine_data.get("is_local", False) 

3775 is_news = engine_data.get("is_news", False) 

3776 is_code = engine_data.get("is_code", False) 

3777 

3778 # Check books attribute 

3779 if engine_class: 

3780 is_books = getattr(engine_class, "is_books", False) 

3781 else: 

3782 is_books = engine_data.get("is_books", False) 

3783 

3784 # Return icon and category based on engine type 

3785 # Priority: local > scientific > news > code > books > generic > default 

3786 if is_local: 

3787 return "📁", "Local RAG" 

3788 if is_scientific: 

3789 return "🔬", "Scientific" 

3790 if is_news: 

3791 return "📰", "News" 

3792 if is_code: 

3793 return "💻", "Code" 

3794 if is_books: 

3795 return "📚", "Books" 

3796 if is_generic: 

3797 return "🌐", "Web Search" 

3798 return "🔍", "Search" 

3799 

3800 

3801# Ported verbatim from the Flask ``settings_routes`` (#5221 / issue 

3802# #5204). The body is framework-agnostic — it only consults the egress 

3803# PDP and the logger — so nothing needed adapting; only its caller did. 

3804# This landed on main against a module this branch had already deleted, 

3805# so the merge dropped it while keeping the frontend that depends on it. 

3806def _classify_options_for_egress( 

3807 engine_options: list, 

3808 *, 

3809 egress_scope: str, 

3810 primary_engine: str, 

3811 settings_snapshot: dict, 

3812 username: Optional[str], 

3813 search_engines: Optional[dict] = None, 

3814) -> None: 

3815 """Stamp each engine option with an ``egress: {allowed, reason}`` field. 

3816 

3817 Used by ``api_get_available_search_engines`` when the caller passes 

3818 ``?egress_scope=…&primary=…``: the frontend needs to know which 

3819 options are selectable under the active scope so it can disable 

3820 (or hide) the rest in the dropdown. Issue #5204. 

3821 

3822 The decision is taken through the same PDP the request-boundary 

3823 precheck uses (``evaluate_engine``) so the dropdown's disabled set 

3824 and the server's 400 denials stay perfectly aligned. Dropdown filtering 

3825 strictly reflects PDP allowed decisions; frontend selection reconciliation 

3826 handles updating any invalid primary selection when scope changes. The factory PEP 

3827 still enforces at instantiation time; this is a UX filter, not a security boundary. 

3828 

3829 After the PDP pass it applies ``_apply_guarded_url_overlay``, which 

3830 can deny an engine the PDP allowed (an unapproved private instance 

3831 URL). That overlay is scope-independent and therefore also runs when 

3832 the caller did NOT opt into scope filtering — see the route — and on 

3833 every degraded early return below (an unimportable policy module, an 

3834 unknown ``?egress_scope=``): it needs only the settings snapshot, not 

3835 the PDP context, and the runtime blocks the URL regardless, so 

3836 skipping it there would make a guard-denied engine read as selectable. 

3837 

3838 Operates in place; safe to call on a freshly built option list. 

3839 Failures are swallowed per option (logged) — a single bad engine 

3840 must not blank the whole dropdown. 

3841 """ 

3842 try: 

3843 from ...security.egress.policy import ( 

3844 context_from_snapshot, 

3845 evaluate_engine, 

3846 resolve_run_primary_engine, 

3847 ) 

3848 except Exception: # pragma: no cover - defensive 

3849 logger.exception( 

3850 "egress policy unavailable; emitting unfiltered options" 

3851 ) 

3852 # The URL guard does not ride on the PDP — it only needs the 

3853 # snapshot — so it still runs when the PDP is unavailable. 

3854 _apply_guarded_url_overlay(engine_options, settings_snapshot) 

3855 return 

3856 

3857 if not isinstance(settings_snapshot, dict): 

3858 # Programming error: the route always passes a snapshot. Log it 

3859 # loudly so a future caller-side change is debuggable instead of 

3860 # silently falling back to the unfiltered list (PR #5221 review). 

3861 logger.error( 

3862 "_classify_options_for_egress received a non-dict " 

3863 "settings_snapshot (type={}); emitting unfiltered options", 

3864 type(settings_snapshot).__name__, 

3865 ) 

3866 # Match every other degraded early return: the overlay is a 

3867 # no-op here (it guards on isinstance(settings_snapshot, dict) 

3868 # itself) since there's no dict to read a URL from, but calling 

3869 # it keeps this branch consistent with the docstring above and 

3870 # fail-safe if a future caller ever reaches this branch with 

3871 # something the overlay CAN read. 

3872 _apply_guarded_url_overlay(engine_options, settings_snapshot) 

3873 return 

3874 

3875 # Build an evaluation snapshot that reflects the requested scope and 

3876 # primary engine parameter overrides sent by the frontend (issue #5204). 

3877 eval_snapshot = dict(settings_snapshot) 

3878 if egress_scope: 

3879 eval_snapshot["policy.egress_scope"] = egress_scope 

3880 if primary_engine: 

3881 eval_snapshot["search.tool"] = primary_engine 

3882 

3883 # The precheck builds an EgressContext via context_from_snapshot; the 

3884 # adaptive resolution requires a primary, so fall back to the supplied 

3885 # primary and ultimately the saved search.tool. 

3886 try: 

3887 try: 

3888 primary = primary_engine or resolve_run_primary_engine( 

3889 eval_snapshot 

3890 ) 

3891 except ValueError: 

3892 # ``resolve_run_primary_engine`` raises ValueError when 

3893 # ``search.tool`` is missing/blank/non-string and no 

3894 # ``default`` is given. That's the only documented failure 

3895 # mode — narrow the catch so unrelated bugs (e.g. an 

3896 # AttributeError on a future code path) surface instead of 

3897 # being silently swallowed (PR #5221 review). 

3898 primary = primary_engine or eval_snapshot.get("search.tool", "") 

3899 try: 

3900 ctx = context_from_snapshot( 

3901 eval_snapshot, 

3902 primary, 

3903 username=username, 

3904 ) 

3905 except Exception: 

3906 # ``context_from_snapshot`` raises ``PolicyDeniedError`` for 

3907 # an unknown scope (``unknown_egress_scope``) and ``ValueError`` 

3908 # for a malformed snapshot; either way the precheck would 

3909 # 400 the run. Stamp a permissive decision so the frontend 

3910 # falls back to the precheck instead of a half-blanked 

3911 # dropdown. Use logger.exception (dev-only traceback, no 

3912 # exc_info kwarg on logger.warning which would expose a 

3913 # full traceback at WARNING level in production — PR #5221 

3914 # review). 

3915 logger.exception( 

3916 "_classify_options_for_egress: context build failed; " 

3917 "emitting permissive policy_unavailable decisions" 

3918 ) 

3919 for opt in engine_options: 

3920 opt["egress"] = { 

3921 "allowed": True, 

3922 "reason": "policy_unavailable", 

3923 } 

3924 # Permissive on SCOPE only. The URL guard is independent of 

3925 # the PDP context that just failed to build, and the runtime 

3926 # still blocks the URL, so a guard-denied engine must not read 

3927 # as selectable on this degraded path either. 

3928 _apply_guarded_url_overlay(engine_options, eval_snapshot) 

3929 return 

3930 except Exception: # pragma: no cover - defensive 

3931 logger.exception( 

3932 "egress context build failed; emitting unfiltered options" 

3933 ) 

3934 _apply_guarded_url_overlay(engine_options, eval_snapshot) 

3935 return 

3936 

3937 for opt in engine_options: 

3938 engine_id = opt.get("value") 

3939 try: 

3940 metadata = search_engines.get(engine_id) if search_engines else None 

3941 decision = evaluate_engine( 

3942 engine_id, 

3943 ctx, 

3944 settings_snapshot=eval_snapshot, 

3945 metadata=metadata, 

3946 ) 

3947 opt["egress"] = { 

3948 "allowed": bool(decision.allowed), 

3949 "reason": decision.reason, 

3950 } 

3951 except Exception: # pragma: no cover - defensive 

3952 logger.exception(f"egress decision failed for engine {engine_id}") 

3953 opt["egress"] = {"allowed": True, "reason": "decision_error"} 

3954 

3955 # Guarded engine-URL overlay: a public engine whose configured private 

3956 # URL lacks operator approval self-disables at runtime, so the dropdown 

3957 # should gray it out too. Applied here (rather than in the caller) so 

3958 # every code path that stamps egress decisions also carries the guard. 

3959 # Deliberately scope-INDEPENDENT — see _apply_guarded_url_overlay. 

3960 _apply_guarded_url_overlay(engine_options, eval_snapshot) 

3961 

3962 

3963def _apply_guarded_url_overlay( 

3964 engine_options: list, settings_snapshot: dict 

3965) -> None: 

3966 """Gray out engines whose configured instance URL the runtime will refuse. 

3967 

3968 A PUBLIC-nature engine (SearXNG and friends) pointed at a private / 

3969 loopback instance URL without operator approval self-disables at init 

3970 and silently contributes zero results to the run. This overlay stamps 

3971 ``egress={"allowed": False, "reason": "private_url_unapproved"}`` on 

3972 those options so the picker grays them out with an explanatory note. 

3973 

3974 SCOPE-INDEPENDENT BY CONSTRUCTION. The approval resolver this rides on 

3975 (``resolve_engine_allow_private_ips``) deliberately ignores 

3976 ``policy.egress_scope`` — it is a user-editable dropdown, so honouring 

3977 it would be a self-service SSRF bypass — which means the runtime blocks 

3978 the URL under EVERY scope. The overlay must therefore run under every 

3979 scope too, including the shipped ``adaptive`` default and the 

3980 ``unprotected`` escape hatch, and NOT only when the caller opted into 

3981 scope filtering via ``?egress_scope=``. Before PR #5648's follow-up 

3982 this lived inside the ``apply_egress_filter`` branch and could only 

3983 ever fire under ``public_only`` (under ``private_only`` a public engine 

3984 is already denied for scope reasons), i.e. never on a default install. 

3985 

3986 ADVISORY, NOT AUTHORITATIVE — it is a strict subset of what the runtime 

3987 enforces. ``check_private_engine_url_blocked`` is deliberately 

3988 DNS-free (it must not turn a settings-page render into a resolver 

3989 round-trip, nor let a hostile URL make the render path probe an 

3990 internal name), so a private-DNS hostname (``searx.internal`` -> 10.x) 

3991 and a short-form literal (``127.1``) are NOT caught here even though 

3992 the runtime's ``validate_url`` resolves and blocks them. The overlay 

3993 therefore fails OPEN: an engine it leaves selectable may still be 

3994 refused at run time. For that residual gap the ONLY backstop is the 

3995 runtime's log-panel ERROR — NOT the research form's warning banner, 

3996 which ``warning_checks`` emits from this very same DNS-free check and 

3997 which therefore stays silent on exactly the URLs the overlay misses 

3998 (and is additionally suppressed once the user dismisses it, which the 

3999 overlay ignores). 

4000 

4001 It effectively never fails CLOSED — nothing the runtime would fetch is 

4002 grayed out here. The one theoretical exception is a host name ending 

4003 in ``.local``, which ``is_private_ip`` classifies by suffix: if a 

4004 non-conforming resolver or a ``hosts`` entry maps it to a PUBLIC 

4005 address the runtime would allow it while this overlay grays it out. 

4006 ``.local`` is reserved for mDNS (RFC 6762), so that is a 

4007 misconfiguration rather than a supported setup. 

4008 

4009 Options already carrying a denial (``allowed: False``) are left alone 

4010 so a scope denial keeps its more specific reason. Operates in place; 

4011 every failure is swallowed per engine so one bad descriptor cannot 

4012 blank the dropdown. 

4013 """ 

4014 if not engine_options or not isinstance(settings_snapshot, dict): 

4015 return 

4016 try: 

4017 from ...security.egress.validators import ( 

4018 guarded_engine_url_descriptors, 

4019 ) 

4020 from ...security.egress.warnings import ( 

4021 check_private_engine_url_blocked, 

4022 ) 

4023 from ...utilities.type_utils import unwrap_setting 

4024 

4025 guarded_urls = { 

4026 d.engine_name: d 

4027 for d in guarded_engine_url_descriptors() 

4028 if d.is_public 

4029 } 

4030 except Exception: # pragma: no cover - defensive 

4031 logger.exception("guarded engine-URL overlay unavailable") 

4032 return 

4033 

4034 for opt in engine_options: 

4035 # Everything that touches the option — including the id lookup and 

4036 # the existing-decision read — runs inside the try, so a malformed 

4037 # entry is swallowed per engine as the docstring promises rather 

4038 # than aborting the overlay for the rest of the list. 

4039 desc = None 

4040 try: 

4041 desc = guarded_urls.get(opt.get("value")) 

4042 if desc is None: 

4043 continue 

4044 existing = opt.get("egress") 

4045 if isinstance(existing, dict) and not existing.get("allowed", True): 

4046 continue 

4047 url_value = unwrap_setting( 

4048 settings_snapshot.get(desc.url_setting, "") 

4049 ) 

4050 if check_private_engine_url_blocked( 

4051 desc.engine_name, 

4052 desc.engine_name, 

4053 desc.url_setting, 

4054 url_value, 

4055 True, 

4056 False, 

4057 ): 

4058 opt["egress"] = { 

4059 "allowed": False, 

4060 "reason": "private_url_unapproved", 

4061 } 

4062 except Exception: 

4063 engine_name = desc.engine_name if desc is not None else "unknown" 

4064 logger.exception( 

4065 f"guarded-URL overlay failed for engine {engine_name}" 

4066 ) 

4067 

4068 

4069@router.get("/api/available-search-engines") 

4070def api_get_available_search_engines( 

4071 request: Request, 

4072 username: Annotated[str, Depends(require_auth)], 

4073): 

4074 """Get available search engines. 

4075 

4076 Optional query params (issue #5204): 

4077 ``egress_scope`` — when set, each option gets an 

4078 ``egress: {allowed, reason}`` field matching the same PDP the 

4079 request-boundary precheck uses. The frontend uses this to disable 

4080 (or hide) options that would be refused under the active scope. 

4081 ``primary`` — the user's saved primary search tool, used by the 

4082 PDP for strict / adaptive scope resolution. 

4083 

4084 When ``egress_scope`` is absent the SCOPE filtering is skipped, but 

4085 the guarded engine-URL overlay still runs: it stamps 

4086 ``egress: {allowed: false, reason: "private_url_unapproved"}`` on any 

4087 public engine whose configured instance URL the runtime will refuse 

4088 for lack of operator approval. That guard ignores 

4089 ``policy.egress_scope`` by design, so gating the overlay on the 

4090 opt-in would have hidden it on every default (``adaptive``) install. 

4091 Options that are not guard-blocked keep the historical shape (no 

4092 ``egress`` key), so existing callers — the settings page, the news 

4093 form — are unaffected except for the engines they could not have 

4094 used anyway. 

4095 """ 

4096 try: 

4097 # Issue #5204: optional egress-scope filter. The scope/primary 

4098 # query params opt INTO a per-option egress decision; without 

4099 # them the response is the historical, unfiltered list. 

4100 # Flask read these from request.args; the FastAPI equivalent is 

4101 # request.query_params. 

4102 requested_scope = ( 

4103 (request.query_params.get("egress_scope") or "").strip().lower() 

4104 ) 

4105 requested_primary = (request.query_params.get("primary") or "").strip() 

4106 apply_egress_filter = requested_scope in ( 

4107 "private_only", 

4108 "public_only", 

4109 ) 

4110 

4111 with get_user_db_session(username) as db_session: 

4112 settings_manager = get_settings_manager(db_session, username) 

4113 

4114 # Get search engines using the same approach as search_engines_config.py 

4115 from ...web_search_engines.search_engines_config import ( 

4116 search_config, 

4117 ) 

4118 from ...web_search_engines.engine_groups import ( 

4119 classify_engine_group, 

4120 effective_group, 

4121 group_label, 

4122 group_order, 

4123 ) 

4124 

4125 search_engines = search_config( 

4126 username=username, db_session=db_session 

4127 ) 

4128 

4129 # Get user's favorites using SettingsManager 

4130 favorites = settings_manager.get_setting("search.favorites", []) 

4131 if not isinstance(favorites, list): 4131 ↛ 4132line 4131 didn't jump to line 4132 because the condition on line 4131 was never true

4132 favorites = [] 

4133 

4134 # Extract search engines from config 

4135 engines_dict = {} 

4136 engine_options = [] 

4137 

4138 if search_engines: 4138 ↛ 4238line 4138 didn't jump to line 4238 because the condition on line 4138 was always true

4139 # Format engines for API response with metadata 

4140 from ...security.module_whitelist import ( 

4141 get_safe_module_class, 

4142 SecurityError, 

4143 ) 

4144 

4145 for engine_id, engine_data in search_engines.items(): 

4146 if not engine_data.get("supports_query_search", True): 

4147 continue 

4148 # Try to load the engine class to get metadata 

4149 engine_class = None 

4150 try: 

4151 module_path = engine_data.get("module_path") 

4152 class_name = engine_data.get("class_name") 

4153 if module_path and class_name: 

4154 # Use secure whitelist-validated import 

4155 engine_class = get_safe_module_class( 

4156 module_path, class_name 

4157 ) 

4158 except SecurityError: 

4159 logger.warning( 

4160 f"Security: Blocked unsafe module for {engine_id}" 

4161 ) 

4162 except Exception as e: 

4163 logger.debug( 

4164 f"Could not load engine class for {engine_id}: {e}" 

4165 ) 

4166 

4167 # Get icon and category from engine attributes 

4168 icon, category = _get_engine_icon_and_category( 

4169 engine_data, engine_class 

4170 ) 

4171 

4172 # Check if engine requires an API key 

4173 requires_api_key = engine_data.get( 

4174 "requires_api_key", False 

4175 ) 

4176 

4177 # Build display name with icon, category, and API key status 

4178 base_name = engine_data.get("display_name", engine_id) 

4179 if requires_api_key: 

4180 label = f"{icon} {base_name} ({category}, API key)" 

4181 else: 

4182 label = f"{icon} {base_name} ({category}, Free)" 

4183 

4184 # Check if engine is a favorite 

4185 is_favorite = engine_id in favorites 

4186 

4187 # Classify the engine into a selector band. base_group 

4188 # ignores favorite status (used by the frontend to move an 

4189 # engine back to its category when un-starred); the 

4190 # effective band is the Favorites overlay when starred. 

4191 # See engine_groups.py. 

4192 base_group = classify_engine_group( 

4193 engine_id, category, requires_api_key 

4194 ) 

4195 shown_group = effective_group(base_group, is_favorite) 

4196 

4197 engines_dict[engine_id] = { 

4198 "display_name": base_name, 

4199 "description": engine_data.get("description", ""), 

4200 "strengths": engine_data.get("strengths", []), 

4201 "icon": icon, 

4202 "category": category, 

4203 "requires_api_key": requires_api_key, 

4204 "is_favorite": is_favorite, 

4205 } 

4206 

4207 engine_options.append( 

4208 { 

4209 "value": engine_id, 

4210 "label": label, 

4211 "icon": icon, 

4212 "category": category, 

4213 "requires_api_key": requires_api_key, 

4214 "is_favorite": is_favorite, 

4215 "group": shown_group, 

4216 "group_label": group_label(shown_group), 

4217 "group_order": group_order(shown_group), 

4218 "base_group": base_group, 

4219 "base_group_label": group_label(base_group), 

4220 "base_group_order": group_order(base_group), 

4221 # Surface the per-engine ``agent_enabled`` flag so 

4222 # the frontend can disable engines the LangGraph 

4223 # research agent hides from its specialized tool 

4224 # list. Defaults to True so engines that don't 

4225 # carry the flag (only ``collection_*`` sets it 

4226 # explicitly) stay selectable — the frontend's 

4227 # LangGraph-strategy check is the only consumer 

4228 # and short-circuits for other strategies, so a 

4229 # True default is safe everywhere. 

4230 "agent_enabled": engine_data.get( 

4231 "agent_enabled", True 

4232 ), 

4233 } 

4234 ) 

4235 

4236 # Sort engine_options by band order (favorites band first), then 

4237 # alphabetically by label within each band. 

4238 engine_options.sort( 

4239 key=lambda x: ( 

4240 x.get("group_order", 999), 

4241 x.get("label", "").lower(), 

4242 ) 

4243 ) 

4244 

4245 # If no engines found, log the issue but return empty list 

4246 if not engine_options: 4246 ↛ 4247line 4246 didn't jump to line 4247 because the condition on line 4246 was never true

4247 logger.warning("No search engines found in configuration") 

4248 

4249 # Issue #5204: when the caller opts in via ?egress_scope=&primary=, 

4250 # stamp each option with the PDP's per-engine decision so the 

4251 # frontend can disable/hide the ones that would be refused at 

4252 # submit time. UNPROTECTED and missing-scope callers skip the 

4253 # SCOPE filtering (the historical shape) but still get the 

4254 # guarded engine-URL overlay: that guard is scope-independent by 

4255 # design (see ``_apply_guarded_url_overlay``), so an engine the 

4256 # runtime will refuse must read as disabled under the shipped 

4257 # ``adaptive`` default too, not only under ``public_only``. 

4258 # A snapshot failure must not be replaced with an empty policy: 

4259 # the filter and the overlay would then deny nothing, so the 

4260 # list would be served unfiltered. Let it reach the handlers 

4261 # below, which return an error instead of a list. 

4262 policy_snapshot = settings_manager.get_settings_snapshot() 

4263 policy_snapshot = policy_snapshot or {} 

4264 if apply_egress_filter: 

4265 # Runs the guarded-URL overlay itself, after the PDP pass. 

4266 _classify_options_for_egress( 

4267 engine_options, 

4268 egress_scope=requested_scope, 

4269 primary_engine=requested_primary, 

4270 settings_snapshot=policy_snapshot, 

4271 username=username, 

4272 search_engines=search_engines, 

4273 ) 

4274 else: 

4275 _apply_guarded_url_overlay(engine_options, policy_snapshot) 

4276 

4277 return { 

4278 "engines": engines_dict, 

4279 "engine_options": engine_options, 

4280 "favorites": favorites, 

4281 } 

4282 

4283 except SettingsValueError as error: 

4284 return _settings_value_error_response("available search engines", error) 

4285 except Exception: 

4286 logger.exception("Error getting available search engines") 

4287 return JSONResponse( 

4288 {"error": "Failed to retrieve search engines"}, status_code=500 

4289 ) 

4290 

4291 

4292@router.get("/api/search-favorites") 

4293def api_get_search_favorites( 

4294 request: Request, 

4295 username: Annotated[str, Depends(require_auth)], 

4296): 

4297 """Get the list of favorite search engines for the current user""" 

4298 try: 

4299 with get_user_db_session(username) as db_session: 

4300 settings_manager = get_settings_manager(db_session, username) 

4301 favorites = settings_manager.get_setting("search.favorites", []) 

4302 if not isinstance(favorites, list): 

4303 favorites = [] 

4304 return {"favorites": favorites} 

4305 

4306 except Exception: 

4307 logger.exception("Error getting search favorites") 

4308 return JSONResponse( 

4309 {"error": "Failed to retrieve favorites"}, status_code=500 

4310 ) 

4311 

4312 

4313@router.put("/api/search-favorites") 

4314@settings_limit 

4315async def api_update_search_favorites( 

4316 request: Request, 

4317 username: Annotated[str, Depends(require_auth)], 

4318): 

4319 """Update the list of favorite search engines for the current user""" 

4320 data, body_error = await read_json_dict( 

4321 request, "simple", "No data provided" 

4322 ) 

4323 if body_error is not None: 

4324 return body_error 

4325 

4326 def _impl(): 

4327 try: 

4328 favorites = data.get("favorites") 

4329 if favorites is None: 

4330 return JSONResponse( 

4331 {"error": "No favorites provided"}, status_code=400 

4332 ) 

4333 

4334 if not isinstance(favorites, list): 

4335 return JSONResponse( 

4336 {"error": "Favorites must be a list"}, status_code=400 

4337 ) 

4338 

4339 with get_user_db_session(username) as db_session: 

4340 settings_manager = get_settings_manager(db_session, username) 

4341 if settings_manager.settings_locked: 

4342 return JSONResponse( 

4343 {"error": "Settings are locked"}, status_code=403 

4344 ) 

4345 if settings_manager.set_setting("search.favorites", favorites): 

4346 invalidate_settings_caches(username) 

4347 return { 

4348 "message": "Favorites updated successfully", 

4349 "favorites": favorites, 

4350 } 

4351 

4352 return JSONResponse( 

4353 {"error": "Failed to update favorites"}, status_code=500 

4354 ) 

4355 

4356 except Exception: 

4357 logger.exception("Error updating search favorites") 

4358 return JSONResponse( 

4359 {"error": "Failed to update favorites"}, status_code=500 

4360 ) 

4361 

4362 # SQLCipher PBKDF2 key derivation in get_user_db_session blocks the 

4363 # event loop for hundreds of ms on first call after login. Offload 

4364 # the entire DB-touching body so concurrent requests don't serialise. 

4365 return await run_db_sync(_impl) 

4366 

4367 

4368@router.post("/api/search-favorites/toggle") 

4369@settings_limit 

4370async def api_toggle_search_favorite( 

4371 request: Request, 

4372 username: Annotated[str, Depends(require_auth)], 

4373): 

4374 """Toggle a search engine as favorite""" 

4375 data, body_error = await read_json_dict( 

4376 request, "simple", "No data provided" 

4377 ) 

4378 if body_error is not None: 

4379 return body_error 

4380 

4381 def _impl(): 

4382 try: 

4383 engine_id = data.get("engine_id") 

4384 if not engine_id: 

4385 return JSONResponse( 

4386 {"error": "No engine_id provided"}, status_code=400 

4387 ) 

4388 

4389 with get_user_db_session(username) as db_session: 

4390 settings_manager = get_settings_manager(db_session, username) 

4391 if settings_manager.settings_locked: 

4392 return JSONResponse( 

4393 {"error": "Settings are locked"}, status_code=403 

4394 ) 

4395 

4396 # Get current favorites 

4397 favorites = settings_manager.get_setting("search.favorites", []) 

4398 if not isinstance(favorites, list): 

4399 favorites = [] 

4400 else: 

4401 # Make a copy to avoid modifying the original 

4402 favorites = list(favorites) 

4403 

4404 # Toggle the engine 

4405 is_favorite = engine_id in favorites 

4406 if is_favorite: 

4407 favorites.remove(engine_id) 

4408 is_favorite = False 

4409 else: 

4410 favorites.append(engine_id) 

4411 is_favorite = True 

4412 

4413 # Update the setting 

4414 if settings_manager.set_setting("search.favorites", favorites): 

4415 invalidate_settings_caches(username) 

4416 return { 

4417 "message": "Favorite toggled successfully", 

4418 "engine_id": engine_id, 

4419 "is_favorite": is_favorite, 

4420 "favorites": favorites, 

4421 } 

4422 

4423 return JSONResponse( 

4424 {"error": "Failed to toggle favorite"}, status_code=500 

4425 ) 

4426 

4427 except Exception: 

4428 logger.exception("Error toggling search favorite") 

4429 return JSONResponse( 

4430 {"error": "Failed to toggle favorite"}, status_code=500 

4431 ) 

4432 

4433 return await run_db_sync(_impl) 

4434 

4435 

4436# Legacy routes for backward compatibility - these will redirect to the new routes 

4437@router.get("/main") 

4438def main_config_page( 

4439 request: Request, username: Annotated[str, Depends(require_auth)] 

4440): 

4441 """Redirect to app settings page""" 

4442 return RedirectResponse(url="/settings/", status_code=302) 

4443 

4444 

4445@router.get("/collections") 

4446def collections_config_page( 

4447 request: Request, username: Annotated[str, Depends(require_auth)] 

4448): 

4449 """Redirect to app settings page""" 

4450 return RedirectResponse(url="/settings/", status_code=302) 

4451 

4452 

4453@router.get("/api_keys") 

4454def api_keys_config_page( 

4455 request: Request, username: Annotated[str, Depends(require_auth)] 

4456): 

4457 """Redirect to LLM settings page""" 

4458 return RedirectResponse(url="/settings/", status_code=302) 

4459 

4460 

4461@router.get("/search_engines") 

4462def search_engines_config_page( 

4463 request: Request, username: Annotated[str, Depends(require_auth)] 

4464): 

4465 """Redirect to search settings page""" 

4466 return RedirectResponse(url="/settings/", status_code=302) 

4467 

4468 

4469@router.get("/llm") 

4470def llm_config_page( 

4471 request: Request, username: Annotated[str, Depends(require_auth)] 

4472): 

4473 """Redirect to LLM settings page""" 

4474 return RedirectResponse(url="/settings/", status_code=302) 

4475 

4476 

4477@router.post("/open_file_location") 

4478def open_file_location( 

4479 request: Request, username: Annotated[str, Depends(require_auth)] 

4480): 

4481 """Open the location of a configuration file. 

4482 

4483 Security: This endpoint is disabled for server deployments. 

4484 It only makes sense for desktop usage where the server and client are on the same machine. 

4485 """ 

4486 return JSONResponse( 

4487 { 

4488 "status": "error", 

4489 "message": "This feature is disabled. It is only available in desktop mode.", 

4490 }, 

4491 status_code=403, 

4492 ) 

4493 

4494 

4495# CSRF token is injected globally via fastapi_app template globals 

4496 

4497 

4498@router.get("/api/warnings") 

4499def api_get_warnings( 

4500 request: Request, username: Annotated[str, Depends(require_auth)] 

4501): 

4502 """Get current warnings based on settings""" 

4503 try: 

4504 warnings = calculate_warnings(username=username) 

4505 return {"warnings": warnings} 

4506 except Exception: 

4507 logger.exception("Error getting warnings") 

4508 return JSONResponse( 

4509 {"error": "Failed to retrieve warnings"}, status_code=500 

4510 ) 

4511 

4512 

4513@router.get("/api/backup-status") 

4514def api_get_backup_status( 

4515 request: Request, username: Annotated[str, Depends(require_auth)] 

4516): 

4517 """Get backup status for the current user.""" 

4518 try: 

4519 from ...config.paths import get_user_backup_directory 

4520 

4521 if not username: 4521 ↛ 4522line 4521 didn't jump to line 4522 because the condition on line 4521 was never true

4522 return JSONResponse({"error": "Not authenticated"}, status_code=401) 

4523 

4524 from ...utilities.formatting import human_size 

4525 

4526 backup_dir = get_user_backup_directory(username) 

4527 

4528 # Sort by modification time (not filename) for robustness 

4529 backup_list = [] 

4530 total_size = 0 

4531 from ...database.backup.backup_service import is_safe_glob_result 

4532 

4533 for b in backup_dir.glob("ldr_backup_*.db"): 

4534 # Symlink/traversal guard (main #4663): skip a planted symlink whose 

4535 # target escapes the per-user backup dir, so its external file's 

4536 # name/size/mtime can't be exfiltrated through this endpoint. 

4537 if not is_safe_glob_result(b, backup_dir): 

4538 continue 

4539 try: 

4540 stat = b.stat() 

4541 total_size += stat.st_size 

4542 backup_list.append( 

4543 { 

4544 "filename": b.name, 

4545 "size_bytes": stat.st_size, 

4546 "size_human": human_size(stat.st_size), 

4547 "created_at": datetime.fromtimestamp( 

4548 stat.st_mtime, tz=timezone.utc 

4549 ).isoformat(), 

4550 "_mtime": stat.st_mtime, 

4551 } 

4552 ) 

4553 except FileNotFoundError: 

4554 continue 

4555 

4556 # Sort newest first by mtime, then remove internal field 

4557 backup_list.sort(key=lambda x: x["_mtime"], reverse=True) 

4558 for entry in backup_list: 

4559 del entry["_mtime"] 

4560 

4561 backup_enabled = _get_setting_from_session( 

4562 "backup.enabled", username, True 

4563 ) 

4564 

4565 return { 

4566 "enabled": bool(backup_enabled), 

4567 "count": len(backup_list), 

4568 "backups": backup_list, 

4569 "total_size_bytes": total_size, 

4570 "total_size_human": human_size(total_size), 

4571 } 

4572 

4573 except Exception: 

4574 logger.exception("Error getting backup status") 

4575 return JSONResponse( 

4576 {"error": "Failed to retrieve backup status"}, status_code=500 

4577 ) 

4578 

4579 

4580@router.get("/api/ollama-status") 

4581def check_ollama_status( 

4582 request: Request, username: Annotated[str, Depends(require_auth)] 

4583): 

4584 """Check if Ollama is running and available""" 

4585 try: 

4586 # Get Ollama URL from settings 

4587 raw_base_url = _get_setting_from_session( 

4588 "llm.ollama.url", username, DEFAULT_OLLAMA_URL 

4589 ) 

4590 base_url = ( 

4591 normalize_url(raw_base_url) if raw_base_url else DEFAULT_OLLAMA_URL 

4592 ) 

4593 

4594 response = safe_get( 

4595 f"{base_url}/api/version", 

4596 timeout=2, 

4597 allow_localhost=True, 

4598 allow_private_ips=True, 

4599 ) 

4600 

4601 if response.status_code == 200: 

4602 return { 

4603 "running": True, 

4604 "version": response.json().get("version", "unknown"), 

4605 } 

4606 return { 

4607 "running": False, 

4608 "error": f"Ollama returned status code {response.status_code}", 

4609 } 

4610 

4611 except requests.exceptions.RequestException: 

4612 logger.exception("Ollama check failed") 

4613 return { 

4614 "running": False, 

4615 "error": "Failed to check search engine status", 

4616 } 

4617 

4618 

4619@router.get("/api/rate-limiting/status") 

4620def api_get_rate_limiting_status( 

4621 request: Request, username: Annotated[str, Depends(require_auth)] 

4622): 

4623 """Get current rate limiting status and statistics""" 

4624 try: 

4625 # exploration_rate / learning_rate are the *configured* settings, not 

4626 # the effective operating values: AdaptiveRateLimitTracker._apply_profile 

4627 # scales them by the active profile (conservative ~0.5x/0.7x, aggressive 

4628 # ~1.5x/1.3x, each capped). The profile is reported alongside so a 

4629 # consumer can tell which transform the running tracker applies; we 

4630 # deliberately don't duplicate that scaling math here. 

4631 status = { 

4632 # Default True to match the schema default (default_settings.json) 

4633 # and the tracker's web-mode default; the prior endpoint reported 

4634 # the tracker's effective enabled state, which was on by default. 

4635 "enabled": _get_setting_from_session( 

4636 "rate_limiting.enabled", username, True 

4637 ), 

4638 "profile": _get_setting_from_session( 

4639 "rate_limiting.profile", username, "balanced" 

4640 ), 

4641 "exploration_rate": _get_setting_from_session( 

4642 "rate_limiting.exploration_rate", username, 0.1 

4643 ), 

4644 "learning_rate": _get_setting_from_session( 

4645 "rate_limiting.learning_rate", username, 0.45 

4646 ), 

4647 "memory_window": _get_setting_from_session( 

4648 "rate_limiting.memory_window", username, 100 

4649 ), 

4650 } 

4651 

4652 with get_user_db_session(username) as db_session: 

4653 estimates = ( 

4654 db_session.query(RateLimitEstimate) 

4655 .order_by(RateLimitEstimate.engine_type) 

4656 .all() 

4657 ) 

4658 

4659 engines = [] 

4660 for est in estimates: 

4661 engines.append( 

4662 { 

4663 "engine_type": est.engine_type, 

4664 "base_wait_seconds": round(est.base_wait_seconds, 2), 

4665 "min_wait_seconds": round(est.min_wait_seconds, 2), 

4666 "max_wait_seconds": round(est.max_wait_seconds, 2), 

4667 "last_updated": est.last_updated, 

4668 "total_attempts": est.total_attempts, 

4669 "success_rate": round(est.success_rate * 100, 1), 

4670 } 

4671 ) 

4672 

4673 return {"status": status, "engines": engines} 

4674 

4675 except Exception: 

4676 logger.exception("Error getting rate limiting status") 

4677 return JSONResponse( 

4678 {"error": "An internal error occurred"}, status_code=500 

4679 ) 

4680 

4681 

4682@router.post("/api/rate-limiting/engines/{engine_type}/reset") 

4683def api_reset_engine_rate_limiting( 

4684 request: Request, 

4685 engine_type, 

4686 username: Annotated[str, Depends(require_auth)], 

4687): 

4688 """Reset (forget) the learned rate-limit estimate for a specific engine. 

4689 

4690 Deletes the engine's persisted ``RateLimitEstimate`` row from the user's 

4691 database so the adaptive tracker re-learns it from scratch. The previous 

4692 implementation called the per-request ``get_tracker()``, whose mutation 

4693 path is gated on a research-session context that is absent in an analytics 

4694 HTTP request — so it was a silent no-op that never cleared the persisted 

4695 estimate the ``/status`` and ``/current`` endpoints display (#4721). 

4696 """ 

4697 try: 

4698 # (#6945) Same retry wrap the tracker's reset_engine got: a 

4699 # transient SQLite writer-lock blip must not fail the request 

4700 # on the first "database is locked". The closure opens a fresh 

4701 # ``get_user_db_session`` per attempt so the writer lock is 

4702 # released between tries; after retry exhaustion the existing 

4703 # error handling below still applies. 

4704 def _reset_db(): 

4705 with get_user_db_session(username) as db_session: 

4706 db_session.query(RateLimitEstimate).filter_by( 

4707 engine_type=engine_type 

4708 ).delete(synchronize_session=False) 

4709 db_session.commit() 

4710 

4711 retry_on_db_lock(_reset_db) 

4712 

4713 return {"message": f"Rate limiting data reset for {engine_type}"} 

4714 

4715 except Exception: 

4716 logger.exception( 

4717 "Error resetting rate limiting for {}", 

4718 _log_value_preview(engine_type), 

4719 ) 

4720 return JSONResponse( 

4721 {"error": "An internal error occurred"}, status_code=500 

4722 ) 

4723 

4724 

4725def _cleanup_rate_limit_estimates_sync(username: str, cutoff: float) -> None: 

4726 """Delete persisted rate-limit estimates last updated before *cutoff*.""" 

4727 

4728 # (#6945) Same retry wrap the tracker's cleanup_old_data got — see 

4729 # api_reset_engine_rate_limiting above for the rationale. Fresh 

4730 # session per attempt; lock exhaustion propagates to the route's 

4731 # existing error handling. 

4732 def _cleanup_db(): 

4733 with get_user_db_session(username) as db_session: 

4734 db_session.query(RateLimitEstimate).filter( 

4735 RateLimitEstimate.last_updated < cutoff 

4736 ).delete(synchronize_session=False) 

4737 db_session.commit() 

4738 

4739 retry_on_db_lock(_cleanup_db) 

4740 

4741 

4742@router.post("/api/rate-limiting/cleanup") 

4743async def api_cleanup_rate_limiting( 

4744 request: Request, username: Annotated[str, Depends(require_auth)] 

4745): 

4746 """Clean up old rate limiting data. 

4747 

4748 Note: not using @require_json_body because the JSON body is optional 

4749 here — the endpoint works with or without a payload (defaults to 30 days). 

4750 

4751 Parse errors use the same response as a non-object body. The body is 

4752 optional, so ``read_json_dict`` would reject valid ``null`` and cannot 

4753 be used here. The isinstance check below has the same reasoning as 

4754 ``@require_json_body``: 

4755 a *valid but truthy non-dict* JSON body (e.g. ``[]`` or a bare number) 

4756 would otherwise reach ``data.get(...)`` and raise ``AttributeError``. 

4757 

4758 The parse catches exactly what ``read_json_dict`` does: ``ValueError`` 

4759 (bad syntax, undecodable bytes, an oversized integer literal) and 

4760 ``RecursionError`` (nesting too deep to parse). Anything else, such as 

4761 the request-size limit's exception, must propagate to its own handler. 

4762 """ 

4763 content_type = request.headers.get("content-type", "") 

4764 is_json = "application/json" in content_type 

4765 try: 

4766 data = await request.json() if is_json else None 

4767 except (ValueError, RecursionError): 

4768 return json_body_error("simple", "Request body must be a JSON object") 

4769 if data is not None and not isinstance(data, dict): 

4770 return json_body_error("simple", "Request body must be a JSON object") 

4771 days = data.get("days", 30) if data is not None else 30 

4772 

4773 try: 

4774 days = int(days) 

4775 except (TypeError, ValueError): 

4776 return JSONResponse( 

4777 {"error": "'days' must be an integer"}, status_code=400 

4778 ) 

4779 if days < 1 or days > 365: 

4780 return JSONResponse( 

4781 {"error": "'days' must be between 1 and 365"}, status_code=400 

4782 ) 

4783 

4784 try: 

4785 # Delete persisted estimates not updated within the window. Mirrors the 

4786 # read endpoints (#4721): operate on RateLimitEstimate rather than the 

4787 # per-request get_tracker(), whose cleanup path is a no-op outside a 

4788 # research-session context. last_updated is a unix timestamp (Float). 

4789 cutoff = time.time() - days * 86400 

4790 await run_db_sync(_cleanup_rate_limit_estimates_sync, username, cutoff) 

4791 

4792 return { 

4793 "message": f"Cleaned up rate limiting data older than {days} days" 

4794 } 

4795 

4796 except Exception: 

4797 logger.exception("Error cleaning up rate limiting data") 

4798 return JSONResponse( 

4799 {"error": "An internal error occurred"}, status_code=500 

4800 ) 

4801 

4802 

4803@router.get("/api/bulk") 

4804def get_bulk_settings( 

4805 request: Request, username: Annotated[str, Depends(require_auth)] 

4806): 

4807 """Get multiple settings at once for performance.""" 

4808 try: 

4809 # Get requested settings from query parameters 

4810 requested = request.query_params.getlist("keys[]") 

4811 if not requested: 

4812 # Default to common settings if none specified 

4813 requested = [ 

4814 "llm.provider", 

4815 "llm.model", 

4816 "search.tool", 

4817 "search.iterations", 

4818 "search.questions_per_iteration", 

4819 "search.search_strategy", 

4820 "benchmark.evaluation.provider", 

4821 "benchmark.evaluation.model", 

4822 "benchmark.evaluation.temperature", 

4823 "benchmark.evaluation.endpoint_url", 

4824 ] 

4825 

4826 # Fetch all settings at once 

4827 from ...security.data_sanitizer import DataSanitizer 

4828 

4829 result = {} 

4830 for key in requested: 

4831 try: 

4832 value = _get_setting_from_session(key, username) 

4833 # Redact secret values (main does this — this endpoint is an 

4834 # exfiltration channel for plaintext API keys/tokens via 

4835 # ?keys[]=...). 'exists' reflects the RAW value, not the sentinel. 

4836 result[key] = { 

4837 "value": DataSanitizer.redact_value(key, None, value), 

4838 "exists": value is not None, 

4839 } 

4840 except Exception: 

4841 logger.warning( 

4842 "Error getting setting {}", _log_value_preview(key) 

4843 ) 

4844 result[key] = { 

4845 "value": None, 

4846 "exists": False, 

4847 "error": "Failed to retrieve setting", 

4848 } 

4849 

4850 return {"success": True, "settings": result} 

4851 

4852 except Exception: 

4853 logger.exception("Error getting bulk settings") 

4854 return JSONResponse( 

4855 {"success": False, "error": "An internal error occurred"}, 

4856 status_code=500, 

4857 ) 

4858 

4859 

4860@router.get("/api/data-location") 

4861def api_get_data_location( 

4862 request: Request, username: Annotated[str, Depends(require_auth)] 

4863): 

4864 """Get information about data storage location and security""" 

4865 try: 

4866 # Get the data directory path 

4867 data_dir = get_data_directory() 

4868 # Get the encrypted databases path 

4869 encrypted_db_path = get_encrypted_database_path() 

4870 

4871 # LDR_DATA_DIR is the only variable that moves the data directory, so 

4872 # it alone decides whether the location is custom. 

4873 custom_data_dir = get_custom_data_directory() 

4874 

4875 # Get platform-specific default location info 

4876 platform_info = { 

4877 "Windows": "C:\\Users\\Username\\AppData\\Local\\local-deep-research", 

4878 "macOS": "~/Library/Application Support/local-deep-research", 

4879 "Linux": "~/.local/share/local-deep-research", 

4880 } 

4881 

4882 # Current platform 

4883 current_platform = platform.system() 

4884 if current_platform == "Darwin": 

4885 current_platform = "macOS" 

4886 

4887 # Get SQLCipher settings from environment 

4888 from ...database.sqlcipher_utils import get_sqlcipher_settings 

4889 

4890 # Debug logging 

4891 logger.info(f"db_manager type: {type(db_manager)}") 

4892 logger.info( 

4893 f"db_manager.has_encryption: {getattr(db_manager, 'has_encryption', 'ATTRIBUTE NOT FOUND')}" 

4894 ) 

4895 

4896 cipher_settings = ( 

4897 get_sqlcipher_settings() if db_manager.has_encryption else {} 

4898 ) 

4899 

4900 return { 

4901 "data_directory": str(data_dir), 

4902 "database_path": str(encrypted_db_path), 

4903 "encrypted_database_path": str(encrypted_db_path), 

4904 "is_custom": custom_data_dir is not None, 

4905 "custom_env_var": "LDR_DATA_DIR", 

4906 "custom_env_value": ( 

4907 str(custom_data_dir) if custom_data_dir is not None else None 

4908 ), 

4909 "platform": current_platform, 

4910 "platform_default": platform_info.get( 

4911 current_platform, str(data_dir) 

4912 ), 

4913 "platform_info": platform_info, 

4914 "security_notice": { 

4915 "encrypted": db_manager.has_encryption, 

4916 "warning": "All data including API keys stored in the database are securely encrypted." 

4917 if db_manager.has_encryption 

4918 else "All data including API keys stored in the database are currently unencrypted. Please ensure appropriate file system permissions are set.", 

4919 "recommendation": "Your data is protected with database encryption." 

4920 if db_manager.has_encryption 

4921 else "Consider using environment variables for sensitive API keys instead of storing them in the database.", 

4922 }, 

4923 "encryption_settings": cipher_settings, 

4924 } 

4925 

4926 except Exception: 

4927 logger.exception("Error getting data location information") 

4928 return JSONResponse( 

4929 {"error": "Failed to retrieve data location"}, status_code=500 

4930 ) 

4931 

4932 

4933def _is_blank_service_url(value) -> bool: 

4934 """True when a notification URL counts as unset. 

4935 

4936 ``DataSanitizer._is_empty_value`` and ``NotificationManager`` both treat 

4937 a whitespace-only ``notifications.service_url`` as unconfigured, so the 

4938 test endpoint has to agree -- a stored ``" "`` is truthy, and without 

4939 this it would be handed to Apprise verbatim. 

4940 """ 

4941 if isinstance(value, str): 

4942 return not value.strip() 

4943 return not value 

4944 

4945 

4946async def _notification_test_body(request: Request) -> dict: 

4947 """Parse the test-url body and stash it for the rate-limit predicate. 

4948 

4949 Runs as a route dependency, i.e. *before* slowapi's decorator wrapper, 

4950 which is the only way ``_caller_supplied_notification_url`` (a 

4951 synchronous callback) can see the request body at all. 

4952 

4953 ``await request.json()`` is deliberately called here rather than inside 

4954 the handler's broad ``except Exception`` so a malformed body reaches the 

4955 app's registered ``json.JSONDecodeError`` -> 400 handler instead of 

4956 being swallowed and reported as a 500 (see 

4957 ``web/dependencies/json_body.py``). A valid but non-dict body (e.g. a 

4958 bare number) is normalised to ``{}`` rather than raising, matching 

4959 main's ``request.get_json(silent=True)`` shape. 

4960 """ 

4961 data = await request.json() 

4962 if not isinstance(data, dict): 

4963 data = {} 

4964 request.state.notification_test_payload = data 

4965 return data 

4966 

4967 

4968def _caller_supplied_notification_url(request: Request) -> bool: 

4969 """True when the test-url request names its own destination. 

4970 

4971 Rate-limit exemption predicate. Testing a URL the caller just typed is 

4972 the case the endpoint exists to serve and stays unlimited. Falling back 

4973 to the caller's STORED URL does not: that path is a zero-argument send 

4974 trigger, so it is the one that gets a bucket. 

4975 """ 

4976 payload = getattr(request.state, "notification_test_payload", None) 

4977 if not isinstance(payload, dict): 4977 ↛ 4978line 4977 didn't jump to line 4978 because the condition on line 4977 was never true

4978 return False 

4979 submitted = payload.get("service_url") 

4980 if _is_blank_service_url(submitted): 

4981 return False 

4982 return submitted != DataSanitizer.REDACTION_TEXT 

4983 

4984 

4985# Error strings ``NotificationService.test_service`` can return that name 

4986# no destination: operator instructions and shape refusals whose text is 

4987# fixed, or parameterised only with counts. Kept as prefixes because two of 

4988# them continue with a count or a second sentence. 

4989_HOST_FREE_NOTIFICATION_TEST_ERRORS = ( 

4990 # The outbound master switch is off; only an operator can change it. 

4991 "Outbound notifications are disabled.", 

4992 # The input does not partition into unambiguous entries. 

4993 "Notification service URL could not be parsed unambiguously", 

4994 # More entries than MAX_NOTIFICATION_TARGETS -- a count, not a host. 

4995 "Too many notification targets", 

4996 # Apprise declined to register the parsed entries. 

4997 "Failed to add service URL", 

4998 # The validator rejected the URL without a reason of its own. 

4999 "Invalid notification service URL.", 

5000 # Validation passed; delivery did not. 

5001 "Failed to send test notification", 

5002) 

5003 

5004 

5005def _is_host_free_notification_error(error_text: object) -> bool: 

5006 """True when a ``test_service`` error is safe to echo for a URL the 

5007 caller never named. 

5008 

5009 The stored-URL fallback tests a destination the caller does not have to 

5010 name and, once the setting is redacted, cannot read back. An error that 

5011 names it -- the validator's ``Blocked private/internal IP address: 

5012 <host>`` and ``Blocked cloud-metadata / link-local IP address: <host>`` 

5013 -- would disclose the stored hostname/IP through the very endpoint that 

5014 exists to keep the URL hidden. 

5015 

5016 Matching an ALLOWLIST rather than denylisting those two prefixes keeps 

5017 the failure closed: an error string added to ``test_service`` later is 

5018 suppressed (the user loses detail, the log keeps it) instead of leaking 

5019 by default. Static operator instructions carry no host and must still 

5020 reach the UI -- "set LDR_NOTIFICATIONS_ALLOW_OUTBOUND=true" is useless 

5021 in a log the user cannot read, and ``docs/NOTIFICATIONS.md`` promises 

5022 it inline. 

5023 """ 

5024 return isinstance(error_text, str) and error_text.startswith( 

5025 _HOST_FREE_NOTIFICATION_TEST_ERRORS 

5026 ) 

5027 

5028 

5029# Own bucket, not the shared "settings" one: this caps the stored-URL 

5030# fallback without spending the quota a user needs for saving settings. 

5031# Keyed per authenticated user (the branch convention for settings routes) 

5032# rather than main's per-IP default -- the destination being spammed is the 

5033# caller's own configured webhook. 

5034notification_test_limit = limiter.shared_limit( 

5035 SETTINGS_RATE_LIMIT, 

5036 scope="notification_test", 

5037 key_func=_user_key, 

5038 exempt_when=_caller_supplied_notification_url, 

5039) 

5040 

5041 

5042@router.post("/api/notifications/test-url") 

5043@notification_test_limit 

5044async def api_test_notification_url( 

5045 request: Request, 

5046 data: Annotated[dict, Depends(_notification_test_body)], 

5047 username: Annotated[str, Depends(require_auth)], 

5048): 

5049 """ 

5050 Test a submitted notification URL or the calling user's stored URL. 

5051 

5052 When ``service_url`` is missing, blank, or the redaction sentinel, the 

5053 authenticated user's stored ``notifications.service_url`` is used 

5054 instead. Blank includes whitespace-only, matching 

5055 ``DataSanitizer._is_empty_value`` and the notification manager, which 

5056 both treat ``" "`` as unconfigured -- otherwise Apprise is handed 

5057 literal whitespace. An unconfigured stored URL returns 400. Test 

5058 notifications still use a temporary Apprise instance. 

5059 

5060 Security note: this endpoint was deliberately unlimited, on the grounds 

5061 that users need to test URLs while configuring notifications. That 

5062 reasoning covers a caller who submits a URL, and that path is still 

5063 exempt. It does not cover the stored-URL fallback: with no body, this 

5064 becomes a zero-argument trigger that sends to a destination the caller 

5065 never has to name, so an authenticated caller could loop on an empty 

5066 body to spam their configured service. That path consumes a dedicated 

5067 rate-limit bucket. ``require_auth`` still bounds the blast radius to the 

5068 caller's own notification services. 

5069 

5070 A wrong-typed or hostile ``service_url`` *value* flows unchanged into 

5071 ``NotificationService.test_service`` / ``NotificationURLValidator``, 

5072 which already reject it cleanly (non-string, unparsable, or 

5073 private/loopback targets all return ``{"success": False, "error": ...}`` 

5074 rather than raising). 

5075 """ 

5076 service_url = data.get("service_url") 

5077 # Captured before the stored-URL fallback replaces ``service_url``: 

5078 # the response shaping below branches on who chose the destination. 

5079 caller_supplied = _caller_supplied_notification_url(request) 

5080 if _is_blank_service_url(service_url) or ( 

5081 service_url == DataSanitizer.REDACTION_TEXT 

5082 ): 

5083 # Off-loop: ``_get_setting_from_session`` opens a SQLCipher session 

5084 # (PBKDF2 key derivation + disk I/O) and constructs a 

5085 # SettingsManager, both synchronous. Called inline from this 

5086 # ``async def`` it stalls the event loop, and the server runs 

5087 # single-worker, so every other in-flight request stalls with it. 

5088 service_url = await run_db_sync( 

5089 _get_setting_from_session, 

5090 "notifications.service_url", 

5091 username, 

5092 default="", 

5093 ) 

5094 if _is_blank_service_url(service_url): 

5095 return JSONResponse( 

5096 {"success": False, "error": "No notification URL configured"}, 

5097 status_code=400, 

5098 ) 

5099 

5100 try: 

5101 from ...notifications.service import NotificationService 

5102 

5103 # Create notification service instance and test the URL. 

5104 # No password/session needed - URL provided directly, no DB access. 

5105 # test_service performs a synchronous Apprise network send (can 

5106 # block for the full connect/read timeout against a slow or 

5107 # unreachable endpoint) — run it off the event loop. 

5108 import asyncio 

5109 

5110 from ...settings.env_registry import get_env_setting 

5111 

5112 # Honour the operator's env gating (main does this) — otherwise the 

5113 # service defaults to outbound_allowed=False and the URL test can never 

5114 # succeed even when the operator set LDR_NOTIFICATIONS_ALLOW_OUTBOUND. 

5115 notification_service = NotificationService( 

5116 allow_private_ips=bool( 

5117 get_env_setting("notifications.allow_private_ips", False) 

5118 ), 

5119 outbound_allowed=bool( 

5120 get_env_setting("notifications.allow_outbound", False) 

5121 ), 

5122 ) 

5123 result = await asyncio.to_thread( 

5124 notification_service.test_service, service_url 

5125 ) 

5126 

5127 # Only return expected fields to prevent information leakage. On 

5128 # the caller-supplied path the validator detail is safe to echo — 

5129 # the caller just typed that URL. The stored-URL fallback is 

5130 # different: an error that names the destination would reveal the 

5131 # stored hostname/IP, content the redacted API otherwise never 

5132 # returns. Those go to the server log and come back generic; the 

5133 # host-free operator instructions still come back inline, since 

5134 # they say what the user has to do next. 

5135 if caller_supplied: 

5136 return { 

5137 "success": result.get("success", False), 

5138 "message": result.get("message", ""), 

5139 "error": result.get("error", ""), 

5140 } 

5141 if result.get("success"): 

5142 return { 

5143 "success": result.get("success", False), 

5144 "message": result.get("message", ""), 

5145 "error": "", 

5146 } 

5147 error_text = result.get("error", "") or "" 

5148 logger.warning( 

5149 "Stored-URL notification test failed for user={!r}: {}", 

5150 username, 

5151 error_text or result.get("message", ""), 

5152 ) 

5153 return { 

5154 "success": False, 

5155 "message": "", 

5156 "error": ( 

5157 error_text 

5158 if _is_host_free_notification_error(error_text) 

5159 else ( 

5160 "Failed to test notification service. Check server " 

5161 "logs for details." 

5162 ) 

5163 ), 

5164 } 

5165 

5166 except Exception: 

5167 logger.exception("Error testing notification URL") 

5168 return JSONResponse( 

5169 { 

5170 "success": False, 

5171 "error": "Failed to test notification service. Check logs for details.", 

5172 }, 

5173 status_code=500, 

5174 ) 

5175 

5176 

5177# ============================================================================= 

5178# Catch-all {key} routes — MUST be last so they don't shadow specific routes 

5179# ============================================================================= 

5180 

5181 

5182@router.get("/api/{key}") 

5183def api_get_db_setting( 

5184 request: Request, 

5185 key, 

5186 username: Annotated[str, Depends(require_auth)], 

5187): 

5188 """Get a specific setting by key from DB, falling back to defaults. 

5189 

5190 Secret values (API keys, passwords, OAuth tokens) are redacted with the 

5191 '[REDACTED]' sentinel — main does this (DataSanitizer.redact_value) so a 

5192 single authenticated GET can't exfiltrate a plaintext credential. Safe to 

5193 round-trip: the save path treats the sentinel as a no-op, so re-saving a 

5194 redacted value never overwrites the stored credential. 

5195 """ 

5196 try: 

5197 with get_user_db_session(username) as db_session: 

5198 settings_manager = get_settings_manager(db_session, username) 

5199 

5200 db_setting = ( 

5201 db_session.query(Setting).filter(Setting.key == key).first() 

5202 ) 

5203 

5204 if db_setting: 

5205 from ...security.data_sanitizer import DataSanitizer 

5206 

5207 # Overlay any LDR_* env-var override BEFORE redaction, so a 

5208 # redacted secret still reflects the effective (env) value 

5209 # rather than the stale DB row — same order main used 

5210 # (_shape_single_effective_metadata, then redact once). 

5211 effective_value, effective_editable = _apply_env_override( 

5212 settings_manager, 

5213 key, 

5214 db_setting.value, 

5215 db_setting.editable, 

5216 ) 

5217 

5218 value = DataSanitizer.redact_value( 

5219 db_setting.key, db_setting.ui_element, effective_value 

5220 ) 

5221 setting_data = { 

5222 "key": db_setting.key, 

5223 "value": value, 

5224 "type": db_setting.type 

5225 if isinstance(db_setting.type, str) 

5226 else db_setting.type.value, 

5227 "name": db_setting.name, 

5228 "description": db_setting.description, 

5229 "category": db_setting.category, 

5230 "ui_element": db_setting.ui_element, 

5231 "options": db_setting.options, 

5232 "min_value": db_setting.min_value, 

5233 "max_value": db_setting.max_value, 

5234 "step": db_setting.step, 

5235 "visible": db_setting.visible, 

5236 "editable": effective_editable, 

5237 } 

5238 # Operator-gate the "unprotected" egress escape hatch and 

5239 # the "filesystem" PDF-storage option out of their 

5240 # respective options lists unless explicitly enabled, and 

5241 # normalise egress-scope's displayed value (#5148 / 

5242 # 87537d9ec / fb49985aa). 

5243 return _shape_pdf_storage_mode_setting( 

5244 key, _shape_egress_scope_setting(key, setting_data) 

5245 ) 

5246 

5247 default_meta = settings_manager.default_settings.get(key) 

5248 if default_meta: 

5249 from ...security.data_sanitizer import DataSanitizer 

5250 

5251 # Same env-var overlay as the DB branch above — a default- 

5252 # only key (no DB row yet) can still be pinned via LDR_*. 

5253 effective_value, effective_editable = _apply_env_override( 

5254 settings_manager, 

5255 key, 

5256 default_meta.get("value"), 

5257 default_meta.get("editable", True), 

5258 ) 

5259 

5260 default_value = DataSanitizer.redact_value( 

5261 key, 

5262 default_meta.get("ui_element", "text"), 

5263 effective_value, 

5264 ) 

5265 setting_data = { 

5266 "key": key, 

5267 "value": default_value, 

5268 "type": default_meta.get("type", "APP"), 

5269 "name": default_meta.get("name", key), 

5270 "description": default_meta.get("description"), 

5271 "category": default_meta.get("category"), 

5272 "ui_element": default_meta.get("ui_element", "text"), 

5273 "options": default_meta.get("options"), 

5274 "min_value": default_meta.get("min_value"), 

5275 "max_value": default_meta.get("max_value"), 

5276 "step": default_meta.get("step"), 

5277 "visible": default_meta.get("visible", True), 

5278 "editable": effective_editable, 

5279 } 

5280 return _shape_pdf_storage_mode_setting( 

5281 key, _shape_egress_scope_setting(key, setting_data) 

5282 ) 

5283 

5284 return JSONResponse( 

5285 {"error": f"Setting not found: {key}"}, status_code=404 

5286 ) 

5287 except SettingsValueError as error: 

5288 return _settings_value_error_response("setting", error) 

5289 except Exception: 

5290 logger.exception("Error getting setting {}", _log_value_preview(key)) 

5291 return JSONResponse( 

5292 {"error": "Failed to retrieve settings"}, status_code=500 

5293 ) 

5294 

5295 

5296@router.put("/api/{key}") 

5297@settings_limit 

5298async def api_update_setting( 

5299 request: Request, 

5300 key, 

5301 username: Annotated[str, Depends(require_auth)], 

5302): 

5303 """Update a setting""" 

5304 data, body_error = await read_json_dict( 

5305 request, "simple", "No data provided" 

5306 ) 

5307 if body_error is not None: 

5308 return body_error 

5309 return await run_db_sync(_api_update_setting_sync, data, key, username) 

5310 

5311 

5312def _api_update_setting_sync(data, key, username): 

5313 try: 

5314 # Key PRESENCE, not truthiness. An *absent* "value" is still a 400, 

5315 # but an explicit JSON null must stay distinguishable from it: 

5316 # embeddings.openai.chunk_size is the one registered setting whose 

5317 # default IS null ("use the provider default"), and the read side 

5318 # already supports it (#5963). 

5319 if "value" not in data: 

5320 return JSONResponse({"error": "No value provided"}, status_code=400) 

5321 value = data["value"] 

5322 is_openai_chunk_size = key == "embeddings.openai.chunk_size" 

5323 

5324 with get_user_db_session(username) as db_session: 

5325 # Environment-locked settings (LDR_* env var override) are 

5326 # rejected up front, before any DB lookup. Ported from Flask's 

5327 # api_update_setting early guard (web/routes/settings_routes.py). 

5328 # Without this, the write is still correctly blocked further 

5329 # down by set_setting()/create_or_update_setting()'s own 

5330 # _is_environment_locked() check — but that failure was 

5331 # indistinguishable from any other and fell through to a 

5332 # generic 500 "Failed to update setting {key}" instead of the 

5333 # 403 naming the lock. Lost diagnostic + wrong status code, not 

5334 # a data-exposure bug (main's contract restored here). 

5335 settings_manager = get_settings_manager(db_session, username) 

5336 if is_operator_only_setting(key): 

5337 return JSONResponse( 

5338 {"error": f"Setting {key} is operator-only"}, 

5339 status_code=403, 

5340 ) 

5341 if settings_manager.settings_locked: 

5342 return JSONResponse( 

5343 {"error": "Settings are locked"}, status_code=403 

5344 ) 

5345 if settings_manager._is_environment_locked( 

5346 key, "api_update_setting" 

5347 ): 

5348 return JSONResponse( 

5349 {"error": f"Setting {key} is environment-locked"}, 

5350 status_code=403, 

5351 ) 

5352 

5353 # embeddings.openai.chunk_size is a whole-number batch size. The 

5354 # generic numeric validation below only checks type + min/max, so 

5355 # a boolean (coerced to 1/0) or a non-integer float (5.7) would 

5356 # silently persist and then fail inside the embedding provider at 

5357 # request time. Validate ONCE, up front, against the REGISTERED 

5358 # metadata — before the db_setting lookup, so the create path 

5359 # (row missing after a DELETE) is guarded too, and so a drifted 

5360 # row ui_element cannot change how the value is coerced (#5979). 

5361 # A null is allowed through unvalidated: it is this setting's 

5362 # registered default (#5963). 

5363 if is_openai_chunk_size: 

5364 registered_metadata = settings_manager.default_settings[key] 

5365 if value is not None: 

5366 raw_value = value 

5367 value = coerce_setting_for_write( 

5368 key=key, 

5369 value=value, 

5370 ui_element=str(registered_metadata["ui_element"]), 

5371 ) 

5372 if ( 

5373 isinstance(raw_value, bool) 

5374 or not isinstance(value, (int, float)) 

5375 or (isinstance(value, float) and not value.is_integer()) 

5376 or value < registered_metadata["min_value"] 

5377 ): 

5378 logger.warning( 

5379 f"Validation failed for setting {key}: " 

5380 "value must be a whole number at or above " 

5381 f"{registered_metadata['min_value']}" 

5382 ) 

5383 return JSONResponse( 

5384 {"error": f"Invalid value for setting {key}"}, 

5385 status_code=400, 

5386 ) 

5387 value = int(value) 

5388 elif value is None: 

5389 # Every other key keeps the pre-#5174 contract: an explicit 

5390 # null is rejected exactly like an absent one. 

5391 return JSONResponse( 

5392 {"error": "No value provided"}, status_code=400 

5393 ) 

5394 

5395 db_setting = ( 

5396 db_session.query(Setting).filter(Setting.key == key).first() 

5397 ) 

5398 

5399 if db_setting: 

5400 if not db_setting.editable: 

5401 return JSONResponse( 

5402 {"error": f"Setting {key} is not editable"}, 

5403 status_code=403, 

5404 ) 

5405 

5406 # See ``_coerced_for_guard``: the sentinel guards must see a 

5407 # JSON-string-encoded container coerced to its real 

5408 # dict/list shape, or a masked container submitted in that 

5409 # form (a supported shape for PUT .../<key> with 

5410 # ``{"value": "<json string>"}``) sails past the container 

5411 # arm entirely. 

5412 _guard_value = _coerced_for_guard( 

5413 db_setting.key, db_setting.ui_element, value 

5414 ) 

5415 

5416 # The redaction sentinel is a no-op for any sensitive 

5417 # setting, and an empty string is a no-op only for password 

5418 # inputs (which render blank, so an untouched field must not 

5419 # wipe the secret). Companion to the same guard in 

5420 # save_all_settings/save_settings. The idempotent 200 keeps 

5421 # client-side save indicators from erroring (#5960). 

5422 if _is_secret_empty_noop( 

5423 key, db_setting.ui_element, _guard_value, db_setting.value 

5424 ): 

5425 logger.debug( 

5426 f"Skipping sensitive value write for {key} via " 

5427 "api_update_setting (no-op)" 

5428 ) 

5429 return { 

5430 "message": ( 

5431 f"Setting {key} unchanged " 

5432 "(sensitive value not overwritten)" 

5433 ) 

5434 } 

5435 

5436 # An embedded sentinel is a corrupted edit rather than an 

5437 # untouched round-trip, so it is a hard error here (400) 

5438 # while the exact-match case above stays an idempotent 200 

5439 # no-op (#5947). 

5440 if _embeds_redaction_sentinel( 

5441 key, db_setting.ui_element, _guard_value, db_setting.value 

5442 ): 

5443 logger.warning( 

5444 "Rejected redaction-sentinel value for {!r} via " 

5445 "api_update_setting (user={!r})", 

5446 key, 

5447 username, 

5448 ) 

5449 return JSONResponse( 

5450 { 

5451 "error": _container_rejection_error( 

5452 db_setting.ui_element, _guard_value 

5453 ) 

5454 }, 

5455 status_code=400, 

5456 ) 

5457 

5458 if value is not None and not is_openai_chunk_size: 

5459 # Persist the MERGED value when a masked container's 

5460 # sentinel leaves resolved against the stored row (see 

5461 # ``_reconciled_container_value_for_write``); an 

5462 # ordinary write is returned unchanged. 

5463 value, _is_reconciled_container = ( 

5464 _reconciled_container_value_for_write( 

5465 value, _guard_value, db_setting.value 

5466 ) 

5467 ) 

5468 

5469 # Coerce to the correct Python type before saving (e.g. 

5470 # string "5" -> int 5 for a number setting). chunk_size 

5471 # was already coerced and bounds-checked above against 

5472 # the registry, which is authoritative for a setting's 

5473 # type; the row is only data. A reconciled secret- 

5474 # bearing container is persisted exactly as merged 

5475 # instead -- coercing it through a str-typed ui_element 

5476 # would flatten it to its Python repr and disclose a 

5477 # just-restored secret as a plain string (#6201). 

5478 if not _is_reconciled_container: 

5479 # Infer from the raw JSON type before text coercion. 

5480 # Otherwise a legacy text row turns False into the 

5481 # truthy string "False" before set_setting can repair 

5482 # its checkbox metadata. 

5483 effective_ui_element = _infer_ui_element( 

5484 value, db_setting.ui_element 

5485 ) 

5486 value = coerce_setting_for_write( 

5487 key=db_setting.key, 

5488 value=value, 

5489 ui_element=effective_ui_element, 

5490 ) 

5491 

5492 # A reconciled secret-bearing container skips 

5493 # ``validate_setting`` the same way it skips coercion 

5494 # above. The load-bearing reason is TYPE REJECTION, not 

5495 # logging: ``validate_setting`` runs the value through 

5496 # ``get_typed_setting_value`` for the row's DECLARED 

5497 # ui_element, so a dict/list on a "number"/"range" row 

5498 # comes back "Value must be a number" and the merged 

5499 # edit -- with the secret just restored into it -- is 

5500 # lost. Its other former reason (that same path logged 

5501 # the raw, secret-bearing value at WARNING) is closed 

5502 # at the converter itself as of #6201 round 6, so it no 

5503 # longer justifies anything on its own; 

5504 # ``_coerced_for_guard``'s comment already says this. 

5505 # ACCEPTED LOSS: skipping validation also skips the 

5506 # options-membership / min-max / checkbox-type checks, 

5507 # so e.g. a "select" row that already holds a container 

5508 # accepts a merged container with no options check. 

5509 # Bounded to rows already holding a container under a 

5510 # scalar ui_element -- a user-constructed shape -- and 

5511 # the merge itself already resolved every leaf against 

5512 # the stored row (#6201). 

5513 is_valid, error_message = ( 

5514 (True, None) 

5515 if _is_reconciled_container 

5516 else validate_setting(db_setting, value) 

5517 ) 

5518 if not is_valid: 

5519 logger.warning( 

5520 f"Validation failed for setting {key}: " 

5521 f"{error_message}" 

5522 ) 

5523 return JSONResponse( 

5524 {"error": f"Invalid value for setting {key}"}, 

5525 status_code=400, 

5526 ) 

5527 # set_setting() re-infers the type of a text row from 

5528 # the value, so a non-finite JSON number would be 

5529 # stored as an invalid number row. Check the row it 

5530 # will store. 

5531 rejection = ( 

5532 None 

5533 if _is_reconciled_container 

5534 else _strict_read_rejection( 

5535 key, 

5536 value, 

5537 _infer_ui_element(value, db_setting.ui_element), 

5538 ) 

5539 ) 

5540 if rejection is not None: 

5541 logger.warning( 

5542 "Validation failed for setting {}: {}", 

5543 _log_value_preview(key), 

5544 rejection, 

5545 ) 

5546 return JSONResponse( 

5547 { 

5548 "error": ( 

5549 f"Invalid value for setting {key}: " 

5550 f"{rejection}" 

5551 ) 

5552 }, 

5553 status_code=400, 

5554 ) 

5555 

5556 # Cross-field egress-policy validation. The full-form saves run 

5557 # these guards unconditionally for every key; this single-key 

5558 # PUT previously restricted the call to a 4-key allowlist 

5559 # (policy.egress_scope / search.tool / 

5560 # llm.allowed_local_hostnames / policy.trusted_search_engines), 

5561 # which let an SSRF-shaped value through on any key the 

5562 # allowlist omitted — notably 

5563 # search.engine.web.searxng.default_params.instance_url. Run 

5564 # it for every key, matching main 

5565 # (web/routes/settings_routes.py, 87537d9ec). 

5566 _all_db_settings = { 

5567 s.key: s for s in db_session.query(Setting).all() 

5568 } 

5569 _err = first_egress_validation_error( 

5570 {key: value}, _all_db_settings 

5571 ) 

5572 if _err is not None: 

5573 logger.bind(policy_audit=True).warning( 

5574 "egress-policy setting rejected at api_update_setting", 

5575 key=key, 

5576 reason=_err.get("error"), 

5577 ) 

5578 return JSONResponse( 

5579 {"error": _err["error"]}, status_code=400 

5580 ) 

5581 

5582 success = set_setting(key, value, db_session=db_session) 

5583 if success: 

5584 invalidate_settings_caches(username) 

5585 # A document_scheduler.* toggle (e.g. 

5586 # sweep_library_collections or generate_rag) must take 

5587 # effect without a re-login. 

5588 reschedule_document_jobs_if_needed(username, [key]) 

5589 reschedule_zotero_jobs_if_needed(username, [key]) 

5590 response_data: dict[str, Any] = { 

5591 "message": f"Setting {key} updated successfully" 

5592 } 

5593 

5594 if key in WARNING_AFFECTING_KEYS: 

5595 warnings = calculate_warnings(username=username) 

5596 response_data["warnings"] = warnings 

5597 # Never the value: it may carry a secret (see #6201). 

5598 logger.debug( 

5599 f"Setting {key} changed, calculated {len(warnings)} warnings" 

5600 ) 

5601 

5602 return response_data 

5603 return JSONResponse( 

5604 {"error": f"Failed to update setting {key}"}, 

5605 status_code=500, 

5606 ) 

5607 # Registered settings may be absent after DELETE. Recreate them 

5608 # from trusted default metadata (type/options/min_value/ 

5609 # max_value/step/ui_element/editable) instead of the 

5610 # caller-supplied request body, so callers cannot replace those 

5611 # by racing a delete+recreate. Building setting_dict from 

5612 # data[...] alone (the prior behaviour here) let a DELETE+PUT 

5613 # round trip silently drop min/max bounds and degrade a 

5614 # "number" setting to "text", after which even the 

5615 # properly-validating bulk path accepted out-of-range values. 

5616 # Genuinely custom keys (no registered default) keep the 

5617 # namespace-prefix contract below. Ported from main 

5618 # (web/routes/settings_routes.py, 87537d9ec). 

5619 default_meta = settings_manager.default_settings.get(key) 

5620 if default_meta is not None: 

5621 if not default_meta.get("editable", True): 5621 ↛ 5622line 5621 didn't jump to line 5622 because the condition on line 5621 was never true

5622 return JSONResponse( 

5623 {"error": f"Setting {key} is not editable"}, 

5624 status_code=403, 

5625 ) 

5626 default_ui = str(default_meta.get("ui_element", "text")) 

5627 if value is not None: 

5628 value = coerce_setting_for_write( 

5629 key=key, value=value, ui_element=default_ui 

5630 ) 

5631 _validation_setting = SimpleNamespace( 

5632 key=key, 

5633 ui_element=default_ui, 

5634 options=default_meta.get("options"), 

5635 min_value=default_meta.get("min_value"), 

5636 max_value=default_meta.get("max_value"), 

5637 ) 

5638 is_valid, error_message = validate_setting( 

5639 _validation_setting, value 

5640 ) 

5641 if not is_valid: 

5642 logger.warning( 

5643 "Validation failed for recreated setting {}: {}", 

5644 key, 

5645 error_message, 

5646 ) 

5647 return JSONResponse( 

5648 {"error": f"Invalid value for setting {key}"}, 

5649 status_code=400, 

5650 ) 

5651 setting_dict = dict(default_meta) 

5652 setting_dict.update({"key": key, "value": value}) 

5653 default_type = setting_dict.get("type") 

5654 if ( 5654 ↛ 5731line 5654 didn't jump to line 5731 because the condition on line 5654 was always true

5655 isinstance(default_type, str) 

5656 and default_type in SettingType.__members__ 

5657 ): 

5658 setting_dict["type"] = SettingType[default_type] 

5659 else: 

5660 # Namespace validation: reject new keys outside allowed 

5661 # prefixes. 

5662 if not _is_allowed_new_setting_key(key): 

5663 logger.warning( 

5664 "Security: Rejected setting outside allowed " 

5665 "namespaces: {} (user={!r})", 

5666 _log_value_preview(key), 

5667 username, 

5668 ) 

5669 return JSONResponse( 

5670 {"error": _new_key_rejection_reason(key)}, 

5671 status_code=400, 

5672 ) 

5673 

5674 setting_dict = { 

5675 "key": key, 

5676 "value": value, 

5677 "name": key.split(".")[-1].replace("_", " ").title(), 

5678 "description": f"Setting for {key}", 

5679 } 

5680 

5681 # Add additional metadata if provided. 

5682 # 'visible' and 'editable' are system-controlled — not 

5683 # accepted from callers. 

5684 for field in [ 

5685 "type", 

5686 "name", 

5687 "description", 

5688 "category", 

5689 "ui_element", 

5690 "options", 

5691 "min_value", 

5692 "max_value", 

5693 "step", 

5694 ]: 

5695 if field in data: 

5696 setting_dict[field] = data[field] 

5697 

5698 # New keys have no registered metadata. Preserve JSON scalar 

5699 # types so a false API toggle is stored as a checkbox, not a 

5700 # text row whose "False" value remains truthy on reads. 

5701 if "ui_element" not in setting_dict: 

5702 setting_dict["ui_element"] = _infer_ui_element(value) 

5703 

5704 # The caller may choose the type. An unsupported type, or a 

5705 # value that type cannot convert, would make every 

5706 # effective settings read of this account fail, and only a 

5707 # DELETE could undo it. 

5708 rejection = _strict_read_rejection( 

5709 key, value, setting_dict["ui_element"] 

5710 ) 

5711 if rejection is not None: 

5712 logger.warning( 

5713 "Rejected new setting {}: {}", 

5714 _log_value_preview(key), 

5715 rejection, 

5716 ) 

5717 return JSONResponse( 

5718 { 

5719 "error": ( 

5720 f"Invalid value for setting {key}: {rejection}" 

5721 ) 

5722 }, 

5723 status_code=400, 

5724 ) 

5725 

5726 # Creation has no prior value, so the sentinel cannot mean 

5727 # "keep the stored secret" the way it does on the update path — 

5728 # every occurrence of it, exact match included, is a corrupted 

5729 # client value that would be stored verbatim as the credential 

5730 # (#5947). 

5731 _create_ui = setting_dict.get("ui_element") 

5732 # This is the one create branch that never coerces before its 

5733 # guard: the REGISTERED-default branch above already coerces 

5734 # `value` in place before reaching here, but a genuinely new 

5735 # (unregistered) key has no coercion step of its own. Without 

5736 # this, a masked container submitted as its JSON-string 

5737 # encoding -- caught for a registered key -- would sail past 

5738 # the container arm here the same way it did on the update 

5739 # paths (see ``_coerced_for_guard``). Only takes effect when 

5740 # the caller explicitly provided ``ui_element`` in the request 

5741 # body (custom keys have none registered to infer it from). 

5742 _create_guard_value = _coerced_for_guard( 

5743 key, 

5744 _create_ui if isinstance(_create_ui, str) else None, 

5745 value, 

5746 ) 

5747 if _embeds_sentinel_on_create(key, _create_ui, _create_guard_value): 

5748 logger.warning( 

5749 "Rejected redaction-sentinel value for {} via " 

5750 "api_update_setting create (user={!r})", 

5751 _log_value_preview(key), 

5752 username, 

5753 ) 

5754 return JSONResponse( 

5755 { 

5756 "error": _container_rejection_error( 

5757 _create_ui if isinstance(_create_ui, str) else None, 

5758 _create_guard_value, 

5759 ) 

5760 }, 

5761 status_code=400, 

5762 ) 

5763 

5764 # Apply egress validation to creation as well as updates. 

5765 # Otherwise DELETE followed by PUT recreates a governed key with 

5766 # no guards: `llm.allowed_local_hostnames` is `editable`, so the 

5767 # delete endpoint accepts it, and `llm.` is an allowed prefix, so 

5768 # re-creation passes the namespace check. A public host smuggled 

5769 # into that key is then read into EgressContext.local_hostnames 

5770 # and classified LOCAL, laundering it past private_only and 

5771 # require_local_llm. 

5772 # 

5773 # Ported from main (web/routes/settings_routes.py), which runs 

5774 # this at FOUR sites; this port had three. The update branch ~40 

5775 # lines above already carries the guard and its comment claims 

5776 # the hole is closed — it was closed for update only. 

5777 _all_db_settings = { 

5778 s.key: s for s in db_session.query(Setting).all() 

5779 } 

5780 _err = first_egress_validation_error({key: value}, _all_db_settings) 

5781 if _err is not None: 

5782 logger.bind(policy_audit=True).warning( 

5783 "egress-policy setting rejected at api_update_setting create", 

5784 key=key, 

5785 reason=_err.get("error"), 

5786 ) 

5787 return JSONResponse({"error": _err["error"]}, status_code=400) 

5788 

5789 db_setting = create_or_update_setting( 

5790 setting_dict, db_session=db_session 

5791 ) 

5792 

5793 if db_setting: 

5794 invalidate_settings_caches(username) 

5795 reschedule_document_jobs_if_needed(username, [key]) 

5796 reschedule_zotero_jobs_if_needed(username, [key]) 

5797 from ...security.data_sanitizer import DataSanitizer 

5798 

5799 return JSONResponse( 

5800 { 

5801 "message": f"Setting {key} created successfully", 

5802 "setting": { 

5803 "key": db_setting.key, 

5804 # Don't echo a freshly-created password back in 

5805 # plaintext — redact like every other settings 

5806 # response that ships to the browser. 

5807 # 

5808 # Route this through ``redact_value`` rather 

5809 # than ``is_sensitive_setting`` (#6201, R4). 

5810 # ``is_sensitive_setting`` is a NAME predicate 

5811 # with no type guard; ``redact_value`` adds the 

5812 # guard every read path already applies, so a 

5813 # checkbox/number row whose name merely matches 

5814 # the broadened snake_case arm (e.g. 

5815 # ``llm.x_supports_api_key``) keeps its typed 

5816 # value instead of echoing the string 

5817 # "[REDACTED]" that no GET of the same row 

5818 # would ever return. It also masks nested 

5819 # string leaves inside a created container, 

5820 # which the bare sentinel substitution could 

5821 # not express at all. 

5822 "value": DataSanitizer.redact_value( 

5823 key, db_setting.ui_element, db_setting.value 

5824 ), 

5825 "type": db_setting.type.value, 

5826 "name": db_setting.name, 

5827 }, 

5828 }, 

5829 status_code=201, 

5830 ) 

5831 return JSONResponse( 

5832 {"error": f"Failed to create setting {key}"}, 

5833 status_code=500, 

5834 ) 

5835 except Exception: 

5836 logger.exception("Error updating setting {}", _log_value_preview(key)) 

5837 return JSONResponse( 

5838 {"error": "Failed to update setting"}, status_code=500 

5839 ) 

5840 

5841 

5842@router.delete("/api/{key}") 

5843@settings_limit 

5844def api_delete_setting( 

5845 request: Request, 

5846 key, 

5847 username: Annotated[str, Depends(require_auth)], 

5848): 

5849 """Delete a setting""" 

5850 try: 

5851 with get_user_db_session(username) as db_session: 

5852 settings_manager = get_settings_manager(db_session, username) 

5853 if is_operator_only_setting(key): 

5854 return JSONResponse( 

5855 {"error": f"Setting {key} is operator-only"}, 

5856 status_code=403, 

5857 ) 

5858 

5859 # Settings lock (app.lock_settings) -- ported from main's 

5860 # api_delete_setting (#5659, "enforce the settings lock on delete, 

5861 # import and reset"). SettingsManager already refuses when 

5862 # locked, so the write cannot happen either way; this repeats 

5863 # the check at the route so a locked instance answers 403 

5864 # rather than 200 with nothing written, which is what main's 

5865 # own comment gives as the reason. Without it the merge that 

5866 # brought #5659 in would have silently dropped the fix, since 

5867 # it landed in a Flask file this migration deletes. 

5868 if settings_manager.settings_locked: 

5869 return JSONResponse( 

5870 {"error": "Settings are locked"}, 

5871 status_code=403, 

5872 ) 

5873 

5874 # Environment-locked settings (LDR_* env var override) are 

5875 # rejected up front — see the matching guard in 

5876 # _api_update_setting_sync above for the full rationale. Checked 

5877 # before the existence lookup so an env-locked key still reports 

5878 # 403 (not 404) even if a client races a delete against a key 

5879 # that hasn't been materialized into a DB row yet, matching 

5880 # main's contract (web/routes/settings_routes.py). 

5881 if settings_manager._is_environment_locked( 

5882 key, "api_delete_setting" 

5883 ): 

5884 return JSONResponse( 

5885 {"error": f"Setting {key} is environment-locked"}, 

5886 status_code=403, 

5887 ) 

5888 

5889 db_setting = ( 

5890 db_session.query(Setting).filter(Setting.key == key).first() 

5891 ) 

5892 if not db_setting: 

5893 return JSONResponse( 

5894 {"error": f"Setting not found: {key}"}, status_code=404 

5895 ) 

5896 

5897 if not db_setting.editable: 

5898 return JSONResponse( 

5899 {"error": f"Setting {key} is not editable"}, 

5900 status_code=403, 

5901 ) 

5902 

5903 success = settings_manager.delete_setting(key) 

5904 if success: 

5905 invalidate_settings_caches(username) 

5906 reschedule_document_jobs_if_needed(username, [key]) 

5907 reschedule_zotero_jobs_if_needed(username, [key]) 

5908 return {"message": f"Setting {key} deleted successfully"} 

5909 return JSONResponse( 

5910 {"error": f"Failed to delete setting {key}"}, status_code=500 

5911 ) 

5912 except Exception: 

5913 logger.exception("Error deleting setting {}", _log_value_preview(key)) 

5914 return JSONResponse( 

5915 {"error": "Failed to delete setting"}, status_code=500 

5916 )