sobres 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. sobres/__about__.py +1 -0
  2. sobres/__init__.py +5 -0
  3. sobres/api/__init__.py +19 -0
  4. sobres/api/app.py +496 -0
  5. sobres/api/auth.py +117 -0
  6. sobres/api/jobs.py +260 -0
  7. sobres/api/static/assets/BacktestCharts-C7phgH_S.js +1 -0
  8. sobres/api/static/assets/EChart-BHrkUjbY.js +1 -0
  9. sobres/api/static/assets/FrontierChart-B3J3HsGE.js +1 -0
  10. sobres/api/static/assets/echarts-BpE1uIOo.js +39 -0
  11. sobres/api/static/assets/index-DikqmPZ6.js +68 -0
  12. sobres/api/static/assets/index-pgZOLb7x.css +1 -0
  13. sobres/api/static/assets/motion-D99TpcCF.js +17 -0
  14. sobres/api/static/index.html +25 -0
  15. sobres/api/static/manifest.json +80 -0
  16. sobres/cli/__init__.py +0 -0
  17. sobres/cli/commands/__init__.py +29 -0
  18. sobres/cli/commands/analyze.py +390 -0
  19. sobres/cli/commands/cache.py +59 -0
  20. sobres/cli/commands/commands.py +26 -0
  21. sobres/cli/commands/config.py +85 -0
  22. sobres/cli/commands/data.py +135 -0
  23. sobres/cli/commands/db.py +103 -0
  24. sobres/cli/commands/deploy.py +102 -0
  25. sobres/cli/commands/doctor.py +105 -0
  26. sobres/cli/commands/econ.py +880 -0
  27. sobres/cli/commands/fx.py +436 -0
  28. sobres/cli/commands/init.py +227 -0
  29. sobres/cli/commands/optimize.py +925 -0
  30. sobres/cli/commands/plan.py +648 -0
  31. sobres/cli/commands/portfolio.py +122 -0
  32. sobres/cli/commands/ppp.py +398 -0
  33. sobres/cli/commands/run.py +174 -0
  34. sobres/cli/commands/serve.py +263 -0
  35. sobres/cli/commands/trade.py +892 -0
  36. sobres/cli/commands/upgrade.py +68 -0
  37. sobres/cli/commands/watchlist.py +112 -0
  38. sobres/cli/context.py +237 -0
  39. sobres/cli/main.py +223 -0
  40. sobres/cli/metric_table.py +45 -0
  41. sobres/cli/render.py +130 -0
  42. sobres/cli/window.py +27 -0
  43. sobres/config.py +221 -0
  44. sobres/core/__init__.py +5 -0
  45. sobres/core/allocate.py +114 -0
  46. sobres/core/backtest.py +241 -0
  47. sobres/core/conventions.py +59 -0
  48. sobres/core/errors.py +120 -0
  49. sobres/core/factors.py +265 -0
  50. sobres/core/forecast.py +1045 -0
  51. sobres/core/fx.py +170 -0
  52. sobres/core/goals.py +408 -0
  53. sobres/core/moments.py +221 -0
  54. sobres/core/optimize.py +545 -0
  55. sobres/core/ppp.py +303 -0
  56. sobres/core/rates.py +58 -0
  57. sobres/core/regression.py +145 -0
  58. sobres/core/returns.py +116 -0
  59. sobres/core/risk.py +216 -0
  60. sobres/core/simulate.py +145 -0
  61. sobres/core/timeseries.py +308 -0
  62. sobres/core/trading.py +318 -0
  63. sobres/core/validation.py +27 -0
  64. sobres/data/__init__.py +5 -0
  65. sobres/data/align.py +62 -0
  66. sobres/data/base.py +212 -0
  67. sobres/data/brokers/__init__.py +1 -0
  68. sobres/data/brokers/alpaca.py +340 -0
  69. sobres/data/brokers/base.py +174 -0
  70. sobres/data/brokers/fake.py +218 -0
  71. sobres/data/cache.py +213 -0
  72. sobres/data/currency.py +277 -0
  73. sobres/data/ecb_provider.py +157 -0
  74. sobres/data/fixtures.py +355 -0
  75. sobres/data/fred_provider.py +196 -0
  76. sobres/data/gaps.py +83 -0
  77. sobres/data/ken_french.py +212 -0
  78. sobres/data/ppp_provider.py +346 -0
  79. sobres/data/providers.py +77 -0
  80. sobres/data/storage/__init__.py +37 -0
  81. sobres/data/storage/adapters/__init__.py +8 -0
  82. sobres/data/storage/adapters/migrations.py +61 -0
  83. sobres/data/storage/adapters/schema.py +257 -0
  84. sobres/data/storage/adapters/sqlite.py +1233 -0
  85. sobres/data/storage/base.py +507 -0
  86. sobres/data/yfinance_provider.py +319 -0
  87. sobres/deploy.py +304 -0
  88. sobres/doctor.py +532 -0
  89. sobres/observability/__init__.py +9 -0
  90. sobres/observability/logging.py +152 -0
  91. sobres/observability/redaction.py +64 -0
  92. sobres/observability/tracing.py +224 -0
  93. sobres/py.typed +0 -0
  94. sobres/registry.py +583 -0
  95. sobres/results.py +215 -0
  96. sobres/settings.py +409 -0
  97. sobres/solvers/__init__.py +1 -0
  98. sobres/solvers/base.py +38 -0
  99. sobres/solvers/scipy.py +35 -0
  100. sobres-1.1.0.dist-info/METADATA +485 -0
  101. sobres-1.1.0.dist-info/RECORD +104 -0
  102. sobres-1.1.0.dist-info/WHEEL +4 -0
  103. sobres-1.1.0.dist-info/entry_points.txt +2 -0
  104. sobres-1.1.0.dist-info/licenses/LICENSE +21 -0
