cli-consumption 0.3.3__tar.gz → 0.4.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 (62) hide show
  1. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/.gitignore +6 -0
  2. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/CHANGELOG.md +36 -1
  3. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/PKG-INFO +22 -2
  4. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/README.md +21 -1
  5. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/pyproject.toml +2 -2
  6. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/api.py +137 -17
  7. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/cli.py +261 -6
  8. cli_consumption-0.4.0/src/cli_consumption/dashboard.py +813 -0
  9. cli_consumption-0.4.0/src/cli_consumption/dashboard_react.css +2 -0
  10. cli_consumption-0.4.0/src/cli_consumption/dashboard_react.js +9 -0
  11. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/reporting.py +109 -23
  12. cli_consumption-0.4.0/src/cli_consumption/reporting_api.py +750 -0
  13. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/schema.py +138 -33
  14. cli_consumption-0.4.0/src/cli_consumption/snapshot_extraction.py +252 -0
  15. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/sync.py +44 -2
  16. cli_consumption-0.3.3/src/cli_consumption/dashboard.py +0 -813
  17. cli_consumption-0.3.3/src/cli_consumption/dashboard_calculations.js +0 -543
  18. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/LICENSE +0 -0
  19. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/NOTICE +0 -0
  20. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/__init__.py +0 -0
  21. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/__main__.py +0 -0
  22. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/__init__.py +0 -0
  23. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/_shared.py +0 -0
  24. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/aider.py +0 -0
  25. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/amazon_q.py +0 -0
  26. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/amp.py +0 -0
  27. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/base.py +0 -0
  28. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/claude.py +0 -0
  29. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/cline.py +0 -0
  30. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/codex.py +0 -0
  31. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/continue_cli.py +0 -0
  32. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/copilot.py +0 -0
  33. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/crush.py +0 -0
  34. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/cursor.py +0 -0
  35. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/gemini.py +0 -0
  36. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/goose.py +0 -0
  37. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/grok.py +0 -0
  38. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/kilo.py +0 -0
  39. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/kimi.py +0 -0
  40. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/mistral_vibe.py +0 -0
  41. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/opencode.py +0 -0
  42. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/openhands.py +0 -0
  43. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/pi.py +0 -0
  44. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/plandex.py +0 -0
  45. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/qwen.py +0 -0
  46. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/adapters/registry.py +0 -0
  47. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/exporting.py +0 -0
  48. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/migrations/__init__.py +0 -0
  49. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/migrations/env.py +0 -0
  50. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/migrations/versions/__init__.py +0 -0
  51. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/migrations/versions/v0001_baseline.py +0 -0
  52. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/migrations/versions/v0002_minimize_subagents.py +0 -0
  53. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/migrations/versions/v0003_canonical_timestamps.py +0 -0
  54. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/migrations/versions/v0004_subagent_scope_freshness.py +0 -0
  55. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/migrations/versions/v0005_sync_receipts.py +0 -0
  56. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/models.py +0 -0
  57. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/py.typed +0 -0
  58. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/qualifications.py +0 -0
  59. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/retention.py +0 -0
  60. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/snapshot_files.py +0 -0
  61. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/storage.py +0 -0
  62. {cli_consumption-0.3.3 → cli_consumption-0.4.0}/src/cli_consumption/timestamps.py +0 -0
@@ -12,6 +12,12 @@ reports/
12
12
  *.sqlite
13
13
  *.sqlite3
14
14
  .env
15
+ node_modules/
16
+ apps/*/.next/
17
+ playwright-report/
18
+ test-results/
19
+ *.tsbuildinfo
20
+ packages/*/dist/
15
21
 
16
22
  # Machine-specific orchestration context. These files must never be committed.
17
23
  .agents/orchestrator.md
