dccd 3.2.0__tar.gz → 3.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. {dccd-3.2.0 → dccd-3.3.0}/CHANGELOG.md +41 -0
  2. {dccd-3.2.0 → dccd-3.3.0}/PKG-INFO +1 -1
  3. {dccd-3.2.0 → dccd-3.3.0}/dccd/application/config.py +20 -1
  4. {dccd-3.2.0 → dccd-3.3.0}/dccd/interfaces/api/app.py +173 -17
  5. {dccd-3.2.0 → dccd-3.3.0}/dccd/interfaces/ui/templates/base.html +46 -3
  6. {dccd-3.2.0 → dccd-3.3.0}/dccd/interfaces/ui/templates/live.html +1 -1
  7. dccd-3.3.0/dccd/interfaces/ui/templates/login.html +47 -0
  8. {dccd-3.2.0 → dccd-3.3.0}/dccd/interfaces/ui/templates/logs.html +1 -1
  9. {dccd-3.2.0 → dccd-3.3.0}/dccd/tests/v3/test_api.py +180 -0
  10. {dccd-3.2.0 → dccd-3.3.0}/dccd.egg-info/PKG-INFO +1 -1
  11. {dccd-3.2.0 → dccd-3.3.0}/dccd.egg-info/SOURCES.txt +1 -0
  12. {dccd-3.2.0 → dccd-3.3.0}/pyproject.toml +1 -1
  13. {dccd-3.2.0 → dccd-3.3.0}/CLAUDE.md +0 -0
  14. {dccd-3.2.0 → dccd-3.3.0}/CONTRIBUTING.md +0 -0
  15. {dccd-3.2.0 → dccd-3.3.0}/LICENSE.txt +0 -0
  16. {dccd-3.2.0 → dccd-3.3.0}/MANIFEST.in +0 -0
  17. {dccd-3.2.0 → dccd-3.3.0}/README.md +0 -0
  18. {dccd-3.2.0 → dccd-3.3.0}/dccd/__init__.py +0 -0
  19. {dccd-3.2.0 → dccd-3.3.0}/dccd/application/__init__.py +0 -0
  20. {dccd-3.2.0 → dccd-3.3.0}/dccd/application/events.py +0 -0
  21. {dccd-3.2.0 → dccd-3.3.0}/dccd/application/jobs.py +0 -0
  22. {dccd-3.2.0 → dccd-3.3.0}/dccd/application/monitor.py +0 -0
  23. {dccd-3.2.0 → dccd-3.3.0}/dccd/application/operations.py +0 -0
  24. {dccd-3.2.0 → dccd-3.3.0}/dccd/application/registry.py +0 -0
  25. {dccd-3.2.0 → dccd-3.3.0}/dccd/application/scheduler.py +0 -0
  26. {dccd-3.2.0 → dccd-3.3.0}/dccd/application/service_factory.py +0 -0
  27. {dccd-3.2.0 → dccd-3.3.0}/dccd/domain/__init__.py +0 -0
  28. {dccd-3.2.0 → dccd-3.3.0}/dccd/domain/capability.py +0 -0
  29. {dccd-3.2.0 → dccd-3.3.0}/dccd/domain/dataset.py +0 -0
  30. {dccd-3.2.0 → dccd-3.3.0}/dccd/domain/errors.py +0 -0
  31. {dccd-3.2.0 → dccd-3.3.0}/dccd/domain/records.py +0 -0
  32. {dccd-3.2.0 → dccd-3.3.0}/dccd/domain/symbol.py +0 -0
  33. {dccd-3.2.0 → dccd-3.3.0}/dccd/domain/timeutils.py +0 -0
  34. {dccd-3.2.0 → dccd-3.3.0}/dccd/domain/transforms.py +0 -0
  35. {dccd-3.2.0 → dccd-3.3.0}/dccd/domain/types.py +0 -0
  36. {dccd-3.2.0 → dccd-3.3.0}/dccd/interfaces/__init__.py +0 -0
  37. {dccd-3.2.0 → dccd-3.3.0}/dccd/interfaces/api/__init__.py +0 -0
  38. {dccd-3.2.0 → dccd-3.3.0}/dccd/interfaces/cli/__init__.py +0 -0
  39. {dccd-3.2.0 → dccd-3.3.0}/dccd/interfaces/cli/main.py +0 -0
  40. {dccd-3.2.0 → dccd-3.3.0}/dccd/interfaces/ui/__init__.py +0 -0
  41. {dccd-3.2.0 → dccd-3.3.0}/dccd/interfaces/ui/static/favicon.svg +0 -0
  42. {dccd-3.2.0 → dccd-3.3.0}/dccd/interfaces/ui/static/logo.svg +0 -0
  43. {dccd-3.2.0 → dccd-3.3.0}/dccd/interfaces/ui/templates/config.html +0 -0
  44. {dccd-3.2.0 → dccd-3.3.0}/dccd/interfaces/ui/templates/dashboard.html +0 -0
  45. {dccd-3.2.0 → dccd-3.3.0}/dccd/interfaces/ui/templates/data.html +0 -0
  46. {dccd-3.2.0 → dccd-3.3.0}/dccd/interfaces/ui/templates/historical.html +0 -0
  47. {dccd-3.2.0 → dccd-3.3.0}/dccd/interfaces/ui/templates/storage.html +0 -0
  48. {dccd-3.2.0 → dccd-3.3.0}/dccd/sources/__init__.py +0 -0
  49. {dccd-3.2.0 → dccd-3.3.0}/dccd/sources/base.py +0 -0
  50. {dccd-3.2.0 → dccd-3.3.0}/dccd/sources/binance.py +0 -0
  51. {dccd-3.2.0 → dccd-3.3.0}/dccd/sources/bitfinex.py +0 -0
  52. {dccd-3.2.0 → dccd-3.3.0}/dccd/sources/bitmex.py +0 -0
  53. {dccd-3.2.0 → dccd-3.3.0}/dccd/sources/bybit.py +0 -0
  54. {dccd-3.2.0 → dccd-3.3.0}/dccd/sources/coinbase.py +0 -0
  55. {dccd-3.2.0 → dccd-3.3.0}/dccd/sources/kraken.py +0 -0
  56. {dccd-3.2.0 → dccd-3.3.0}/dccd/sources/okx.py +0 -0
  57. {dccd-3.2.0 → dccd-3.3.0}/dccd/sources/registry.py +0 -0
  58. {dccd-3.2.0 → dccd-3.3.0}/dccd/storage/__init__.py +0 -0
  59. {dccd-3.2.0 → dccd-3.3.0}/dccd/storage/coverage_sqlite.py +0 -0
  60. {dccd-3.2.0 → dccd-3.3.0}/dccd/storage/parquet.py +0 -0
  61. {dccd-3.2.0 → dccd-3.3.0}/dccd/storage/purge.py +0 -0
  62. {dccd-3.2.0 → dccd-3.3.0}/dccd/storage/remote.py +0 -0
  63. {dccd-3.2.0 → dccd-3.3.0}/dccd/storage/runs_sqlite.py +0 -0
  64. {dccd-3.2.0 → dccd-3.3.0}/dccd/tests/__init__.py +0 -0
  65. {dccd-3.2.0 → dccd-3.3.0}/dccd/tests/v3/__init__.py +0 -0
  66. {dccd-3.2.0 → dccd-3.3.0}/dccd/tests/v3/test_application.py +0 -0
  67. {dccd-3.2.0 → dccd-3.3.0}/dccd/tests/v3/test_backfill_lookback.py +0 -0
  68. {dccd-3.2.0 → dccd-3.3.0}/dccd/tests/v3/test_client.py +0 -0
  69. {dccd-3.2.0 → dccd-3.3.0}/dccd/tests/v3/test_coverage.py +0 -0
  70. {dccd-3.2.0 → dccd-3.3.0}/dccd/tests/v3/test_domain.py +0 -0
  71. {dccd-3.2.0 → dccd-3.3.0}/dccd/tests/v3/test_domain_extended.py +0 -0
  72. {dccd-3.2.0 → dccd-3.3.0}/dccd/tests/v3/test_network.py +0 -0
  73. {dccd-3.2.0 → dccd-3.3.0}/dccd/tests/v3/test_purge.py +0 -0
  74. {dccd-3.2.0 → dccd-3.3.0}/dccd/tests/v3/test_remote_sync.py +0 -0
  75. {dccd-3.2.0 → dccd-3.3.0}/dccd/tests/v3/test_restart.py +0 -0
  76. {dccd-3.2.0 → dccd-3.3.0}/dccd/tests/v3/test_restore.py +0 -0
  77. {dccd-3.2.0 → dccd-3.3.0}/dccd/tests/v3/test_sources.py +0 -0
  78. {dccd-3.2.0 → dccd-3.3.0}/dccd/tests/v3/test_storage.py +0 -0
  79. {dccd-3.2.0 → dccd-3.3.0}/dccd/tests/v3/test_storage_extended.py +0 -0
  80. {dccd-3.2.0 → dccd-3.3.0}/dccd/tests/v3/test_transport.py +0 -0
  81. {dccd-3.2.0 → dccd-3.3.0}/dccd/transport/__init__.py +0 -0
  82. {dccd-3.2.0 → dccd-3.3.0}/dccd/transport/http.py +0 -0
  83. {dccd-3.2.0 → dccd-3.3.0}/dccd/transport/paginate.py +0 -0
  84. {dccd-3.2.0 → dccd-3.3.0}/dccd/transport/ratelimit.py +0 -0
  85. {dccd-3.2.0 → dccd-3.3.0}/dccd/transport/ws.py +0 -0
  86. {dccd-3.2.0 → dccd-3.3.0}/dccd.egg-info/dependency_links.txt +0 -0
  87. {dccd-3.2.0 → dccd-3.3.0}/dccd.egg-info/entry_points.txt +0 -0
  88. {dccd-3.2.0 → dccd-3.3.0}/dccd.egg-info/requires.txt +0 -0
  89. {dccd-3.2.0 → dccd-3.3.0}/dccd.egg-info/top_level.txt +0 -0
  90. {dccd-3.2.0 → dccd-3.3.0}/setup.cfg +0 -0
