diff --git a/.env.example b/.env.example index f8a0e6e..e616c79 100644 --- a/.env.example +++ b/.env.example @@ -152,4 +152,3 @@ 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/.gitignore b/.gitignore index 6cf3a67..5d4b9d3 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,12 @@ dash_leaflet2/metadata.json # Runtime state written by the app. # page_visibility.json — control-board overrides, per-deployment # visitor_analytics.json — the 2plot.ai analytics ledger (plus its .lock/.tmp + +# Flask-Session's server-side session store. dash-clerk-auth creates it in +# the CWD the moment Clerk is configured, so any local run WITH the CLERK_* +# keys drops session blobs into the repo — and `git add -A` will happily +# commit somebody's session token. Never tracked. +flask_session/ # siblings and the reporter's lease file). Committing it would publish # visitor IPs and user agents. # satellite_traffic.jsonl — the RETIRED Gen-1 ledger; kept ignored so a stale diff --git a/CLAUDE.md b/CLAUDE.md index c3a5496..eeca81a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,10 +100,10 @@ Two docs-only deps come from PyPI with load-bearing version floors: working builds reached PyPI. Neither is needed by the `dash_leaflet2` package itself — that needs only `dash>=4.1`. -`vendor/` now holds exactly one tarball: `dash_clerk_auth-1.0.2.tar.gz`, which +`vendor/` now holds exactly one tarball: `dash_clerk_auth-1.0.3.tar.gz`, which is vendored across every 2plot satellite rather than published to PyPI. It requires Python >=3.10, which binds the docs site only (Docker is 3.12); the -package keeps `requires-python >=3.9`. 1.0.2 is a floor: this site renders the +package keeps `requires-python >=3.9`. 1.0.3 is a SECURITY floor: this site renders the Clerk menu (`components/header.py`), so it was exposed to the avatar/session race fixed there. Its `clerk-backend-api<8` cap (widened in 1.0.1) is what lets `requirements.txt` hold the `cryptography>=50.0.0` security floor. diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index f0b1fb2..424a2b3 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -151,7 +151,7 @@ is logged. | `DISABLE_CLERK` | `1` | Dev kill switch — reads as "intentionally off" without touching the keys. Never set in production. | | `ALLOW_UNGATED_ADMIN` | `1` | Lets `/admin/control-board` render without Clerk. **Never set in production.** | -> **`dash-clerk-auth` is not a dependency of this project.** The 1.0.2 build is +> **`dash-clerk-auth` is not a dependency of this project.** The 1.0.3 build is > not resolved from PyPI — it is vendored across the 2plot network — so a stock > deploy has **no Clerk at all** and `clerk_enabled()` is `False` however many > `CLERK_*` variables you set. @@ -161,7 +161,7 @@ is logged. > it returns a 404-style response and its save callback refuses writes, rather > than handing an open admin panel to anyone who guesses the URL. > -> **Clerk is enabled** — `vendor/dash_clerk_auth-1.0.2.tar.gz` is committed and +> **Clerk is enabled** — `vendor/dash_clerk_auth-1.0.3.tar.gz` is committed and > active in `requirements.txt`. One operational risk to know before debugging a > dead site: the package registers a `[dash_hooks]` entry point that Dash > auto-imports at **every** `Dash()` construction, so it sits in the boot path diff --git a/Dockerfile b/Dockerfile index c785aa4..08fb94a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,7 +23,7 @@ ENV PYTHONUNBUFFERED=1 \ WORKDIR /app # Install Python deps first so this layer is cached across app-code changes. -# vendor/ must come along: requirements.txt installs dash-clerk-auth 1.0.2 from +# vendor/ must come along: requirements.txt installs dash-clerk-auth 1.0.3 from # a local tarball there (it is vendored across the 2plot network, not on PyPI). # dash-emoji-mart and flexlayout-dash used to live here too and now come from # PyPI, so this is down to the single Clerk tarball. diff --git a/lib/auth.py b/lib/auth.py index b9c42ad..4b99018 100644 --- a/lib/auth.py +++ b/lib/auth.py @@ -265,6 +265,24 @@ def register() -> bool: satellite_domain=sat_domain, clerk_frontend_api=(os.getenv("CLERK_FRONTEND_API") or None), sign_up_url=(os.getenv("CLERK_SIGN_UP_URL") or None), + # THE REPLAY WINDOW, stated rather than inherited. dash-clerk-auth + # 1.0.3 made this an explicit re-vendor decision, because it is the + # only knob an application controls here: `__dca_identity` is a + # STATELESS signed token, so a value captured before sign-out keeps + # verifying until its max-age expires — the server holds no + # revocation list, and closing that would mean giving up the fast + # path the cookie exists to provide. + # + # 7 (the package default) is the choice for THIS host. What it gates + # is documentation: a captured cookie buys read access to pages a + # free account also unlocks, and the gate exists to drive account + # creation, so re-authenticating readers every day would tax the + # funnel for almost nothing. Shortening it is the whole mitigation + # if that calculus changes — and the one thing that would change it + # is /admin/control-board, which trusts the identity in this cookie + # and can hide or unhide any page on the site. If an admin account + # is ever used from a shared machine, drop this well below a week. + session_lifetime_days=7, ) if not os.getenv("SESSION_SECRET"): @@ -383,8 +401,16 @@ def _clerk_satellite_signin(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 + FIXED UPSTREAM IN 1.0.3 — this shim is now a deliberate duplicate, kept + for one release and retiring in 1.0.4. The package's POST is idempotent + (its changelog says so explicitly, so an app may keep its own handler + through the upgrade), and both handlers cannot both run on one click + anyway: this delegate takes the capture phase and calls + ``stopImmediatePropagation``. Retiring it early would be the riskier + move, because it is what actually shipped the fix to this host. + + The defect, for the record. dash-clerk-auth 1.0.2's logout handler ran + ``window.Clerk.signOut()`` and reloaded — 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 @@ -401,10 +427,12 @@ def _install_signout_delegation() -> None: 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. + 1.0.3 sequences exactly this upstream — Clerk sign-out, then the revoke + POST, then the reload, awaited — and additionally fires the POST on a + signed-in -> signed-out transition, which covers a sign-out performed in + another tab or on another host of the same Clerk instance. That last + part this shim does NOT do, which is the second reason to keep the + package's handler rather than replace it. """ from dash import hooks as _dash_hooks diff --git a/requirements.txt b/requirements.txt index 0594a1f..c9b4f4c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -121,22 +121,65 @@ flexlayout-dash>=1.1.0 # and redeploy; /admin/control-board fails CLOSED without Clerk, so nothing is # left exposed by doing so. # -# 1.0.2 — a floor, not a preference. This host RENDERS the Clerk menu -# (components/header.py calls `create_clerk_menu`), so it was directly exposed -# to the avatar race 1.0.2 fixes: the injected script resolved the menu with +# 1.0.3 — a SECURITY floor. Sign out did not reach the server: the package's +# logout handler ran `window.Clerk.signOut()` and reloaded, which invalidates +# `__session` and nothing else. Server-side identity resolves from the signed +# `__dca_identity` cookie FIRST, so a signed-out browser kept being served +# every gated page from that layer until the cookie aged out — up to +# `session_lifetime_days`, seven days by default. On a shared machine the next +# person inherited the previous user's access for a week. This site hit it +# live on 2026-08-21 and shipped a local shim (lib/auth.py +# `_install_signout_delegation`); 1.0.3 sequences it upstream — Clerk sign-out, +# then `POST /api/auth/signout`, then reload, awaited so the reload cannot race +# the Set-Cookie. The POST is idempotent, so the shim stays harmlessly through +# this release and retires in the next one. +# +# The 1.0.3 build here is the one carrying the follow-up import fix: the avatar +# helper inlined `quote(svg, safe="/:='? ")` inside an f-string, which is PEP +# 701 syntax and a hard SyntaxError on 3.10/3.11. That is an IMPORT failure, +# and the package declares a `[dash_hooks]` entry point Dash resolves at every +# `Dash()` construction — so it would have taken the whole documentation site +# down on this repo's own CI matrix (3.10/3.12/3.13), not just sign-in. +# +# This exact artifact is confirmed good, three ways rather than by timestamp +# (the tarball's mtime is 23s BEFORE the fix commit, because the sequence was +# build-then-commit — alarming to read, and not a problem): +# +# sha256 2c6b40f4620afdc016580d75c01b7f7f6760f030b61d1d827c498bcb01da1944 +# — byte-identical to the authoring repo's dist, and this is the +# hash of the blob committed here, not just the working file. +# source `encoded` is hoisted out of the f-string. +# runtime the authoring repo compiled AND executed it under a real 3.11 +# (`compileall -f` over every module, plus calling the avatar helper +# in isolation): output byte-identical to 3.12, so the fix is +# behaviourally neutral across versions, not merely parseable. +# +# Re-vendoring from the authoring repo's `main` is NOT yet safe: as of +# 2026-08-21 the broken build is what main holds, and the import fix is still +# waiting on a PR to land before v1.0.3 is tagged. Take the dist artifact whose +# hash matches above, or wait for the tag. +# +# NOT changed on this bump, and a deliberate decision each application owns: +# `session_lifetime_days` stays at 7 (lib/auth.py passes it explicitly now, so +# the value is recorded rather than inherited). `__dca_identity` is a stateless +# signed token — a value captured before sign-out keeps verifying until its +# max-age expires, because the server holds no revocation list. That window is +# the knob; see lib/auth.py for why 7 is the choice here and what would change +# it. +# +# 1.0.2 fixed the avatar race this host was exposed to (components/header.py +# calls `create_clerk_menu`): the injected script resolved the menu with # `getElementById` at the moment `Clerk.load()` resolved, but Dash mounts that -# menu from a separate /_dash-layout fetch. Whenever Clerk won the race — warm -# CDN, valid session cookie — the lookup returned null, the update no-opped, -# and `addListener` never fired again because the session never CHANGED. A -# signed-in user then sat behind a placeholder avatar and a "Sign In" menu for -# the life of the page, while `current_user()` and `clerk-auth-store` were both -# correct the whole time. Anything older than 1.0.2 has that race. +# menu from a separate /_dash-layout fetch, so whenever Clerk won the race a +# signed-in user sat behind a placeholder avatar for the life of the page. # # 1.0.1 widened `clerk-backend-api` to `<8` — see the cryptography floor below. # 1.0.0 was the first stable release and the first build needing NO local # patching: 0.9.1 stamps `data-clerk-domain` onto the ClerkJS script tag, and # 0.9.2 replaced the forbidden `Clerk.openSignIn()` modal with a navigation to -# the primary (`satellite_sign_in_redirect`, which lib/auth.py now passes). +# the primary. That navigation is configured by CLERK_SATELLITE_SIGN_IN_REDIRECT, +# which the package reads from the environment ITSELF — lib/auth.py does not +# pass it through, and an earlier version of this note claimed otherwise. # Anything older than 0.9.2 needs those patches back. # # PYTHON FLOOR: >=3.10 (clerk-backend-api publishes no 3.9 build, so the old @@ -149,7 +192,7 @@ flexlayout-dash>=1.1.0 # Still not on PyPI — vendored the same way across every 2plot satellite, so # the tarball is the distribution channel. # Used by: lib/auth.py, components/header.py, /admin/control-board -./vendor/dash_clerk_auth-1.0.2.tar.gz +./vendor/dash_clerk_auth-1.0.3.tar.gz # SECURITY FLOOR, and the reason dash-clerk-auth had to move first. This pin # is what `pip-audit` resolves; the vendored tarball's own cap is what gates # it. clerk-backend-api 5.x declares `cryptography>=45.0.0,<47.0.0`, holding diff --git a/tests/test_page_visibility_reload.py b/tests/test_page_visibility_reload.py new file mode 100644 index 0000000..502b804 --- /dev/null +++ b/tests/test_page_visibility_reload.py @@ -0,0 +1,95 @@ +"""The control-board store must reconcile across gunicorn workers. + +The Dockerfile runs this app with more than one worker process +(``--workers ${WEB_CONCURRENCY:-2}``). A board toggle mutates +``_overrides`` only in the worker that served the POST and persists to +``PAGE_VISIBILITY_FILE``; every other worker holds its import-time copy +unless it re-reads the file. Before the reload landed, an anonymous +refresh of a page the board had just made public was a coin flip decided +by which worker answered — the pilot's live defect of 2026-08-21. + +These tests play the *other* worker: they write the store file behind +the module's back and check the read path notices. They also pin the +convention the rest of this suite relies on — injecting straight into +``_overrides`` without touching the file must never be clobbered. +""" + +import json +import os +import time + +import pytest + +from lib import page_visibility + + +@pytest.fixture +def clean_store(): + """Snapshot module state; leave no store file behind for later tests.""" + saved_overrides = { + path: dict(entry) for path, entry in page_visibility._overrides.items() + } + saved_stamp = page_visibility._store_mtime_ns + yield + page_visibility._STORE_PATH.unlink(missing_ok=True) + page_visibility._overrides.clear() + page_visibility._overrides.update(saved_overrides) + page_visibility._store_mtime_ns = saved_stamp + page_visibility._next_stat_at = 0.0 + + +def _foreign_write(payload: dict) -> None: + """A write by 'another worker': same file, not through this module.""" + path = page_visibility._STORE_PATH + path.write_text(json.dumps(payload)) + stamp = time.time_ns() + os.utime(path, ns=(stamp, stamp)) # guarantee the mtime actually moves + + +def test_a_foreign_workers_toggle_is_picked_up(clean_store): + """The whole defect: another worker made a page public; we must see it.""" + _foreign_write({"/reload-canary": {"visibility": "public"}}) + page_visibility._next_stat_at = 0.0 # skip the 1s stat throttle + + assert page_visibility.tier_override("/reload-canary") == "public" + assert page_visibility.get_settings("/reload-canary")["visibility"] == "public" + + +def test_a_second_foreign_write_supersedes_the_first(clean_store): + """Reload keys on mtime, so consecutive toggles each land.""" + _foreign_write({"/reload-canary": {"visibility": "public"}}) + page_visibility._next_stat_at = 0.0 + assert page_visibility.tier_override("/reload-canary") == "public" + + _foreign_write({"/reload-canary": {"visibility": "auth", "llms_public": False}}) + page_visibility._next_stat_at = 0.0 + assert page_visibility.tier_override("/reload-canary") == "auth" + assert page_visibility.llms_public_override("/reload-canary") is False + + +def test_direct_injection_survives_when_the_file_never_moves(clean_store): + """The suite-wide convention: tests write ``_overrides`` directly. + + With no store file on disk (stat fails) the reload must be a no-op, + or every existing access test would have its fixtures wiped mid-run. + """ + page_visibility._STORE_PATH.unlink(missing_ok=True) + page_visibility._overrides["/injected"] = {"visibility": "hidden"} + page_visibility._next_stat_at = 0.0 + + assert page_visibility.tier_override("/injected") == "hidden" + + +def test_own_persist_does_not_bounce_back(clean_store): + """The writing worker records its own stamp — no self-reload churn. + + After ``set_visibility`` persists, injecting an extra entry directly + must survive a reload check: the file's mtime equals the recorded + stamp, so nothing is re-read and the injection stays. + """ + page_visibility.set_visibility("/reload-canary", "public") + page_visibility._overrides["/injected"] = {"visibility": "hidden"} + page_visibility._next_stat_at = 0.0 + + assert page_visibility.tier_override("/injected") == "hidden" + assert page_visibility.tier_override("/reload-canary") == "public" diff --git a/vendor/dash_clerk_auth-1.0.2.tar.gz b/vendor/dash_clerk_auth-1.0.2.tar.gz deleted file mode 100644 index fd43377..0000000 Binary files a/vendor/dash_clerk_auth-1.0.2.tar.gz and /dev/null differ diff --git a/vendor/dash_clerk_auth-1.0.3.tar.gz b/vendor/dash_clerk_auth-1.0.3.tar.gz new file mode 100644 index 0000000..55f1587 Binary files /dev/null and b/vendor/dash_clerk_auth-1.0.3.tar.gz differ