sobres/__about__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.1.0"
sobres/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """sobres — a one-stop CLI and library for equity analysis."""
2
+
3
+ from sobres.__about__ import __version__
4
+
5
+ __all__ = ["__version__"]
sobres/api/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """The HTTP adapter (0004): FastAPI routes generated from the command registry.
2
+
3
+ No business logic lives here — request handling, validation, serialization,
4
+ job dispatch and access control only. ``pip install sobres[web]`` brings the
5
+ runtime; the built SPA ships under ``static/``. ``create_app`` is imported
6
+ lazily so the base install (no FastAPI) can still import ``sobres.api.auth``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ __all__ = ["create_app"]
14
+
15
+
16
+ def create_app(*args: Any, **kwargs: Any) -> Any:
17
+ from sobres.api.app import create_app as _create_app
18
+
19
+ return _create_app(*args, **kwargs)
sobres/api/app.py ADDED
@@ -0,0 +1,496 @@
1
+ """``create_app``: the FastAPI application, generated from the command registry.
2
+
3
+ Every registered command becomes ``POST /api/v1/<group>/<name>`` with its
4
+ parameter model as the request body — one validation path shared with the
5
+ CLI. Long-running commands are dispatched as jobs (``202 {job_id}``) and
6
+ stream progress over SSE. Errors map 0001's taxonomy onto HTTP status codes
7
+ and carry the same actionable message the CLI prints, plus the run id.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import inspect
13
+ import json
14
+ import time
15
+ from collections.abc import Callable, Iterator, Mapping
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from fastapi import FastAPI, Request, Response
21
+ from fastapi.exceptions import RequestValidationError
22
+ from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
23
+ from fastapi.staticfiles import StaticFiles
24
+ from pydantic import BaseModel, ValidationError
25
+
26
+ from sobres.__about__ import __version__
27
+ from sobres.api import auth
28
+ from sobres.api.jobs import JobRunner, job_event
29
+ from sobres.cli.context import Context
30
+ from sobres.config import Config, display_value, process_environment, resolve
31
+ from sobres.core.errors import (
32
+ ConfigurationError,
33
+ InsufficientDataError,
34
+ ProviderError,
35
+ SobresError,
36
+ UsageError,
37
+ )
38
+ from sobres.data.storage.base import TERMINAL_JOB_STATES, OpenOptions, Storage, open_storage
39
+ from sobres.deploy import require_data_volume
40
+ from sobres.doctor import run_checks
41
+ from sobres.observability import get_logger, new_run_id, run_id, span
42
+ from sobres.observability.tracing import current_trace_ids
43
+ from sobres.registry import Command, all_commands, command_schema, validate_params
44
+ from sobres.settings import all_settings, get_setting
45
+
46
+ STATIC_DIR = Path(__file__).resolve().parent / "static"
47
+ API_PREFIX = "/api/v1"
48
+ READINESS_CHECKS = ("config-file", "db-reachable", "db-schema", "disk-space", "data-volume")
49
+ PUBLIC_PATHS = {f"{API_PREFIX}/health", f"{API_PREFIX}/auth/login", f"{API_PREFIX}/auth/logout"}
50
+ STATUS_FOR: dict[type[SobresError], int] = {
51
+ UsageError: 400,
52
+ ConfigurationError: 400,
53
+ ProviderError: 502,
54
+ InsufficientDataError: 422,
55
+ }
56
+
57
+
58
+ class LoginBody(BaseModel):
59
+ token: str
60
+
61
+
62
+ class SettingBody(BaseModel):
63
+ key: str
64
+ value: str
65
+
66
+
67
+ class VerifyBody(BaseModel):
68
+ value: str | None = None
69
+
70
+
71
+ @dataclass
72
+ class AppState:
73
+ environ: dict[str, str]
74
+ config: Config
75
+ storage: Storage
76
+ runner: JobRunner
77
+ require_token: bool
78
+ sources: dict[str, Any] = field(default_factory=dict)
79
+ limiter: auth.RateLimiter = field(default_factory=auth.RateLimiter)
80
+
81
+ def context(self) -> Context:
82
+ return Context(
83
+ config=self.config,
84
+ environ=self.environ,
85
+ interactive=False,
86
+ sources=dict(self.sources),
87
+ surface="api",
88
+ _storage=self.storage,
89
+ )
90
+
91
+
92
+ def create_app(
93
+ environ: Mapping[str, str] | None = None,
94
+ *,
95
+ require_token: bool = False,
96
+ config_path: Path | None = None,
97
+ sources: dict[str, Any] | None = None,
98
+ start_worker: bool = True,
99
+ static_dir: Path | None = None,
100
+ ) -> FastAPI:
101
+ """Build the application over one resolved configuration and one opened storage."""
102
+ env = process_environment() if environ is None else dict(environ)
103
+ config = resolve(None, env, path=config_path)
104
+ if config.error is not None:
105
+ raise config.error
106
+ require_data_volume(config)
107
+ storage = open_storage(config.db_url, OpenOptions())
108
+ runner = JobRunner(storage, config, env, sources=sources)
109
+ state = AppState(
110
+ environ=env,
111
+ config=config,
112
+ storage=storage,
113
+ runner=runner,
114
+ require_token=require_token,
115
+ sources=dict(sources or {}),
116
+ )
117
+ app = FastAPI(
118
+ title="sobres",
119
+ version=__version__,
120
+ description="Every CLI command as POST /api/v1/<group>/<name>; long-running ones as jobs.",
121
+ docs_url="/api/docs",
122
+ openapi_url="/api/openapi.json",
123
+ redoc_url=None,
124
+ )
125
+ app.state.sobres = state
126
+ log = get_logger("sobres.api")
127
+
128
+ @app.on_event("startup")
129
+ def _start() -> None:
130
+ if start_worker:
131
+ runner.start()
132
+
133
+ @app.on_event("shutdown")
134
+ def _stop() -> None:
135
+ runner.stop()
136
+ storage.close()
137
+
138
+ # ----------------------------------------------------------- middleware
139
+ @app.middleware("http")
140
+ async def _observe(request: Request, call_next: Callable[[Request], Any]) -> Response:
141
+ rid = new_run_id()
142
+ started = time.perf_counter()
143
+ carrier = {k: v for k, v in request.headers.items() if k in ("traceparent", "tracestate")}
144
+ route_name = _registry_name(request.url.path)
145
+ with span(
146
+ "http.request",
147
+ {"http.method": request.method, "http.route": request.url.path, "command": route_name},
148
+ carrier=carrier,
149
+ ) as sp:
150
+ denied = _authorize(request, state)
151
+ if denied is not None:
152
+ response: Response = denied
153
+ else:
154
+ response = await call_next(request)
155
+ sp.set_attribute("http.status_code", response.status_code)
156
+ ids = current_trace_ids()
157
+ response.headers["X-Run-Id"] = rid
158
+ if ids is not None:
159
+ response.headers["X-Trace-Id"] = ids[0]
160
+ log.info(
161
+ "http.request",
162
+ method=request.method,
163
+ path=request.url.path,
164
+ status=response.status_code,
165
+ elapsed_ms=round((time.perf_counter() - started) * 1000, 2),
166
+ )
167
+ return response
168
+
169
+ # ------------------------------------------------------------ errors
170
+ @app.exception_handler(SobresError)
171
+ async def _sobres_error(request: Request, exc: SobresError) -> JSONResponse:
172
+ status = next((code for cls, code in STATUS_FOR.items() if isinstance(exc, cls)), 500)
173
+ body = {
174
+ "error": exc.message,
175
+ "hint": exc.hint,
176
+ "error_class": type(exc).__name__,
177
+ "exit_code": exc.exit_code,
178
+ "run_id": run_id(),
179
+ }
180
+ return JSONResponse(body, status_code=status)
181
+
182
+ @app.exception_handler(RequestValidationError)
183
+ async def _request_validation(request: Request, exc: RequestValidationError) -> JSONResponse:
184
+ # Same shape and status as the CLI's usage error: name the field and the constraint.
185
+ problems = [
186
+ ".".join(str(p) for p in err.get("loc", ()) if p != "body") + ": " + str(err.get("msg"))
187
+ for err in exc.errors()
188
+ ]
189
+ return JSONResponse(
190
+ {
191
+ "error": "invalid parameters: " + "; ".join(problems),
192
+ "hint": "see /api/docs for the parameter schema",
193
+ "error_class": "UsageError",
194
+ "exit_code": 2,
195
+ "run_id": run_id(),
196
+ },
197
+ status_code=400,
198
+ )
199
+
200
+ @app.exception_handler(ValidationError)
201
+ async def _validation_error(request: Request, exc: ValidationError) -> JSONResponse:
202
+ return JSONResponse({"error": str(exc), "run_id": run_id()}, status_code=400)
203
+
204
+ @app.exception_handler(Exception)
205
+ async def _internal(request: Request, exc: Exception) -> JSONResponse:
206
+ log.error("http.internal_error", error=f"{type(exc).__name__}: {exc}", exc_info=True)
207
+ return JSONResponse(
208
+ {
209
+ "error": "internal error",
210
+ "hint": f"quote run id {run_id()} when reporting this",
211
+ "run_id": run_id(),
212
+ },
213
+ status_code=500,
214
+ # Starlette's ServerErrorMiddleware handles this outside ``_observe``.
215
+ headers={"X-Run-Id": run_id() or ""},
216
+ )
217
+
218
+ # ------------------------------------------------------------ core routes
219
+ @app.get(f"{API_PREFIX}/health", tags=["meta"])
220
+ def health() -> dict[str, Any]:
221
+ # Readiness is doctor's own checks (the fast, offline ones), not a second notion of health.
222
+ reports = run_checks(state.context(), offline=True, only=READINESS_CHECKS)
223
+ checks = {r.name: r.status for r in reports}
224
+ return {
225
+ "app": "sobres",
226
+ "version": __version__,
227
+ "ok": True,
228
+ "ready": not any(s == "fail" for s in checks.values()),
229
+ "checks": checks,
230
+ "token_required": state.require_token,
231
+ }
232
+
233
+ @app.get(f"{API_PREFIX}/commands", tags=["meta"])
234
+ def commands() -> dict[str, Any]:
235
+ return {"commands": [command_schema(c) for c in all_commands()]}
236
+
237
+ @app.post(f"{API_PREFIX}/auth/login", tags=["auth"])
238
+ def login(body: LoginBody, request: Request, response: Response) -> dict[str, Any]:
239
+ source = request.client.host if request.client else "unknown"
240
+ if state.require_token and not state.limiter.allow(source):
241
+ return JSONResponse({"error": "too many attempts"}, status_code=429) # type: ignore[return-value]
242
+ if not state.require_token:
243
+ return {"ok": True, "token_required": False}
244
+ if not auth.verify_token(body.token, auth.stored_token(state.storage.kv)):
245
+ return JSONResponse({"error": "unauthorized"}, status_code=401) # type: ignore[return-value]
246
+ response.set_cookie(
247
+ auth.COOKIE_NAME, body.token, httponly=True, samesite="strict", secure=False
248
+ )
249
+ return {"ok": True, "token_required": True}
250
+
251
+ @app.post(f"{API_PREFIX}/auth/logout", tags=["auth"])
252
+ def logout(response: Response) -> dict[str, Any]:
253
+ response.delete_cookie(auth.COOKIE_NAME)
254
+ return {"ok": True}
255
+
256
+ # ------------------------------------------------------------ settings
257
+ @app.get(f"{API_PREFIX}/settings", tags=["settings"])
258
+ def settings_list() -> dict[str, Any]:
259
+ cfg = resolve(None, state.environ, path=state.config.path)
260
+ return {
261
+ "settings": [
262
+ {
263
+ "key": s.key,
264
+ "env": s.env,
265
+ "description": s.description,
266
+ "type": s.type,
267
+ "secret": s.secret,
268
+ "required": s.required,
269
+ "obtain": s.obtain,
270
+ "affects": list(s.affects),
271
+ "choices": list(s.choices),
272
+ "has_live_validator": s.validate_live is not None,
273
+ "browser_editable": s.browser_editable,
274
+ "value": display_value(s, cfg.get(s.key)),
275
+ "source": cfg.source(s.key),
276
+ "default": s.default,
277
+ }
278
+ for s in all_settings()
279
+ ],
280
+ "config_path": str(state.config.path),
281
+ }
282
+
283
+ @app.put(f"{API_PREFIX}/settings", tags=["settings"])
284
+ def settings_put(body: SettingBody) -> dict[str, Any]:
285
+ # The same code path as `sobres config set`: validate, then write the file at 0600.
286
+ # Settings that move data or traces elsewhere are refused here, before that path
287
+ # runs; they stay available from the terminal.
288
+ from sobres.cli.commands.config import ConfigSetParams, config_set
289
+
290
+ if not get_setting(body.key).browser_editable:
291
+ raise UsageError(
292
+ f"{body.key} cannot be changed from the browser",
293
+ hint=f"run: sobres config set {body.key} <value>",
294
+ )
295
+ result = config_set(ConfigSetParams(key=body.key, value=body.value), state.context())
296
+ state.config = resolve(None, state.environ, path=state.config.path)
297
+ state.runner._config = state.config
298
+ return {"ok": True, "message": result.summary_line()}
299
+
300
+ @app.post(f"{API_PREFIX}/settings/{{key}}/verify", tags=["settings"])
301
+ def settings_verify(key: str, body: VerifyBody) -> dict[str, Any]:
302
+ setting = get_setting(key)
303
+ if setting.validate_live is None:
304
+ raise UsageError(f"{key} has no live validator")
305
+ value = body.value if body.value is not None else state.config.get(key)
306
+ if not value:
307
+ raise UsageError(f"{key} is not set; nothing to verify")
308
+ outcome = setting.validate_live(str(value))
309
+ return {"ok": outcome.ok, "message": outcome.message}
310
+
311
+ # ---------------------------------------------------------------- jobs
312
+ @app.get(f"{API_PREFIX}/jobs", tags=["jobs"])
313
+ def jobs_list(limit: int = 20, state_filter: str | None = None) -> dict[str, Any]:
314
+ return {
315
+ "jobs": [job_event(j) for j in state.storage.jobs.list(limit=limit, state=state_filter)]
316
+ }
317
+
318
+ @app.get(f"{API_PREFIX}/jobs/{{job_id}}", tags=["jobs"])
319
+ def job_get(job_id: str) -> dict[str, Any]:
320
+ job = state.storage.jobs.get(job_id)
321
+ if job is None:
322
+ raise UsageError(f"no job {job_id!r}")
323
+ return job_event(job)
324
+
325
+ @app.post(f"{API_PREFIX}/jobs/{{job_id}}/cancel", tags=["jobs"])
326
+ def job_cancel(job_id: str) -> dict[str, Any]:
327
+ job = state.runner.cancel(job_id)
328
+ if job is None:
329
+ raise UsageError(f"no job {job_id!r}")
330
+ return job_event(job, "cancel requested")
331
+
332
+ @app.get(f"{API_PREFIX}/jobs/{{job_id}}/events", tags=["jobs"])
333
+ def job_events(job_id: str) -> StreamingResponse:
334
+ job = state.storage.jobs.get(job_id)
335
+ if job is None:
336
+ raise UsageError(f"no job {job_id!r}")
337
+ return StreamingResponse(
338
+ _sse(state, job_id),
339
+ media_type="text/event-stream",
340
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
341
+ )
342
+
343
+ # ---------------------------------------------------------- registry
344
+ for cmd in all_commands():
345
+ app.add_api_route(
346
+ cmd.route,
347
+ _make_endpoint(cmd, state),
348
+ methods=["POST"],
349
+ name=cmd.name,
350
+ summary=cmd.help,
351
+ tags=[cmd.group or "root"],
352
+ status_code=202 if cmd.long_running else 200,
353
+ )
354
+
355
+ # ----------------------------------------------------------- frontend
356
+ assets = (static_dir or STATIC_DIR) / "assets"
357
+ if assets.is_dir():
358
+ app.mount("/assets", StaticFiles(directory=assets), name="assets")
359
+
360
+ @app.get("/manifest.json", include_in_schema=False)
361
+ def manifest() -> Response:
362
+ path = (static_dir or STATIC_DIR) / "manifest.json"
363
+ if path.exists():
364
+ return Response(path.read_text(encoding="utf-8"), media_type="application/json")
365
+ return JSONResponse({"views": {}}, status_code=404)
366
+
367
+ @app.get("/{path:path}", include_in_schema=False)
368
+ def spa(path: str) -> Response:
369
+ root = static_dir or STATIC_DIR
370
+ candidate = root / path
371
+ if path and candidate.is_file() and candidate.resolve().is_relative_to(root.resolve()):
372
+ media = "text/javascript" if path.endswith(".js") else None
373
+ return Response(candidate.read_bytes(), media_type=media)
374
+ index = root / "index.html"
375
+ if index.exists():
376
+ return HTMLResponse(index.read_text(encoding="utf-8"))
377
+ return HTMLResponse(_PLACEHOLDER, status_code=200)
378
+
379
+ return app
380
+
381
+
382
+ _PLACEHOLDER = """<!doctype html><title>sobres</title>
383
+ <body style="font-family:system-ui;background:#0b0f14;color:#e6edf3;padding:2rem">
384
+ <h1>sobres API is running</h1>
385
+ <p>The web UI's built assets are not present in this install. The API is at
386
+ <a href="/api/docs" style="color:#7cc4ff">/api/docs</a>. Build the frontend with
387
+ <code>cd frontend &amp;&amp; npm ci &amp;&amp; npm run build</code>.</p></body>"""
388
+
389
+
390
+ def _registry_name(path: str) -> str | None:
391
+ if not path.startswith(API_PREFIX + "/"):
392
+ return None
393
+ return ".".join(path[len(API_PREFIX) + 1 :].split("/"))
394
+
395
+
396
+ def _authorize(request: Request, state: AppState) -> Response | None:
397
+ """401 for API calls without a valid token when one is required; never via query string."""
398
+ path = request.url.path
399
+ if not path.startswith(API_PREFIX) or path in PUBLIC_PATHS or not state.require_token:
400
+ if "token" in request.query_params and path.startswith(API_PREFIX):
401
+ return JSONResponse({"error": "unauthorized"}, status_code=401)
402
+ return None
403
+ if "token" in request.query_params:
404
+ return JSONResponse({"error": "unauthorized"}, status_code=401)
405
+ presented = None
406
+ header = request.headers.get("authorization", "")
407
+ if header.lower().startswith("bearer "):
408
+ presented = header[7:].strip()
409
+ elif auth.COOKIE_NAME in request.cookies:
410
+ presented = request.cookies[auth.COOKIE_NAME]
411
+ if presented and auth.verify_token(presented, auth.stored_token(state.storage.kv)):
412
+ return None
413
+ return JSONResponse({"error": "unauthorized"}, status_code=401)
414
+
415
+
416
+ def _make_endpoint(cmd: Command, state: AppState) -> Callable[..., Any]:
417
+ params_model = cmd.params
418
+
419
+ def endpoint(body: BaseModel, request: Request) -> Any:
420
+ raw = body.model_dump(mode="json", exclude_unset=True)
421
+ params = validate_params(cmd, raw)
422
+ if cmd.long_running:
423
+ carrier = {
424
+ k: v for k, v in request.headers.items() if k in ("traceparent", "tracestate")
425
+ }
426
+ job = state.runner.submit(
427
+ cmd,
428
+ params.model_dump(mode="json"),
429
+ run_id=run_id(),
430
+ trace_context=carrier or None,
431
+ )
432
+ return JSONResponse(
433
+ {**job_event(job, "queued"), "url": f"{API_PREFIX}/jobs/{job.id}"}, status_code=202
434
+ )
435
+ ctx = state.context()
436
+ with span(f"api.{cmd.name}", {"command": cmd.name}):
437
+ result = cmd.handler(params, ctx)
438
+ if getattr(params, "save_run", False):
439
+ from sobres.cli.main import record_run
440
+
441
+ record_run(cmd, params, result, ctx)
442
+ return result.payload()
443
+
444
+ endpoint.__signature__ = inspect.Signature( # type: ignore[attr-defined]
445
+ [
446
+ inspect.Parameter(
447
+ "body", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=params_model
448
+ ),
449
+ inspect.Parameter(
450
+ "request", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=Request
451
+ ),
452
+ ]
453
+ )
454
+ endpoint.__annotations__ = {"body": params_model, "request": Request, "return": Any}
455
+ endpoint.__name__ = cmd.name.replace(".", "_")
456
+ endpoint.__doc__ = cmd.help
457
+ return endpoint
458
+
459
+
460
+ def _sse(state: AppState, job_id: str) -> Iterator[str]:
461
+ """Current state first, then every event until the job is terminal."""
462
+ q = state.runner.subscribe(job_id)
463
+ try:
464
+ job = state.storage.jobs.get(job_id)
465
+ if job is None:
466
+ return
467
+ yield _frame(job_event(job, "current"))
468
+ if job.state in TERMINAL_JOB_STATES:
469
+ return
470
+ last_state = job.state
471
+ while True:
472
+ try:
473
+ event = q.get(timeout=1.0)
474
+ except Exception:
475
+ current = state.storage.jobs.get(job_id)
476
+ if current is None:
477
+ return
478
+ if current.state != last_state:
479
+ yield _frame(job_event(current, "state"))
480
+ last_state = current.state
481
+ if current.state in TERMINAL_JOB_STATES:
482
+ return
483
+ yield ": keepalive\n\n"
484
+ continue
485
+ yield _frame(event)
486
+ if event["state"] in TERMINAL_JOB_STATES:
487
+ return
488
+ finally:
489
+ state.runner.unsubscribe(job_id, q)
490
+
491
+
492
+ def _frame(event: dict[str, Any]) -> str:
493
+ return f"event: job\ndata: {json.dumps(event, default=str)}\n\n"
494
+
495
+
496
+ __all__ = ["create_app"]
sobres/api/auth.py ADDED
@@ -0,0 +1,117 @@
1
+ """Deployment token: generated, stored hashed, compared in constant time.
2
+
3
+ A loopback-only server needs no token — the socket is unreachable remotely.
4
+ Any other bind address requires one. Tokens are 32 random bytes (256 bits of
5
+ entropy) as base64url; only a salted PBKDF2 hash is stored, in the key/value
6
+ store, and comparison is constant-time. Presented as a bearer header or an
7
+ httpOnly cookie, never in a query string.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import hashlib
14
+ import hmac
15
+ import math
16
+ import secrets
17
+ import threading
18
+ import time
19
+ from collections import Counter, defaultdict, deque
20
+ from typing import Any
21
+
22
+ from sobres.core.errors import UsageError
23
+ from sobres.data.storage.base import KeyValueStore
24
+
25
+ KV_KEY = "auth.token"
26
+ COOKIE_NAME = "sobres_token"
27
+ MIN_TOKEN_BITS = 128
28
+ PBKDF2_ROUNDS = 100_000
29
+ LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
30
+
31
+
32
+ def is_loopback(host: str) -> bool:
33
+ return host.strip().lower() in LOOPBACK_HOSTS or host.startswith("127.")
34
+
35
+
36
+ def generate_token() -> str:
37
+ """32 random bytes from the OS CSPRNG, base64url without padding."""
38
+ return base64.urlsafe_b64encode(secrets.token_bytes(32)).decode().rstrip("=")
39
+
40
+
41
+ def _entropy_bits(token: str) -> float:
42
+ """Shannon entropy of the characters times the length: a floor on strength."""
43
+ if not token:
44
+ return 0.0
45
+ counts = Counter(token)
46
+ n = len(token)
47
+ per_char = -sum(c / n * math.log2(c / n) for c in counts.values())
48
+ return per_char * n
49
+
50
+
51
+ def validate_strength(token: str) -> None:
52
+ """Reject a weak user-supplied token; the tool prefers to generate its own."""
53
+ if len(token) < 22 or _entropy_bits(token) < MIN_TOKEN_BITS:
54
+ raise UsageError(
55
+ "that token is too weak (it needs at least 128 bits of entropy)",
56
+ hint="let sobres generate one: sobres serve token rotate",
57
+ )
58
+
59
+
60
+ def hash_token(token: str, salt: bytes | None = None) -> dict[str, str]:
61
+ salt = salt or secrets.token_bytes(16)
62
+ digest = hashlib.pbkdf2_hmac("sha256", token.encode(), salt, PBKDF2_ROUNDS)
63
+ return {"salt": base64.b64encode(salt).decode(), "hash": base64.b64encode(digest).decode()}
64
+
65
+
66
+ def verify_token(token: str, stored: dict[str, Any] | None) -> bool:
67
+ if not stored or not token:
68
+ return False
69
+ salt = base64.b64decode(stored["salt"])
70
+ expected = base64.b64decode(stored["hash"])
71
+ digest = hashlib.pbkdf2_hmac("sha256", token.encode(), salt, PBKDF2_ROUNDS)
72
+ return hmac.compare_digest(digest, expected)
73
+
74
+
75
+ def stored_token(kv: KeyValueStore) -> dict[str, Any] | None:
76
+ value = kv.get(KV_KEY)
77
+ return dict(value) if isinstance(value, dict) else None
78
+
79
+
80
+ def ensure_token(kv: KeyValueStore) -> str | None:
81
+ """Store a generated token if none exists; return the plaintext only when new."""
82
+ if stored_token(kv) is not None:
83
+ return None
84
+ token = generate_token()
85
+ kv.set(KV_KEY, hash_token(token))
86
+ return token
87
+
88
+
89
+ def rotate_token(kv: KeyValueStore, token: str | None = None) -> str:
90
+ """Replace the stored token (generated unless supplied and strong enough)."""
91
+ if token is not None:
92
+ validate_strength(token)
93
+ else:
94
+ token = generate_token()
95
+ kv.set(KV_KEY, hash_token(token))
96
+ return token
97
+
98
+
99
+ class RateLimiter:
100
+ """Per-source sliding window for authentication attempts."""
101
+
102
+ def __init__(self, limit: int = 10, window_s: float = 60.0) -> None:
103
+ self.limit = limit
104
+ self.window_s = window_s
105
+ self._hits: dict[str, deque[float]] = defaultdict(deque)
106
+ self._lock = threading.Lock()
107
+
108
+ def allow(self, source: str, now: float | None = None) -> bool:
109
+ stamp = time.monotonic() if now is None else now
110
+ with self._lock:
111
+ hits = self._hits[source]
112
+ while hits and hits[0] <= stamp - self.window_s:
113
+ hits.popleft()
114
+ if len(hits) >= self.limit:
115
+ return False
116
+ hits.append(stamp)
117
+ return True