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: 0 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
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.
Expand All @@ -47,4 +47,4 @@

# run:server is the Flask WSGI callable (run.py: `server = app.server`).
# Shell form so ${PORT} / ${WEB_CONCURRENCY} expand when the container starts.
CMD gunicorn run:server --bind "0.0.0.0:${PORT}" --workers "${WEB_CONCURRENCY:-2}" --threads 4 --timeout 120 --access-logfile - --error-logfile -

Check warning on line 50 in Dockerfile

View workflow job for this annotation

GitHub Actions / docker image · boot · battery

JSON arguments recommended for ENTRYPOINT/CMD to prevent unintended behavior related to OS signals

JSONArgsRecommended: JSON arguments recommended for CMD to prevent unintended behavior related to OS signals More info: https://docs.docker.com/go/dockerfile/rule/json-args-recommended/
40 changes: 34 additions & 6 deletions lib/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
65 changes: 54 additions & 11 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
95 changes: 95 additions & 0 deletions tests/test_page_visibility_reload.py
Original file line number Diff line number Diff line change
@@ -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"
Binary file removed vendor/dash_clerk_auth-1.0.2.tar.gz
Binary file not shown.
Binary file added vendor/dash_clerk_auth-1.0.3.tar.gz
Binary file not shown.
Loading