@@ -16,6 +16,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
16
16
 
17
17
  ### Removed
18
18
 
19
+ ## [3.3.0] - 2026-06-10
20
+
21
+ ### Added
22
+
23
+ - Threat-model section in `how-to/expose-remote` (trust boundaries: localhost / tailnet
24
+ / public; what the token+cookie session protect and don't; residual risks; a
25
+ recommended-postures table) — completing **Epic B** (view the UI remotely). (#110)
26
+ - API hardening for remote exposure (all opt-in, off by default): `ui_rate_limit`
27
+ (token-bucket per client on `/api/*`, over budget → `429` + `Retry-After`),
28
+ `ui_readonly` (block mutating methods → `403`, view-only share), and
29
+ `ui_trusted_proxy` (trust `X-Forwarded-For` as the rate-limit key only behind a
30
+ vetted proxy). Regression tests prove CORS is never wildcard and every mutating
31
+ route is `401` without a token. Verified live over Tailscale. (#108)
32
+ - Browser login + session: when `ui_auth_token` is set, the web UI now serves a
33
+ `/login` page and an `HttpOnly`, `SameSite=Lax` session cookie (marked `Secure`
34
+ behind an HTTPS proxy), with a Logout control. Page routes are gated (an
35
+ unauthenticated load is redirected to `/login`, no longer served), and the API
36
+ accepts the cookie alongside `Bearer`/`?token=`. Verified live over Tailscale. (#107)
37
+ - How-to guide `how-to/expose-remote` for reaching the UI from a laptop/phone behind
38
+ TLS (Caddy/nginx/Cloudflare Tunnel) or a private Tailscale overlay — never the API
39
+ plaintext off-box. Verified on a real server (Tailscale path reached live; Caddy
40
+ installs + reverse-proxies). (#106)
41
+
42
+ ### Changed
43
+
44
+ - Responsive layout for the web UI on narrow (mobile) viewports: wide/dense tables
45
+ scroll inside their own box (a `MutationObserver` wraps tables built after fetch),
46
+ bigger tap targets, and tighter nav/chrome under 640px — desktop layout unchanged.
47
+ `ui_smoke.py` gains a 390px mobile pass asserting no page-wide horizontal overflow.
48
+ Verified: 27/27 smoke steps, Δ=0px overflow on every page. (#109)
49
+
50
+ ### Fixed
51
+
52
+ - The web UI no longer injects the raw `ui_auth_token` into served pages (it was
53
+ templated into `base.html`); a remotely reachable UI could leak the token to anyone
54
+ who loaded a page. The browser now holds only an opaque session cookie. (#107)
55
+
56
+ ### Deprecated
57
+
58
+ ### Removed
59
+
19
60
  ## [3.2.0] - 2026-06-10
20
61
 
21
62
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dccd
3
- Version: 3.2.0
3
+ Version: 3.3.0
4
4
  Summary: Download Crypto Currency Data — hexagonal architecture, async-first.
5
5
  Author-email: Arthur Bernard <arthur.bernard.92@gmail.com>
6
6
  License: MIT
@@ -37,19 +37,38 @@ SUPPORTED_EXCHANGES: frozenset[str] = frozenset(
37
37
 
38
38
 
39
39
  class SettingsConfig(BaseModel):
40
- """Global settings: data path, timezone, and web-UI bind/auth."""
40
+ """Global settings: data path, timezone, and web-UI bind/auth.
41
+
42
+ Hardening knobs (all off by default, i.e. localhost-safe):
43
+
44
+ - ``ui_readonly`` — block mutating HTTP methods on ``/api/*`` (read-only share).
45
+ - ``ui_rate_limit`` — requests/sec per client on ``/api/*`` (``0`` disables).
46
+ - ``ui_trusted_proxy`` — trust ``X-Forwarded-For`` as the rate-limit client key.
47
+ Enable **only** behind a reverse proxy that overwrites the header, else a direct
48
+ client can forge it and bypass the limit.
49
+ """
41
50
  data_path: str = "./data/crypto"
42
51
  timezone: str = "local"
43
52
  ui_host: str = "127.0.0.1"
44
53
  ui_port: int = 8080
45
54
  ui_auth_token: str | None = None
46
55
  ui_allow_origins: list[str] = Field(default_factory=list)
56
+ ui_readonly: bool = False
57
+ ui_rate_limit: int = 0
58
+ ui_trusted_proxy: bool = False
47
59
 
48
60
  @field_validator("data_path")
49
61
  @classmethod
50
62
  def _expand(cls, v: str) -> str:
51
63
  return str(pathlib.Path(v).expanduser())
52
64
 
65
+ @field_validator("ui_rate_limit")
66
+ @classmethod
67
+ def _non_negative(cls, v: int) -> int:
68
+ if v < 0:
69
+ raise ValueError("ui_rate_limit must be >= 0")
70
+ return v
71
+
53
72
  @field_validator("timezone")
54
73
  @classmethod
55
74
  def _validate_tz(cls, v: str) -> str:
@@ -24,8 +24,11 @@ import asyncio
24
24
  import contextlib
25
25
  import logging
26
26
  import pathlib
27
+ import secrets
28
+ import time
27
29
  from collections.abc import Coroutine
28
30
  from typing import Any, cast
31
+ from urllib.parse import parse_qs
29
32
 
30
33
  from fastapi import FastAPI, HTTPException, Request
31
34
  from fastapi.middleware.cors import CORSMiddleware
@@ -56,6 +59,14 @@ _UI_DIR = pathlib.Path(__file__).parent.parent / "ui"
56
59
  _TEMPLATES_DIR = _UI_DIR / "templates"
57
60
  _STATIC_DIR = _UI_DIR / "static"
58
61
 
62
+ # Browser session cookie (set on /login when ui_auth_token is configured). The
63
+ # cookie is opaque and HttpOnly; the raw token is never sent to the browser.
64
+ _SESSION_COOKIE = "dccd_session"
65
+ _SESSION_TTL_SECONDS = 7 * 24 * 3600
66
+ _SESSION_TTL_NS = _SESSION_TTL_SECONDS * 1_000_000_000
67
+ # Paths reachable without a session (so the login flow itself is not gated).
68
+ _OPEN_PREFIXES = ("/login", "/logout", "/static", "/health")
69
+
59
70
  __all__ = ["create_app"]
60
71
 
61
72
  logger = logging.getLogger(__name__)
@@ -219,6 +230,73 @@ def create_app(
219
230
 
220
231
  app = FastAPI(title="dccd v3", version="3.0.0", lifespan=lifespan)
221
232
 
233
+ # Browser sessions: sid -> creation time (ns). Opaque, in-process; reset on
234
+ # restart (acceptable for a single-node daemon). Populated by POST /login.
235
+ app.state.sessions = {}
236
+
237
+ # --- session / auth helpers (closures over app.state) ---
238
+
239
+ def _configured_token() -> str | None:
240
+ cfg = getattr(app.state, "config", None)
241
+ return getattr(getattr(cfg, "settings", None), "ui_auth_token", None)
242
+
243
+ def _prune_sessions() -> None:
244
+ cutoff = time.time_ns() - _SESSION_TTL_NS
245
+ for sid in [s for s, ts in app.state.sessions.items() if ts < cutoff]:
246
+ app.state.sessions.pop(sid, None)
247
+
248
+ def _new_session() -> str:
249
+ sid = secrets.token_urlsafe(32)
250
+ app.state.sessions[sid] = time.time_ns()
251
+ return sid
252
+
253
+ def _valid_session(request: Request) -> bool:
254
+ sid = request.cookies.get(_SESSION_COOKIE)
255
+ if not sid:
256
+ return False
257
+ _prune_sessions()
258
+ return sid in app.state.sessions
259
+
260
+ def _request_is_https(request: Request) -> bool:
261
+ """True over real HTTPS or behind a proxy that sets X-Forwarded-Proto."""
262
+ if request.url.scheme == "https":
263
+ return True
264
+ fwd = request.headers.get("x-forwarded-proto", "")
265
+ return fwd.split(",", 1)[0].strip() == "https"
266
+
267
+ def _safe_next(nxt: str | None) -> str:
268
+ """Local-path-only redirect target (blocks //evil.com and absolute URLs)."""
269
+ if nxt and nxt.startswith("/") and not nxt.startswith("//") and "\\" not in nxt:
270
+ return nxt
271
+ return "/"
272
+
273
+ # --- hardening helpers (rate limit + read-only) ---
274
+
275
+ # key -> (tokens, last monotonic). Non-blocking token bucket: over budget ->
276
+ # 429 immediately (unlike transport.ratelimit which sleeps for outbound calls).
277
+ app.state.rate_buckets = {}
278
+
279
+ def _client_key(request: Request, settings: Any) -> str:
280
+ """Rate-limit key. Trust X-Forwarded-For only behind a vetted proxy, else
281
+ a direct client could forge it and mint unlimited keys."""
282
+ if getattr(settings, "ui_trusted_proxy", False):
283
+ xff = request.headers.get("x-forwarded-for", "")
284
+ if xff:
285
+ return xff.split(",", 1)[0].strip()
286
+ client = request.client
287
+ return client.host if client else "unknown"
288
+
289
+ def _rate_allow(key: str, limit: int) -> bool:
290
+ buckets = app.state.rate_buckets
291
+ now = time.monotonic()
292
+ tokens, last = buckets.get(key, (float(limit), now))
293
+ tokens = min(float(limit), tokens + (now - last) * limit)
294
+ if tokens < 1.0:
295
+ buckets[key] = (tokens, now)
296
+ return False
297
+ buckets[key] = (tokens - 1.0, now)
298
+ return True
299
+
222
300
  # CORS: no wildcard. The UI is served same-origin, so it needs no CORS at
223
301
  # all; allowing every origin let any website's JS drive the local API
224
302
  # (reachable on 127.0.0.1 from the user's browser). Cross-origin access is
@@ -234,24 +312,51 @@ def create_app(
234
312
 
235
313
  @app.middleware("http")
236
314
  async def _auth_guard(request: Request, call_next):
237
- """Require a Bearer token on /api/* when settings.ui_auth_token is set.
238
-
239
- Page routes are intentionally not gated (browsers can't send Bearer on
240
- navigation); for untrusted networks, front the UI with a reverse proxy.
315
+ """Rate-limit, authenticate and (optionally) read-only-gate requests.
316
+
317
+ Order matters: rate limit ``/api/*`` first (cheap rejection, before any
318
+ auth work), then authenticate, then enforce read-only so an
319
+ unauthenticated mutating call still gets ``401`` (auth wins) rather than
320
+ ``403``. ``/api/*`` accepts a Bearer header, a ``?token=`` query (non-browser
321
+ SSE) or the session cookie; page routes accept only the cookie and otherwise
322
+ redirect to ``/login``. With no token configured everything authenticates,
323
+ but rate-limit and read-only still apply if enabled.
241
324
  """
242
- cfg = getattr(request.app.state, "config", None)
243
- token = getattr(getattr(cfg, "settings", None), "ui_auth_token", None)
325
+ settings = getattr(getattr(app.state, "config", None), "settings", None)
326
+ path = request.url.path
327
+ method = request.method
328
+ is_api = path.startswith("/api/")
329
+
330
+ # 1) Rate limit /api/* (independent of auth), opt-in via ui_rate_limit.
331
+ limit = int(getattr(settings, "ui_rate_limit", 0) or 0)
332
+ if is_api and method != "OPTIONS" and limit > 0:
333
+ if not _rate_allow(_client_key(request, settings), limit):
334
+ return JSONResponse(
335
+ {"detail": "rate limited"}, status_code=429,
336
+ headers={"Retry-After": "1"},
337
+ )
338
+
339
+ # 2) Authenticate (when a token is configured).
340
+ token = _configured_token()
341
+ if token and method != "OPTIONS":
342
+ if is_api:
343
+ bearer = request.headers.get("Authorization") == f"Bearer {token}"
344
+ query = request.query_params.get("token") == token
345
+ if not (bearer or query or _valid_session(request)):
346
+ return JSONResponse({"detail": "Unauthorized"}, status_code=401)
347
+ elif templates is not None and not path.startswith(_OPEN_PREFIXES):
348
+ # Page route: only the session cookie authenticates a browser.
349
+ if not _valid_session(request):
350
+ return RedirectResponse(f"/login?next={path}", status_code=303)
351
+
352
+ # 3) Read-only gate (after auth): block mutating /api/* when ui_readonly.
244
353
  if (
245
- token
246
- and request.url.path.startswith("/api/")
247
- and request.method != "OPTIONS"
354
+ is_api
355
+ and getattr(settings, "ui_readonly", False)
356
+ and method in ("POST", "PUT", "PATCH", "DELETE")
248
357
  ):
249
- # Header for normal calls; query param for EventSource (SSE), which
250
- # cannot set Authorization headers.
251
- bearer = request.headers.get("Authorization") == f"Bearer {token}"
252
- query = request.query_params.get("token") == token
253
- if not (bearer or query):
254
- return JSONResponse({"detail": "Unauthorized"}, status_code=401)
358
+ return JSONResponse({"detail": "read-only"}, status_code=403)
359
+
255
360
  return await call_next(request)
256
361
 
257
362
  if _STATIC_DIR.exists():
@@ -718,13 +823,64 @@ def create_app(
718
823
  ver = "dev"
719
824
  cfg = getattr(request.app.state, "config", None)
720
825
  settings = getattr(cfg, "settings", None)
721
- token = getattr(settings, "ui_auth_token", None)
722
826
  tz = getattr(settings, "timezone", None) or "local"
827
+ # `authed` drives the Logout affordance; only meaningful when a token
828
+ # is configured. The raw token is never injected into the page.
829
+ authed = bool(_configured_token()) and _valid_session(request)
723
830
  return {
724
831
  "active": request.url.path, "version": ver, "page": page,
725
- "auth_token": token or "", "timezone": tz,
832
+ "authed": authed, "timezone": tz,
726
833
  }
727
834
 
835
+ @app.get("/login")
836
+ async def ui_login(request: Request):
837
+ # No token configured, or already authenticated → nothing to log into.
838
+ if not _configured_token() or _valid_session(request):
839
+ return RedirectResponse("/", status_code=303)
840
+ try:
841
+ ver = _pkg_version("dccd")
842
+ except Exception:
843
+ ver = "dev"
844
+ return templates.TemplateResponse(
845
+ request, "login.html",
846
+ {"version": ver, "next": _safe_next(request.query_params.get("next")),
847
+ "error": False},
848
+ )
849
+
850
+ @app.post("/login")
851
+ async def ui_login_post(request: Request):
852
+ token = _configured_token()
853
+ # Parse the urlencoded form by hand to avoid a python-multipart dep.
854
+ fields = parse_qs((await request.body()).decode("utf-8", "replace"))
855
+ submitted = (fields.get("token") or [""])[0]
856
+ nxt = _safe_next((fields.get("next") or ["/"])[0])
857
+ if not token or not secrets.compare_digest(submitted, token):
858
+ try:
859
+ ver = _pkg_version("dccd")
860
+ except Exception:
861
+ ver = "dev"
862
+ return templates.TemplateResponse(
863
+ request, "login.html",
864
+ {"version": ver, "next": nxt, "error": True},
865
+ status_code=401,
866
+ )
867
+ sid = _new_session()
868
+ resp = RedirectResponse(nxt, status_code=303)
869
+ resp.set_cookie(
870
+ _SESSION_COOKIE, sid, httponly=True, samesite="lax",
871
+ secure=_request_is_https(request), max_age=_SESSION_TTL_SECONDS, path="/",
872
+ )
873
+ return resp
874
+
875
+ @app.post("/logout")
876
+ async def ui_logout(request: Request):
877
+ sid = request.cookies.get(_SESSION_COOKIE)
878
+ if sid:
879
+ app.state.sessions.pop(sid, None)
880
+ resp = RedirectResponse("/login", status_code=303)
881
+ resp.delete_cookie(_SESSION_COOKIE, path="/")
882
+ return resp
883
+
728
884
  # Starlette >= 0.29 / 1.x signature: TemplateResponse(request, name, context)
729
885
  @app.get("/")
730
886
  async def ui_dashboard(request: Request):
@@ -27,6 +27,7 @@
27
27
  nav a:hover { background:var(--border); color:var(--fg); }
28
28
  nav a.active { color:var(--accent); background:rgba(74,158,255,.12);
29
29
  font-weight:600; }
30
+ nav .nav-logout { margin:0; margin-left:auto; }
30
31
  nav .nav-menu { position:relative; }
31
32
  nav .nav-menu-btn { color:var(--muted); background:transparent; border:0;
32
33
  padding:.4rem .8rem; border-radius:6px; cursor:pointer; font:inherit; }
@@ -128,6 +129,24 @@
128
129
  to { opacity:1; transform:none; } }
129
130
  .j-key { color:#4a9eff; } .j-str { color:#3fb950; }
130
131
  .j-num { color:#d2a8ff; } .j-bool { color:#f0883e; } .j-null { color:#8b98a5; }
132
+ /* Wide/dense tables scroll horizontally inside their own box instead of
133
+ pushing the whole page wide (wrapper added by wrapTables(), below). */
134
+ .table-scroll { overflow-x:auto; -webkit-overflow-scrolling:touch; max-width:100%; }
135
+ /* Mobile / narrow viewport: bigger tap targets, tighter chrome, no page-wide
136
+ horizontal scroll. Desktop layout above the breakpoint is untouched. */
137
+ @media (max-width: 640px) {
138
+ nav { padding:.4rem .6rem; gap:.15rem; }
139
+ nav a, nav .nav-menu-btn, nav .nav-logout button { padding:.55rem .7rem; }
140
+ nav .brand-group .ver { display:none; }
141
+ nav .nav-menu-items { max-width:calc(100vw - 1.2rem); }
142
+ main { padding:.8rem .7rem; }
143
+ h1 { font-size:1.15rem; }
144
+ h2 { font-size:1rem; }
145
+ button, .tab { min-height:40px; }
146
+ details.ex > summary { padding:.7rem .8rem; }
147
+ .inline-form { gap:.5rem; }
148
+ .modal { min-width:0; width:calc(100vw - 2rem); }
149
+ }
131
150
  </style>
132
151
  </head>
133
152
  <body>
@@ -159,6 +178,11 @@
159
178
  {% endfor %}
160
179
  </div>
161
180
  </div>
181
+ {% if authed %}
182
+ <form method="post" action="/logout" class="nav-logout">
183
+ <button type="submit" class="nav-menu-btn" title="Sign out">Logout</button>
184
+ </form>
185
+ {% endif %}
162
186
  </nav>
163
187
  <main>
164
188
  {% block content %}{% endblock %}
@@ -305,10 +329,29 @@
305
329
  root.appendChild(el);
306
330
  setTimeout(function () { el.remove(); }, kind === 'err' ? 6000 : 3500);
307
331
  }
308
- const DCCD_TOKEN = "{{ auth_token }}";
332
+ // Keep wide tables inside a horizontally-scrollable box so a narrow viewport
333
+ // never scrolls the whole page sideways. Idempotent; a MutationObserver re-runs
334
+ // it for tables that pages build after fetching data.
335
+ function wrapTables(root) {
336
+ (root || document).querySelectorAll('main table').forEach(function (t) {
337
+ if (!t.parentElement.classList.contains('table-scroll')) {
338
+ var w = document.createElement('div');
339
+ w.className = 'table-scroll';
340
+ t.parentNode.insertBefore(w, t);
341
+ w.appendChild(t);
342
+ }
343
+ });
344
+ }
345
+ document.addEventListener('DOMContentLoaded', function () {
346
+ wrapTables();
347
+ var main = document.querySelector('main') || document.body;
348
+ new MutationObserver(function () { wrapTables(); })
349
+ .observe(main, { childList: true, subtree: true });
350
+ });
351
+ // Auth is carried by the HttpOnly `dccd_session` cookie (set at /login); the
352
+ // raw token is never exposed to JS. `same-origin` ensures the cookie is sent.
309
353
  async function api(method, url, body) {
310
- const opts = { method, headers: {} };
311
- if (DCCD_TOKEN) opts.headers['Authorization'] = 'Bearer ' + DCCD_TOKEN;
354
+ const opts = { method, headers: {}, credentials: 'same-origin' };
312
355
  if (body !== undefined) {
313
356
  opts.headers['Content-Type'] = 'application/json';
314
357
  opts.body = JSON.stringify(body);
@@ -278,7 +278,7 @@ async function createNew() {
278
278
  let es;
279
279
  function connectSSE() {
280
280
  if (es) es.close();
281
- es = new EventSource('/api/events' + (DCCD_TOKEN ? '?token='+encodeURIComponent(DCCD_TOKEN) : ''));
281
+ es = new EventSource('/api/events');
282
282
  const st = document.getElementById('sse-status');
283
283
  es.onopen = () => { st.innerHTML = '<span class="ok">● connected</span>'; };
284
284
  es.onmessage = (e) => {
@@ -0,0 +1,47 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Sign in — dccd UI</title>
7
+ <link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
8
+ <style>
9
+ :root { --bg:#0f1419; --fg:#e6e6e6; --muted:#8b98a5; --accent:#4a9eff;
10
+ --border:#2a3038; --card:#161b22; --ok:#3fb950; --err:#f85149; }
11
+ * { box-sizing: border-box; }
12
+ body { margin:0; font:14px/1.5 system-ui, sans-serif; background:var(--bg);
13
+ color:var(--fg); display:flex; min-height:100vh; align-items:center;
14
+ justify-content:center; }
15
+ .card { background:var(--card); border:1px solid var(--border); border-radius:10px;
16
+ padding:2rem; width:min(92vw, 360px); }
17
+ .brand { display:flex; align-items:center; gap:.5rem; margin-bottom:1.25rem; }
18
+ .brand img { height:32px; width:auto; }
19
+ .brand .name { font-size:1.2rem; font-weight:700; }
20
+ .brand .ver { color:var(--muted); font-size:.78rem; }
21
+ label { display:block; font-size:.85rem; color:var(--muted); margin-bottom:.35rem; }
22
+ input[type=password] { width:100%; padding:.6rem .7rem; border-radius:8px;
23
+ border:1px solid var(--border); background:var(--bg); color:var(--fg);
24
+ font-size:1rem; }
25
+ button { margin-top:1rem; width:100%; padding:.65rem; border:0; border-radius:8px;
26
+ background:var(--accent); color:#06121f; font-weight:700; font-size:1rem;
27
+ cursor:pointer; }
28
+ button:hover { filter:brightness(1.08); }
29
+ .err { margin-top:.9rem; color:var(--err); font-size:.85rem; }
30
+ </style>
31
+ </head>
32
+ <body>
33
+ <form class="card" method="post" action="/login">
34
+ <div class="brand">
35
+ <img src="/static/logo.svg" alt="dccd">
36
+ <span class="name">dccd</span>
37
+ <span class="ver">v{{ version }}</span>
38
+ </div>
39
+ <input type="hidden" name="next" value="{{ next }}">
40
+ <label for="token">Access token</label>
41
+ <input id="token" name="token" type="password" autocomplete="current-password"
42
+ autofocus required>
43
+ <button type="submit">Sign in</button>
44
+ {% if error %}<div class="err">Invalid token — try again.</div>{% endif %}
45
+ </form>
46
+ </body>
47
+ </html>
@@ -50,7 +50,7 @@ function clearLive() { logEl.textContent = 'Waiting for events…\n'; eventCount
50
50
 
51
51
  function connect() {
52
52
  if (es) es.close();
53
- es = new EventSource('/api/events' + (DCCD_TOKEN ? '?token=' + encodeURIComponent(DCCD_TOKEN) : ''));
53
+ es = new EventSource('/api/events');
54
54
  es.onopen = () => { statusEl.innerHTML = '<span class="ok">● connected</span>'; };
55
55
  es.onmessage = (e) => {
56
56
  if (e.data.startsWith(':')) return;
@@ -66,6 +66,186 @@ class TestAuth:
66
66
  def test_no_token_means_open(self, client):
67
67
  assert client.get("/api/inventory").status_code == 200
68
68
 
69
+ def test_api_accepts_query_token(self, auth_client):
70
+ # SSE/EventSource path: ?token= still authorises (non-browser clients).
71
+ r = auth_client.get("/api/inventory?token=s3cret")
72
+ assert r.status_code == 200
73
+
74
+
75
+ class TestAuthSession:
76
+ """Browser login/session: page routes gated, token never templated."""
77
+
78
+ @pytest.fixture
79
+ def cfg(self, tmp_data_path):
80
+ cfg = AppConfig()
81
+ cfg.settings.data_path = tmp_data_path
82
+ cfg.storage.local_path = tmp_data_path
83
+ cfg.settings.ui_auth_token = "s3cret"
84
+ return cfg
85
+
86
+ @pytest.fixture
87
+ def auth_client(self, cfg):
88
+ with TestClient(create_app(config=cfg)) as c:
89
+ yield c
90
+
91
+ def test_page_redirects_to_login_when_unauthenticated(self, auth_client):
92
+ r = auth_client.get("/", follow_redirects=False)
93
+ assert r.status_code == 303
94
+ assert r.headers["location"] == "/login?next=/"
95
+
96
+ def test_login_page_renders(self, auth_client):
97
+ r = auth_client.get("/login")
98
+ assert r.status_code == 200
99
+ assert "token" in r.text.lower()
100
+
101
+ def test_login_wrong_token_401(self, auth_client):
102
+ r = auth_client.post(
103
+ "/login", data={"token": "nope", "next": "/"}, follow_redirects=False
104
+ )
105
+ assert r.status_code == 401
106
+
107
+ def test_login_sets_httponly_lax_cookie_and_grants_access(self, cfg):
108
+ with TestClient(create_app(config=cfg)) as c:
109
+ r = c.post(
110
+ "/login", data={"token": "s3cret", "next": "/"}, follow_redirects=False
111
+ )
112
+ assert r.status_code == 303
113
+ set_cookie = r.headers["set-cookie"].lower()
114
+ assert "dccd_session=" in set_cookie
115
+ assert "httponly" in set_cookie
116
+ assert "samesite=lax" in set_cookie
117
+ assert "samesite=none" not in set_cookie # CSRF: never cross-site
118
+ # The session cookie now drives both pages and the API.
119
+ assert c.get("/", follow_redirects=False).status_code == 200
120
+ assert c.get("/api/inventory").status_code == 200
121
+
122
+ def test_api_rejects_without_cookie_or_bearer(self, auth_client):
123
+ # Fresh client (no cookie) cannot reach the API.
124
+ assert auth_client.get("/api/inventory").status_code == 401
125
+
126
+ def test_logout_clears_session(self, cfg):
127
+ with TestClient(create_app(config=cfg)) as c:
128
+ c.post("/login", data={"token": "s3cret", "next": "/"})
129
+ assert c.get("/", follow_redirects=False).status_code == 200
130
+ c.post("/logout", follow_redirects=False)
131
+ assert c.get("/", follow_redirects=False).status_code == 303
132
+
133
+ def test_token_never_in_page(self, cfg):
134
+ with TestClient(create_app(config=cfg)) as c:
135
+ c.post("/login", data={"token": "s3cret", "next": "/"})
136
+ body = c.get("/").text
137
+ assert "s3cret" not in body # regression: token must not leak into HTML
138
+
139
+ def test_open_redirect_blocked(self, cfg):
140
+ for bad in ("//evil.com", "https://evil", "/\\evil"):
141
+ with TestClient(create_app(config=cfg)) as c:
142
+ r = c.post(
143
+ "/login", data={"token": "s3cret", "next": bad},
144
+ follow_redirects=False,
145
+ )
146
+ assert r.headers["location"] == "/", bad
147
+ with TestClient(create_app(config=cfg)) as c:
148
+ r = c.post(
149
+ "/login", data={"token": "s3cret", "next": "/data"},
150
+ follow_redirects=False,
151
+ )
152
+ assert r.headers["location"] == "/data"
153
+
154
+ def test_no_token_pages_open(self, client):
155
+ # Default localhost (no token): pages render without a login.
156
+ assert client.get("/", follow_redirects=False).status_code == 200
157
+
158
+
159
+ class TestHardening:
160
+ """Rate limit, CORS-never-wildcard, read-only mode, mutating-routes-need-auth."""
161
+
162
+ def _cfg(self, tmp_data_path, **over):
163
+ cfg = AppConfig()
164
+ cfg.settings.data_path = tmp_data_path
165
+ cfg.storage.local_path = tmp_data_path
166
+ for k, v in over.items():
167
+ setattr(cfg.settings, k, v)
168
+ return cfg
169
+
170
+ # --- CORS: never wildcard ---
171
+
172
+ def test_cors_no_origin_when_unconfigured(self, tmp_data_path):
173
+ cfg = self._cfg(tmp_data_path) # ui_allow_origins=[]
174
+ with TestClient(create_app(config=cfg)) as c:
175
+ r = c.get("/api/inventory", headers={"Origin": "http://evil.test"})
176
+ assert "access-control-allow-origin" not in {k.lower() for k in r.headers}
177
+
178
+ def test_cors_echoes_allowed_origin_never_star(self, tmp_data_path):
179
+ cfg = self._cfg(tmp_data_path, ui_allow_origins=["http://good.test"])
180
+ with TestClient(create_app(config=cfg)) as c:
181
+ ok = c.get("/api/inventory", headers={"Origin": "http://good.test"})
182
+ assert ok.headers.get("access-control-allow-origin") == "http://good.test"
183
+ evil = c.get("/api/inventory", headers={"Origin": "http://evil.test"})
184
+ assert evil.headers.get("access-control-allow-origin") != "*"
185
+
186
+ # --- Rate limit ---
187
+
188
+ def test_rate_limit_trips_429(self, tmp_data_path):
189
+ cfg = self._cfg(tmp_data_path, ui_rate_limit=2)
190
+ with TestClient(create_app(config=cfg)) as c:
191
+ responses = [c.get("/api/inventory") for _ in range(8)]
192
+ codes = [r.status_code for r in responses]
193
+ assert 429 in codes
194
+ # every 429 must carry Retry-After
195
+ assert all("retry-after" in {k.lower() for k in r.headers}
196
+ for r in responses if r.status_code == 429)
197
+
198
+ def test_rate_limit_off_never_429(self, tmp_data_path):
199
+ cfg = self._cfg(tmp_data_path, ui_rate_limit=0)
200
+ with TestClient(create_app(config=cfg)) as c:
201
+ assert all(c.get("/api/inventory").status_code == 200 for _ in range(12))
202
+
203
+ def test_xff_not_trusted_by_default(self, tmp_data_path):
204
+ # Forged X-Forwarded-For must not mint fresh buckets when proxy not trusted.
205
+ cfg = self._cfg(tmp_data_path, ui_rate_limit=2)
206
+ with TestClient(create_app(config=cfg)) as c:
207
+ codes = [c.get("/api/inventory", headers={"X-Forwarded-For": f"9.9.9.{i}"}).status_code
208
+ for i in range(8)]
209
+ assert 429 in codes # all share the real peer key despite forged XFF
210
+
211
+ def test_xff_trusted_gives_distinct_buckets(self, tmp_data_path):
212
+ cfg = self._cfg(tmp_data_path, ui_rate_limit=2, ui_trusted_proxy=True)
213
+ with TestClient(create_app(config=cfg)) as c:
214
+ codes = [c.get("/api/inventory", headers={"X-Forwarded-For": f"9.9.9.{i}"}).status_code
215
+ for i in range(8)]
216
+ assert codes.count(200) == 8 # each distinct client has its own bucket
217
+
218
+ # --- Read-only ---
219
+
220
+ def test_readonly_blocks_mutating(self, tmp_data_path):
221
+ cfg = self._cfg(tmp_data_path, ui_readonly=True)
222
+ with TestClient(create_app(config=cfg)) as c:
223
+ assert c.post("/api/jobs/create", json={}).status_code == 403
224
+ assert c.get("/api/jobs").status_code == 200
225
+
226
+ def test_readonly_401_wins_for_unauth_mutating(self, tmp_data_path):
227
+ # token + readonly: an unauthenticated mutating call is 401, not 403.
228
+ cfg = self._cfg(tmp_data_path, ui_readonly=True, ui_auth_token="s3cret")
229
+ with TestClient(create_app(config=cfg)) as c:
230
+ assert c.post("/api/jobs/create", json={}).status_code == 401
231
+ # authenticated mutating call is then blocked by read-only (403).
232
+ r = c.post("/api/jobs/create", json={},
233
+ headers={"Authorization": "Bearer s3cret"})
234
+ assert r.status_code == 403
235
+
236
+ # --- Mutating routes require auth ---
237
+
238
+ @pytest.mark.parametrize("method,path", [
239
+ ("post", "/api/jobs/create"), ("post", "/api/jobs/delete"),
240
+ ("post", "/api/jobs/update"), ("post", "/api/jobs/run"),
241
+ ("post", "/api/jobs/run-all"), ("post", "/api/streams/start"),
242
+ ("post", "/api/streams/stop"), ("delete", "/api/backfill/x"),
243
+ ])
244
+ def test_mutating_routes_require_token(self, tmp_data_path, method, path):
245
+ cfg = self._cfg(tmp_data_path, ui_auth_token="s3cret")
246
+ with TestClient(create_app(config=cfg)) as c:
247
+ assert getattr(c, method)(path).status_code == 401
248
+
69
249
 
70
250
  class _OkRemote:
71
251
  """In-test remote that reports success without invoking rclone."""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dccd
3
- Version: 3.2.0
3
+ Version: 3.3.0
4
4
  Summary: Download Crypto Currency Data — hexagonal architecture, async-first.
5
5
  Author-email: Arthur Bernard <arthur.bernard.92@gmail.com>
6
6
  License: MIT
@@ -44,6 +44,7 @@ dccd/interfaces/ui/templates/dashboard.html
44
44
  dccd/interfaces/ui/templates/data.html
45
45
  dccd/interfaces/ui/templates/historical.html
46
46
  dccd/interfaces/ui/templates/live.html
47
+ dccd/interfaces/ui/templates/login.html
47
48
  dccd/interfaces/ui/templates/logs.html
48
49
  dccd/interfaces/ui/templates/storage.html
49
50
  dccd/sources/__init__.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "dccd"
7
- version = "3.2.0"
7
+ version = "3.3.0"
8
8
  description = "Download Crypto Currency Data — hexagonal architecture, async-first."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes