Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
53 changes: 53 additions & 0 deletions lib/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}, "
Expand Down Expand Up @@ -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"<script data-{marker}>(function(){{"
"document.addEventListener('click',function(e){"
"var b=e.target&&e.target.closest?e.target.closest('#clerk-logout-menu-item'):null;"
"if(!b)return;"
"e.stopImmediatePropagation();e.preventDefault();"
"var done=function(){window.location.reload();};"
"var server=function(){return fetch('/api/auth/signout',"
"{method:'POST',credentials:'same-origin'}).catch(function(){});};"
"var clerk=(window.Clerk&&typeof window.Clerk.signOut==='function')"
"?window.Clerk.signOut().catch(function(){}):Promise.resolve();"
"clerk.then(server).then(done,done);"
"},true);})();</script>"
)

@_dash_hooks.index()
def _clerk_signout(index_string):
if marker not in index_string and "</body>" in index_string:
index_string = index_string.replace("</body>", signout_js + "</body>", 1)
return index_string


def configure_app(app) -> None:
"""Post-construction Clerk wiring (sessions, /api/auth/*, request identity).

Expand Down
49 changes: 48 additions & 1 deletion lib/page_visibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
import logging
import os
import threading
import time
from pathlib import Path

from lib import page_tiers
Expand Down Expand Up @@ -88,26 +89,69 @@ 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 = {}


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()


Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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)

Expand Down
Loading