Promote dev → main (frontend QA batch → beta) (#71)
Some checks failed
Deploy frontend to beta VPS / deploy (push) Failing after 8s
Sync infra to hosts / sync-beta (push) Successful in 7s
Deploy backend to beta VPS / deploy (push) Failing after 13s
secrets-guard / encrypted (push) Successful in 12s
Sync infra to hosts / sync-prod (push) Successful in 14s
shell-lint / shellcheck (push) Successful in 12s
Build + push backend image (Forgejo registry) / build-push (push) Successful in 57s
Build + push frontend image (Forgejo registry) / build-push (push) Successful in 1m2s
Some checks failed
Deploy frontend to beta VPS / deploy (push) Failing after 8s
Sync infra to hosts / sync-beta (push) Successful in 7s
Deploy backend to beta VPS / deploy (push) Failing after 13s
secrets-guard / encrypted (push) Successful in 12s
Sync infra to hosts / sync-prod (push) Successful in 14s
shell-lint / shellcheck (push) Successful in 12s
Build + push backend image (Forgejo registry) / build-push (push) Successful in 57s
Build + push frontend image (Forgejo registry) / build-push (push) Successful in 1m2s
This commit is contained in:
parent
150029075e
commit
ca84e0ce95
21 changed files with 374 additions and 54 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
|
||||
# Claude Code worktrees. These are other sessions' checkouts living inside this
|
||||
# repo; `git add -A` will otherwise commit them as embedded git repositories.
|
||||
.claude/worktrees/
|
||||
|
|
@ -6,6 +6,7 @@ pattern web/app.py's own endpoints use.
|
|||
"""
|
||||
import hashlib
|
||||
import os
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
|
||||
|
|
@ -84,9 +85,24 @@ def _load_history(cell):
|
|||
return history
|
||||
|
||||
|
||||
# The configured public origin is the source of truth for the scheme. Caddy
|
||||
# terminates TLS and reverse-proxies plain HTTP, so x-forwarded-proto (and
|
||||
# request.url.scheme) read "http" even though the public site is HTTPS-only --
|
||||
# and this origin is folded into the jsonld "url" the payloads carry. Trusting
|
||||
# the forwarded scheme makes that url (and the canonical/og the frontend builds
|
||||
# from the same origin it forwards here) emit http://, which 308-redirects to
|
||||
# https://. So a request that arrives on the configured public host is answered
|
||||
# with the configured public scheme; localhost/LAN dev never matches that host
|
||||
# and keeps the observed scheme, leaving plain-HTTP development unaffected.
|
||||
_PUBLIC = urlsplit(os.environ.get("THERMOGRAPH_BASE_URL", "https://thermograph.org"))
|
||||
|
||||
|
||||
def _origin(request: Request) -> str:
|
||||
proto = request.headers.get("x-forwarded-proto") or request.url.scheme
|
||||
host = request.headers.get("host") or request.url.netloc
|
||||
if _PUBLIC.scheme and _PUBLIC.netloc and host == _PUBLIC.netloc:
|
||||
proto = _PUBLIC.scheme
|
||||
else:
|
||||
proto = request.headers.get("x-forwarded-proto") or request.url.scheme
|
||||
return f"{proto}://{host}"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -937,9 +937,29 @@ def _fetch_recent_forecast(cell: dict) -> pl.DataFrame:
|
|||
.sort("date"))
|
||||
|
||||
|
||||
def _om_local_today(payload: dict) -> "datetime.date | None":
|
||||
"""The calendar date it is *now* at the cell, from the UTC offset Open-Meteo
|
||||
reports for a ``timezone=auto`` request. None when the offset is absent, which
|
||||
tells the caller to skip the in-progress-day guard rather than guess a date."""
|
||||
offset = payload.get("utc_offset_seconds")
|
||||
if offset is None:
|
||||
return None
|
||||
return (datetime.datetime.now(datetime.timezone.utc)
|
||||
+ datetime.timedelta(seconds=int(offset))).date()
|
||||
|
||||
|
||||
def _fetch_recent_forecast_om(cell: dict) -> pl.DataFrame:
|
||||
"""Fallback recent+forecast bundle from the Open-Meteo forecast API (the former
|
||||
primary): recent past + forward days in one call."""
|
||||
primary): recent past + forward days in one call.
|
||||
|
||||
The cell's in-progress *local* day is dropped from the bundle. Open-Meteo's
|
||||
daily high/low for today aggregates only the hours elapsed so far, so grading it
|
||||
reads a still-unfolding day as complete and produces spurious extremes (a cool
|
||||
morning served as a record-low high, seen live at the 1st percentile). This is
|
||||
the same failure the MET Norway path guards against with its diurnal-coverage
|
||||
gate (see _metno_to_frame); the fallback needs the equivalent. Past days are
|
||||
complete and future days are whole-day forecasts, so only today is excluded —
|
||||
the day lands in the record once it is over."""
|
||||
params = {
|
||||
"latitude": cell["center_lat"],
|
||||
"longitude": cell["center_lon"],
|
||||
|
|
@ -952,7 +972,12 @@ def _fetch_recent_forecast_om(cell: dict) -> pl.DataFrame:
|
|||
"forecast_days": FORECAST_DAYS,
|
||||
}
|
||||
r = _request(FORECAST_URL, params, 60, phase="recent_forecast_fetch")
|
||||
return _to_frame(r.json()["daily"])
|
||||
payload = r.json()
|
||||
df = _to_frame(payload["daily"])
|
||||
local_today = _om_local_today(payload)
|
||||
if local_today is not None:
|
||||
df = df.filter(pl.col("date") != local_today)
|
||||
return df
|
||||
|
||||
|
||||
def _load_recent_forecast(cell: dict) -> pl.DataFrame:
|
||||
|
|
|
|||
|
|
@ -279,8 +279,10 @@ def all_time_records(df: pl.DataFrame) -> dict:
|
|||
|
||||
|
||||
def longest_dry_streak(df: pl.DataFrame) -> tuple[int, str | None]:
|
||||
"""Longest run of consecutive days without measurable rain, and the ISO date the
|
||||
streak began. A dry day is precip < RAIN_THRESHOLD; null precip counts as dry."""
|
||||
"""Longest run of consecutive days without any rain, and the ISO date the streak
|
||||
began. A dry day has no rain at all (precip <= 0); null precip counts as dry. The
|
||||
threshold matches _grade_precip's dry/rain split — any measurable rain, however
|
||||
slight, is a rain day that breaks the streak (not the 0.01" rain-frequency line)."""
|
||||
if "precip" not in df.columns:
|
||||
return (0, None)
|
||||
d = df.select(["date", "precip"]).sort("date")
|
||||
|
|
@ -288,7 +290,7 @@ def longest_dry_streak(df: pl.DataFrame) -> tuple[int, str | None]:
|
|||
best_len, best_start = 0, None
|
||||
cur_len, cur_start = 0, None
|
||||
for dt, p in zip(dates, precips):
|
||||
wet = p is not None and not (isinstance(p, float) and np.isnan(p)) and p >= RAIN_THRESHOLD
|
||||
wet = p is not None and not (isinstance(p, float) and np.isnan(p)) and p > 0
|
||||
if wet:
|
||||
cur_len, cur_start = 0, None
|
||||
else:
|
||||
|
|
@ -305,11 +307,12 @@ def longest_dry_streak(df: pl.DataFrame) -> tuple[int, str | None]:
|
|||
def _band_stats(samples: np.ndarray) -> dict | None:
|
||||
if samples.size == 0:
|
||||
return None
|
||||
# Percentiles for the chart's nested "normal" fan, matching the 9 tier bounds:
|
||||
# p40-p60 is the Normal band; p25/p75, p10/p90 and p1/p99 mark the successive
|
||||
# Below/Above Normal, Low/High, Very Low/High and Near-Record edges.
|
||||
p1, p10, p25, p40, p50, p60, p75, p90, p99 = np.percentile(
|
||||
samples, [1, 10, 25, 40, 50, 60, 75, 90, 99]
|
||||
# Percentiles for the chart's nested "normal" fan. p40-p60 is the Normal band;
|
||||
# p25/p75, p10/p90 and p1/p99 mark the successive Below/Above Normal, Low/High,
|
||||
# Very Low/High and Near-Record edges. p95 additionally splits the rain fan's top
|
||||
# region into Very Heavy (90-95) and Severe (95-99); unused by the temperature fan.
|
||||
p1, p10, p25, p40, p50, p60, p75, p90, p95, p99 = np.percentile(
|
||||
samples, [1, 10, 25, 40, 50, 60, 75, 90, 95, 99]
|
||||
)
|
||||
return {
|
||||
"p1": round(float(p1), 1),
|
||||
|
|
@ -320,6 +323,7 @@ def _band_stats(samples: np.ndarray) -> dict | None:
|
|||
"p60": round(float(p60), 1),
|
||||
"p75": round(float(p75), 1),
|
||||
"p90": round(float(p90), 1),
|
||||
"p95": round(float(p95), 1),
|
||||
"p99": round(float(p99), 1),
|
||||
}
|
||||
|
||||
|
|
@ -352,13 +356,14 @@ def _grade_precip(samples: np.ndarray, value) -> dict | None:
|
|||
|
||||
|
||||
def dry_streaks(dates, precips) -> dict[str, int]:
|
||||
"""Map each date (ISO string) to days since the last measurable rain, walking a
|
||||
chronological precip series. Missing precip counts as a dry day. `dates` is an
|
||||
iterable of ``datetime.date`` (a polars Date column's ``.to_list()``)."""
|
||||
"""Map each date (ISO string) to days since the last day with any rain, walking a
|
||||
chronological precip series. Any measurable rain (precip > 0) resets the count, so
|
||||
this matches _grade_precip's dry/rain split; missing precip counts as a dry day.
|
||||
`dates` is an iterable of ``datetime.date`` (a polars Date column's ``.to_list()``)."""
|
||||
out: dict[str, int] = {}
|
||||
streak = 0
|
||||
for d, p in zip(dates, precips):
|
||||
wet = p is not None and not (isinstance(p, float) and np.isnan(p)) and p >= RAIN_THRESHOLD
|
||||
wet = p is not None and not (isinstance(p, float) and np.isnan(p)) and p > 0
|
||||
streak = 0 if wet else streak + 1
|
||||
out[_as_date(d).isoformat()] = streak
|
||||
return out
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ log = logging.getLogger("thermograph.push")
|
|||
_DATA_DIR = paths.DATA_DIR
|
||||
_VAPID_PATH = os.environ.get("THERMOGRAPH_VAPID_FILE") or os.path.join(_DATA_DIR, "vapid.json")
|
||||
# The VAPID "sub" claim — a contact the push service can reach about our traffic.
|
||||
_CONTACT = os.environ.get("THERMOGRAPH_VAPID_CONTACT", "mailto:admin@thermograph.app")
|
||||
_CONTACT = os.environ.get("THERMOGRAPH_VAPID_CONTACT", "mailto:admin@thermograph.org")
|
||||
|
||||
# pywebpush 2.0.0 forwards `timeout` straight to requests.post with no default of
|
||||
# its own — omit the kwarg here and the send blocks with NO timeout at all (the
|
||||
|
|
@ -174,10 +174,13 @@ def send(subscription_info: dict, payload: dict) -> str:
|
|||
return "ok"
|
||||
except WebPushException as e:
|
||||
status = getattr(getattr(e, "response", None), "status_code", None)
|
||||
if status in (404, 410):
|
||||
return "gone" # endpoint retired — caller should delete it
|
||||
# 401/403 = VAPID key mismatch, etc. Record it where it's visible (the errors
|
||||
# JSONL / dashboard), not just the system journal.
|
||||
if status in (401, 403, 404, 410):
|
||||
# 404/410 = endpoint retired; 401/403 = VAPID mismatch (e.g. after a key
|
||||
# rotation) — the row was minted under a key we can no longer sign for and
|
||||
# will never authenticate again. All are permanently dead: caller deletes.
|
||||
return "gone"
|
||||
# Anything else (rate limits, 5xx, malformed payload) may be transient — keep
|
||||
# the row and record it where it's visible (the errors JSONL / dashboard).
|
||||
log.warning("web push failed (status=%s): %s", status, e)
|
||||
audit.log_event("error", {"phase": "push", "status": status,
|
||||
"endpoint": (subscription_info.get("endpoint") or "")[:120]})
|
||||
|
|
|
|||
|
|
@ -118,6 +118,28 @@ def test_records_payload_shape(client):
|
|||
assert body["canonical_path"] == "/climate/testville/records"
|
||||
|
||||
|
||||
# --- https origin (jsonld url) -----------------------------------------------
|
||||
|
||||
def test_public_host_jsonld_url_is_https(client):
|
||||
"""The public site is HTTPS-only behind Caddy, which proxies plain HTTP, so
|
||||
x-forwarded-proto reads "http". A request on the configured public host
|
||||
(THERMOGRAPH_BASE_URL defaults to https://thermograph.org) must still fold an
|
||||
https:// origin into the payload's jsonld url; otherwise the frontend renders
|
||||
an http:// canonical/JSON-LD that 308-redirects."""
|
||||
import json as _json
|
||||
hdr = {"host": "thermograph.org", "x-forwarded-proto": "http"}
|
||||
for path in ("city/testville", "city/testville/records"):
|
||||
s = _json.dumps(client.get(f"{BASE}/{path}", headers=hdr).json())
|
||||
assert "https://thermograph.org/thermograph/climate/testville" in s
|
||||
assert "http://thermograph.org" not in s
|
||||
|
||||
|
||||
def test_non_public_host_jsonld_keeps_forwarded_scheme(client):
|
||||
hdr = {"host": "192.168.1.10:8000", "x-forwarded-proto": "http"}
|
||||
body = client.get(f"{BASE}/city/testville", headers=hdr).json()
|
||||
assert body["jsonld"]["@graph"][0]["url"].startswith("http://192.168.1.10:8000/")
|
||||
|
||||
|
||||
# --- 404s --------------------------------------------------------------------
|
||||
|
||||
def test_unknown_slug_is_404(client, counts):
|
||||
|
|
|
|||
|
|
@ -208,6 +208,49 @@ def test_recent_forecast_falls_back_to_open_meteo(monkeypatch, tmp_path):
|
|||
assert df.height == om.height
|
||||
|
||||
|
||||
def test_recent_forecast_om_drops_the_in_progress_local_day(monkeypatch):
|
||||
"""The Open-Meteo fallback must not grade the cell's in-progress local day: its
|
||||
daily high/low is only a partial aggregate of the hours elapsed so far (a cool
|
||||
morning would read as a record-low high). Past days and future forecast days
|
||||
survive; today — per the UTC offset Open-Meteo reports for a timezone=auto
|
||||
request — is dropped, matching the MET path's diurnal-coverage gate."""
|
||||
offset = 3 * 3600 # UTC+3, e.g. Europe/Vilnius (the reported Ringaudai incident)
|
||||
local_today = (datetime.datetime.now(datetime.timezone.utc)
|
||||
+ datetime.timedelta(seconds=offset)).date()
|
||||
days = [local_today + datetime.timedelta(days=n) for n in (-2, -1, 0, 1)]
|
||||
daily = {
|
||||
"time": [d.isoformat() for d in days],
|
||||
"temperature_2m_max": [80.0, 82.0, 61.0, 84.0], # today's 61 is the partial value
|
||||
"temperature_2m_min": [60.0, 61.0, 57.0, 62.0],
|
||||
"precipitation_sum": [0.0, 0.0, 0.0, 0.0],
|
||||
}
|
||||
payload = {"utc_offset_seconds": offset, "daily": daily}
|
||||
|
||||
class Resp:
|
||||
def json(self): return payload
|
||||
monkeypatch.setattr(climate, "_request", lambda *a, **k: Resp())
|
||||
|
||||
got = climate._fetch_recent_forecast_om(
|
||||
{"center_lat": 54.9, "center_lon": 23.8})["date"].to_list()
|
||||
assert local_today not in got # partial today dropped
|
||||
assert local_today - datetime.timedelta(days=1) in got # yesterday kept
|
||||
assert local_today + datetime.timedelta(days=1) in got # tomorrow's forecast kept
|
||||
assert len(got) == 3
|
||||
|
||||
|
||||
def test_recent_forecast_om_keeps_all_days_without_a_utc_offset(monkeypatch):
|
||||
"""No UTC offset reported -> skip the in-progress-day guard rather than guess a
|
||||
date, so the bundle passes through as before (only the usual null-day filter)."""
|
||||
payload = {"daily": _om_daily(3)} # no utc_offset_seconds
|
||||
|
||||
class Resp:
|
||||
def json(self): return payload
|
||||
monkeypatch.setattr(climate, "_request", lambda *a, **k: Resp())
|
||||
|
||||
df = climate._fetch_recent_forecast_om({"center_lat": 1.0, "center_lon": 2.0})
|
||||
assert df.height == climate._to_frame(_om_daily(3)).height
|
||||
|
||||
|
||||
def test_recent_forecast_serves_stale_cache_when_all_sources_fail(monkeypatch, tmp_path):
|
||||
"""With every source down (the NASA + MET Norway primary and the Open-Meteo
|
||||
fallback), an existing (stale) cache is served rather than failing — and it is NOT
|
||||
|
|
|
|||
|
|
@ -113,7 +113,9 @@ def test_dry_streaks_walk():
|
|||
dates = [datetime.date(2024, 1, 1) + datetime.timedelta(days=i) for i in range(5)]
|
||||
precips = [0.5, 0.0, float("nan"), 0.02, 0.005]
|
||||
out = grading.dry_streaks(dates, precips)
|
||||
assert list(out.values()) == [0, 1, 2, 0, 1] # NaN counts as dry
|
||||
# Any rain > 0 breaks the streak (matching the dry/rain grading split), so the
|
||||
# 0.005" trace day resets to 0; only exact 0.0 and NaN count as dry.
|
||||
assert list(out.values()) == [0, 1, 2, 0, 0]
|
||||
|
||||
|
||||
# ---- range + day grading over a synthetic record --------------------------------
|
||||
|
|
@ -141,7 +143,7 @@ def test_grade_day_normals_and_departure(history):
|
|||
result = grading.grade_day(history, target, {"tmax": row["tmax"], "tmin": row["tmin"],
|
||||
"precip": row["precip"]})
|
||||
assert set(result["normals"]["tmax"]) == {"p1", "p10", "p25", "p40", "p50", "p60",
|
||||
"p75", "p90", "p99"}
|
||||
"p75", "p90", "p95", "p99"}
|
||||
expected = max(abs(result["tmax"]["percentile"] - 50), abs(result["tmin"]["percentile"] - 50))
|
||||
assert result["departure"] == round(expected, 1)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
"""VAPID key resolution: the atomic first-writer-wins claim of the keypair file
|
||||
(push.py's `_claim_file`), and that `_load()` caches whatever that settles on
|
||||
rather than its own local generation when it loses the race.
|
||||
rather than its own local generation when it loses the race. Also `send()`'s
|
||||
mapping of push-service responses onto 'ok'/'gone'/'error'.
|
||||
|
||||
No network — `send()`'s pywebpush call isn't exercised here (that's notify.py's
|
||||
`test_push_dispatched_on_new_notification` etc., which stub `push.send` itself)."""
|
||||
No network — where `send()` is exercised the pywebpush call is stubbed; the
|
||||
dispatch path (notify.py's `test_push_dispatched_on_new_notification` etc.) stubs
|
||||
`push.send` itself."""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from pywebpush import WebPushException
|
||||
|
||||
from notifications import push
|
||||
|
||||
|
||||
|
|
@ -94,3 +99,44 @@ def test_load_prefers_env_over_file(monkeypatch, tmp_path):
|
|||
result = push._load()
|
||||
|
||||
assert result == {"private_key": "priv-env", "public_key": "pub-env"}
|
||||
|
||||
|
||||
# --- send() response mapping ------------------------------------------------
|
||||
_SUB = {"endpoint": "https://push.example.com/ep-1", "keys": {"p256dh": "BKEY", "auth": "YXV0aA"}}
|
||||
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, status_code):
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
def _raise_status(status):
|
||||
def _webpush(**kwargs):
|
||||
raise WebPushException("boom", response=_Resp(status))
|
||||
return _webpush
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [401, 403, 404, 410])
|
||||
def test_send_prunes_permanently_dead_endpoints(monkeypatch, status):
|
||||
# 404/410 = endpoint retired; 401/403 = VAPID key mismatch (e.g. after a key
|
||||
# rotation). All are permanently dead, so send() reports 'gone' and the caller
|
||||
# deletes the row — otherwise a rotated key leaves dead subscriptions forever.
|
||||
monkeypatch.setattr(push, "webpush", _raise_status(status))
|
||||
assert push.send(_SUB, {"hello": "world"}) == "gone"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [429, 500, 502])
|
||||
def test_send_keeps_row_on_transient_failure(monkeypatch, status):
|
||||
# Rate limits / 5xx may recover; keep the row and report it as an error.
|
||||
monkeypatch.setattr(push, "webpush", _raise_status(status))
|
||||
assert push.send(_SUB, {"hello": "world"}) == "error"
|
||||
|
||||
|
||||
def test_send_ok(monkeypatch):
|
||||
monkeypatch.setattr(push, "webpush", lambda **kwargs: None)
|
||||
assert push.send(_SUB, {"hello": "world"}) == "ok"
|
||||
|
||||
|
||||
def test_default_contact_is_the_org_domain():
|
||||
# The VAPID "sub" contact must be a domain we actually own; .app was a typo.
|
||||
assert push._CONTACT == "mailto:admin@thermograph.org"
|
||||
|
|
|
|||
|
|
@ -71,6 +71,18 @@ def test_api_version_reports_backend_contract(client):
|
|||
assert body["backend_version"] == "2"
|
||||
|
||||
|
||||
def test_malformed_date_is_rejected(client):
|
||||
# A malformed or non-calendar date must be a 422, not a 500: fromisoformat
|
||||
# raises ValueError on these and the handler used to leak it as a crash.
|
||||
for bad in ("notadate", "2026-13-40", "2026-02-30"):
|
||||
for route in ("grade", "day"):
|
||||
r = client.get(f"/thermograph/api/v2/{route}", params={**Q, "date": bad})
|
||||
assert r.status_code == 422, (route, bad, r.status_code)
|
||||
# A valid date still works.
|
||||
assert client.get("/thermograph/api/v2/grade",
|
||||
params={**Q, "date": "2026-07-11"}).status_code == 200
|
||||
|
||||
|
||||
def test_day_detail_and_ladders(client, history):
|
||||
r = client.get("/thermograph/api/v2/day", params=Q)
|
||||
assert r.status_code == 200
|
||||
|
|
|
|||
|
|
@ -79,6 +79,20 @@ def _weather_fetch_error(e) -> HTTPException:
|
|||
return HTTPException(status_code=502, detail=f"weather data fetch failed: {e}")
|
||||
|
||||
|
||||
def _parse_target_date(date: str | None, default: datetime.date) -> datetime.date:
|
||||
"""Parse the ``date`` query param (YYYY-MM-DD) into a date, falling back to
|
||||
``default`` when it's absent. A malformed or non-calendar date (e.g. 2026-13-40)
|
||||
is a client error, not a crash: surface it as a 422 rather than let
|
||||
fromisoformat's ValueError become a 500."""
|
||||
if not date:
|
||||
return default
|
||||
try:
|
||||
return datetime.date.fromisoformat(date[:10])
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=422,
|
||||
detail=f"invalid date: {date!r} (expected YYYY-MM-DD)")
|
||||
|
||||
|
||||
# --- background neighbor warming ---------------------------------------------
|
||||
# /cell?neighbors=1 asks the server to warm the 8 grid cells around the request
|
||||
# (the frontend used to fire 8 staggered prefetch requests, mirroring grid.py's
|
||||
|
|
@ -492,7 +506,7 @@ def api_grade(
|
|||
description="days to grade after the target (observed, or forecast when future; "
|
||||
"the forecast reaches ~7 days out so future days cap there)"),
|
||||
):
|
||||
target = datetime.date.fromisoformat(date[:10]) if date else datetime.date.today()
|
||||
target = _parse_target_date(date, datetime.date.today())
|
||||
cell = grid.snap(lat, lon)
|
||||
|
||||
with audit.RunAudit(
|
||||
|
|
@ -580,7 +594,7 @@ def api_day(
|
|||
) as run:
|
||||
history, cache_meta, _ = _fetch_history(run, cell)
|
||||
last = history["date"].max()
|
||||
target = datetime.date.fromisoformat(date[:10]) if date else last
|
||||
target = _parse_target_date(date, last)
|
||||
run.set(target_date=target.isoformat())
|
||||
|
||||
def build(place):
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import hashlib
|
|||
import json
|
||||
import logging
|
||||
import os
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException, Request, Response
|
||||
|
|
@ -56,13 +57,28 @@ _log = logging.getLogger(__name__)
|
|||
|
||||
|
||||
# --- helpers -------------------------------------------------------------
|
||||
# The configured public origin is the source of truth for the scheme. Caddy
|
||||
# terminates TLS and reverse-proxies plain HTTP, so x-forwarded-proto (and
|
||||
# request.url.scheme) read "http" even though the public site is HTTPS-only.
|
||||
# Trusting that scheme makes every canonical / og:url / og:image / sitemap <loc>
|
||||
# emit http://, which 308-redirects to https:// -- self-conflicting canonicals
|
||||
# and a sitemap full of redirecting URLs. So a request that arrives on the
|
||||
# configured public host is answered with the configured public scheme (https on
|
||||
# prod/beta, both of which set THERMOGRAPH_BASE_URL per host); localhost/LAN dev
|
||||
# never matches it and keeps the observed scheme, so plain-HTTP dev is unchanged.
|
||||
_PUBLIC = urlsplit(os.environ.get("THERMOGRAPH_BASE_URL", "https://thermograph.org"))
|
||||
|
||||
|
||||
def _origin(request: Request) -> str:
|
||||
# x-forwarded-host takes precedence over host: when reached via backend's
|
||||
# internal proxy fallback (no Caddy in front -- see _proxy_to_frontend in
|
||||
# backend/web/app.py), Host is the internal hop's own address, not the
|
||||
# browser-facing one.
|
||||
proto = request.headers.get("x-forwarded-proto") or request.url.scheme
|
||||
host = request.headers.get("x-forwarded-host") or request.headers.get("host") or request.url.netloc
|
||||
if _PUBLIC.scheme and _PUBLIC.netloc and host == _PUBLIC.netloc:
|
||||
proto = _PUBLIC.scheme
|
||||
else:
|
||||
proto = request.headers.get("x-forwarded-proto") or request.url.scheme
|
||||
return f"{proto}://{host}"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { W, PW, H, PL, PR, plotTop, plotBot, xLabY, setChartWidth, chartPalette,
|
|||
tempChart, precipChart, dryChart, attachChartHover } from "./chart.js";
|
||||
import { track } from "./digest.js";
|
||||
import { TIER_COLORS, SCALE_TEMP, drynessColor, pctOrd, esc, todayISO, placeLabel,
|
||||
tierKeySegs, GUIDE_LINK, fmtPrecip, fmtWind, fmtHumid } from "./shared.js";
|
||||
tierKeySegs, GUIDE_LINK, fmtPrecip, fmtPrecipTier, fmtWind, fmtHumid } from "./shared.js";
|
||||
|
||||
let selected = null; // {lat, lon}
|
||||
|
||||
|
|
@ -323,11 +323,14 @@ function precipCell(d, isFc, isToday) {
|
|||
const dd = ` data-date="${d.date}"`;
|
||||
const cls = "rd-c" + (isFc ? " rd-fc" : isToday ? " rd-today" : "");
|
||||
if (!g) return `<span class="${cls}"${dd}>—</span>`;
|
||||
if (g.value === 0 && d.dsr != null && d.dsr > 0) {
|
||||
return `<span class="${cls}"${dd} style="--c:${drynessColor(d.dsr)}" title="${d.dsr} days since measurable rain">${d.dsr}d</span>`;
|
||||
// Only a genuinely dry day (no rain at all) labels the streak; any rain day —
|
||||
// down to sub-0.01" trace that rounds to 0 — shows its depth, tinted by tier.
|
||||
if (g.class === "dry" && d.dsr != null && d.dsr > 0) {
|
||||
return `<span class="${cls}"${dd} style="--c:${drynessColor(d.dsr)}" title="${d.dsr} days since rain">${d.dsr}d</span>`;
|
||||
}
|
||||
const col = TIER_COLORS[g.class] || "";
|
||||
return `<span class="${cls}"${dd} style="--c:${col}">${cRain(g.value)}</span>`;
|
||||
const amt = g.class && g.class !== "dry" ? fmtPrecipTier(g.value, g.class, false) : cRain(g.value);
|
||||
return `<span class="${cls}"${dd} style="--c:${col}">${amt}</span>`;
|
||||
}
|
||||
|
||||
// Compact "graded days" table — a ROW per metric, a COLUMN per day (newest first).
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { uv } from "./account.js"; // header sign-in entry + notification bell
|
|||
import { TTL, chunkedFetch, prefetchViews } from "./cache.js";
|
||||
import { initFindButton, setFindLabel } from "./mappicker.js";
|
||||
import { TIER_COLORS, PRECIP_COLORS, SCALE_TEMP, SCALE_RAIN, drynessColor, pctOrd,
|
||||
placeLabel, fmtPrecip, fmtWind, fmtHumid, MONTHS, isoOfDate, monthStart, monthEnd,
|
||||
placeLabel, fmtPrecip, fmtPrecipTier, fmtWind, fmtHumid, MONTHS, isoOfDate, monthStart, monthEnd,
|
||||
CHUNK_MONTHS, buildChunks, clickOpensPicker, metricBuckets, distStrip,
|
||||
seasonFilterDropdown, seasonSummaryText, syncSeasonChecks,
|
||||
applySeasonMonthChange, initSeasonExpand, allMonths, monthsToMask, maskToMonths,
|
||||
|
|
@ -42,7 +42,7 @@ const SKY_WORDS = [
|
|||
// The shared weather summary, adapted to the calendar's compact day records
|
||||
// (dsr included, so a long-dry no-rain day reads "dry" rather than "clear").
|
||||
const weatherType = (rec) =>
|
||||
wxType(rec.tmax && rec.tmax.v, rec.precip && rec.precip.v, rec.dsr);
|
||||
wxType(rec.tmax && rec.tmax.v, rec.precip && rec.precip.v, rec.dsr, rec.precip && rec.precip.c);
|
||||
|
||||
let selected = null; // {lat, lon}
|
||||
let data = null; // last /api/v2/calendar response
|
||||
|
|
@ -704,7 +704,7 @@ function attachHover(byDate) {
|
|||
["humid", line("humid", "Humid", rec.humid, fmtHumid, tempColor(rec.humid))],
|
||||
["wind", line("wind", "Wind", rec.wind, fmtWind, tempColor(rec.wind))],
|
||||
["gust", line("gust", "Gust", rec.gust, fmtWind, tempColor(rec.gust))],
|
||||
["precip", line("precip", "Precip", rec.precip, fmtPrecip, precipColor)],
|
||||
["precip", line("precip", "Precip", rec.precip, (v) => fmtPrecipTier(v, rec.precip && rec.precip.c), precipColor)],
|
||||
];
|
||||
if (dsrStr) {
|
||||
const rc = metric === "dsr" ? " tt-r-active" : "";
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ const PCT_FALLBACK = {
|
|||
p1: ["p1", "p10", "min", "p50"], p10: ["p10", "p50"],
|
||||
p25: ["p25", "p30", "p50"], p40: ["p40", "p30", "p50"],
|
||||
p60: ["p60", "p70", "p50"], p75: ["p75", "p70", "p50"],
|
||||
p90: ["p90", "p50"], p99: ["p99", "p90", "max", "p50"],
|
||||
p90: ["p90", "p50"], p95: ["p95", "p90", "p50"], p99: ["p99", "p90", "max", "p50"],
|
||||
};
|
||||
const pget = (o, k) => {
|
||||
for (const kk of (PCT_FALLBACK[k] || [k])) if (o && o[kk] != null) return o[kk];
|
||||
|
|
@ -93,8 +93,12 @@ const TEMP_FAN = [
|
|||
["p90", "p99", "very-hot"], ["p75", "p90", "hot"], ["p60", "p75", "warm"],
|
||||
["p40", "p60", "normal"], ["p25", "p40", "cool"], ["p10", "p25", "cold"], ["p1", "p10", "very-cold"],
|
||||
];
|
||||
// Eight rain-intensity tiers (post-split): Trace / Light / Brisk / Typical / Heavy
|
||||
// (60-90) / Very Heavy (90-95) / Severe (95-99) / Extreme. Each band maps a rain-day
|
||||
// percentile range to its calendar tier colour; the p75 vertex inside the single
|
||||
// Heavy tier keeps the fan following the p75 contour.
|
||||
const RAIN_FAN = [
|
||||
["p90", "p99", "wet-8"], ["p75", "p90", "wet-7"], ["p60", "p75", "wet-6"],
|
||||
["p95", "p99", "wet-8"], ["p90", "p95", "wet-7"], ["p75", "p90", "wet-6"], ["p60", "p75", "wet-6"],
|
||||
["p40", "p60", "wet-5"], ["p25", "p40", "wet-4"], ["p10", "p25", "wet-3"], ["p1", "p10", "wet-2"],
|
||||
];
|
||||
|
||||
|
|
@ -212,7 +216,7 @@ export function precipChart(days, n, xFor, C) {
|
|||
getPct: (d) => (d.precip ? d.precip.percentile : null),
|
||||
// Rain days take their intensity-tier color; no-rain days warm with the dry
|
||||
// streak (tan→red) so a dry spell reads as dry, matching the Dry chart.
|
||||
dotColor: (d) => (d.precip && d.precip.value > 0 ? (TIER_COLORS[d.precip.class] || C.precip) : (drynessColor(d.dsr) || C.precip)),
|
||||
dotColor: (d) => (d.precip && d.precip.class && d.precip.class !== "dry" ? (TIER_COLORS[d.precip.class] || C.precip) : (drynessColor(d.dsr) || C.precip)),
|
||||
fmtAxis: (v) => fmtPrecip(v, false),
|
||||
fmtLabel: (v) => fmtPrecip(v, false),
|
||||
labelOn: (v) => v > 0, // only label rain days; dry days rest on the baseline
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import { fmtTemp, onUnitChange } from "./units.js";
|
|||
import { uv } from "./account.js"; // header sign-in entry + notification bell, and the API-version resolver
|
||||
import { getJSON, TTL, prefetchViews } from "./cache.js";
|
||||
import { initFindButton, setFindLabel } from "./mappicker.js";
|
||||
import { TIER_COLORS, PRECIP_COLORS, DRY_COLOR, pctOrd, fmtPrecip, fmtWind, fmtHumid,
|
||||
todayISO, weatherType, placeLabel } from "./shared.js";
|
||||
import { TIER_COLORS, PRECIP_COLORS, DRY_COLOR, pctOrd, fmtPrecip, fmtPrecipTier, fmtWind, fmtHumid,
|
||||
todayISO, isoOfDate, weatherType, placeLabel } from "./shared.js";
|
||||
|
||||
// Color a tier the same way the calendar cell would be colored for it.
|
||||
const tierColor = (c) => (c === "dry" ? DRY_COLOR : TIER_COLORS[c] || PRECIP_COLORS[c] || "");
|
||||
|
|
@ -44,7 +44,9 @@ function stepDay(delta) {
|
|||
if (!curDate) return;
|
||||
const d = new Date(curDate + "T00:00:00");
|
||||
d.setDate(d.getDate() + delta);
|
||||
const iso = d.toISOString().slice(0, 10);
|
||||
// Format in LOCAL time — curDate was parsed as local midnight, so toISOString()
|
||||
// (UTC) would shift the result a day for UTC± viewers and desync the today guard.
|
||||
const iso = isoOfDate(d);
|
||||
if (iso > todayISO()) return; // don't step past today
|
||||
curDate = iso;
|
||||
fetchDay();
|
||||
|
|
@ -115,7 +117,8 @@ function render(data) {
|
|||
// Weather summary from the observed high + precip (omitted for days with no obs yet).
|
||||
const th = d.metrics.tmax.obs ? d.metrics.tmax.obs.value : null;
|
||||
const pr = d.metrics.precip.obs ? d.metrics.precip.obs.value : null;
|
||||
const wt = th != null || pr != null ? weatherType(th, pr) : null;
|
||||
const prCls = d.metrics.precip.obs ? d.metrics.precip.obs.class : null;
|
||||
const wt = th != null || pr != null ? weatherType(th, pr, undefined, prCls) : null;
|
||||
dayHead.innerHTML = `
|
||||
<h2>${nice}</h2>
|
||||
${wt ? `<p class="day-weather">${wt.icon} ${wt.text}</p>` : ""}
|
||||
|
|
@ -155,11 +158,14 @@ function ladderCard(title, m, fmt, kind) {
|
|||
if (!m || !m.ladder) return "";
|
||||
const obs = m.obs;
|
||||
const activeClass = obs ? obs.class : null;
|
||||
// The observed precip depth reads "trace" for a sub-0.01" rain day (rounds to 0
|
||||
// but is graded a rain tier); ladder tier ranges keep the plain numeric formatter.
|
||||
const obsFmt = kind === "precip" ? ((v) => fmtPrecipTier(v, activeClass)) : fmt;
|
||||
|
||||
let summary;
|
||||
if (obs) {
|
||||
const pct = obs.percentile == null ? "" : ` · ${pctOrd(obs.percentile)} pct`;
|
||||
summary = `<span class="day-obs" style="--cat:${tierColor(obs.class)}">${fmt(obs.value)}</span>
|
||||
summary = `<span class="day-obs" style="--cat:${tierColor(obs.class)}">${obsFmt(obs.value)}</span>
|
||||
<span class="day-obs-meta">${obs.grade}${pct}</span>`;
|
||||
} else {
|
||||
summary = `<span class="day-obs-meta">No observation for this day yet</span>`;
|
||||
|
|
@ -182,7 +188,7 @@ function ladderCard(title, m, fmt, kind) {
|
|||
else if (t.lo == null) val = `< ${fmt(t.hi)}`; // bottom tier: strictly below p1
|
||||
else val = fmtRange(fmt, t.lo, t.hi);
|
||||
const marker = t.c === activeClass && obs
|
||||
? `<span class="ladder-mark">◀ ${bareVal(fmt, obs.value)}</span>` : "";
|
||||
? `<span class="ladder-mark">◀ ${bareVal(obsFmt, obs.value)}</span>` : "";
|
||||
return `<div class="ladder-row${active}">
|
||||
<span class="ladder-sw" style="background:${tierColor(t.c)}"></span>
|
||||
<span class="ladder-label" style="--cat:${tierColor(t.c)}">${t.label}</span>
|
||||
|
|
|
|||
|
|
@ -36,6 +36,21 @@ async function registration() {
|
|||
return navigator.serviceWorker.ready;
|
||||
}
|
||||
|
||||
// Does an existing subscription's baked-in applicationServerKey still match the
|
||||
// server's current VAPID public key? After a key rotation it won't: the browser
|
||||
// keeps signing pushes the server can no longer authenticate, so we must replace
|
||||
// the subscription. `options.applicationServerKey` is an ArrayBuffer of the raw
|
||||
// key bytes (or null on browsers that don't expose it — treat that as a mismatch
|
||||
// and re-subscribe rather than leaving a possibly-stale subscription in place).
|
||||
function applicationServerKeyMatches(sub, serverKey) {
|
||||
const current = sub.options && sub.options.applicationServerKey;
|
||||
if (!current) return false;
|
||||
const a = new Uint8Array(current);
|
||||
if (a.length !== serverKey.length) return false;
|
||||
for (let i = 0; i < a.length; i++) if (a[i] !== serverKey[i]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Is this device currently subscribed? (a PushSubscription exists locally)
|
||||
export async function isEnabled() {
|
||||
if (!supported()) return false;
|
||||
|
|
@ -60,13 +75,24 @@ export async function enable() {
|
|||
|
||||
const reg = await registration();
|
||||
let sub = await reg.pushManager.getSubscription();
|
||||
|
||||
// Always resolve the server's current VAPID key: it's needed to subscribe, and
|
||||
// — when a subscription already exists — to detect a key rotation. A subscription
|
||||
// minted under an old key silently stops receiving pushes, so on a mismatch we
|
||||
// drop it and re-subscribe with the new key. Matching key = happy path, untouched.
|
||||
const keyRes = await apiFetch(uv("push/vapid-key"));
|
||||
if (!keyRes.ok) throw new Error("Couldn't fetch the server key.");
|
||||
const { key } = await keyRes.json();
|
||||
const serverKey = urlB64ToUint8Array(key);
|
||||
|
||||
if (sub && !applicationServerKeyMatches(sub, serverKey)) {
|
||||
await sub.unsubscribe();
|
||||
sub = null;
|
||||
}
|
||||
if (!sub) {
|
||||
const res = await apiFetch(uv("push/vapid-key"));
|
||||
if (!res.ok) throw new Error("Couldn't fetch the server key.");
|
||||
const { key } = await res.json();
|
||||
sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlB64ToUint8Array(key),
|
||||
applicationServerKey: serverKey,
|
||||
});
|
||||
}
|
||||
const res = await apiFetch(uv("push/subscribe"), { method: "POST", json: sub.toJSON() });
|
||||
|
|
|
|||
|
|
@ -257,7 +257,10 @@ export const pctOrd = (n) => {
|
|||
if (!Number.isFinite(v)) return "—";
|
||||
return ord(Math.min(99, Math.max(1, Math.round(v))));
|
||||
};
|
||||
export const todayISO = () => new Date().toISOString().slice(0, 10);
|
||||
// Today in the viewer's LOCAL zone (see isoOfDate below). A UTC date rolls a day
|
||||
// early/late for UTC± viewers past local midnight, putting "today" and the
|
||||
// date-picker max out of reach of the day they're actually living in.
|
||||
export const todayISO = () => isoOfDate(new Date());
|
||||
export const esc = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
// The heading label for a graded response: the resolved place name, or the
|
||||
// cell-center coordinates while (or if) no name resolves.
|
||||
|
|
@ -464,13 +467,28 @@ export const WX_ICONS = {
|
|||
snow: WX(`<path d="M20 17.6A5 5 0 0 0 18 8h-1.26A8 8 0 1 0 4 16.25"/><line x1="8" y1="18" x2="8" y2="18"/><line x1="12" y1="20" x2="12" y2="20"/><line x1="16" y1="18" x2="16" y2="18"/><line x1="12" y1="16" x2="12" y2="16"/>`),
|
||||
};
|
||||
|
||||
// A graded precip depth for display, given its grade class. Reanalysis "trace"
|
||||
// rain — any measurable precip below 0.01" — is graded as a rain tier but rounds to
|
||||
// 0.00" / 0 mm, so its depth prints as "trace" rather than a bone-dry "0.00" that
|
||||
// would contradict the rain grade (and the now-reset dry streak). Genuinely dry
|
||||
// days (class "dry") and real, roundable depths format as usual.
|
||||
export function fmtPrecipTier(v, cls, withUnit = true) {
|
||||
if (cls && cls !== "dry" && v != null && parseFloat(fmtPrecip(v, false)) === 0)
|
||||
return "trace";
|
||||
return fmtPrecip(v, withUnit);
|
||||
}
|
||||
|
||||
// Plain-language "what was the day like" descriptor from the raw values: a
|
||||
// temperature word (from the daily high, °F) crossed with a sky/precip word
|
||||
// (precip in inches — falling as snow at/below freezing). `dsr` is optional:
|
||||
// when a long dry streak is known, a no-rain day reads "dry" instead of
|
||||
// "clear" (the calendar passes it; the day page doesn't track streaks).
|
||||
export function weatherType(t, p, dsr) {
|
||||
const wet = p != null && p >= 0.01;
|
||||
// `precipCls` is the precip grade class when known: a rain tier means it rained
|
||||
// even when the rounded depth reads 0 (trace), so it decides wet/dry ahead of the
|
||||
// depth — matching the dry/rain grading split (precip > 0). Without it, any
|
||||
// positive depth counts as wet.
|
||||
export function weatherType(t, p, dsr, precipCls) {
|
||||
const wet = precipCls != null ? precipCls !== "dry" : (p != null && p > 0);
|
||||
const freezing = t != null && t <= 34;
|
||||
const temp = t == null ? "" :
|
||||
t >= 95 ? "Scorching" : t >= 85 ? "Hot" : t >= 72 ? "Warm" :
|
||||
|
|
|
|||
|
|
@ -29,6 +29,51 @@ def test_sitemap_lists_city_urls(client):
|
|||
assert f"/climate/{SLUG}/records</loc>" in r.text
|
||||
|
||||
|
||||
# The public site is HTTPS-only behind Caddy, which terminates TLS and proxies
|
||||
# plain HTTP -- so x-forwarded-proto / request.url.scheme read "http". A request
|
||||
# arriving on the configured public host (THERMOGRAPH_BASE_URL defaults to
|
||||
# https://thermograph.org in the test env) must still emit https:// in every
|
||||
# frontend-built absolute URL, or canonicals self-conflict and the sitemap lists
|
||||
# redirecting URLs.
|
||||
_PUBLIC = {"host": "thermograph.org", "x-forwarded-proto": "http"}
|
||||
|
||||
|
||||
def test_public_host_canonical_and_og_are_https(client):
|
||||
b = client.get(f"{B}/climate/{SLUG}", headers=_PUBLIC).text
|
||||
assert f'<link rel="canonical" href="https://thermograph.org{B}/climate/{SLUG}"' in b
|
||||
assert f'<meta property="og:url" content="https://thermograph.org{B}/climate/{SLUG}"' in b
|
||||
assert 'property="og:image" content="https://thermograph.org' in b
|
||||
|
||||
|
||||
def test_public_host_sitemap_locs_are_https(client):
|
||||
body = client.get(f"{B}/sitemap.xml", headers=_PUBLIC).text
|
||||
assert f"<loc>https://thermograph.org{B}/climate/{SLUG}</loc>" in body
|
||||
assert "<loc>http://thermograph.org" not in body
|
||||
|
||||
|
||||
def test_public_host_robots_sitemap_is_https(client):
|
||||
body = client.get(f"{B}/robots.txt", headers=_PUBLIC).text
|
||||
assert f"Sitemap: https://thermograph.org{B}/sitemap.xml" in body
|
||||
|
||||
|
||||
def test_non_public_host_keeps_forwarded_scheme(client):
|
||||
# A LAN/dev host never matches the configured public host, so the observed
|
||||
# (plain-http) scheme is preserved -- http development is unaffected.
|
||||
b = client.get(f"{B}/climate/{SLUG}",
|
||||
headers={"host": "192.168.1.10:8000", "x-forwarded-proto": "http"}).text
|
||||
assert f'<link rel="canonical" href="http://192.168.1.10:8000{B}/climate/{SLUG}"' in b
|
||||
|
||||
|
||||
def test_proxied_forwarded_host_still_https(client):
|
||||
# Reached via backend's proxy fallback: Host is the internal hop, the real
|
||||
# browser host arrives in X-Forwarded-Host -- which still resolves to the
|
||||
# public host and must go https.
|
||||
b = client.get(f"{B}/climate/{SLUG}",
|
||||
headers={"host": "frontend:8080", "x-forwarded-host": "thermograph.org",
|
||||
"x-forwarded-proto": "http"}).text
|
||||
assert f'<link rel="canonical" href="https://thermograph.org{B}/climate/{SLUG}"' in b
|
||||
|
||||
|
||||
def test_city_page_renders_structure(client):
|
||||
r = client.get(f"{B}/climate/{SLUG}")
|
||||
assert r.status_code == 200
|
||||
|
|
|
|||
|
|
@ -34,8 +34,18 @@ Gather the key names over SSH (never the values), then audit. Hosts/keys per INF
|
|||
K=~/.ssh/thermograph_agent_ed25519
|
||||
# sudo: /etc/thermograph.env is root-owned (0640). On prod `agent` can read it
|
||||
# directly too, but sudo works uniformly on both boxes.
|
||||
ssh -i $K agent@169.58.46.181 'sudo grep -oE "^[A-Z_]+=" /etc/thermograph.env' > /tmp/prod.keys # prod
|
||||
ssh -i $K agent@75.119.132.91 'sudo grep -oE "^[A-Z_]+=" /etc/thermograph.env' > /tmp/beta.keys # beta
|
||||
#
|
||||
# The character class must allow DIGITS. This was `^[A-Z_]+=` until 2026-07-24,
|
||||
# which silently skipped every key whose name contains a digit — in this estate
|
||||
# that is all six *_S3_* keys. Prod audited as 26 keys against 32 real ones, and
|
||||
# the audit reported the S3 credentials as missing from both hosts. They were
|
||||
# present the whole time, and the phantom was independently "confirmed" twice by
|
||||
# people re-running the same pattern. An audit that under-reports is worse than
|
||||
# no audit: the invented finding sends someone to provision a credential that
|
||||
# already exists, and it was briefly recorded as a root cause of a real bug.
|
||||
# The match still stops at the `=`, so no value is ever read.
|
||||
ssh -i $K agent@169.58.46.181 'sudo grep -oE "^[A-Z][A-Z0-9_]*=" /etc/thermograph.env' > /tmp/prod.keys # prod
|
||||
ssh -i $K agent@75.119.132.91 'sudo grep -oE "^[A-Z][A-Z0-9_]*=" /etc/thermograph.env' > /tmp/beta.keys # beta
|
||||
python3 .claude/skills/key-gaps/key_gaps.py prod=/tmp/prod.keys beta=/tmp/beta.keys
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ It reads only KEY NAMES, never values — safe to run anywhere. Sources per envi
|
|||
* a SOPS-encrypted YAML (deploy/secrets/<env>.yaml) — keys are plaintext even when
|
||||
encrypted, so no age key or decryption is needed; or
|
||||
* a plain key-list file (one KEY per line, or KEY=... lines) — e.g. the output of
|
||||
`ssh <box> 'grep -oE "^[A-Z_]+=" /etc/thermograph.env'` for a live audit.
|
||||
`ssh <box> 'grep -oE "^[A-Z][A-Z0-9_]*=" /etc/thermograph.env'` for a live audit.
|
||||
|
||||
Usage:
|
||||
key_gaps.py prod=deploy/secrets/prod.yaml beta=deploy/secrets/beta.yaml
|
||||
|
|
|
|||
Loading…
Reference in a new issue