From 0b1b80b5c67b0509bde1d55a86ade679ec6b7fc5 Mon Sep 17 00:00:00 2001 From: pip-install-python Date: Thu, 20 Aug 2026 18:21:47 -0500 Subject: [PATCH] auth gate bug fixes --- .env.example | 1 + lib/auth.py | 53 ++++++++++++++++++++++++++++++++++++++++++ lib/page_visibility.py | 49 +++++++++++++++++++++++++++++++++++++- 3 files changed, 102 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index e616c79..f8a0e6e 100644 --- a/.env.example +++ b/.env.example @@ -152,3 +152,4 @@ PAGE_DEFAULT_TIER=public # gating the interactive site never silently gates the corpus. # LLMS_SMALL_TIER=public # LLMS_FULL_TIER=public +2plot-clerk-satellite , 2plot-satellite-reporting , 2plot-network-shared \ No newline at end of file diff --git a/lib/auth.py b/lib/auth.py index fbfa9ae..b9c42ad 100644 --- a/lib/auth.py +++ b/lib/auth.py @@ -308,6 +308,7 @@ def register() -> bool: if is_satellite and sat_domain: _install_satellite_signin_delegation() + _install_signout_delegation() print( f"[auth] Clerk ENABLED (headless; satellite={is_satellite}, " @@ -379,6 +380,58 @@ def _clerk_satellite_signin(index_string): return index_string +def _install_signout_delegation() -> None: + """Make Sign Out actually revoke the SERVER's idea of who you are. + + dash-clerk-auth 1.0.2's logout handler runs ``window.Clerk.signOut()`` + and reloads — client-side only. The server keeps trusting the signed + ``__dca_identity`` cookie (and the Flask session) it minted at sign-in + for the rest of ``session_lifetime_days`` (default **7 days**): a + signed-out browser still renders every auth-gated page — the pilot's + live defect of 2026-08-21. The package ships the endpoint that fixes + this — ``POST /api/auth/signout`` clears the session and the identity + cookie — but nothing ever calls it. + + This capture-phase delegate owns the click (``stopImmediatePropagation``, + the sign-in delegation's proven pattern) and sequences what the package + should have: Clerk sign-out FIRST (kills ``__session``, so the slow path + cannot re-verify and re-mint identity), then the server signout (kills + the Flask session + ``__dca_identity``), then the reload — awaited, so + the reload can never race the cookie clears. Every failure still ends in + a reload, and the server POST runs even when ClerkJS never loaded — + which is exactly the stale-ghost case that needs it most. + + The upstream fix is specced for dash-clerk-auth 1.0.3 (boilerplate's + kickoff/fleet/KICKOFF-clerk-avatar-release.md). Once the package + sequences this itself, this delegate degrades to a harmless duplicate + POST and can be retired a release later. + """ + from dash import hooks as _dash_hooks + + marker = "dl2-clerk-signout-delegate" + + signout_js = ( + f"" + ) + + @_dash_hooks.index() + def _clerk_signout(index_string): + if marker not in index_string and "" in index_string: + index_string = index_string.replace("", signout_js + "", 1) + return index_string + + def configure_app(app) -> None: """Post-construction Clerk wiring (sessions, /api/auth/*, request identity). diff --git a/lib/page_visibility.py b/lib/page_visibility.py index a7ecfbb..aeb8f38 100644 --- a/lib/page_visibility.py +++ b/lib/page_visibility.py @@ -56,6 +56,7 @@ import logging import os import threading +import time from pathlib import Path from lib import page_tiers @@ -88,13 +89,29 @@ def default_tier() -> str: _defaults: dict[str, dict] = {} _overrides: dict[str, dict] = {} +# Cross-worker reconciliation. gunicorn runs this app with more than one +# worker process (Dockerfile: --workers ${WEB_CONCURRENCY:-2}), and a board +# toggle mutates _overrides only in the worker that served the POST. Every +# other worker kept its import-time copy — so an anonymous refresh became a +# coin flip between the new verdict and the stale one, decided by which +# worker answered. The store file is the one thing all workers share; +# re-reading it when its mtime moves is what makes a toggle land everywhere. +# The stat is throttled so hot paths pay at most one os.stat per second. +_store_mtime_ns: int | None = None +_next_stat_at = 0.0 +_STAT_INTERVAL_S = 1.0 + def _load_overrides() -> None: - global _overrides + global _overrides, _store_mtime_ns try: if _STORE_PATH.exists(): + # stat BEFORE read: a write that lands between the two is picked + # up by the next mtime check instead of being masked forever. + stamp = _STORE_PATH.stat().st_mtime_ns loaded = json.loads(_STORE_PATH.read_text()) _overrides = loaded if isinstance(loaded, dict) else {} + _store_mtime_ns = stamp except Exception as exc: # a corrupt file must not kill the app logger.error("%s unreadable (%s) — ignoring overrides", _STORE_PATH, exc) _overrides = {} @@ -102,12 +119,39 @@ def _load_overrides() -> None: def _persist() -> None: """Write overrides to disk. Call while holding ``_lock``.""" + global _store_mtime_ns try: _STORE_PATH.write_text(json.dumps(_overrides, indent=2, sort_keys=True)) + # Record our own write's stamp so this worker doesn't re-read it. + _store_mtime_ns = _STORE_PATH.stat().st_mtime_ns except Exception as exc: logger.error("Could not persist %s: %s", _STORE_PATH, exc) +def _maybe_reload() -> None: + """Pick up another worker's board writes; no-op when nothing changed. + + Reload triggers ONLY on an observed mtime change of the store file: + a missing file, a stat error, or an unchanged stamp all leave the + in-memory dict alone — which is also what keeps tests that inject + straight into ``_overrides`` (without touching the file) valid. + """ + global _next_stat_at + if time.monotonic() < _next_stat_at: + return + with _lock: + if time.monotonic() < _next_stat_at: # another thread just checked + return + _next_stat_at = time.monotonic() + _STAT_INTERVAL_S + try: + stamp = _STORE_PATH.stat().st_mtime_ns + except OSError: + return + if stamp == _store_mtime_ns: + return + _load_overrides() + + _load_overrides() @@ -139,6 +183,7 @@ def get_settings(path: str) -> dict: accessors below instead, because a merged value cannot say whether an operator chose it. """ + _maybe_reload() base = _defaults.get(path) if base is None: base = {"visibility": default_tier(), "llms_public": None, "name": path} @@ -208,12 +253,14 @@ def pin_default(path: str, visibility: str) -> None: def tier_override(path: str) -> str | None: """The tier the control board wrote for ``path``, or None.""" + _maybe_reload() tier = (_overrides.get(path) or {}).get("visibility") return tier if tier in TIERS else None def llms_public_override(path: str) -> bool | None: """The machine-surface switch the control board wrote, or None.""" + _maybe_reload() value = (_overrides.get(path) or {}).get("llms_public") return None if value is None else bool(value)