cctally 1.114.0 → 1.115.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 +25 -0
- package/bin/_cctally_account.py +32 -8
- package/bin/_cctally_cache.py +4394 -279
- package/bin/_cctally_completion.py +1287 -0
- package/bin/_cctally_config.py +37 -0
- package/bin/_cctally_core.py +178 -2
- package/bin/_cctally_dashboard.py +2 -2
- package/bin/_cctally_dashboard_conversation.py +292 -23
- package/bin/_cctally_dashboard_envelope.py +16 -3
- package/bin/_cctally_dashboard_sources.py +18 -9
- package/bin/_cctally_db.py +316 -5
- package/bin/_cctally_doctor.py +436 -27
- package/bin/_cctally_five_hour.py +82 -31
- package/bin/_cctally_forecast.py +81 -9
- package/bin/_cctally_journal.py +3884 -112
- package/bin/_cctally_journal_repair.py +101 -12
- package/bin/_cctally_parser.py +42 -0
- package/bin/_cctally_project.py +8 -2
- package/bin/_cctally_quarantine.py +652 -0
- package/bin/_cctally_quota.py +26 -4
- package/bin/_cctally_quota_model.py +360 -83
- package/bin/_cctally_record.py +677 -149
- package/bin/_cctally_rederive.py +297 -32
- package/bin/_cctally_refresh.py +84 -13
- package/bin/_cctally_source_analytics.py +23 -2
- package/bin/_cctally_statusline.py +6137 -517
- package/bin/_cctally_store.py +287 -48
- package/bin/_cctally_transcript.py +8 -7
- package/bin/_cctally_tui.py +57 -53
- package/bin/_lib_account_binding.py +152 -0
- package/bin/_lib_account_protocol.py +386 -0
- package/bin/_lib_aggregators.py +4 -0
- package/bin/_lib_cache_coverage.py +13 -1
- package/bin/_lib_cache_writer_lock.py +19 -3
- package/bin/_lib_clone_copy.py +142 -0
- package/bin/_lib_codex_conversation_query.py +133 -19
- package/bin/_lib_codex_conversation_watch.py +59 -14
- package/bin/_lib_codex_drain_admission.py +214 -0
- package/bin/_lib_codex_metadata.py +18 -1
- package/bin/_lib_codex_publication_barrier.py +277 -0
- package/bin/_lib_codex_rollout_container.py +502 -0
- package/bin/_lib_conversation_dispatch.py +3 -0
- package/bin/_lib_conversation_query.py +11 -3
- package/bin/_lib_detector_access_registry.py +3817 -0
- package/bin/_lib_doctor.py +452 -1
- package/bin/_lib_five_hour.py +126 -0
- package/bin/_lib_ingest_frontier.py +414 -20
- package/bin/_lib_journal.py +141 -10
- package/bin/_lib_jsonl.py +5 -4
- package/bin/_lib_legacy_spool.py +126 -0
- package/bin/_lib_legacy_writer_evidence.py +136 -0
- package/bin/_lib_merge_gate.py +29 -302
- package/bin/_lib_meter_rate_change.py +10 -0
- package/bin/_lib_outcome_quarantine.py +690 -0
- package/bin/_lib_pricing.py +7 -2
- package/bin/_lib_quarantine.py +154 -0
- package/bin/_lib_quota_calibration.py +53 -8
- package/bin/_lib_quota_copy.py +2 -1
- package/bin/_lib_quota_model.py +288 -46
- package/bin/_lib_rederive.py +94 -5
- package/bin/_lib_segment_summary.py +35 -2
- package/bin/_lib_statusline_candidates.py +292 -23
- package/bin/cctally +23 -1
- package/package.json +14 -1
package/bin/_cctally_tui.py
CHANGED
|
@@ -348,6 +348,10 @@ def _fetch_current_week_snapshots(*args, **kwargs):
|
|
|
348
348
|
return sys.modules["cctally"]._fetch_current_week_snapshots(*args, **kwargs)
|
|
349
349
|
|
|
350
350
|
|
|
351
|
+
def _current_week_five_hour_reading(*args, **kwargs):
|
|
352
|
+
return sys.modules["cctally"]._current_week_five_hour_reading(*args, **kwargs)
|
|
353
|
+
|
|
354
|
+
|
|
351
355
|
def _load_forecast_inputs(*args, **kwargs):
|
|
352
356
|
return sys.modules["cctally"]._load_forecast_inputs(*args, **kwargs)
|
|
353
357
|
|
|
@@ -1682,12 +1686,14 @@ def _tui_build_current_week(
|
|
|
1682
1686
|
# controls decoration, not the physical population used for computation.
|
|
1683
1687
|
# Cost and forecast retain their existing merged semantics.
|
|
1684
1688
|
fetched = _fetch_current_week_snapshots(
|
|
1685
|
-
conn, now_utc, include_held=True, include_account=True
|
|
1689
|
+
conn, now_utc, include_held=True, include_account=True,
|
|
1690
|
+
include_scope=True)
|
|
1686
1691
|
if fetched is None:
|
|
1687
1692
|
return None
|
|
1688
|
-
week_start_at, week_end_at, observations = fetched
|
|
1693
|
+
week_start_at, week_end_at, observations, week_scope = fetched
|
|
1689
1694
|
if not observations:
|
|
1690
1695
|
return None
|
|
1696
|
+
week_start_before_override = week_start_at
|
|
1691
1697
|
# Mirror the reset override applied by `_load_forecast_inputs` so the
|
|
1692
1698
|
# Current Week card's spent_usd and $/1% reflect the post-reset window.
|
|
1693
1699
|
week_start_at, observations = _apply_midweek_reset_override(
|
|
@@ -1698,8 +1704,8 @@ def _tui_build_current_week(
|
|
|
1698
1704
|
# Held-inclusive sample shape: (captured_at_utc, weekly_percent,
|
|
1699
1705
|
# five_hour_percent, weekly_observation_held[, account_key]). The optional
|
|
1700
1706
|
# account key identifies the displayed weekly sample on a decorated store.
|
|
1701
|
-
# The helper does not surface five_hour_resets_at
|
|
1702
|
-
# below
|
|
1707
|
+
# The helper does not surface five_hour_resets_at; the five-hour reading
|
|
1708
|
+
# selected below supplies it, with its percentage.
|
|
1703
1709
|
#
|
|
1704
1710
|
# #769 S11 (#824). This card publishes BOTH axes, and the dashboard
|
|
1705
1711
|
# envelope reads `used_pct` and `latest_snapshot_at` straight off it for
|
|
@@ -1716,10 +1722,16 @@ def _tui_build_current_week(
|
|
|
1716
1722
|
used_pct = float(latest[1])
|
|
1717
1723
|
weekly_account = latest[4]
|
|
1718
1724
|
# Five-hour readings may be newer held observations, but must remain on
|
|
1719
|
-
# the account that supplied the displayed weekly reading.
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1725
|
+
# the account that supplied the displayed weekly reading. The reading is
|
|
1726
|
+
# the newest five-hour READING under this card's own week and as-of scope
|
|
1727
|
+
# (#825/#832 spec 4.7 revision 7, the one shared selection): the newest
|
|
1728
|
+
# sample can be a retired replica or a weekly-only reading, which carries
|
|
1729
|
+
# no five-hour value and used to render as 0 %.
|
|
1730
|
+
five_hour = _current_week_five_hour_reading(
|
|
1731
|
+
conn, now_utc, week_scope, account_key=weekly_account,
|
|
1732
|
+
week_start_at=week_start_at,
|
|
1733
|
+
week_start_before_override=week_start_before_override)
|
|
1734
|
+
five_hr_pct = None if five_hour is None else five_hour.percent
|
|
1723
1735
|
# #556 S1 §3.3: one walk yields both halves. The range is whatever
|
|
1724
1736
|
# `spent_usd` already used — taken AFTER `_apply_midweek_reset_override`
|
|
1725
1737
|
# above, so a mid-week reset shortens the accumulation and the published
|
|
@@ -1738,47 +1750,23 @@ def _tui_build_current_week(
|
|
|
1738
1750
|
used_pct_corrected = corrected_percent_point(used_pct)
|
|
1739
1751
|
dpp_divisor = used_pct if used_pct_corrected is None else used_pct_corrected
|
|
1740
1752
|
dpp = (spent / dpp_divisor) if used_pct > 0 else None
|
|
1741
|
-
#
|
|
1742
|
-
#
|
|
1743
|
-
#
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
matching_ws_texts: list[str] = []
|
|
1749
|
-
for r in ws_texts:
|
|
1753
|
+
# The reset instant comes from the SAME reading as the percentage beside
|
|
1754
|
+
# it (#825/#832 spec 4.7 revision 7: the shared selection returns the
|
|
1755
|
+
# percent and the reset together from one row), so the countdown never
|
|
1756
|
+
# describes another row. That reading is held-inclusive (#769 S11, #824),
|
|
1757
|
+
# because a held row's five-hour fields are the writing tick's own.
|
|
1758
|
+
five_hr_resets_at: dt.datetime | None = None
|
|
1759
|
+
if five_hour is not None and five_hour.resets_at is not None:
|
|
1750
1760
|
try:
|
|
1751
|
-
|
|
1761
|
+
five_hr_resets_at = parse_iso_datetime(
|
|
1762
|
+
five_hour.resets_at, "five_hour_resets_at"
|
|
1763
|
+
)
|
|
1752
1764
|
except ValueError:
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
placeholders = ",".join("?" * len(matching_ws_texts))
|
|
1759
|
-
# HELD-INCLUSIVE, deliberately (#769 S11, #824): the reset instant is a
|
|
1760
|
-
# five-hour fact, and a held row's five-hour fields are the writing
|
|
1761
|
-
# tick's own. Excluding held rows here would make the countdown lag the
|
|
1762
|
-
# five-hour percentage rendered beside it.
|
|
1763
|
-
reset_row = conn.execute(
|
|
1764
|
-
f"SELECT five_hour_resets_at FROM weekly_usage_snapshots "
|
|
1765
|
-
f"WHERE week_start_at IN ({placeholders}) "
|
|
1766
|
-
f" AND five_hour_resets_at IS NOT NULL "
|
|
1767
|
-
f" AND account_key = ? "
|
|
1768
|
-
f"ORDER BY captured_at_utc DESC, id DESC LIMIT 1",
|
|
1769
|
-
tuple(matching_ws_texts) + (weekly_account,),
|
|
1770
|
-
).fetchone()
|
|
1771
|
-
if reset_row is not None:
|
|
1772
|
-
try:
|
|
1773
|
-
five_hr_resets_at = parse_iso_datetime(
|
|
1774
|
-
reset_row[0], "five_hour_resets_at"
|
|
1775
|
-
)
|
|
1776
|
-
except ValueError:
|
|
1777
|
-
five_hr_resets_at = None
|
|
1778
|
-
# Suppress stale resets that have already elapsed so renderers
|
|
1779
|
-
# don't show "resets 0h 00m" or a negative duration at the boundary.
|
|
1780
|
-
if five_hr_resets_at is not None and five_hr_resets_at <= now_utc:
|
|
1781
|
-
five_hr_resets_at = None
|
|
1765
|
+
five_hr_resets_at = None
|
|
1766
|
+
# Suppress stale resets that have already elapsed so renderers
|
|
1767
|
+
# don't show "resets 0h 00m" or a negative duration at the boundary.
|
|
1768
|
+
if five_hr_resets_at is not None and five_hr_resets_at <= now_utc:
|
|
1769
|
+
five_hr_resets_at = None
|
|
1782
1770
|
# Freshness — compute label/age from latest snapshot vs. now using the
|
|
1783
1771
|
# configured oauth_usage thresholds. Mirrors the dashboard envelope's
|
|
1784
1772
|
# cw_freshness derivation in `snapshot_to_envelope`. Refs spec §3.4.
|
|
@@ -6878,7 +6866,6 @@ def _tui_panel_current_week(
|
|
|
6878
6866
|
bar_fill = _tui_bar_string(cw.used_pct, bar_w)
|
|
6879
6867
|
|
|
6880
6868
|
five = cw.five_hour_pct or 0.0
|
|
6881
|
-
five_bar = _tui_bar_string(five, bar_w)
|
|
6882
6869
|
|
|
6883
6870
|
reset_delta = cw.week_end_at - snap.generated_at
|
|
6884
6871
|
reset_days = max(0, reset_delta.days)
|
|
@@ -6914,6 +6901,17 @@ def _tui_panel_current_week(
|
|
|
6914
6901
|
for c in fh_credits
|
|
6915
6902
|
)
|
|
6916
6903
|
fh_credit_badge = f" {{bright}}⚡ {deltas}{{/}}"
|
|
6904
|
+
# The 5-hour row is " 5-hour " (8 cells) + bar + " NNN%" (5) + badge, and
|
|
6905
|
+
# it must fit the panel interior (width - 2). A bar of the Used row's width
|
|
6906
|
+
# leaves five cells, while the badge's wide ⚡ glyph alone costs two, so a
|
|
6907
|
+
# badge shortens the 5-hour bar instead of spilling into the next panel.
|
|
6908
|
+
# It also leaves one blank cell before the right border, as every other
|
|
6909
|
+
# row of the panel does.
|
|
6910
|
+
five_w = bar_w
|
|
6911
|
+
if fh_credit_badge:
|
|
6912
|
+
badge_cells = _tui_lines_to_text([fh_credit_badge]).cell_len
|
|
6913
|
+
five_w = max(10, min(bar_w, width - 2 - 13 - badge_cells - 1))
|
|
6914
|
+
five_bar = _tui_bar_string(five, five_w)
|
|
6917
6915
|
lines = [
|
|
6918
6916
|
"",
|
|
6919
6917
|
f" Used {{{used_cls}}}{bar_fill}{{/}} {{{used_cls}.b}}{cw.used_pct:>5.1f}%{{/}}",
|
|
@@ -7648,7 +7646,13 @@ def _tui_tagged_box_lines(
|
|
|
7648
7646
|
When a body line's plain length exceeds the interior width it is truncated
|
|
7649
7647
|
with ``{/}`` appended as a safety net — callers should size their content
|
|
7650
7648
|
to avoid this branch.
|
|
7649
|
+
|
|
7650
|
+
Lengths are terminal CELLS, not characters: a wide glyph such as the
|
|
7651
|
+
5-hour credit badge's ``⚡`` is one character but two cells, and padding
|
|
7652
|
+
by characters pushed that row's right border one cell out of the box
|
|
7653
|
+
(#825/#832, UI-QA of fix round K16c).
|
|
7651
7654
|
"""
|
|
7655
|
+
from rich.cells import cell_len, set_cell_size
|
|
7652
7656
|
H, V = _TUI_BOX["h"], _TUI_BOX["v"]
|
|
7653
7657
|
TL, TR, BL, BR = _TUI_BOX["tl"], _TUI_BOX["tr"], _TUI_BOX["bl"], _TUI_BOX["br"]
|
|
7654
7658
|
interior = width - 2
|
|
@@ -7662,12 +7666,12 @@ def _tui_tagged_box_lines(
|
|
|
7662
7666
|
t_seg = f" {title} " if title else ""
|
|
7663
7667
|
p_seg = f" {pin} " if pin else ""
|
|
7664
7668
|
if title and pin:
|
|
7665
|
-
fill = width - 4 -
|
|
7669
|
+
fill = width - 4 - cell_len(_tui_strip_tags(t_seg)) - cell_len(_tui_strip_tags(p_seg))
|
|
7666
7670
|
if fill >= 1:
|
|
7667
7671
|
return (_wrap_border(TL + H) + t_seg
|
|
7668
7672
|
+ _wrap_border(H * fill) + p_seg + _wrap_border(H + TR))
|
|
7669
7673
|
if title:
|
|
7670
|
-
fill = width - 3 -
|
|
7674
|
+
fill = width - 3 - cell_len(_tui_strip_tags(t_seg))
|
|
7671
7675
|
if fill >= 1:
|
|
7672
7676
|
return _wrap_border(TL + H) + t_seg + _wrap_border(H * fill + TR)
|
|
7673
7677
|
return _wrap_border(TL + H * interior + TR)
|
|
@@ -7675,14 +7679,14 @@ def _tui_tagged_box_lines(
|
|
|
7675
7679
|
lines: list[str] = [_top()]
|
|
7676
7680
|
for line in body_tagged:
|
|
7677
7681
|
plain = _tui_strip_tags(line)
|
|
7678
|
-
if
|
|
7682
|
+
if cell_len(plain) > interior:
|
|
7679
7683
|
# On overflow, drop color markup entirely — partial-tag truncation
|
|
7680
7684
|
# would produce malformed tokens that crash _tui_colortag. Plain-
|
|
7681
7685
|
# text is always safe, and callers are expected to size content
|
|
7682
7686
|
# to fit (this branch is a safety net only).
|
|
7683
|
-
line = plain
|
|
7687
|
+
line = set_cell_size(plain, max(0, interior - 1)) + "…"
|
|
7684
7688
|
plain = line
|
|
7685
|
-
pad = interior -
|
|
7689
|
+
pad = interior - cell_len(plain)
|
|
7686
7690
|
lines.append(_wrap_border(V) + line + " " * pad + _wrap_border(V))
|
|
7687
7691
|
lines.append(_wrap_border(BL + H * interior + BR))
|
|
7688
7692
|
return lines
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Immutable account binding carried by every current statusline/OAuth producer.
|
|
2
|
+
|
|
3
|
+
#825/#832 spec 4.1. A binding is captured ONCE, from the stable-read identity
|
|
4
|
+
resolver (`_cctally_core._resolve_active_claude_identity`), and then travels
|
|
5
|
+
with the candidate, the publication plan or the OAuth publication it belongs
|
|
6
|
+
to. No later stage re-resolves the active account: an A->B switch between
|
|
7
|
+
capture and publication can therefore never re-label A's reading as B's.
|
|
8
|
+
|
|
9
|
+
- ``identified`` binds the real account key, which must equal
|
|
10
|
+
``_lib_accounts.account_key("claude", natural_id)``;
|
|
11
|
+
- ``stably_absent`` (no ``~/.claude.json`` account, api-key mode) binds the
|
|
12
|
+
``unattributed`` sentinel deliberately;
|
|
13
|
+
- ``torn`` and every unreadable or internally inconsistent read yield NO
|
|
14
|
+
binding. The caller then writes no candidate, mutates no control state and
|
|
15
|
+
consumes no retry attempt.
|
|
16
|
+
|
|
17
|
+
The credential evidence is a salted digest of the selected OAuth credential,
|
|
18
|
+
never the credential itself. Serialization is strict: an exact camelCase key
|
|
19
|
+
set, and ``load`` re-derives the identity evidence and compares it.
|
|
20
|
+
|
|
21
|
+
Pure: no filesystem, clock or ``cctally`` namespace dependency.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import dataclasses
|
|
26
|
+
import hashlib
|
|
27
|
+
import json
|
|
28
|
+
import re
|
|
29
|
+
|
|
30
|
+
import _lib_accounts
|
|
31
|
+
|
|
32
|
+
STATUS_IDENTIFIED = "identified"
|
|
33
|
+
STATUS_STABLY_ABSENT = "stably_absent"
|
|
34
|
+
|
|
35
|
+
_CREDENTIAL_SALT = "cctally-selected-credential-v1\0"
|
|
36
|
+
_DIGEST_RE = re.compile(r"[0-9a-f]{64}\Z")
|
|
37
|
+
_DOCUMENT_KEYS = frozenset({
|
|
38
|
+
"status", "accountKey", "identityEvidence", "credentialEvidence",
|
|
39
|
+
"naturalId", "email", "planType",
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclasses.dataclass(frozen=True)
|
|
44
|
+
class AccountBinding:
|
|
45
|
+
status: str
|
|
46
|
+
account_key: str
|
|
47
|
+
identity_evidence: str
|
|
48
|
+
credential_evidence: str | None = None
|
|
49
|
+
natural_id: str | None = None
|
|
50
|
+
email: str | None = None
|
|
51
|
+
plan_type: str | None = None
|
|
52
|
+
|
|
53
|
+
def document(self) -> dict:
|
|
54
|
+
"""The exact persisted shape (camelCase, every key always present)."""
|
|
55
|
+
return {
|
|
56
|
+
"status": self.status,
|
|
57
|
+
"accountKey": self.account_key,
|
|
58
|
+
"identityEvidence": self.identity_evidence,
|
|
59
|
+
"credentialEvidence": self.credential_evidence,
|
|
60
|
+
"naturalId": self.natural_id,
|
|
61
|
+
"email": self.email,
|
|
62
|
+
"planType": self.plan_type,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
def identity(self) -> dict:
|
|
66
|
+
"""The resolver's own shape, for consumers that take an identity."""
|
|
67
|
+
return {
|
|
68
|
+
"status": self.status,
|
|
69
|
+
"account_key": self.account_key,
|
|
70
|
+
"natural_id": self.natural_id,
|
|
71
|
+
"email": self.email,
|
|
72
|
+
"plan_type": self.plan_type,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
def without_credential(self) -> "AccountBinding":
|
|
76
|
+
"""The same identity with no credential evidence (identity equality)."""
|
|
77
|
+
return dataclasses.replace(self, credential_evidence=None)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def credential_digest(credential: str) -> str:
|
|
81
|
+
"""The salted credential evidence; the credential itself is never kept."""
|
|
82
|
+
return hashlib.sha256(
|
|
83
|
+
(_CREDENTIAL_SALT + credential).encode("utf-8")).hexdigest()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _identity_evidence(fields: dict) -> str:
|
|
87
|
+
return hashlib.sha256(json.dumps(
|
|
88
|
+
fields, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
89
|
+
).hexdigest()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def capture(identity, *, credential: str | None = None) -> AccountBinding | None:
|
|
93
|
+
"""Bind one resolver result, or return None for a torn, unreadable or
|
|
94
|
+
internally inconsistent read. Never guesses an account."""
|
|
95
|
+
if not isinstance(identity, dict):
|
|
96
|
+
return None
|
|
97
|
+
status = identity.get("status")
|
|
98
|
+
key = identity.get("account_key")
|
|
99
|
+
natural = identity.get("natural_id")
|
|
100
|
+
if status == STATUS_IDENTIFIED:
|
|
101
|
+
if not isinstance(natural, str) or not natural:
|
|
102
|
+
return None
|
|
103
|
+
if key != _lib_accounts.account_key("claude", natural):
|
|
104
|
+
return None
|
|
105
|
+
elif status == STATUS_STABLY_ABSENT:
|
|
106
|
+
if key != _lib_accounts.UNATTRIBUTED or natural is not None:
|
|
107
|
+
return None
|
|
108
|
+
else:
|
|
109
|
+
return None
|
|
110
|
+
fields = {
|
|
111
|
+
"status": status,
|
|
112
|
+
"account_key": key,
|
|
113
|
+
"natural_id": natural,
|
|
114
|
+
"email": identity.get("email"),
|
|
115
|
+
"plan_type": identity.get("plan_type"),
|
|
116
|
+
}
|
|
117
|
+
if any(value is not None and not isinstance(value, str)
|
|
118
|
+
for value in fields.values()):
|
|
119
|
+
return None
|
|
120
|
+
if credential is not None and (not isinstance(credential, str)
|
|
121
|
+
or not credential):
|
|
122
|
+
return None
|
|
123
|
+
return AccountBinding(
|
|
124
|
+
status=status,
|
|
125
|
+
account_key=key,
|
|
126
|
+
identity_evidence=_identity_evidence(fields),
|
|
127
|
+
credential_evidence=(
|
|
128
|
+
None if credential is None else credential_digest(credential)),
|
|
129
|
+
natural_id=natural,
|
|
130
|
+
email=fields["email"],
|
|
131
|
+
plan_type=fields["plan_type"],
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def load(document) -> AccountBinding:
|
|
136
|
+
"""Validate a persisted binding document; ValueError on any mismatch."""
|
|
137
|
+
if not isinstance(document, dict) or set(document) != _DOCUMENT_KEYS:
|
|
138
|
+
raise ValueError("invalid account binding document")
|
|
139
|
+
binding = capture({
|
|
140
|
+
"status": document["status"],
|
|
141
|
+
"account_key": document["accountKey"],
|
|
142
|
+
"natural_id": document["naturalId"],
|
|
143
|
+
"email": document["email"],
|
|
144
|
+
"plan_type": document["planType"],
|
|
145
|
+
})
|
|
146
|
+
if binding is None or binding.identity_evidence != document["identityEvidence"]:
|
|
147
|
+
raise ValueError("inconsistent account binding")
|
|
148
|
+
evidence = document["credentialEvidence"]
|
|
149
|
+
if evidence is not None and (not isinstance(evidence, str)
|
|
150
|
+
or _DIGEST_RE.fullmatch(evidence) is None):
|
|
151
|
+
raise ValueError("invalid credential evidence")
|
|
152
|
+
return dataclasses.replace(binding, credential_evidence=evidence)
|