@@ -6,6 +6,39 @@ use [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.0] - 2026-09-01
10
+
11
+ ### Added
12
+
13
+ - Added a blocking browser CI gate that opens detailed and share-safe dashboards
14
+ directly from `file://`, exercises their controls, and rejects network activity.
15
+ - Defined the versioned, scoped, and bounded contracts for database upload, persistent
16
+ dashboard reporting, conversation pagination, and standalone web export.
17
+ - Added read-only, migration-free extraction of bounded snapshot-schema-v1 payloads
18
+ from current local SQLite collection databases, including WAL-consistent reads,
19
+ exact schema verification, provider grouping, and privacy-safe failures.
20
+ - Added `upload-db` with stable per-snapshot replay keys, required capability
21
+ negotiation, bounded retries, deterministic partial results, resumable uploads, and
22
+ strict fail-fast automation.
23
+ - Added scoped `read` and `export` bearer credentials plus strict, bounded reporting
24
+ endpoints for dashboard datasets, filter options, opaque stable pagination,
25
+ conversation detail, and self-contained HTML downloads.
26
+ - Added a locked TypeScript workspace with versioned dashboard contracts, pure shared
27
+ analytics, presentation helpers, Vitest parity coverage, ESM output, and a
28
+ reproducible network-free browser bundle included in Python wheels.
29
+ - Added an authenticated responsive Next.js dashboard with a server-only collector
30
+ credential, bounded reporting BFF, URL-safe filters, conversation pagination and
31
+ detail, shared analytics, accessibility checks, and desktop/mobile browser coverage.
32
+ - Replaced the classic offline renderer with the self-contained React/Tailwind runtime,
33
+ shared web primitives, streamed bounded dataset injection, reproducible packaged
34
+ assets, and detailed/share-safe `file://` browser coverage. The temporary
35
+ `--renderer` migration option was removed.
36
+ - Added persistent-dashboard offline downloads for the exact visible selection, with
37
+ detailed/share-safe profiles, a credential-isolating bounded BFF, private temporary
38
+ cleanup, fixed failures, and self-contained browser coverage.
39
+
40
+ ## [0.3.3] - 2026-08-31
41
+
9
42
  ### Added
10
43
 
11
44
  - Added deterministic, compressed offline snapshot files authenticated with Ed25519,
