cctally 1.95.4 → 1.96.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +71 -0
- package/README.md +4 -5
- package/bin/_cctally_config.py +46 -22
- package/bin/_cctally_core.py +125 -42
- package/bin/_cctally_dashboard.py +258 -100
- package/bin/_cctally_dashboard_cache_report.py +24 -1
- package/bin/_cctally_dashboard_envelope.py +11 -0
- package/bin/_cctally_dashboard_share.py +38 -4
- package/bin/_cctally_db.py +1 -1
- package/bin/_cctally_journal.py +272 -20
- package/bin/_cctally_parser.py +14 -3
- package/bin/_cctally_refresh.py +12 -2
- package/bin/_cctally_reporting.py +12 -8
- package/bin/_cctally_share.py +86 -12
- package/bin/_cctally_store.py +26 -21
- package/bin/_cctally_tui.py +11 -5
- package/bin/_lib_dashboard_settings_contract.py +86 -0
- package/bin/_lib_journal.py +4 -2
- package/bin/_lib_render.py +47 -20
- package/bin/_lib_share.py +191 -48
- package/bin/cctally +5 -1
- package/dashboard/static/assets/index-Bhr5gZ14.js +97 -0
- package/dashboard/static/assets/index-DfN_fsLZ.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-BvCbpJJA.css +0 -1
- package/dashboard/static/assets/index-CRZMxeYI.js +0 -97
|
@@ -397,6 +397,17 @@ from _lib_dashboard_json import (
|
|
|
397
397
|
encode_dashboard_json,
|
|
398
398
|
encode_dashboard_json_bytes,
|
|
399
399
|
)
|
|
400
|
+
# #513 S1: the one map that says which settings leaves this endpoint writes,
|
|
401
|
+
# which it accepts and ignores, and which it rejects. Pure and import-free, so
|
|
402
|
+
# tests/test_config_documentation.py can read the same contract without
|
|
403
|
+
# loading the dashboard.
|
|
404
|
+
from _lib_dashboard_settings_contract import (
|
|
405
|
+
KNOWN_IGNORED as _SETTINGS_KNOWN_IGNORED,
|
|
406
|
+
SETTINGS_LEAF_DISPOSITIONS,
|
|
407
|
+
SETTINGS_OBJECT_PATHS,
|
|
408
|
+
SETTINGS_REQUIRED_LEAVES,
|
|
409
|
+
SETTINGS_TOP_LEVEL_BLOCKS,
|
|
410
|
+
)
|
|
400
411
|
from _cctally_config import save_config, _load_config_unlocked
|
|
401
412
|
from _cctally_db import _render_migration_error_banner
|
|
402
413
|
from _cctally_cache import (
|
|
@@ -1559,6 +1570,120 @@ def _config_known_value(*args, **kwargs):
|
|
|
1559
1570
|
return sys.modules["cctally"]._config_known_value(*args, **kwargs)
|
|
1560
1571
|
|
|
1561
1572
|
|
|
1573
|
+
# === POST /api/settings path classification (#513 S1, spec §1.3) =========
|
|
1574
|
+
# One rule for the whole endpoint: every submitted terminal path is looked up
|
|
1575
|
+
# in SETTINGS_LEAF_DISPOSITIONS. A writable leaf is validated and merged, a
|
|
1576
|
+
# known-ignored leaf is accepted and disclosed on the echo, and anything else
|
|
1577
|
+
# is a 400 naming that dotted path. This replaces the per-block unknown-key
|
|
1578
|
+
# loops that used to carry the same knowledge in five separate tuples.
|
|
1579
|
+
|
|
1580
|
+
#: The six blocks in the order the "at least one of" message names them. The
|
|
1581
|
+
#: SET is asserted against the contract by
|
|
1582
|
+
#: tests/test_config_settings_dispositions.py; the ORDER is presentation.
|
|
1583
|
+
_SETTINGS_BLOCK_ORDER = (
|
|
1584
|
+
"display", "alerts", "update", "cache_report", "budget", "dashboard",
|
|
1585
|
+
)
|
|
1586
|
+
|
|
1587
|
+
#: Leaves that are refused for a specific, explainable reason rather than
|
|
1588
|
+
#: because nobody has heard of them. The classification adds a field pointer
|
|
1589
|
+
#: to these messages and changes nothing else -- a generic "unknown settings
|
|
1590
|
+
#: key" would be a worse answer than the sentence already written here.
|
|
1591
|
+
_SETTINGS_PURPOSE_WRITTEN_REJECTIONS = {
|
|
1592
|
+
"alerts.command_template": (
|
|
1593
|
+
"alerts.command_template is CLI/config-only "
|
|
1594
|
+
"(not settable via the dashboard)"
|
|
1595
|
+
),
|
|
1596
|
+
"dashboard.bind": (
|
|
1597
|
+
"dashboard.bind is not settable via the dashboard "
|
|
1598
|
+
"(bind-time / privacy-gate setting)"
|
|
1599
|
+
),
|
|
1600
|
+
"dashboard.expose_transcripts": (
|
|
1601
|
+
"dashboard.expose_transcripts is not settable via the dashboard "
|
|
1602
|
+
"(bind-time / privacy-gate setting)"
|
|
1603
|
+
),
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
#: Unknown-key wording that shipped before the classification existed, keyed
|
|
1607
|
+
#: by the parent object path. Kept because harness cases pin these exact
|
|
1608
|
+
#: strings; paths without an entry get the generic dotted form.
|
|
1609
|
+
_SETTINGS_LEGACY_UNKNOWN_MESSAGES = {
|
|
1610
|
+
"update": "unknown update settings key: {leaf}",
|
|
1611
|
+
"update.check": "unknown update.check key: {leaf}",
|
|
1612
|
+
"dashboard": "unknown dashboard settings key: {leaf}",
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
|
|
1616
|
+
def _settings_unknown_message(path: str) -> str:
|
|
1617
|
+
"""The 400 message for a submitted path this endpoint does not accept."""
|
|
1618
|
+
purpose_written = _SETTINGS_PURPOSE_WRITTEN_REJECTIONS.get(path)
|
|
1619
|
+
if purpose_written is not None:
|
|
1620
|
+
return purpose_written
|
|
1621
|
+
parent, _, leaf = path.rpartition(".")
|
|
1622
|
+
legacy = _SETTINGS_LEGACY_UNKNOWN_MESSAGES.get(parent)
|
|
1623
|
+
if legacy is not None:
|
|
1624
|
+
return legacy.format(leaf=leaf)
|
|
1625
|
+
return f"unknown settings key: {path}"
|
|
1626
|
+
|
|
1627
|
+
|
|
1628
|
+
def _classify_settings_payload(payload: dict) -> tuple:
|
|
1629
|
+
"""Classify every terminal path in a settings body.
|
|
1630
|
+
|
|
1631
|
+
Returns ``(rejection, ignored_fields)``. ``rejection`` is ``None`` or the
|
|
1632
|
+
``(message, field)`` pair for the FIRST unacceptable path, walked in the
|
|
1633
|
+
request's own key order so the pointer is stable for a given body.
|
|
1634
|
+
``ignored_fields`` is the sorted list of known-ignored paths present.
|
|
1635
|
+
|
|
1636
|
+
A non-dict value sitting at an interior path is deliberately NOT a
|
|
1637
|
+
finding here: that is a structural failure, and the block's own
|
|
1638
|
+
"must be an object" check owns it and words it better.
|
|
1639
|
+
"""
|
|
1640
|
+
ignored: list[str] = []
|
|
1641
|
+
rejection: "tuple[str, str] | None" = None
|
|
1642
|
+
|
|
1643
|
+
def walk(path: str, value: object) -> None:
|
|
1644
|
+
nonlocal rejection
|
|
1645
|
+
if rejection is not None:
|
|
1646
|
+
return
|
|
1647
|
+
disposition = SETTINGS_LEAF_DISPOSITIONS.get(path)
|
|
1648
|
+
if disposition is not None:
|
|
1649
|
+
if disposition == _SETTINGS_KNOWN_IGNORED:
|
|
1650
|
+
ignored.append(path)
|
|
1651
|
+
return
|
|
1652
|
+
if path in SETTINGS_OBJECT_PATHS:
|
|
1653
|
+
if isinstance(value, dict):
|
|
1654
|
+
for leaf, sub_value in value.items():
|
|
1655
|
+
walk(f"{path}.{leaf}", sub_value)
|
|
1656
|
+
return
|
|
1657
|
+
rejection = (_settings_unknown_message(path), path)
|
|
1658
|
+
|
|
1659
|
+
for block, block_value in payload.items():
|
|
1660
|
+
walk(block, block_value)
|
|
1661
|
+
return rejection, sorted(ignored)
|
|
1662
|
+
|
|
1663
|
+
|
|
1664
|
+
def _settings_missing_required_leaf(payload: dict) -> "str | None":
|
|
1665
|
+
"""Return the first required leaf a named block failed to supply.
|
|
1666
|
+
|
|
1667
|
+
The emptiness rule operates on BLOCKS, not leaves: a named block carrying
|
|
1668
|
+
no leaves is an ordinary partial-PUT no-op, which is what
|
|
1669
|
+
``{"cache_report": {}}`` relies on when a combined save's UI never opened
|
|
1670
|
+
that tab. ``display`` is the one block that declares a required leaf.
|
|
1671
|
+
"""
|
|
1672
|
+
for block, required in SETTINGS_REQUIRED_LEAVES.items():
|
|
1673
|
+
if block not in payload:
|
|
1674
|
+
continue
|
|
1675
|
+
block_value = payload[block]
|
|
1676
|
+
present = (
|
|
1677
|
+
{f"{block}.{leaf}" for leaf in block_value}
|
|
1678
|
+
if isinstance(block_value, dict)
|
|
1679
|
+
else set()
|
|
1680
|
+
)
|
|
1681
|
+
missing = sorted(required - present)
|
|
1682
|
+
if missing:
|
|
1683
|
+
return missing[0]
|
|
1684
|
+
return None
|
|
1685
|
+
|
|
1686
|
+
|
|
1562
1687
|
def config_writer_lock(*args, **kwargs):
|
|
1563
1688
|
return sys.modules["cctally"].config_writer_lock(*args, **kwargs)
|
|
1564
1689
|
|
|
@@ -5177,6 +5302,28 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5177
5302
|
— every top-level key is optional; any subset may be sent together
|
|
5178
5303
|
(combined save). Unknown top-level keys are rejected with 400.
|
|
5179
5304
|
|
|
5305
|
+
One classification rule (#513 S1). Every submitted terminal path is
|
|
5306
|
+
looked up in ``SETTINGS_LEAF_DISPOSITIONS``
|
|
5307
|
+
(``bin/_lib_dashboard_settings_contract.py``). A writable leaf is
|
|
5308
|
+
validated and merged; a known-ignored leaf is accepted, not
|
|
5309
|
+
persisted, and disclosed on the echo via ``ignored_fields``; anything
|
|
5310
|
+
else is a 400 naming that dotted path in ``field``. Classification
|
|
5311
|
+
runs BEFORE the per-block validators and before the ``budget.codex``
|
|
5312
|
+
fail-closed guard, so an unknown leaf under ``budget.codex`` reports
|
|
5313
|
+
itself rather than the prerequisite.
|
|
5314
|
+
|
|
5315
|
+
The emptiness rule operates on BLOCKS, not leaves: the body must name
|
|
5316
|
+
at least one known block, but a named block carrying no leaves is an
|
|
5317
|
+
ordinary partial-PUT no-op that still echoes its cooked block.
|
|
5318
|
+
``{"cache_report": {}}`` depends on this — it is what a combined save
|
|
5319
|
+
sends when the user never opened that tab. ``display`` is the one
|
|
5320
|
+
block declaring a required leaf (``SETTINGS_REQUIRED_LEAVES``), so
|
|
5321
|
+
``{"display": {}}`` stays a 400.
|
|
5322
|
+
|
|
5323
|
+
Every 400 carries a ``field``. Whole-document failures (over-cap
|
|
5324
|
+
body, zero-length body, malformed JSON, non-object payload, no known
|
|
5325
|
+
block) use ``"$"``; everything else names a block or a dotted leaf.
|
|
5326
|
+
|
|
5180
5327
|
Per-block validation:
|
|
5181
5328
|
* ``display.tz`` — "local", "utc", or a valid IANA zone (via
|
|
5182
5329
|
``normalize_display_tz_value``); 400 on invalid.
|
|
@@ -5192,9 +5339,11 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5192
5339
|
would silently accept ``true`` for a numeric field).
|
|
5193
5340
|
* ``cache_report.anomaly_threshold_pp`` — JSON int (NOT bool /
|
|
5194
5341
|
float / string), in ``[1, 100]``; 400 with
|
|
5195
|
-
``{error, field: "anomaly_threshold_pp"}`` on
|
|
5196
|
-
or non-int.
|
|
5197
|
-
|
|
5342
|
+
``{error, field: "cache_report.anomaly_threshold_pp"}`` on
|
|
5343
|
+
out-of-range or non-int. The validator raises the bare leaf
|
|
5344
|
+
name and this handler qualifies it, so the response carries
|
|
5345
|
+
the same dotted form every other rejection uses. Spec §6.1
|
|
5346
|
+
hardcodes ``anomaly_window_days``; F10 tracks lifting that.
|
|
5198
5347
|
* ``budget`` — must be a dict; the inbound leaves
|
|
5199
5348
|
(``weekly_usd`` / ``alerts_enabled`` / ``alert_thresholds`` /
|
|
5200
5349
|
``projected_enabled``) are merged onto the persisted ``budget``
|
|
@@ -5242,6 +5391,10 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5242
5391
|
/ bearer tokens — and the echo is returned to the client; the
|
|
5243
5392
|
SSE ``alerts_settings`` mirror redacts identically). Do NOT
|
|
5244
5393
|
re-add the raw template to the echo.
|
|
5394
|
+
``ignored_fields`` — a sorted array of the known-ignored dotted
|
|
5395
|
+
paths this request carried — is present only when that set is
|
|
5396
|
+
non-empty, which is what keeps the field additive for clients
|
|
5397
|
+
written before it existed.
|
|
5245
5398
|
``saved_at`` is included for backward compat.
|
|
5246
5399
|
"""
|
|
5247
5400
|
if not self._check_origin_csrf():
|
|
@@ -5251,44 +5404,59 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5251
5404
|
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
5252
5405
|
except ValueError:
|
|
5253
5406
|
length = 0
|
|
5407
|
+
# Whole-document failures name "$" as their field: there is no leaf
|
|
5408
|
+
# to point at when the body itself is the problem.
|
|
5254
5409
|
if length <= 0 or length > 4096:
|
|
5255
|
-
self._respond_json(
|
|
5410
|
+
self._respond_json(
|
|
5411
|
+
400, {"error": "body required (<=4 KB)", "field": "$"}
|
|
5412
|
+
)
|
|
5256
5413
|
return
|
|
5257
5414
|
try:
|
|
5258
5415
|
payload = json.loads(self.rfile.read(length).decode("utf-8"))
|
|
5259
5416
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
5260
|
-
self._respond_json(400, {"error": "malformed json"})
|
|
5417
|
+
self._respond_json(400, {"error": "malformed json", "field": "$"})
|
|
5261
5418
|
return
|
|
5262
5419
|
if not isinstance(payload, dict):
|
|
5263
|
-
self._respond_json(
|
|
5420
|
+
self._respond_json(
|
|
5421
|
+
400, {"error": "expected JSON object", "field": "$"}
|
|
5422
|
+
)
|
|
5264
5423
|
return
|
|
5265
5424
|
|
|
5266
5425
|
# Reject unknown top-level keys (forward-compat hygiene).
|
|
5267
|
-
allowed_top_keys = {
|
|
5268
|
-
"display", "alerts", "update", "cache_report", "budget", "dashboard",
|
|
5269
|
-
}
|
|
5270
5426
|
for k in payload.keys():
|
|
5271
|
-
if k not in
|
|
5427
|
+
if k not in SETTINGS_TOP_LEVEL_BLOCKS:
|
|
5272
5428
|
self._respond_json(
|
|
5273
|
-
400, {"error": f"unknown settings key: {k}"}
|
|
5429
|
+
400, {"error": f"unknown settings key: {k}", "field": k}
|
|
5274
5430
|
)
|
|
5275
5431
|
return
|
|
5276
5432
|
|
|
5277
|
-
# Body must
|
|
5278
|
-
if (
|
|
5279
|
-
"display" not in payload
|
|
5280
|
-
and "alerts" not in payload
|
|
5281
|
-
and "update" not in payload
|
|
5282
|
-
and "cache_report" not in payload
|
|
5283
|
-
and "budget" not in payload
|
|
5284
|
-
and "dashboard" not in payload
|
|
5285
|
-
):
|
|
5433
|
+
# Body must name at least one known block.
|
|
5434
|
+
if not (set(payload) & SETTINGS_TOP_LEVEL_BLOCKS):
|
|
5286
5435
|
self._respond_json(
|
|
5287
5436
|
400,
|
|
5288
5437
|
{"error": (
|
|
5289
5438
|
"body must contain at least one of: "
|
|
5290
|
-
"
|
|
5291
|
-
)},
|
|
5439
|
+
+ ", ".join(_SETTINGS_BLOCK_ORDER)
|
|
5440
|
+
), "field": "$"},
|
|
5441
|
+
)
|
|
5442
|
+
return
|
|
5443
|
+
|
|
5444
|
+
# Classify every submitted path against the one contract. This runs
|
|
5445
|
+
# BEFORE the per-block validators and before the budget.codex
|
|
5446
|
+
# fail-closed guard, so {"budget":{"codex":{"bogus":1}}} reports the
|
|
5447
|
+
# unknown leaf rather than the prerequisite.
|
|
5448
|
+
settings_rejection, ignored_fields = _classify_settings_payload(payload)
|
|
5449
|
+
if settings_rejection is not None:
|
|
5450
|
+
message, field_path = settings_rejection
|
|
5451
|
+
self._respond_json(400, {"error": message, "field": field_path})
|
|
5452
|
+
return
|
|
5453
|
+
|
|
5454
|
+
# A named block with no leaves is a no-op; `display` is the one block
|
|
5455
|
+
# that declares a required leaf.
|
|
5456
|
+
missing_leaf = _settings_missing_required_leaf(payload)
|
|
5457
|
+
if missing_leaf is not None:
|
|
5458
|
+
self._respond_json(
|
|
5459
|
+
400, {"error": f"missing {missing_leaf}", "field": missing_leaf}
|
|
5292
5460
|
)
|
|
5293
5461
|
return
|
|
5294
5462
|
|
|
@@ -5318,9 +5486,14 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5318
5486
|
cache_report_block
|
|
5319
5487
|
)
|
|
5320
5488
|
except _CacheReportConfigError as exc:
|
|
5489
|
+
# The validator names the bare leaf (its pure-function tests
|
|
5490
|
+
# pin that); the HTTP contract is dotted end to end, so
|
|
5491
|
+
# qualify it here rather than changing the kernel.
|
|
5492
|
+
field_path = exc.field or "cache_report"
|
|
5493
|
+
if field_path != "cache_report":
|
|
5494
|
+
field_path = f"cache_report.{field_path}"
|
|
5321
5495
|
self._respond_json(
|
|
5322
|
-
400,
|
|
5323
|
-
{"error": str(exc), "field": exc.field or "cache_report"},
|
|
5496
|
+
400, {"error": str(exc), "field": field_path},
|
|
5324
5497
|
)
|
|
5325
5498
|
return
|
|
5326
5499
|
|
|
@@ -5329,11 +5502,6 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5329
5502
|
display_canonical: "str | None" = None
|
|
5330
5503
|
if "display" in payload:
|
|
5331
5504
|
display_block = payload["display"]
|
|
5332
|
-
if not isinstance(display_block, dict) or "tz" not in display_block:
|
|
5333
|
-
self._respond_json(
|
|
5334
|
-
400, {"error": "missing display.tz", "field": "display.tz"}
|
|
5335
|
-
)
|
|
5336
|
-
return
|
|
5337
5505
|
try:
|
|
5338
5506
|
display_canonical = normalize_display_tz_value(display_block["tz"])
|
|
5339
5507
|
except ValueError:
|
|
@@ -5363,7 +5531,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5363
5531
|
alerts_block = payload["alerts"]
|
|
5364
5532
|
if not isinstance(alerts_block, dict):
|
|
5365
5533
|
self._respond_json(
|
|
5366
|
-
400, {"error": "alerts must be an object"}
|
|
5534
|
+
400, {"error": "alerts must be an object", "field": "alerts"}
|
|
5367
5535
|
)
|
|
5368
5536
|
return
|
|
5369
5537
|
if "enabled" in alerts_block and not isinstance(
|
|
@@ -5371,7 +5539,8 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5371
5539
|
):
|
|
5372
5540
|
self._respond_json(
|
|
5373
5541
|
400,
|
|
5374
|
-
{"error": "alerts.enabled must be a JSON boolean"
|
|
5542
|
+
{"error": "alerts.enabled must be a JSON boolean",
|
|
5543
|
+
"field": "alerts.enabled"},
|
|
5375
5544
|
)
|
|
5376
5545
|
return
|
|
5377
5546
|
if "projected_enabled" in alerts_block and not isinstance(
|
|
@@ -5379,20 +5548,15 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5379
5548
|
):
|
|
5380
5549
|
self._respond_json(
|
|
5381
5550
|
400,
|
|
5382
|
-
{"error": "alerts.projected_enabled must be a JSON boolean"
|
|
5383
|
-
|
|
5384
|
-
return
|
|
5385
|
-
# The dispatch command template is CLI/config-only — never
|
|
5386
|
-
# settable via the dashboard (it routinely holds secrets and the
|
|
5387
|
-
# dashboard echoes settings to the client). Reject it explicitly
|
|
5388
|
-
# rather than silently dropping it.
|
|
5389
|
-
if "command_template" in alerts_block:
|
|
5390
|
-
self._respond_json(
|
|
5391
|
-
400,
|
|
5392
|
-
{"error": "alerts.command_template is CLI/config-only "
|
|
5393
|
-
"(not settable via the dashboard)"},
|
|
5551
|
+
{"error": "alerts.projected_enabled must be a JSON boolean",
|
|
5552
|
+
"field": "alerts.projected_enabled"},
|
|
5394
5553
|
)
|
|
5395
5554
|
return
|
|
5555
|
+
# `alerts.command_template` is refused by the classification pass
|
|
5556
|
+
# above (it routinely holds secrets and the dashboard echoes
|
|
5557
|
+
# settings back to the client); its purpose-written message lives
|
|
5558
|
+
# in _SETTINGS_PURPOSE_WRITTEN_REJECTIONS.
|
|
5559
|
+
#
|
|
5396
5560
|
# `notifier` is settable (the backend selector). Structural type
|
|
5397
5561
|
# check only; the enum + cross-field rule (command needs a stored
|
|
5398
5562
|
# template) is enforced free by `_get_alerts_config(merged)` below.
|
|
@@ -5400,7 +5564,8 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5400
5564
|
alerts_block["notifier"], str
|
|
5401
5565
|
):
|
|
5402
5566
|
self._respond_json(
|
|
5403
|
-
400, {"error": "alerts.notifier must be a string"
|
|
5567
|
+
400, {"error": "alerts.notifier must be a string",
|
|
5568
|
+
"field": "alerts.notifier"}
|
|
5404
5569
|
)
|
|
5405
5570
|
return
|
|
5406
5571
|
|
|
@@ -5412,17 +5577,18 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5412
5577
|
budget_block = payload["budget"]
|
|
5413
5578
|
if not isinstance(budget_block, dict):
|
|
5414
5579
|
self._respond_json(
|
|
5415
|
-
400, {"error": "budget must be an object"}
|
|
5580
|
+
400, {"error": "budget must be an object", "field": "budget"}
|
|
5416
5581
|
)
|
|
5417
5582
|
return
|
|
5418
5583
|
|
|
5419
|
-
# Pre-validate the dashboard block (spec §5).
|
|
5420
|
-
#
|
|
5421
|
-
#
|
|
5422
|
-
# ``
|
|
5423
|
-
#
|
|
5424
|
-
#
|
|
5425
|
-
#
|
|
5584
|
+
# Pre-validate the dashboard block (spec §5). The writable leaves are
|
|
5585
|
+
# JSON booleans (string/int rejected, mirroring the strict bool check
|
|
5586
|
+
# for ``alerts.enabled``). ``dashboard.bind`` /
|
|
5587
|
+
# ``dashboard.expose_transcripts`` are bind-time / privacy-gate
|
|
5588
|
+
# settings, NOT live-mutable; the classification pass above refuses
|
|
5589
|
+
# them by name with their purpose-written message, and refuses every
|
|
5590
|
+
# other unknown leaf under this block. Outside the config_writer_lock
|
|
5591
|
+
# so a 400 short-circuit doesn't take the lock.
|
|
5426
5592
|
dashboard_validated: "dict | None" = None
|
|
5427
5593
|
if "dashboard" in payload:
|
|
5428
5594
|
dashboard_block = payload["dashboard"]
|
|
@@ -5432,24 +5598,6 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5432
5598
|
{"error": "dashboard must be an object", "field": "dashboard"},
|
|
5433
5599
|
)
|
|
5434
5600
|
return
|
|
5435
|
-
for leaf in dashboard_block.keys():
|
|
5436
|
-
if leaf in ("bind", "expose_transcripts"):
|
|
5437
|
-
self._respond_json(
|
|
5438
|
-
400,
|
|
5439
|
-
{"error": (f"dashboard.{leaf} is not settable via the "
|
|
5440
|
-
"dashboard (bind-time / privacy-gate setting)"),
|
|
5441
|
-
"field": f"dashboard.{leaf}"},
|
|
5442
|
-
)
|
|
5443
|
-
return
|
|
5444
|
-
if leaf not in (
|
|
5445
|
-
"cache_failure_markers", "live_tail", "lan_auth"
|
|
5446
|
-
):
|
|
5447
|
-
self._respond_json(
|
|
5448
|
-
400,
|
|
5449
|
-
{"error": f"unknown dashboard settings key: {leaf}",
|
|
5450
|
-
"field": f"dashboard.{leaf}"},
|
|
5451
|
-
)
|
|
5452
|
-
return
|
|
5453
5601
|
dashboard_validated = {}
|
|
5454
5602
|
for _leaf in ("cache_failure_markers", "live_tail", "lan_auth"):
|
|
5455
5603
|
if _leaf in dashboard_block:
|
|
@@ -5463,27 +5611,20 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5463
5611
|
dashboard_validated[_leaf] = dashboard_block[_leaf]
|
|
5464
5612
|
|
|
5465
5613
|
# Pre-validate update shape. Only `update.check.{enabled,ttl_hours}`
|
|
5466
|
-
#
|
|
5467
|
-
#
|
|
5468
|
-
#
|
|
5469
|
-
# (bools rejected —
|
|
5614
|
+
# and `update.channel` are settable today; the classification pass
|
|
5615
|
+
# above refuses any other key under `update` or `update.check`, so
|
|
5616
|
+
# adding e.g. `update.banner.*` later is forward compatible.
|
|
5617
|
+
# `enabled` must be a JSON bool; `ttl_hours` an int (bools rejected —
|
|
5618
|
+
# see _validate_update_check_ttl_hours_value).
|
|
5470
5619
|
update_check_validated: "dict | None" = None
|
|
5471
5620
|
update_channel_validated: "str | None" = None
|
|
5472
5621
|
if "update" in payload:
|
|
5473
5622
|
update_in = payload["update"]
|
|
5474
5623
|
if not isinstance(update_in, dict):
|
|
5475
5624
|
self._respond_json(
|
|
5476
|
-
400, {"error": "update must be an object"}
|
|
5625
|
+
400, {"error": "update must be an object", "field": "update"}
|
|
5477
5626
|
)
|
|
5478
5627
|
return
|
|
5479
|
-
for inner in update_in.keys():
|
|
5480
|
-
if inner not in ("check", "channel"):
|
|
5481
|
-
self._respond_json(
|
|
5482
|
-
400,
|
|
5483
|
-
{"error": f"unknown update settings key: {inner}",
|
|
5484
|
-
"field": f"update.{inner}"},
|
|
5485
|
-
)
|
|
5486
|
-
return
|
|
5487
5628
|
# Release channel opt-in (beta-channel, spec 2026-07-21 §3). Enum
|
|
5488
5629
|
# {stable,beta}; 400 on invalid. Mirrors the config-key validator.
|
|
5489
5630
|
if "channel" in update_in:
|
|
@@ -5507,14 +5648,6 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5507
5648
|
"field": "update.check"},
|
|
5508
5649
|
)
|
|
5509
5650
|
return
|
|
5510
|
-
for leaf in check_in.keys():
|
|
5511
|
-
if leaf not in ("enabled", "ttl_hours"):
|
|
5512
|
-
self._respond_json(
|
|
5513
|
-
400,
|
|
5514
|
-
{"error": f"unknown update.check key: {leaf}",
|
|
5515
|
-
"field": f"update.check.{leaf}"},
|
|
5516
|
-
)
|
|
5517
|
-
return
|
|
5518
5651
|
update_check_validated = {}
|
|
5519
5652
|
if "enabled" in check_in:
|
|
5520
5653
|
if not isinstance(check_in["enabled"], bool):
|
|
@@ -5569,7 +5702,8 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5569
5702
|
existing_alerts, dict
|
|
5570
5703
|
):
|
|
5571
5704
|
self._respond_json(
|
|
5572
|
-
400, {"error": "alerts must be an object"
|
|
5705
|
+
400, {"error": "alerts must be an object",
|
|
5706
|
+
"field": "alerts"}
|
|
5573
5707
|
)
|
|
5574
5708
|
return
|
|
5575
5709
|
merged_alerts = dict(existing_alerts or {})
|
|
@@ -5589,7 +5723,10 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5589
5723
|
try:
|
|
5590
5724
|
_get_alerts_config(merged)
|
|
5591
5725
|
except _AlertsConfigError as exc:
|
|
5592
|
-
self._respond_json(
|
|
5726
|
+
self._respond_json(
|
|
5727
|
+
400,
|
|
5728
|
+
{"error": str(exc), "field": exc.field or "alerts"},
|
|
5729
|
+
)
|
|
5593
5730
|
return
|
|
5594
5731
|
|
|
5595
5732
|
if "budget" in payload:
|
|
@@ -5603,7 +5740,8 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5603
5740
|
existing_budget, dict
|
|
5604
5741
|
):
|
|
5605
5742
|
self._respond_json(
|
|
5606
|
-
400, {"error": "budget must be an object"
|
|
5743
|
+
400, {"error": "budget must be an object",
|
|
5744
|
+
"field": "budget"}
|
|
5607
5745
|
)
|
|
5608
5746
|
return
|
|
5609
5747
|
merged_budget = dict(existing_budget or {})
|
|
@@ -5625,7 +5763,8 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5625
5763
|
incoming_codex = budget_in["codex"]
|
|
5626
5764
|
if not isinstance(incoming_codex, dict):
|
|
5627
5765
|
self._respond_json(
|
|
5628
|
-
400, {"error": "budget.codex must be an object"
|
|
5766
|
+
400, {"error": "budget.codex must be an object",
|
|
5767
|
+
"field": "budget.codex"}
|
|
5629
5768
|
)
|
|
5630
5769
|
return
|
|
5631
5770
|
existing_codex = merged_budget.get("codex")
|
|
@@ -5633,16 +5772,23 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5633
5772
|
# Fail closed: the dashboard only TOGGLES an existing
|
|
5634
5773
|
# Codex budget — amounts are CLI-only — so it must never
|
|
5635
5774
|
# invent one. The frontend disables the toggle, this
|
|
5636
|
-
# backstops a direct POST.
|
|
5775
|
+
# backstops a direct POST. Reached only when every
|
|
5776
|
+
# submitted leaf is known: classification runs first.
|
|
5637
5777
|
self._respond_json(400, {"error": (
|
|
5638
5778
|
"no Codex budget configured — set one via the CLI "
|
|
5639
5779
|
"first (cctally budget set <amount> --vendor codex)"
|
|
5640
|
-
)})
|
|
5780
|
+
), "field": "budget.codex"})
|
|
5641
5781
|
return
|
|
5642
5782
|
merged_codex = dict(existing_codex)
|
|
5643
5783
|
for sub in ("alerts_enabled", "projected_enabled"):
|
|
5644
5784
|
if sub in incoming_codex:
|
|
5645
|
-
|
|
5785
|
+
# Assigned verbatim. A bool() here would coerce
|
|
5786
|
+
# "yes" to True and destroy the evidence before
|
|
5787
|
+
# _get_budget_config could reject it, which is
|
|
5788
|
+
# exactly how this leaf came to disagree with its
|
|
5789
|
+
# Claude sibling (#513 F5). One boolean rule, and
|
|
5790
|
+
# it lives in the canonical validator.
|
|
5791
|
+
merged_codex[sub] = incoming_codex[sub]
|
|
5646
5792
|
merged_budget["codex"] = merged_codex
|
|
5647
5793
|
merged["budget"] = merged_budget
|
|
5648
5794
|
# Final validation against the merged block.
|
|
@@ -5651,7 +5797,10 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5651
5797
|
try:
|
|
5652
5798
|
_get_budget_config(merged)
|
|
5653
5799
|
except _BudgetConfigError as exc:
|
|
5654
|
-
self._respond_json(
|
|
5800
|
+
self._respond_json(
|
|
5801
|
+
400,
|
|
5802
|
+
{"error": str(exc), "field": exc.field or "budget"},
|
|
5803
|
+
)
|
|
5655
5804
|
return
|
|
5656
5805
|
|
|
5657
5806
|
if (
|
|
@@ -5860,6 +6009,15 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
5860
6009
|
}
|
|
5861
6010
|
if "lan_auth" in dashboard_validated:
|
|
5862
6011
|
out["restart_required"] = ["dashboard.lan_auth"]
|
|
6012
|
+
if ignored_fields:
|
|
6013
|
+
# Disclosure, not a new capability: these leaves were always
|
|
6014
|
+
# accepted-and-not-persisted, and a client had no way to tell
|
|
6015
|
+
# that from a plain 200. Additive and emitted only when
|
|
6016
|
+
# non-empty, so an ordinary write keeps its exact current shape.
|
|
6017
|
+
# snake_case matches the surrounding legacy echo
|
|
6018
|
+
# (`command_configured`, `saved_at`); CRUD echoes carry no
|
|
6019
|
+
# schemaVersion (docs/cli-contract.md).
|
|
6020
|
+
out["ignored_fields"] = ignored_fields
|
|
5863
6021
|
out["saved_at"] = (
|
|
5864
6022
|
dt.datetime.now(dt.timezone.utc)
|
|
5865
6023
|
.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
@@ -7212,7 +7370,7 @@ def _dashboard_stats_deferred_snapshot(args, *, pinned_now, exc):
|
|
|
7212
7370
|
tui.SyncFailureAttribution(
|
|
7213
7371
|
leg="stats-open",
|
|
7214
7372
|
database="stats",
|
|
7215
|
-
corruption=
|
|
7373
|
+
corruption=tui._stats_open_failure_is_corruption(exc),
|
|
7216
7374
|
),
|
|
7217
7375
|
),
|
|
7218
7376
|
"doctor_payload": doctor_payload,
|
|
@@ -26,6 +26,10 @@ import datetime as dt
|
|
|
26
26
|
import sys
|
|
27
27
|
from dataclasses import dataclass
|
|
28
28
|
|
|
29
|
+
from _lib_dashboard_settings_contract import (
|
|
30
|
+
SETTINGS_LEAF_DISPOSITIONS as _SETTINGS_LEAF_DISPOSITIONS,
|
|
31
|
+
WRITABLE as _WRITABLE,
|
|
32
|
+
)
|
|
29
33
|
from _lib_fmt import stable_sum
|
|
30
34
|
from _lib_pricing import _calculate_entry_cost
|
|
31
35
|
from _lib_pricing import claude_usage_dict as _claude_usage_dict
|
|
@@ -58,7 +62,19 @@ class _CacheReportConfigError(Exception):
|
|
|
58
62
|
self.field = field
|
|
59
63
|
|
|
60
64
|
|
|
61
|
-
|
|
65
|
+
#: Derived from the one settings contract rather than restated here, so this
|
|
66
|
+
#: validator and ``POST /api/settings``'s classification pass cannot disagree
|
|
67
|
+
#: about what the ``cache_report`` block may carry (#513 S1).
|
|
68
|
+
#:
|
|
69
|
+
#: The disposition is part of the filter, not just the prefix: a future
|
|
70
|
+
#: ``cache_report.*`` leaf marked ``KNOWN_IGNORED`` is by definition one the
|
|
71
|
+
#: endpoint does not write, and admitting it here would let this validator
|
|
72
|
+
#: treat it as settable.
|
|
73
|
+
_CACHE_REPORT_ALLOWED_KEYS = frozenset(
|
|
74
|
+
path.split(".", 1)[1]
|
|
75
|
+
for path, disposition in _SETTINGS_LEAF_DISPOSITIONS.items()
|
|
76
|
+
if path.startswith("cache_report.") and disposition == _WRITABLE
|
|
77
|
+
)
|
|
62
78
|
|
|
63
79
|
|
|
64
80
|
def _validate_cache_report_settings(block: dict) -> dict:
|
|
@@ -81,6 +97,13 @@ def _validate_cache_report_settings(block: dict) -> dict:
|
|
|
81
97
|
raise _CacheReportConfigError(
|
|
82
98
|
"cache_report must be an object", field="cache_report",
|
|
83
99
|
)
|
|
100
|
+
# Defense in depth, and no longer the message an HTTP client sees. Since
|
|
101
|
+
# #513 S1 the endpoint classifies every submitted path first, so a POST of
|
|
102
|
+
# `{"cache_report": {"foo": 1}}` is answered by the classification pass
|
|
103
|
+
# with `unknown settings key: cache_report.foo` and
|
|
104
|
+
# `field: "cache_report.foo"` before this validator runs. This branch is
|
|
105
|
+
# retained for callers that reach the validator directly, which is how
|
|
106
|
+
# `bin/cctally`'s re-export surface exposes it.
|
|
84
107
|
for key in block:
|
|
85
108
|
if key not in _CACHE_REPORT_ALLOWED_KEYS:
|
|
86
109
|
raise _CacheReportConfigError(
|
|
@@ -1404,6 +1404,17 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
1404
1404
|
"five_hour_thresholds": list(_alerts_cfg["five_hour_thresholds"]),
|
|
1405
1405
|
"budget_thresholds": list(_budget_cfg["alert_thresholds"]),
|
|
1406
1406
|
"budget_enabled": _budget_alerts_active(_budget_cfg),
|
|
1407
|
+
# Claude weekly budget AMOUNT mirror (#513 S2 §5.1). `budget_enabled`
|
|
1408
|
+
# above collapses "no budget set" and "budget set, alerts off" into one
|
|
1409
|
+
# false, so Settings could not state which of the two a user is in, nor
|
|
1410
|
+
# name the remedy. The amount itself distinguishes them. It is read
|
|
1411
|
+
# from the same validated `_budget_cfg` the neighbouring keys use, and
|
|
1412
|
+
# the `_BudgetConfigError` fallback dict already carries
|
|
1413
|
+
# `"weekly_usd": None`, so the null path needs no separate default.
|
|
1414
|
+
# Reading it back through an empty `POST {"budget": {}}` is NOT an
|
|
1415
|
+
# option: that request reaches `save_config` and triggers the
|
|
1416
|
+
# synchronous rebuild, so this mirror is the only read path.
|
|
1417
|
+
"weekly_usd": _budget_cfg.get("weekly_usd"),
|
|
1407
1418
|
# Projected-pace opt-in mirrors (#121). Two flags, one per parent
|
|
1408
1419
|
# axis — the frontend SettingsOverlay seeds two toggles. Sourced
|
|
1409
1420
|
# from the validated getters' ``projected_enabled`` (default False).
|