loki-mode 7.81.0 → 7.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -164,6 +164,21 @@ class _RateLimiter:
164
164
  _control_limiter = _RateLimiter(max_calls=10, window_seconds=60)
165
165
  _read_limiter = _RateLimiter(max_calls=60, window_seconds=60)
166
166
 
167
+
168
+ def _rate_key(base: str, request: Optional[Request]) -> str:
169
+ """Build a per-client rate-limit key.
170
+
171
+ Static literal keys make the read limiter a single global cap: one client
172
+ can exhaust the window for everyone else. Mirror the /ws path, which keys
173
+ by client.host, so the cap is enforced per client. Falls back to the bare
174
+ base key when the client address is unavailable (preserving prior
175
+ behaviour rather than failing open).
176
+ """
177
+ host = None
178
+ if request is not None and request.client is not None:
179
+ host = request.client.host
180
+ return f"{base}_{host}" if host else base
181
+
167
182
  # Set up logging
168
183
  logger = logging.getLogger(__name__)
169
184
 
@@ -1402,7 +1417,7 @@ async def get_status() -> StatusResponse:
1402
1417
 
1403
1418
 
1404
1419
  # Project endpoints
1405
- @app.get("/api/projects", response_model=list[ProjectResponse])
1420
+ @app.get("/api/projects", response_model=list[ProjectResponse], dependencies=[Depends(auth.require_scope("read"))])
1406
1421
  async def list_projects(
1407
1422
  status: Optional[str] = Query(None),
1408
1423
  limit: int = Query(default=50, ge=1, le=500),
@@ -1521,7 +1536,7 @@ async def create_project(
1521
1536
  )
1522
1537
 
1523
1538
 
1524
- @app.get("/api/projects/{project_id}", response_model=ProjectResponse)
1539
+ @app.get("/api/projects/{project_id}", response_model=ProjectResponse, dependencies=[Depends(auth.require_scope("read"))])
1525
1540
  async def get_project(
1526
1541
  project_id: int,
1527
1542
  db: AsyncSession = Depends(get_db),
@@ -1736,7 +1751,7 @@ def _apply_task_section(task: dict, section: str, lines: list):
1736
1751
 
1737
1752
 
1738
1753
  # Task endpoints - reads from .loki/dashboard-state.json
1739
- @app.get("/api/tasks")
1754
+ @app.get("/api/tasks", dependencies=[Depends(auth.require_scope("read"))])
1740
1755
  async def list_tasks(
1741
1756
  project_id: Optional[int] = Query(None),
1742
1757
  status: Optional[str] = Query(None),
@@ -1959,7 +1974,7 @@ async def create_task(
1959
1974
  return _task_response_from_db(db_task)
1960
1975
 
1961
1976
 
1962
- @app.get("/api/tasks/{task_id}", response_model=TaskResponse)
1977
+ @app.get("/api/tasks/{task_id}", response_model=TaskResponse, dependencies=[Depends(auth.require_scope("read"))])
1963
1978
  async def get_task(
1964
1979
  task_id: int,
1965
1980
  db: AsyncSession = Depends(get_db),
@@ -2282,7 +2297,7 @@ class HealthResponse(BaseModel):
2282
2297
  checks: dict
2283
2298
 
2284
2299
 
2285
- @app.get("/api/registry/projects", response_model=list[RegisteredProjectResponse])
2300
+ @app.get("/api/registry/projects", response_model=list[RegisteredProjectResponse], dependencies=[Depends(auth.require_scope("read"))])
2286
2301
  async def list_registered_projects(include_inactive: bool = False):
2287
2302
  """List all registered projects."""
2288
2303
  projects = registry.list_projects(include_inactive=include_inactive)
@@ -2303,7 +2318,7 @@ async def register_project(request: RegisterProjectRequest):
2303
2318
  raise HTTPException(status_code=400, detail=str(e))
2304
2319
 
2305
2320
 
2306
- @app.get("/api/registry/projects/{identifier}", response_model=RegisteredProjectResponse)
2321
+ @app.get("/api/registry/projects/{identifier}", response_model=RegisteredProjectResponse, dependencies=[Depends(auth.require_scope("read"))])
2307
2322
  async def get_registered_project(identifier: str):
2308
2323
  """Get a registered project by ID, path, or alias."""
2309
2324
  project = registry.get_project(identifier)
@@ -2326,7 +2341,7 @@ async def unregister_project(identifier: str, request: Request):
2326
2341
  )
2327
2342
 
2328
2343
 
2329
- @app.get("/api/registry/projects/{identifier}/health", response_model=HealthResponse)
2344
+ @app.get("/api/registry/projects/{identifier}/health", response_model=HealthResponse, dependencies=[Depends(auth.require_scope("read"))])
2330
2345
  async def get_project_health(identifier: str):
2331
2346
  """Check the health of a registered project."""
2332
2347
  health = registry.check_project_health(identifier)
@@ -2344,7 +2359,7 @@ async def update_project_access(identifier: str):
2344
2359
  return project
2345
2360
 
2346
2361
 
2347
- @app.get("/api/registry/discover", response_model=list[DiscoverResponse])
2362
+ @app.get("/api/registry/discover", response_model=list[DiscoverResponse], dependencies=[Depends(auth.require_scope("read"))])
2348
2363
  async def discover_projects(max_depth: int = Query(default=3, ge=1, le=10)):
2349
2364
  """Discover projects with .loki directories."""
2350
2365
  max_depth = min(max_depth, 10)
@@ -2374,7 +2389,7 @@ async def sync_registry():
2374
2389
  }
2375
2390
 
2376
2391
 
2377
- @app.get("/api/registry/tasks")
2392
+ @app.get("/api/registry/tasks", dependencies=[Depends(auth.require_scope("read"))])
2378
2393
  async def get_cross_project_tasks(project_ids: Optional[str] = None):
2379
2394
  """Get tasks from multiple projects for unified view."""
2380
2395
  ids = project_ids.split(",") if project_ids else None
@@ -2382,7 +2397,7 @@ async def get_cross_project_tasks(project_ids: Optional[str] = None):
2382
2397
  return tasks
2383
2398
 
2384
2399
 
2385
- @app.get("/api/registry/learnings")
2400
+ @app.get("/api/registry/learnings", dependencies=[Depends(auth.require_scope("read"))])
2386
2401
  async def get_cross_project_learnings():
2387
2402
  """Get learnings from the global learnings database."""
2388
2403
  learnings = registry.get_cross_project_learnings()
@@ -2551,7 +2566,7 @@ async def set_focus(request: FocusRequest):
2551
2566
  return {"project_dir": _active_project_dir, "loki_dir": str(_get_loki_dir())}
2552
2567
 
2553
2568
 
2554
- @app.get("/api/focus")
2569
+ @app.get("/api/focus", dependencies=[Depends(auth.require_scope("read"))])
2555
2570
  async def get_focus():
2556
2571
  """Get the currently focused project directory."""
2557
2572
  return {
@@ -2870,7 +2885,7 @@ async def set_session_model(request: SessionModelRequest):
2870
2885
  return {"model": model, "effective": effective, "clamped": clamped}
2871
2886
 
2872
2887
 
2873
- @app.get("/api/running-projects")
2888
+ @app.get("/api/running-projects", dependencies=[Depends(auth.require_scope("read"))])
2874
2889
  async def list_running_projects():
2875
2890
  """List registered projects enriched with live status for the dashboard
2876
2891
  project switcher (v7.7.29 multi-project support).
@@ -2961,10 +2976,13 @@ class StartBuildRequest(BaseModel):
2961
2976
  def validate_provider(self) -> None:
2962
2977
  """Validate provider is from the supported list.
2963
2978
 
2964
- Mirrors dashboard/control.py StartRequest.validate_provider so the
2965
- dashboard and the standalone control app accept the same set.
2979
+ Mirrors providers/loader.sh SUPPORTED_PROVIDERS so the dashboard
2980
+ rejects providers the runtime rejects. gemini was deprecated in
2981
+ v7.5.18 (runtime removed); accepting it here let the dashboard
2982
+ report a false "Build started" while run.sh killed the child on the
2983
+ deprecation guard.
2966
2984
  """
2967
- allowed = ["claude", "codex", "gemini", "cline", "aider"]
2985
+ allowed = ["claude", "codex", "cline", "aider"]
2968
2986
  if self.provider not in allowed:
2969
2987
  raise ValueError(
2970
2988
  f"Invalid provider: {self.provider}. "
@@ -3138,6 +3156,26 @@ async def start_build(request: Request, body: StartBuildRequest):
3138
3156
  except (OSError, subprocess.SubprocessError) as e:
3139
3157
  raise HTTPException(status_code=500, detail=f"Failed to start build: {e}")
3140
3158
 
3159
+ # Liveness check: a provider that dies on a startup guard (e.g. an
3160
+ # unsupported provider, a missing CLI, or a preflight failure in run.sh)
3161
+ # would otherwise let us report a false "Build started". Poll briefly and
3162
+ # surface an honest error if the child exits immediately.
3163
+ early_exit = None
3164
+ for _ in range(3):
3165
+ await asyncio.sleep(0.1)
3166
+ early_exit = process.poll()
3167
+ if early_exit is not None:
3168
+ break
3169
+ if early_exit is not None and early_exit != 0:
3170
+ raise HTTPException(
3171
+ status_code=500,
3172
+ detail=(
3173
+ f"Build process exited immediately (code {early_exit}). "
3174
+ f"The '{body.provider}' provider or run.sh preflight may have "
3175
+ f"rejected the request."
3176
+ ),
3177
+ )
3178
+
3141
3179
  # Persist provider for status tracking (same as control.py).
3142
3180
  try:
3143
3181
  state_dir = loki_dir / "state"
@@ -3872,7 +3910,7 @@ _COMPLIANCE_TYPES = ("soc2", "iso27001", "gdpr")
3872
3910
 
3873
3911
 
3874
3912
  @app.get("/api/compliance", dependencies=[Depends(auth.require_scope("audit"))])
3875
- def get_compliance_status(report_type: str = Query("soc2", alias="type")):
3913
+ def get_compliance_status(request: Request, report_type: str = Query("soc2", alias="type")):
3876
3914
  """Live compliance status for the active project's agent audit chain.
3877
3915
 
3878
3916
  Auth/tenant scoping: requires the `audit` scope (same gate as the
@@ -3890,7 +3928,7 @@ def get_compliance_status(report_type: str = Query("soc2", alias="type")):
3890
3928
  available:false payload (HTTP 200) rather than masquerading as "no
3891
3929
  compliance".
3892
3930
  """
3893
- if not _read_limiter.check("compliance"):
3931
+ if not _read_limiter.check(_rate_key("compliance", request)):
3894
3932
  raise HTTPException(status_code=429, detail="Rate limit exceeded")
3895
3933
  if report_type not in _COMPLIANCE_TYPES:
3896
3934
  raise HTTPException(
@@ -5339,9 +5377,9 @@ async def get_learning_aggregation():
5339
5377
 
5340
5378
 
5341
5379
  @app.post("/api/learning/aggregate", dependencies=[Depends(auth.require_scope("control"))])
5342
- async def trigger_aggregation():
5380
+ async def trigger_aggregation(request: Request):
5343
5381
  """Aggregate learning signals from events.jsonl into structured metrics."""
5344
- if not _read_limiter.check("learning_aggregate"):
5382
+ if not _read_limiter.check(_rate_key("learning_aggregate", request)):
5345
5383
  raise HTTPException(status_code=429, detail="Rate limit exceeded")
5346
5384
 
5347
5385
  # Reads up to 10 MB of events.jsonl, parses every line, then writes the
@@ -5828,7 +5866,7 @@ def _calculate_model_cost(model: str, input_tokens: int, output_tokens: int) ->
5828
5866
  return round(input_cost + output_cost, 6)
5829
5867
 
5830
5868
 
5831
- @app.get("/api/cost")
5869
+ @app.get("/api/cost", dependencies=[Depends(auth.require_scope("read"))])
5832
5870
  async def get_cost():
5833
5871
  """Get cost visibility data from .loki/metrics/efficiency/ and budget.json.
5834
5872
 
@@ -5955,7 +5993,7 @@ def _compute_cost_snapshot() -> dict:
5955
5993
  }
5956
5994
 
5957
5995
 
5958
- @app.get("/api/budget")
5996
+ @app.get("/api/budget", dependencies=[Depends(auth.require_scope("read"))])
5959
5997
  async def get_budget():
5960
5998
  """Get current budget status from .loki/metrics/budget.json and cost data."""
5961
5999
  loki_dir = _get_loki_dir()
@@ -6011,6 +6049,25 @@ async def get_budget():
6011
6049
  budget_limit_f = _to_float(budget_limit, None) if budget_limit is not None else None
6012
6050
  budget_used_f = _to_float(budget_used, 0.0)
6013
6051
 
6052
+ # current_cost must reflect real live spend, not the static budget.json
6053
+ # field which only updates when run.sh persists it. The same divergence
6054
+ # made the widget show $0 mid-run while /api/cost summed real spend.
6055
+ # Derive from _compute_budget_snapshot (sums live efficiency records,
6056
+ # the single source of truth shared with /api/cost/timeline and the WS
6057
+ # push); keep budget.json's value only as a fallback when no live spend
6058
+ # has been recorded yet.
6059
+ try:
6060
+ snapshot = _compute_budget_snapshot(loki_dir)
6061
+ live_used = snapshot.get("used")
6062
+ if isinstance(live_used, (int, float)) and live_used > 0:
6063
+ budget_used_f = float(live_used)
6064
+ if budget_limit_f is None and snapshot.get("limit") is not None:
6065
+ budget_limit_f = _to_float(snapshot.get("limit"), None)
6066
+ except Exception:
6067
+ # Never let the live computation break the endpoint; fall back to the
6068
+ # static budget.json value already loaded above.
6069
+ pass
6070
+
6014
6071
  remaining = None
6015
6072
  if budget_limit_f is not None:
6016
6073
  remaining = max(0.0, budget_limit_f - budget_used_f)
@@ -6115,7 +6172,7 @@ def _compute_budget_snapshot(loki_dir: _Path) -> dict:
6115
6172
  }
6116
6173
 
6117
6174
 
6118
- @app.get("/api/cost/timeline")
6175
+ @app.get("/api/cost/timeline", dependencies=[Depends(auth.require_scope("read"))])
6119
6176
  async def get_cost_timeline():
6120
6177
  """Cost over time: intra-run per-iteration series + per-run history.
6121
6178
 
@@ -6275,7 +6332,7 @@ def _load_trust_module():
6275
6332
  return None
6276
6333
 
6277
6334
 
6278
- @app.get("/api/trust/trajectory")
6335
+ @app.get("/api/trust/trajectory", dependencies=[Depends(auth.require_scope("read"))])
6279
6336
  async def get_trust_trajectory():
6280
6337
  """Per-project trust trajectory derived from proof-of-run history.
6281
6338
 
@@ -6336,7 +6393,7 @@ _MODEL_PROVIDERS = {
6336
6393
  }
6337
6394
 
6338
6395
 
6339
- @app.get("/api/pricing")
6396
+ @app.get("/api/pricing", dependencies=[Depends(auth.require_scope("read"))])
6340
6397
  async def get_pricing():
6341
6398
  """Get current model pricing. Reads from .loki/pricing.json if available, falls back to static defaults."""
6342
6399
  loki_dir = _get_loki_dir()
@@ -6383,7 +6440,7 @@ async def get_pricing():
6383
6440
  # Completion Council API (v5.25.0)
6384
6441
  # =============================================================================
6385
6442
 
6386
- @app.get("/api/council/state")
6443
+ @app.get("/api/council/state", dependencies=[Depends(auth.require_scope("read"))])
6387
6444
  async def get_council_state():
6388
6445
  """Get current Completion Council state."""
6389
6446
  state_file = _get_loki_dir() / "council" / "state.json"
@@ -6395,7 +6452,7 @@ async def get_council_state():
6395
6452
  return {"enabled": False, "total_votes": 0, "verdicts": []}
6396
6453
 
6397
6454
 
6398
- @app.get("/api/council/verdicts")
6455
+ @app.get("/api/council/verdicts", dependencies=[Depends(auth.require_scope("read"))])
6399
6456
  async def get_council_verdicts(limit: int = Query(default=20, ge=1, le=1000)):
6400
6457
  """Get council vote history (decision log).
6401
6458
 
@@ -6452,7 +6509,7 @@ async def get_council_verdicts(limit: int = Query(default=20, ge=1, le=1000)):
6452
6509
  return await asyncio.to_thread(_collect_verdicts)
6453
6510
 
6454
6511
 
6455
- @app.get("/api/council/convergence")
6512
+ @app.get("/api/council/convergence", dependencies=[Depends(auth.require_scope("read"))])
6456
6513
  async def get_council_convergence():
6457
6514
  """Get convergence tracking data for visualization."""
6458
6515
  convergence_file = _get_loki_dir() / "council" / "convergence.log"
@@ -6474,7 +6531,7 @@ async def get_council_convergence():
6474
6531
  return {"dataPoints": data_points}
6475
6532
 
6476
6533
 
6477
- @app.get("/api/council/report")
6534
+ @app.get("/api/council/report", dependencies=[Depends(auth.require_scope("read"))])
6478
6535
  async def get_council_report():
6479
6536
  """Get the final council completion report."""
6480
6537
  report_file = _get_loki_dir() / "council" / "report.md"
@@ -6494,7 +6551,7 @@ async def force_council_review():
6494
6551
  return {"success": True, "message": "Council review requested"}
6495
6552
 
6496
6553
 
6497
- @app.get("/api/council/transcripts")
6554
+ @app.get("/api/council/transcripts", dependencies=[Depends(auth.require_scope("read"))])
6498
6555
  async def get_council_transcripts(
6499
6556
  limit: int = Query(default=20, ge=1, le=200),
6500
6557
  since: Optional[str] = Query(default=None),
@@ -6571,7 +6628,7 @@ async def get_council_transcripts(
6571
6628
  return response
6572
6629
 
6573
6630
 
6574
- @app.get("/api/council/transcripts/{iteration_id}")
6631
+ @app.get("/api/council/transcripts/{iteration_id}", dependencies=[Depends(auth.require_scope("read"))])
6575
6632
  async def get_council_transcript(iteration_id: str):
6576
6633
  """Fetch a single council transcript by iteration_id.
6577
6634
 
@@ -6603,7 +6660,7 @@ async def get_council_transcript(iteration_id: str):
6603
6660
  # Context Window Tracking API (v5.40.0)
6604
6661
  # =============================================================================
6605
6662
 
6606
- @app.get("/api/context")
6663
+ @app.get("/api/context", dependencies=[Depends(auth.require_scope("read"))])
6607
6664
  async def get_context():
6608
6665
  """Get context window tracking data from .loki/context/tracking.json."""
6609
6666
  loki_dir = _get_loki_dir()
@@ -6639,7 +6696,7 @@ async def get_context():
6639
6696
  # Notification Trigger API (v5.40.0)
6640
6697
  # =============================================================================
6641
6698
 
6642
- @app.get("/api/notifications")
6699
+ @app.get("/api/notifications", dependencies=[Depends(auth.require_scope("read"))])
6643
6700
  async def get_notifications(
6644
6701
  severity: Optional[str] = Query(None, pattern="^(critical|warning|info)$"),
6645
6702
  unread_only: bool = Query(False),
@@ -6676,7 +6733,7 @@ async def get_notifications(
6676
6733
  }
6677
6734
 
6678
6735
 
6679
- @app.get("/api/notifications/triggers")
6736
+ @app.get("/api/notifications/triggers", dependencies=[Depends(auth.require_scope("read"))])
6680
6737
  async def get_notification_triggers():
6681
6738
  """Get notification trigger configuration from .loki/notifications/triggers.json."""
6682
6739
  loki_dir = _get_loki_dir()
@@ -6786,7 +6843,7 @@ def _sanitize_checkpoint_id(checkpoint_id: str) -> str:
6786
6843
  return checkpoint_id
6787
6844
 
6788
6845
 
6789
- @app.get("/api/checkpoints")
6846
+ @app.get("/api/checkpoints", dependencies=[Depends(auth.require_scope("read"))])
6790
6847
  async def list_checkpoints(limit: int = Query(default=20, ge=1, le=200)):
6791
6848
  """List recent checkpoints from index.jsonl, enriched with metadata when available.
6792
6849
 
@@ -6853,7 +6910,7 @@ def _collect_checkpoints(limit: int) -> list:
6853
6910
  return checkpoints[:limit]
6854
6911
 
6855
6912
 
6856
- @app.get("/api/checkpoints/{checkpoint_id}")
6913
+ @app.get("/api/checkpoints/{checkpoint_id}", dependencies=[Depends(auth.require_scope("read"))])
6857
6914
  async def get_checkpoint(checkpoint_id: str):
6858
6915
  """Get checkpoint details by ID."""
6859
6916
  checkpoint_id = _sanitize_checkpoint_id(checkpoint_id)
@@ -7926,7 +7983,7 @@ async def prometheus_metrics():
7926
7983
  # PRD Checklist Endpoints (v5.44.0)
7927
7984
  # =============================================================================
7928
7985
 
7929
- @app.get("/api/checklist")
7986
+ @app.get("/api/checklist", dependencies=[Depends(auth.require_scope("read"))])
7930
7987
  async def get_checklist():
7931
7988
  """Get full PRD checklist with verification status."""
7932
7989
  loki_dir = _get_loki_dir()
@@ -7939,7 +7996,7 @@ async def get_checklist():
7939
7996
  return {"status": "error", "categories": [], "summary": {"total": 0, "verified": 0, "failing": 0, "pending": 0}}
7940
7997
 
7941
7998
 
7942
- @app.get("/api/usage")
7999
+ @app.get("/api/usage", dependencies=[Depends(auth.require_scope("read"))])
7943
8000
  async def get_usage_doc():
7944
8001
  """v7.7.1 F-1 follow-up: return the auto-generated USAGE.md from the
7945
8002
  project root so Dashboard + Lab can surface "how to run / test the app".
@@ -7979,7 +8036,7 @@ async def get_usage_doc():
7979
8036
  return out
7980
8037
 
7981
8038
 
7982
- @app.get("/api/checklist/summary")
8039
+ @app.get("/api/checklist/summary", dependencies=[Depends(auth.require_scope("read"))])
7983
8040
  async def get_checklist_summary():
7984
8041
  """Get checklist verification summary."""
7985
8042
  loki_dir = _get_loki_dir()
@@ -7992,7 +8049,7 @@ async def get_checklist_summary():
7992
8049
  return {"status": "error", "summary": {"total": 0, "verified": 0, "failing": 0, "pending": 0}}
7993
8050
 
7994
8051
 
7995
- @app.get("/api/prd-observations")
8052
+ @app.get("/api/prd-observations", dependencies=[Depends(auth.require_scope("read"))])
7996
8053
  async def get_prd_observations():
7997
8054
  """Get PRD quality analysis observations."""
7998
8055
  loki_dir = _get_loki_dir()
@@ -8010,7 +8067,7 @@ async def get_prd_observations():
8010
8067
  # Checklist Waiver Management Endpoints (Phase 4)
8011
8068
  # =============================================================================
8012
8069
 
8013
- @app.get("/api/checklist/waivers")
8070
+ @app.get("/api/checklist/waivers", dependencies=[Depends(auth.require_scope("read"))])
8014
8071
  async def get_checklist_waivers():
8015
8072
  """Get all checklist waivers."""
8016
8073
  waivers_file = _get_loki_dir() / "checklist" / "waivers.json"
@@ -8132,7 +8189,7 @@ _DEFAULT_QUALITY_GATES = [
8132
8189
  ]
8133
8190
 
8134
8191
 
8135
- @app.get("/api/council/gate")
8192
+ @app.get("/api/council/gate", dependencies=[Depends(auth.require_scope("read"))])
8136
8193
  async def get_council_gate():
8137
8194
  """Get council hard gate status.
8138
8195
 
@@ -8757,7 +8814,7 @@ def _discover_compose_app_runner_state_uncached(project_dir):
8757
8814
  return None
8758
8815
 
8759
8816
 
8760
- @app.get("/api/app-runner/status")
8817
+ @app.get("/api/app-runner/status", dependencies=[Depends(auth.require_scope("read"))])
8761
8818
  async def get_app_runner_status():
8762
8819
  """Get app runner current status (with dead-run liveness reconciliation).
8763
8820
 
@@ -8834,7 +8891,7 @@ def _get_log_redactor():
8834
8891
  return redactor
8835
8892
 
8836
8893
 
8837
- @app.get("/api/app-runner/logs")
8894
+ @app.get("/api/app-runner/logs", dependencies=[Depends(auth.require_scope("read"))])
8838
8895
  async def get_app_runner_logs(lines: int = Query(default=100, ge=1, le=1000)):
8839
8896
  """Get last N lines of app runner logs (redacted)."""
8840
8897
  loki_dir = _get_loki_dir()
@@ -8853,7 +8910,7 @@ async def get_app_runner_logs(lines: int = Query(default=100, ge=1, le=1000)):
8853
8910
  return {"lines": []}
8854
8911
 
8855
8912
 
8856
- @app.get("/api/app-runner/errors")
8913
+ @app.get("/api/app-runner/errors", dependencies=[Depends(auth.require_scope("read"))])
8857
8914
  async def get_app_runner_errors(lines: int = Query(default=50, ge=1, le=500)):
8858
8915
  """Get the last N lines of app runner output, redacted, plus crash state.
8859
8916
 
@@ -8928,7 +8985,7 @@ async def control_app_stop(request: Request):
8928
8985
  # Playwright Verification Endpoints (v5.46.0)
8929
8986
  # =============================================================================
8930
8987
 
8931
- @app.get("/api/playwright/results")
8988
+ @app.get("/api/playwright/results", dependencies=[Depends(auth.require_scope("read"))])
8932
8989
  async def get_playwright_results():
8933
8990
  """Get latest Playwright smoke test results."""
8934
8991
  loki_dir = _get_loki_dir()
@@ -8941,7 +8998,7 @@ async def get_playwright_results():
8941
8998
  return {"status": "error"}
8942
8999
 
8943
9000
 
8944
- @app.get("/api/playwright/screenshot")
9001
+ @app.get("/api/playwright/screenshot", dependencies=[Depends(auth.require_scope("read"))])
8945
9002
  async def get_playwright_screenshot():
8946
9003
  """Get path to latest Playwright screenshot."""
8947
9004
  loki_dir = _get_loki_dir()
@@ -8981,12 +9038,12 @@ def _get_prompt_optimizer():
8981
9038
  return _prompt_optimizer
8982
9039
 
8983
9040
 
8984
- @app.get("/api/failures")
8985
- def get_failures(sessions: int = 10):
9041
+ @app.get("/api/failures", dependencies=[Depends(auth.require_scope("read"))])
9042
+ def get_failures(request: Request, sessions: int = 10):
8986
9043
  """Get failure patterns from recent sessions."""
8987
9044
  if sessions < 1 or sessions > 1000:
8988
9045
  raise HTTPException(status_code=400, detail="sessions must be between 1 and 1000")
8989
- if not _read_limiter.check("failures"):
9046
+ if not _read_limiter.check(_rate_key("failures", request)):
8990
9047
  raise HTTPException(status_code=429, detail="Rate limit exceeded")
8991
9048
  try:
8992
9049
  return _get_failure_extractor().extract(sessions=sessions)
@@ -8995,7 +9052,7 @@ def get_failures(sessions: int = 10):
8995
9052
  raise HTTPException(status_code=500, detail="Failed to extract failure patterns")
8996
9053
 
8997
9054
 
8998
- @app.get("/api/prompt-versions")
9055
+ @app.get("/api/prompt-versions", dependencies=[Depends(auth.require_scope("read"))])
8999
9056
  def get_prompt_versions():
9000
9057
  """Get current prompt optimization status."""
9001
9058
  if not _read_limiter.check("prompt_versions"):
@@ -9070,10 +9127,10 @@ if STATIC_DIR:
9070
9127
  # Activity Logger & Session Diff
9071
9128
  # ---------------------------------------------------------------------------
9072
9129
 
9073
- @app.get("/api/activity")
9074
- def get_activity(since: Optional[str] = None, limit: int = Query(default=100, ge=1, le=1000)):
9130
+ @app.get("/api/activity", dependencies=[Depends(auth.require_scope("read"))])
9131
+ def get_activity(request: Request, since: Optional[str] = None, limit: int = Query(default=100, ge=1, le=1000)):
9075
9132
  """Get activity log entries, optionally filtered by timestamp."""
9076
- if not _read_limiter.check("activity"):
9133
+ if not _read_limiter.check(_rate_key("activity", request)):
9077
9134
  raise HTTPException(status_code=429, detail="Rate limit exceeded")
9078
9135
  try:
9079
9136
  activity_logger = get_activity_logger()
@@ -9088,7 +9145,7 @@ def get_activity(since: Optional[str] = None, limit: int = Query(default=100, ge
9088
9145
  raise HTTPException(status_code=500, detail="Failed to read activity log")
9089
9146
 
9090
9147
 
9091
- @app.get("/api/session-diff")
9148
+ @app.get("/api/session-diff", dependencies=[Depends(auth.require_scope("read"))])
9092
9149
  def get_session_diff(since: Optional[str] = None):
9093
9150
  """Get structured session diff since timestamp. Defaults to last 24h."""
9094
9151
  if not _read_limiter.check("session-diff"):
@@ -9139,7 +9196,7 @@ async def serve_favicon():
9139
9196
  # Serve the self-contained cost + observability panel (R3). Zero-build
9140
9197
  # standalone page that fetches /api/cost/timeline. Mirrors the proofs.html
9141
9198
  # pattern: works without the SPA build.
9142
- @app.get("/cost", include_in_schema=False)
9199
+ @app.get("/cost", include_in_schema=False, dependencies=[Depends(auth.require_scope("read"))])
9143
9200
  async def serve_cost_panel():
9144
9201
  """Serve the standalone cost + observability HTML panel."""
9145
9202
  if STATIC_DIR:
@@ -9151,7 +9208,7 @@ async def serve_cost_panel():
9151
9208
 
9152
9209
  # R4: standalone trust-trajectory page that fetches /api/trust/trajectory.
9153
9210
  # Mirrors the cost.html / /cost pattern: works without the SPA build.
9154
- @app.get("/trust", include_in_schema=False)
9211
+ @app.get("/trust", include_in_schema=False, dependencies=[Depends(auth.require_scope("read"))])
9155
9212
  async def serve_trust_panel():
9156
9213
  """Serve the standalone trust-trajectory HTML panel."""
9157
9214
  if STATIC_DIR:
@@ -9162,7 +9219,7 @@ async def serve_trust_panel():
9162
9219
 
9163
9220
 
9164
9221
  # Serve index.html or standalone HTML for root
9165
- @app.get("/", include_in_schema=False)
9222
+ @app.get("/", include_in_schema=False, dependencies=[Depends(auth.require_scope("read"))])
9166
9223
  async def serve_index():
9167
9224
  """Serve the frontend SPA or standalone HTML."""
9168
9225
  # Try multiple index file locations
@@ -9211,10 +9268,10 @@ def _get_rigour() -> "RigourIntegration":
9211
9268
  return _rigour
9212
9269
 
9213
9270
 
9214
- @app.get("/api/quality-score")
9215
- def get_quality_score():
9271
+ @app.get("/api/quality-score", dependencies=[Depends(auth.require_scope("read"))])
9272
+ def get_quality_score(request: Request):
9216
9273
  """Get current quality score from the most recent Rigour scan."""
9217
- if not _read_limiter.check("quality-score"):
9274
+ if not _read_limiter.check(_rate_key("quality-score", request)):
9218
9275
  raise HTTPException(status_code=429, detail="Rate limit exceeded")
9219
9276
  try:
9220
9277
  rigour = _get_rigour()
@@ -9224,7 +9281,7 @@ def get_quality_score():
9224
9281
  raise HTTPException(status_code=500, detail="Failed to read quality score")
9225
9282
 
9226
9283
 
9227
- @app.get("/api/quality-score/history")
9284
+ @app.get("/api/quality-score/history", dependencies=[Depends(auth.require_scope("read"))])
9228
9285
  def get_quality_score_history(limit: int = Query(50, ge=1, le=500)):
9229
9286
  """Get quality score trend over time."""
9230
9287
  if not _read_limiter.check("quality-history"):
@@ -9257,7 +9314,7 @@ async def run_quality_scan(preset: str = Query("default")):
9257
9314
  return result
9258
9315
 
9259
9316
 
9260
- @app.get("/api/quality-report")
9317
+ @app.get("/api/quality-report", dependencies=[Depends(auth.require_scope("read"))])
9261
9318
  def get_quality_report(fmt: str = Query("json", alias="format", pattern="^(json|markdown|html)$")):
9262
9319
  """Get an exportable quality audit report."""
9263
9320
  if not _read_limiter.check("quality-report"):
@@ -9617,7 +9674,7 @@ def _last_fallback_ts(events: list[dict[str, Any]]) -> Optional[str]:
9617
9674
  return None
9618
9675
 
9619
9676
 
9620
- @app.get("/api/managed/events")
9677
+ @app.get("/api/managed/events", dependencies=[Depends(auth.require_scope("read"))])
9621
9678
  async def get_managed_events(
9622
9679
  limit: int = Query(default=100, ge=1, le=_MANAGED_EVENTS_TAIL_MAX),
9623
9680
  since: Optional[str] = Query(default=None),
@@ -9646,7 +9703,7 @@ async def get_managed_events(
9646
9703
  return {"events": [], "count": 0, "error": str(exc)}
9647
9704
 
9648
9705
 
9649
- @app.get("/api/managed/status")
9706
+ @app.get("/api/managed/status", dependencies=[Depends(auth.require_scope("read"))])
9650
9707
  async def get_managed_status():
9651
9708
  """
9652
9709
  Return the managed-agents flag snapshot plus last_fallback_ts.
@@ -9672,7 +9729,7 @@ async def get_managed_status():
9672
9729
  return snapshot
9673
9730
 
9674
9731
 
9675
- @app.get("/api/managed/memory_versions/{memory_id}")
9732
+ @app.get("/api/managed/memory_versions/{memory_id}", dependencies=[Depends(auth.require_scope("read"))])
9676
9733
  async def list_managed_memory_versions(memory_id: str):
9677
9734
  """
9678
9735
  Proxy to beta.memory_stores.memory_versions.list(memory_id=...).
@@ -9771,7 +9828,7 @@ async def list_managed_memory_versions(memory_id: str):
9771
9828
  # ---------------------------------------------------------------------------
9772
9829
 
9773
9830
 
9774
- @app.get("/api/findings/{iteration}")
9831
+ @app.get("/api/findings/{iteration}", dependencies=[Depends(auth.require_scope("read"))])
9775
9832
  async def get_findings(iteration: int):
9776
9833
  """Read structured code-review findings for a given iteration."""
9777
9834
  base = _get_loki_dir()
@@ -9800,7 +9857,7 @@ async def get_findings(iteration: int):
9800
9857
  detail=f"No findings for iteration {iteration}")
9801
9858
 
9802
9859
 
9803
- @app.get("/api/quality/architecture")
9860
+ @app.get("/api/quality/architecture", dependencies=[Depends(auth.require_scope("read"))])
9804
9861
  async def get_quality_architecture():
9805
9862
  """Return the sentrux architectural-drift series.
9806
9863
 
@@ -9862,7 +9919,7 @@ async def get_quality_architecture():
9862
9919
  return {"series": series, "current": current, "samples": len(series)}
9863
9920
 
9864
9921
 
9865
- @app.get("/api/learnings")
9922
+ @app.get("/api/learnings", dependencies=[Depends(auth.require_scope("read"))])
9866
9923
  async def get_learnings(limit: int = 50):
9867
9924
  """Read recent learnings (newest first)."""
9868
9925
  base = _get_loki_dir()
@@ -9881,7 +9938,7 @@ async def get_learnings(limit: int = 50):
9881
9938
  "learnings": sliced}
9882
9939
 
9883
9940
 
9884
- @app.get("/api/escalations")
9941
+ @app.get("/api/escalations", dependencies=[Depends(auth.require_scope("read"))])
9885
9942
  async def list_escalations():
9886
9943
  """List handoff documents under .loki/escalations/."""
9887
9944
  base = _get_loki_dir()
@@ -9913,7 +9970,7 @@ async def list_escalations():
9913
9970
  return {"escalations": items}
9914
9971
 
9915
9972
 
9916
- @app.get("/api/escalations/{filename}")
9973
+ @app.get("/api/escalations/{filename}", dependencies=[Depends(auth.require_scope("read"))])
9917
9974
  async def get_escalation(filename: str):
9918
9975
  """Read one handoff document. Path-traversal-safe."""
9919
9976
  if "\\" in filename or filename.startswith("."):
@@ -9975,7 +10032,7 @@ def _safe_proof_run_dir(run_id: str) -> _Path:
9975
10032
  return _Path(target)
9976
10033
 
9977
10034
 
9978
- @app.get("/api/proofs")
10035
+ @app.get("/api/proofs", dependencies=[Depends(auth.require_scope("read"))])
9979
10036
  async def list_proofs():
9980
10037
  """List proof-of-run artifacts for the active project's .loki/proofs/."""
9981
10038
  proofs_dir = _proofs_dir()
@@ -10007,7 +10064,7 @@ async def list_proofs():
10007
10064
  return {"proofs": items}
10008
10065
 
10009
10066
 
10010
- @app.get("/api/proofs/{run_id}")
10067
+ @app.get("/api/proofs/{run_id}", dependencies=[Depends(auth.require_scope("read"))])
10011
10068
  async def get_proof(run_id: str):
10012
10069
  """Return the redacted proof.json for one run."""
10013
10070
  run_dir = _safe_proof_run_dir(run_id)
@@ -10020,7 +10077,7 @@ async def get_proof(run_id: str):
10020
10077
  return JSONResponse(content=data)
10021
10078
 
10022
10079
 
10023
- @app.get("/api/proofs/{run_id}/html")
10080
+ @app.get("/api/proofs/{run_id}/html", dependencies=[Depends(auth.require_scope("read"))])
10024
10081
  async def get_proof_html(run_id: str):
10025
10082
  """Serve the self-contained shareable proof page for one run."""
10026
10083
  run_dir = _safe_proof_run_dir(run_id)
@@ -10159,7 +10216,7 @@ async def post_wiki_ask(req: WikiAskRequest):
10159
10216
  # or static asset mounts. This lets the dashboard UI handle client-side routing.
10160
10217
  # Must be registered LAST so it never shadows an API endpoint.
10161
10218
  # ---------------------------------------------------------------------------
10162
- @app.get("/{full_path:path}", include_in_schema=False)
10219
+ @app.get("/{full_path:path}", include_in_schema=False, dependencies=[Depends(auth.require_scope("read"))])
10163
10220
  async def serve_spa_catchall(full_path: str):
10164
10221
  """Serve static files or fall back to index.html for SPA routing.
10165
10222