@@ -131,7 +164,9 @@ use [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
131
164
 
132
165
  - Refreshed the provider guide for the first minor release ([#26]).
133
166
 
134
- [Unreleased]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.3.2...HEAD
167
+ [Unreleased]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.4.0...HEAD
168
+ [0.4.0]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.3.3...v0.4.0
169
+ [0.3.3]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.3.2...v0.3.3
135
170
  [0.3.2]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.3.1...v0.3.2
136
171
  [0.3.1]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.3.0...v0.3.1
137
172
  [0.3.0]: https://github.com/Guillaume-Lombardo/cli-consumption/compare/v0.2.1...v0.3.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cli-consumption
3
- Version: 0.3.3
3
+ Version: 0.4.0
4
4
  Summary: Analyze and consolidate AI coding CLI consumption across machines.
5
5
  Project-URL: Homepage, https://github.com/Guillaume-Lombardo/cli-consumption
6
6
  Project-URL: Documentation, https://github.com/Guillaume-Lombardo/cli-consumption#readme
@@ -97,6 +97,20 @@ uv run cli-consumption export --output reports
97
97
  Open `reports/dashboard.html` locally. It makes no network requests. Detailed CSV
98
98
  tables are generated only when `--csv` is passed.
99
99
 
100
+ Dashboard development lives in the locked npm workspace under `packages/`. It builds
101
+ provider-neutral ESM analytics, shared React presentation primitives, and deterministic
102
+ React/Tailwind browser assets that Python embeds in the wheel. The React runtime is the
103
+ only offline renderer; installing or using the Python CLI does not require Node.js.
104
+
105
+ The authenticated persistent dashboard lives in `apps/web/`. It reads the same
106
+ minimized reporting contract through a server-side Next.js BFF: the browser never
107
+ receives a collector credential or a database connection string. Its **Export
108
+ offline** action downloads the exact visible selection as a self-contained detailed or
109
+ share-safe HTML file. For a production
110
+ setup, required environment variables, reverse-proxy constraints, and startup commands
111
+ are documented in the
112
+ [deployment guide](https://github.com/Guillaume-Lombardo/cli-consumption/blob/main/docs/deployment.md#run-the-persistent-dashboard).
113
+
100
114
  To collect one provider or select another database:
101
115
 
102
116
  ```bash
@@ -157,7 +171,8 @@ for ingestion, idempotency, migrations, report limits, and collector behavior.
157
171
  | `collect` | Collect local or copied provider data into SQL. |
158
172
  | `snapshot` | Create or ingest signed, compressed offline snapshot files. |
159
173
  | `sync` | Collect and send metadata-only snapshots to a central API. |
160
- | `serve` | Run the central collection API. |
174
+ | `upload-db` | Upload validated snapshots reconstructed from a local collect database. |
175
+ | `serve` | Run the scoped central ingestion, reporting, and export API. |
161
176
  | `export` | Write the HTML dashboard and optional CSV tables. |
162
177
  | `providers` | List provider names and compatibility status. |
163
178
  | `retention` | Preview or apply deletion outside a retention window. |
@@ -188,6 +203,11 @@ uv run ruff check .
188
203
  uv run ty check
189
204
  uv run pytest --cov --cov-report=term-missing
190
205
  uv build
206
+ npm ci
207
+ npm run verify
208
+ npm audit --audit-level=high
209
+ npm run build:web
210
+ npm run test:e2e
191
211
  ```
192
212
 
193
213
  Development uses short-lived branches and squash-merged pull requests into protected
@@ -58,6 +58,20 @@ uv run cli-consumption export --output reports
58
58
  Open `reports/dashboard.html` locally. It makes no network requests. Detailed CSV
59
59
  tables are generated only when `--csv` is passed.
60
60
 
61
+ Dashboard development lives in the locked npm workspace under `packages/`. It builds
62
+ provider-neutral ESM analytics, shared React presentation primitives, and deterministic
63
+ React/Tailwind browser assets that Python embeds in the wheel. The React runtime is the
64
+ only offline renderer; installing or using the Python CLI does not require Node.js.
65
+
66
+ The authenticated persistent dashboard lives in `apps/web/`. It reads the same
67
+ minimized reporting contract through a server-side Next.js BFF: the browser never
68
+ receives a collector credential or a database connection string. Its **Export
69
+ offline** action downloads the exact visible selection as a self-contained detailed or
70
+ share-safe HTML file. For a production
71
+ setup, required environment variables, reverse-proxy constraints, and startup commands
72
+ are documented in the
73
+ [deployment guide](https://github.com/Guillaume-Lombardo/cli-consumption/blob/main/docs/deployment.md#run-the-persistent-dashboard).
74
+
61
75
  To collect one provider or select another database:
62
76
 
63
77
  ```bash
@@ -118,7 +132,8 @@ for ingestion, idempotency, migrations, report limits, and collector behavior.
118
132
  | `collect` | Collect local or copied provider data into SQL. |
119
133
  | `snapshot` | Create or ingest signed, compressed offline snapshot files. |
120
134
  | `sync` | Collect and send metadata-only snapshots to a central API. |
121
- | `serve` | Run the central collection API. |
135
+ | `upload-db` | Upload validated snapshots reconstructed from a local collect database. |
136
+ | `serve` | Run the scoped central ingestion, reporting, and export API. |
122
137
  | `export` | Write the HTML dashboard and optional CSV tables. |
123
138
  | `providers` | List provider names and compatibility status. |
124
139
  | `retention` | Preview or apply deletion outside a retention window. |
@@ -149,6 +164,11 @@ uv run ruff check .
149
164
  uv run ty check
150
165
  uv run pytest --cov --cov-report=term-missing
151
166
  uv build
167
+ npm ci
168
+ npm run verify
169
+ npm audit --audit-level=high
170
+ npm run build:web
171
+ npm run test:e2e
152
172
  ```
153
173
 
154
174
  Development uses short-lived branches and squash-merged pull requests into protected
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cli-consumption"
7
- version = "0.3.3"
7
+ version = "0.4.0"
8
8
  description = "Analyze and consolidate AI coding CLI consumption across machines."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -54,6 +54,7 @@ dev = [
54
54
  "httpx>=0.27",
55
55
  "hypothesis>=6.165.10",
56
56
  "pip-audit>=2.10.1",
57
+ "playwright>=1.55",
57
58
  "pre-commit>=4.6.2",
58
59
  "psycopg[binary]>=3.2",
59
60
  "pytest>=8.3",
@@ -110,6 +111,5 @@ select = ["E", "F", "I", "UP", "B", "SIM", "RUF", "S"]
110
111
  "tests/**" = ["S101"]
111
112
  "tests/smoke_minimal_install.py" = ["S603", "S607"]
112
113
  "tests/test_codex_adapter.py" = ["S608"]
113
- "tests/test_dashboard_calculations.py" = ["S607"]
114
114
  "tests/test_demo.py" = ["S603"]
115
115
  "tests/test_packaging.py" = ["S603", "S607"]
@@ -27,6 +27,22 @@ from cli_consumption.models import (
27
27
  SnapshotPayload,
28
28
  SnapshotValidationError,
29
29
  )
30
+ from cli_consumption.reporting_api import (
31
+ CACHE_HEADERS,
32
+ EXPORT_TIMEOUT_SECONDS,
33
+ MAX_CONCURRENT_EXPORTS,
34
+ MAX_CONCURRENT_REPORTS,
35
+ MAX_CONVERSATION_PAGE_SIZE,
36
+ MAX_DASHBOARD_RESPONSE_BYTES,
37
+ MAX_EXPORT_RESPONSE_BYTES,
38
+ MAX_FILTER_VALUES,
39
+ MAX_PAGINATION_SESSION_BYTES,
40
+ MAX_REPORTING_RECORDS,
41
+ MAX_REPORTING_SCALAR_BYTES,
42
+ REPORTING_REQUEST_BYTES,
43
+ REPORTING_TIMEOUT_SECONDS,
44
+ install_reporting_routes,
45
+ )
30
46
  from cli_consumption.schema import CURRENT_DATABASE_REVISION
31
47
  from cli_consumption.storage import (
32
48
  create_postgresql_readiness_engine,
@@ -73,6 +89,11 @@ SAFE_ROUTES = frozenset(
73
89
  "/ready",
74
90
  "/api/v1/capabilities",
75
91
  "/api/v1/snapshots",
92
+ "/api/v1/reporting/dashboard",
93
+ "/api/v1/reporting/filters",
94
+ "/api/v1/reporting/conversations",
95
+ "/api/v1/reporting/conversation",
96
+ "/api/v1/reporting/export",
76
97
  }
77
98
  )
78
99
  SAFE_EXCEPTION_TYPES = frozenset(
@@ -311,10 +332,13 @@ class SafeExceptionBoundary:
311
332
  error=error,
312
333
  )
313
334
  if not response_started:
335
+ headers = {"X-Request-ID": request_id}
336
+ if str(scope.get("path", "")).startswith("/api/v1/reporting/"):
337
+ headers.update(CACHE_HEADERS)
314
338
  response = JSONResponse(
315
339
  status_code=500,
316
340
  content={"detail": "internal_server_error"},
317
- headers={"X-Request-ID": request_id},
341
+ headers=headers,
318
342
  )
319
343
  await response(scope, receive, send)
320
344
 
@@ -330,11 +354,16 @@ class RequestSizeLimitMiddleware:
330
354
  if scope["type"] != "http":
331
355
  await self.app(scope, receive, send)
332
356
  return
357
+ maximum = (
358
+ REPORTING_REQUEST_BYTES
359
+ if str(scope.get("path", "")).startswith("/api/v1/reporting/")
360
+ else self.maximum
361
+ )
333
362
  headers = dict(scope.get("headers", []))
334
363
  content_length = headers.get(b"content-length")
335
364
  if content_length is not None:
336
365
  try:
337
- if int(content_length) > self.maximum:
366
+ if int(content_length) > maximum:
338
367
  await self._reject(scope, receive, send)
339
368
  return
340
369
  except ValueError:
@@ -348,7 +377,7 @@ class RequestSizeLimitMiddleware:
348
377
  message = await receive()
349
378
  if message["type"] == "http.request":
350
379
  received += len(message.get("body", b""))
351
- if received > self.maximum:
380
+ if received > maximum:
352
381
  raise RequestTooLarge
353
382
  return message
354
383
 
@@ -359,14 +388,56 @@ class RequestSizeLimitMiddleware:
359
388
 
360
389
  @staticmethod
361
390
  async def _reject(scope: Scope, receive: Receive, send: Send) -> None:
391
+ headers = (
392
+ CACHE_HEADERS
393
+ if str(scope.get("path", "")).startswith("/api/v1/reporting/")
394
+ else None
395
+ )
362
396
  response = JSONResponse(
363
397
  status_code=413,
364
398
  content={"detail": "request_too_large"},
399
+ headers=headers,
365
400
  )
366
401
  await response(scope, receive, send)
367
402
 
368
403
 
369
- def create_app(engine: Engine, api_token: str | None = None) -> SafeExceptionBoundary:
404
+ def _configured_credentials(
405
+ api_token: str | None,
406
+ read_token: str | None,
407
+ export_token: str | None,
408
+ ) -> list[tuple[str, frozenset[str]]]:
409
+ credentials: list[tuple[str, frozenset[str]]] = []
410
+ configured_values: set[str] = set()
411
+ for credential, scopes in (
412
+ (api_token, frozenset({"ingest"})),
413
+ (read_token, frozenset({"read"})),
414
+ (export_token, frozenset({"read", "export"})),
415
+ ):
416
+ if credential is None:
417
+ continue
418
+ if (
419
+ not credential
420
+ or len(credential.encode("utf-8")) > 4_096
421
+ or any(
422
+ character.isspace() or ord(character) < 32 for character in credential
423
+ )
424
+ ):
425
+ raise ValueError("invalid API credential configuration")
426
+ if credential in configured_values:
427
+ raise ValueError("invalid API credential configuration")
428
+ configured_values.add(credential)
429
+ credentials.append((credential, scopes))
430
+ return credentials
431
+
432
+
433
+ def create_app(
434
+ engine: Engine,
435
+ api_token: str | None = None,
436
+ *,
437
+ read_token: str | None = None,
438
+ export_token: str | None = None,
439
+ ) -> SafeExceptionBoundary:
440
+ credentials = _configured_credentials(api_token, read_token, export_token)
370
441
  initialize_database(engine)
371
442
  if engine.dialect.name == "postgresql":
372
443
  probe_engine = create_postgresql_readiness_engine(
@@ -386,8 +457,14 @@ def create_app(engine: Engine, api_token: str | None = None) -> SafeExceptionBou
386
457
 
387
458
  @app.exception_handler(RequestValidationError)
388
459
  async def invalid_request(
389
- _request: Request, error: RequestValidationError
460
+ request: Request, error: RequestValidationError
390
461
  ) -> JSONResponse:
462
+ if request.url.path.startswith("/api/v1/reporting/"):
463
+ return JSONResponse(
464
+ status_code=422,
465
+ content={"detail": "invalid_reporting_request"},
466
+ headers=CACHE_HEADERS,
467
+ )
391
468
  code = (
392
469
  "unsupported_schema_version"
393
470
  if any(
@@ -403,21 +480,42 @@ def create_app(engine: Engine, api_token: str | None = None) -> SafeExceptionBou
403
480
 
404
481
  @app.exception_handler(Exception)
405
482
  async def internal_error(request: Request, error: Exception) -> JSONResponse:
483
+ headers = {"X-Request-ID": request.state.request_id}
484
+ if request.url.path.startswith("/api/v1/reporting/"):
485
+ headers.update(CACHE_HEADERS)
406
486
  return JSONResponse(
407
487
  status_code=500,
408
488
  content={"detail": "internal_server_error"},
409
- headers={"X-Request-ID": request.state.request_id},
489
+ headers=headers,
410
490
  )
411
491
 
412
- def authorize(authorization: Annotated[str | None, Header()] = None) -> None:
413
- if api_token is None:
414
- return
415
- expected = f"Bearer {api_token}"
416
- if authorization is None or not secrets.compare_digest(authorization, expected):
417
- raise HTTPException(
418
- status_code=status.HTTP_401_UNAUTHORIZED,
419
- detail="Missing or invalid bearer token",
420
- )
492
+ def require_scopes(*required: str) -> Any:
493
+ required_scopes = frozenset(required)
494
+
495
+ def authorize(authorization: Annotated[str | None, Header()] = None) -> None:
496
+ if not credentials:
497
+ return
498
+ scheme, separator, supplied = (authorization or "").partition(" ")
499
+ if not separator or scheme.casefold() != "bearer":
500
+ supplied = ""
501
+ matched_scopes: set[str] = set()
502
+ for credential, scopes in credentials:
503
+ if secrets.compare_digest(supplied, credential):
504
+ matched_scopes.update(scopes)
505
+ if not matched_scopes:
506
+ raise HTTPException(
507
+ status_code=status.HTTP_401_UNAUTHORIZED,
508
+ detail="authentication_required",
509
+ headers=CACHE_HEADERS,
510
+ )
511
+ if not required_scopes.issubset(matched_scopes):
512
+ raise HTTPException(
513
+ status_code=status.HTTP_403_FORBIDDEN,
514
+ detail="authorization_denied",
515
+ headers=CACHE_HEADERS,
516
+ )
517
+
518
+ return authorize
421
519
 
422
520
  @app.get("/health")
423
521
  def health() -> dict[str, int | str]:
@@ -446,16 +544,31 @@ def create_app(engine: Engine, api_token: str | None = None) -> SafeExceptionBou
446
544
  return JSONResponse(content={"status": "ready"})
447
545
 
448
546
  @app.get("/api/v1/capabilities")
449
- def capabilities() -> dict[str, int | bool]:
547
+ def capabilities() -> dict[str, Any]:
450
548
  return {
451
549
  "snapshot_schema_min": MIN_SUPPORTED_SNAPSHOT_SCHEMA,
452
550
  "snapshot_schema_max": CURRENT_SNAPSHOT_SCHEMA,
453
551
  "max_request_bytes": MAX_REQUEST_BYTES,
454
552
  "max_snapshot_records": MAX_SNAPSHOT_RECORDS,
455
553
  "idempotent_snapshot_uploads": True,
554
+ "dashboard_query_versions": [1],
555
+ "dashboard_dataset_versions": [1],
556
+ "cursor_versions": [1],
557
+ "max_reporting_request_bytes": REPORTING_REQUEST_BYTES,
558
+ "max_reporting_filter_values": MAX_FILTER_VALUES,
559
+ "max_reporting_records": MAX_REPORTING_RECORDS,
560
+ "max_reporting_scalar_bytes": MAX_REPORTING_SCALAR_BYTES,
561
+ "max_dashboard_response_bytes": MAX_DASHBOARD_RESPONSE_BYTES,
562
+ "max_export_response_bytes": MAX_EXPORT_RESPONSE_BYTES,
563
+ "max_conversation_page_size": MAX_CONVERSATION_PAGE_SIZE,
564
+ "max_pagination_session_bytes": MAX_PAGINATION_SESSION_BYTES,
565
+ "max_concurrent_reporting_reads": MAX_CONCURRENT_REPORTS,
566
+ "max_concurrent_exports": MAX_CONCURRENT_EXPORTS,
567
+ "reporting_timeout_seconds": REPORTING_TIMEOUT_SECONDS,
568
+ "export_timeout_seconds": EXPORT_TIMEOUT_SECONDS,
456
569
  }
457
570
 
458
- @app.post("/api/v1/snapshots", dependencies=[Depends(authorize)])
571
+ @app.post("/api/v1/snapshots", dependencies=[Depends(require_scopes("ingest"))])
459
572
  def receive_snapshot(
460
573
  payload: SnapshotPayload,
461
574
  idempotency_key: Annotated[
@@ -481,4 +594,11 @@ def create_app(engine: Engine, api_token: str | None = None) -> SafeExceptionBou
481
594
  "skipped": result.skipped,
482
595
  }
483
596
 
597
+ install_reporting_routes(
598
+ app,
599
+ engine,
600
+ authorize_read=require_scopes("read"),
601
+ authorize_export=require_scopes("read", "export"),
602
+ )
603
+
484
604
  return SafeExceptionBoundary(app)