diff --git a/.env.example b/.env.example index de8db6d..9b07a80 100644 --- a/.env.example +++ b/.env.example @@ -53,6 +53,12 @@ DASH_LEAFLET2_BASE_URL=http://localhost:8050 # SATELLITE_TRAFFIC_URL=https://2plot.ai/api/satellite/traffic # SATELLITE_REPORT_INTERVAL_S=3600 # SATELLITE_REPORT_DELAY_S=90 +# +# Live "active now" presence beacon (POST /api/satellite/active, URL derived +# from SATELLITE_TRAFFIC_URL). Display-only and ephemeral hub-side; the +# rollup above stays the source of the daily numbers. 0 disables; floor 30s. +# SATELLITE_PRESENCE_INTERVAL_S=60 +# SATELLITE_PRESENCE_URL=https://2plot.ai/api/satellite/active # Behind Cloudflare the country header is already present, so the ip-api.com # fallback lookup is redundant latency; "0" disables it. # ANALYTICS_GEO_LOOKUP=0 @@ -101,6 +107,33 @@ DASH_LEAFLET2_BASE_URL=http://localhost:8050 # Set this to work on the board locally without Clerk. NEVER set in production. # ALLOW_UNGATED_ADMIN=1 -# --- Optional: page visibility ---------------------------------------------- -PAGE_DEFAULT_VISIBILITY=public +# --- Optional: the interactive gate (lib/access.py) ------------------------- +# Baseline tier for pages whose frontmatter declares none: +# public | auth | admin | hidden. `auth` puts a sign-in card in front of every +# documentation page; `/`, /llms-small.txt and /llms-full.txt stay public +# (pinned in run.py). PAGE_DEFAULT_VISIBILITY is the same knob under this +# site's older name and is still read — set one, not both. +# +# Locally this does nothing visible without the CLERK_* keys above: with Clerk +# unavailable every tier except `hidden` falls open, because documentation +# must not brick over a missing credential. To see the gate, set the Clerk +# keys, or read tests/test_access.py, which drives it with a fake session. +PAGE_DEFAULT_TIER=public +# PAGE_DEFAULT_VISIBILITY=public + +# The SECOND axis: whether a gated page's MACHINE twin (//llms.txt, the +# crawler document, the prerender) stays open anyway. Unset = open, which is +# the current network posture — humans meet the sign-in card while agent and +# crawler demand keeps being measured. Setting it to 0 is the phase-4 agent +# flip and closes every page that did not pin `llms_public:` in frontmatter. +# LLMS_PUBLIC_DEFAULT=0 + +# Where the control board writes its overrides. An override beats both the +# frontmatter tier and PAGE_DEFAULT_TIER; only the hub's ceiling outranks it. +# In production this points at a persistent disk so toggles survive a deploy. # PAGE_VISIBILITY_FILE=page_visibility.json + +# Tiers for the two corpus documents, independent of PAGE_DEFAULT_TIER so +# gating the interactive site never silently gates the corpus. +# LLMS_SMALL_TIER=public +# LLMS_FULL_TIER=public diff --git a/CHANGELOG.md b/CHANGELOG.md index 8461b67..12a1dd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,11 +12,83 @@ will move until v2 leaves alpha upstream. ## [Unreleased] The fleet's x402 instrumentation sync (1.3.x) — measurement only, no payment -or gating code, per the network's "instrument first, price later" rule. -Documentation site and network wiring only; no `dl2.*` component changed. +code — followed by the **sign-in gate pilot**, which this site runs first for +the network. Documentation site and network wiring only; no `dl2.*` component +changed, and `pip install dash-leaflet2` is untouched by any of it. + +### Added + +- **The sitemap stopped lying.** Every entry used to claim the page changed + today, regenerated on every crawl — a sitemap asserting that 27 pages change + daily is one search engines learn to discard wholesale. Each page now + publishes the real date its prose last changed, and a page that declares no + date gets no date: truth or silence. + +- **Google now sees this site's own icons.** The crawler document carried no + favicon at all — browsers got six, Googlebot got zero, which is why search + showed a generic globe. It also had no social image and described every + documentation page as an untyped generic web page. All three now match what + a browser gets. (Requires `dash-improve-my-llms` 2.6.0, which discovers the + icons from `assets/favicon_io/` with nothing declared.) + +- **A live "active now" figure** on the hub's dashboard, from a lightweight + presence beacon alongside the existing hourly rollup. Display-only — the + daily numbers still come from the rollup, which now reports every 15 minutes + rather than hourly. + +- **The network directory caught up with the fleet**: muicharts, flexlayout + and llms.2plot.dev added, and pannellum/emojimart restored now that they + resolve — twelve peers, no dead links. + +- **A sign-in gate, shipped dark.** Documentation pages can now require an + account. Nothing is gated yet: the site deploys with the gate wired and + every verdict answering "allow", so the whole path runs in production + before the single environment variable (`PAGE_DEFAULT_TIER=auth`) that + turns it on — and setting that variable back is the entire rollback. + + A signed-out visitor on a gated page gets a **sign-in card at HTTP 200**, + not a redirect and not a 404: the URL stays shareable, and "Create free + account" now carries the current page in its return trip, so a visitor + lands back where they started instead of on the primary's home page. That + return leak is the one user-visible bug this pass fixes today. + + **Machine surfaces stay open.** `//llms.txt`, the crawler document + and the prerender keep serving prose to agents and crawlers while humans + meet the card — a deliberate 30-day posture, switched network-wide later + with `LLMS_PUBLIC_DEFAULT=0` rather than per-page edits. + +- **`GET /api/agent-key`** — the person-to-agent handoff. Copying a page's + `llms.txt` URL while signed in now carries a key, so the link still + resolves when it is pasted into an assistant, whose fetch arrives with no + session cookie. Signed out, the copy button behaves exactly as before. + +- **The network's page-tier ceiling.** 2plot.dev can now restrict a page + across the network; this site may lock a page down further but can never + open one the network gated. A hub outage changes nothing for a signed-in + reader — sessions resolve locally — and resolves to "gated" for anyone + else, never to publishing restricted prose and never to a dead site. ### Changed +- **One access system instead of two.** `tier:` and `visibility:` in a page's + frontmatter were independent fields naming the same four values, so a page + could declare one tier and be enforced at another, with a control-board row + that quietly disagreed. `tier:` is now canonical, `visibility:` is an + accepted alias, and one declared value feeds both. `PAGE_DEFAULT_TIER` is + likewise the canonical spelling of `PAGE_DEFAULT_VISIBILITY`, which is + still read so the running service does not change posture underneath a + deploy. + + The control board keeps everything it did — live toggles, four tiers, and + overrides that outlive a deploy — and its override is still the most + authoritative local word on a page. What moved out of it is the decision + itself, into `lib/access.py`. + +- **Admin surfaces now fail closed everywhere.** Documentation still falls + open when Clerk is unavailable — it must never brick over a missing + credential — but the retired resolver fell open for admin pages too. Only + `/admin/control-board`'s own double gate stopped that mattering. + - **Analytics: Gen-1 single-module tracker retired for the boilerplate's trio.** `lib/analytics_tracker.py` (per-request JSON ledger), `lib/traffic_rollup.py` (the hub's own daily v2+v3 definitions — its diff --git a/CLAUDE.md b/CLAUDE.md index e50262c..c3a5496 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,11 +36,39 @@ The site de-risks; the package is the durable artifact. Keep both working. boilerplate's trio; it replaced the Gen-1 single-module tracker in the 1.3.x instrumentation sync — `traffic_rollup._SKIP` must stay byte-identical to the boilerplate's) · `lib/auth.py` → Clerk satellite of 2plot.ai · -`lib/page_visibility.py` + `pages/control_board.py` → `/admin/control-board`, -four tiers re-checked every render. Full reference in `DEPLOYMENT.md`. These -are shared drop-in modules — when fixing a bug in `ad_client.py` or the -analytics trio, the fix probably belongs in the other satellites too -(canonical source: `../dash-documentation-boilerplate`). +`lib/access.py` (+ `page_tiers` / `hub_client` / `gate_layouts` / `agent_key`) +→ the gate · `lib/page_visibility.py` + `pages/control_board.py` → +`/admin/control-board`. Full reference in `DEPLOYMENT.md`. These are shared +drop-in modules — when fixing a bug in `ad_client.py` or the analytics trio, +the fix probably belongs in the other satellites too (canonical source: +`../dash-documentation-boilerplate`). + +## The gate (this repo is the fleet's pilot) + +`lib/access.py` is the enforcement engine; `lib/page_visibility.py` was demoted +to the control board's **override store + UX** and no longer resolves access or +wraps layouts. A verdict resolves from three inputs, in order: the board's +override (most local, wins — that is what a live toggle is), the frontmatter +registration in `page_tiers`, then the hub's ceiling, which only ever restricts. + +Two lanes, deliberately different: `resolve_page_access` answers what a BROWSER +gets (`gate_layouts` renders the card), `check` answers what a MACHINE fetch +gets (`//llms.txt`, crawler HTML, prerender) and honours `?key=` plus the +`llms_public` axis. A key never unlocks a layout. + +Frontmatter: `tier:` is canonical, `visibility:` is an accepted alias for the +same four values, and ONE declared value feeds both ledgers — they were +independent keys before this pass, which let a page declare one tier and be +enforced at another. + +Two postures that look like bugs and are not: docs fall **open** without Clerk +(documentation must not brick over a missing credential) while admin fails +**closed**; and a hub failure resolves to `gated`, never `allow`, never `deny`. + +Shipped **dark**: `run.py` wires the policy with `force=True` even though every +tier is public, so the verdict path (and the prerender's use of it) runs in +production before `PAGE_DEFAULT_TIER=auth` turns it on. That env flip is the +whole change, and flipping it back is the rollback. ## Commands @@ -140,6 +168,14 @@ race fixed there. Its `clerk-backend-api<8` cap (widened in 1.0.1) is what lets is hand-maintained (registers `_js_dist`); everything else in `dash_leaflet2/` is generated. - **Showcase JS** (`assets/leaflet2_maps.js`): a new example = one `DEMOS` entry + one `docs//{.md, example.py}` pair (`example.py` exports `component`). JS→Python uses `toStore()` (hardened `set_props` with retry — don't bypass it). +- **`lastmod:` rides the prose.** Every `docs//.md` declares a + sitemap date; `dash-improve-my-llms` >= 2.6.0 emits it verbatim and omits the + tag when absent. Edit a page's prose → bump its `lastmod` in the SAME commit. + Never script these from file mtimes (they reset on every Docker build, which + re-creates the every-page-changed-today sitemap the 2.6.0 floor exists to + end). The initial values came from `git log -1 --format=%cs -- `. + `tests/test_seo_icons.py` fails if the sitemap ever emits a date no page + declared, and if crawler-head icon discovery comes back empty. ## More detail diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 74a55ba..446740a 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -77,10 +77,25 @@ hourly, HMAC-signed. `/healthz` itself now lives in `lib/health.py`. | `SATELLITE_APP_KEY` | `leaflet` | Series name on 2plot.ai's `/traffic`. This is the network-directory key. (The Gen-1 spelling `SATELLITE_APP_ID` is retired.) | | `SATELLITE_TRAFFIC_URL` | `https://2plot.ai/api/satellite/traffic` | Hub endpoint override. | | `TRAFFIC_ANALYTICS_FILE` | `visitor_analytics.json` (repo root) | Visit ledger. Production points it at the persistent disk (`/var/data/visitor_analytics.json` in render.yaml) — the hub keeps the LAST report per (app, date), so a ledger that dies with the container under-reports every deploy day. | -| `SATELLITE_REPORT_INTERVAL_S` | `3600` | Seconds between rollup POSTs. | +| `SATELLITE_REPORT_INTERVAL_S` | `3600` (render.yaml sets `900`) | Seconds between rollup POSTs. 15 minutes on the live service: the fleet is on paid instances and the hub board reads near-real-time. | | `SATELLITE_REPORT_DELAY_S` | `90` | Delay before the first report after boot. | +| `SATELLITE_PRESENCE_INTERVAL_S` | `60` | Seconds between "active now" presence pings. `0` disables; values below the hub's `30` floor are raised to it. | +| `SATELLITE_PRESENCE_URL` | derived from `SATELLITE_TRAFFIC_URL` | Presence endpoint override (`POST /api/satellite/active`). | | `ANALYTICS_GEO_LOOKUP` | `1` | `0` disables the ip-api.com fallback — behind Cloudflare the `CF-IPCountry` header already answers it. | +The **presence beacon** is a second, faster loop alongside the rollup, and the +two are not interchangeable: presence is display-only and ephemeral hub-side +(it drives the live "active now" number), while the rollup stays the sole +source of the daily figures. It derives its count the same way the hub does — +distinct human visitor keys inside the session window — so the two never +disagree about what "active" means. Fail-silent by contract: nothing it does +escapes its loop. + +`SATELLITE_APP_KEY` is deliberately NOT chained to `AD_APP_ID` here, unlike +some satellites. This host historically ran `AD_APP_ID=dash-leaflet2` against +directory key `leaflet`; setting one for the ad network must never silently +rename this app's traffic series. + To exercise the payload without a secret: `python -m lib.satellite_reporter --dry-run`. ### Sign-in attribution → `POST /api/satellite/auth` (retired with Gen-1) @@ -135,7 +150,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.0 build is +> **`dash-clerk-auth` is not a dependency of this project.** The 1.0.2 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. @@ -240,15 +255,18 @@ in the primary's `CLERK_ALLOWED_REDIRECT_ORIGINS` (see `lib/auth.py` in the 2plotai repo) *with a scheme* — a missing scheme silently strands users on the primary's home page, which has bitten this network before. -### Page visibility +### The interactive gate | Variable | Default | What it does | |---|---|---| -| `PAGE_DEFAULT_VISIBILITY` | `public` | Baseline tier for pages whose frontmatter sets none. | +| `PAGE_DEFAULT_TIER` | `public` | Baseline tier for pages whose frontmatter sets none. **This is the flip.** | +| `PAGE_DEFAULT_VISIBILITY` | — | This service's older name for the same knob, still read. Set one, not both; `PAGE_DEFAULT_TIER` wins. | +| `LLMS_PUBLIC_DEFAULT` | unset (= open) | The second axis. `0` closes every gated page's machine twin — the phase-4 agent flip. | +| `LLMS_SMALL_TIER` / `LLMS_FULL_TIER` | `public` | Tiers for the two corpus documents, independent of the default above. | | `PAGE_VISIBILITY_FILE` | `page_visibility.json` | Where control-board overrides persist. | -Four tiers, re-checked on **every page render** so a toggle applies with no -restart: +Four tiers, re-resolved on **every render and every machine fetch**, so a +toggle, a hub change or an env flip applies with no restart: | Tier | Who gets in | |---|---| @@ -257,25 +275,147 @@ restart: | `admin` | Signed in *and* allowlisted. Its `llms.txt` is never served anonymously. | | `hidden` | Nobody. The page and its `llms.txt` return a 404-style response. | -Per-page baselines come from the markdown frontmatter: +#### Where a verdict comes from + +Three inputs, resolved in `lib/access.py`: + +1. **The control board's override** (`lib/page_visibility.py`, persisted to + `/var/data`). Most local, and it wins — that is the point of a live toggle. +2. **The frontmatter registration** (`lib/page_tiers.py`), underneath it. +3. **The hub's ceiling** (`lib/hub_client.py` → `POST 2plot.dev/api/page-tiers`). + Applied last and only ever *restricts*: this site may lock a page down + further, never open one the network gated. Needs + `CROSS_APP_WEBHOOK_SECRET`; without it the ceiling is simply absent. + +Then, per request: `public`/`hidden` short-circuit → a local Clerk session +answers for a person in a browser → and only for a cookie-less fetch does a +`?key=` go to the hub for verification. **A signed-in reader never needs the +hub**, which is why a hub outage gates nobody who is signed in. + +Two lanes, and they answer different questions. `resolve_page_access` is what a +BROWSER gets (`lib/gate_layouts.py` renders the card); `check` is what a MACHINE +fetch gets (`//llms.txt`, the crawler document, the prerender). Keys +unlock the machine lane only — a `?key=` that opened layouts would turn every +copied URL into a shareable session. + +#### Fail postures — both deliberate + +- **Docs fall OPEN without Clerk.** Every tier except `hidden` degrades to + public. Documentation must never brick because a deploy forgot a credential. +- **Admin fails CLOSED.** `/admin/control-board` returns a 404-style response + without Clerk, and its save callback refuses writes, rather than handing an + open admin panel to whoever guesses the URL. `ALLOW_UNGATED_ADMIN=1` opens it + for local work only. +- **A hub failure is `gated`.** Never `allow` (an outage must not publish + restricted prose), never `deny` (an outage must not black-hole the site). + +#### Per-page baselines, from the markdown frontmatter ```yaml --- name: "Tile Selector" endpoint: "/tile-selector" -visibility: public # optional; defaults to PAGE_DEFAULT_VISIBILITY -llms_public: true # optional; defaults to true +tier: public # optional; defaults to PAGE_DEFAULT_TIER +llms_public: true # optional; defaults to LLMS_PUBLIC_DEFAULT --- ``` -The board lives at **`/admin/control-board`**. It is excluded from `robots.txt` -and from the docs navbar. With Clerk off it renders ungated behind a dev-mode -warning banner — so **do not deploy publicly without the Clerk keys** if any page -is meant to be gated. +`tier:` is canonical. `visibility:` is accepted as an alias for the same four +values; setting both to different values logs a warning and `tier:` wins. One +declared value feeds both the control board's row and the network ledger, so +the board can never show a tier the site does not enforce. + +#### Shipping dark, then flipping + +The gate is wired unconditionally at boot (`run.py` calls +`_access.configure(force=True)`), so the verdict path runs in production while +every verdict still answers `allow`. Confirm the boot line: + +``` +[dash-leaflet2] interactive gate: default tier 'public', 0 non-public page(s), +machine surfaces open by default (LLMS_PUBLIC_DEFAULT), access wiring ON, hub … +``` + +`access wiring ON` with `default tier 'public'` is the dark launch. To flip: +set `PAGE_DEFAULT_TIER=auth` in the Render dashboard and restart. **Rollback is +the same edit in reverse** — back to `public`, restart, fully public site, no +code revert. Rehearse it once before relying on it. + +`/`, `/llms-small.txt` and `/llms-full.txt` are pinned public in `run.py` and +do not move with the default: the funnel's front door and the corpus documents +are deliberate settings, never an ambient default. + +#### The control board + +Lives at **`/admin/control-board`**, excluded from `robots.txt` and from the +docs navbar, and gates itself twice — the layout re-checks on every render and +the save callback re-checks before writing, because a pattern-matching callback +stays callable by anyone who can POST a reconstructed component id. Without +Clerk it is hidden, not merely unstyled. + +> **Persistence.** Overrides are a JSON file. `PAGE_VISIBILITY_FILE` points at +> the `/var/data` disk in production, so a toggle outlives a deploy; on an +> ephemeral filesystem it would reset with every one. + +### Crawler identity and sitemap honesty (dash-improve-my-llms >= 2.6.0) + +The 2.6.0 floor in `requirements.txt` is load-bearing — `pages/markdown.py` +passes `lastmod=` unconditionally, and that argument does not exist below it. + +**Icons come from discovery, not a declaration.** This app has never called +`configure_seo`, so before 2.6.0 Googlebot received a crawler document with +zero icons while browsers got six from `templates/index.html`. 2.6.0 scans the +assets tree — `assets/favicon_io/` is one of its covered directory names — and +this site's own art becomes its crawler-head identity with nothing declared. +Discovery fails **soft** (it returns nothing and logs at debug), so a renamed +favicon directory would take the icons away in silence; `tests/test_seo_icons.py` +is the alarm for that, and it also pins that the emitted hrefs resolve. + +**`` is verbatim or absent.** Before 2.6.0 every sitemap entry claimed +"today", regenerated on every crawl — a sitemap asserting that 27 pages change +daily is one a search engine learns to discard wholesale. Each page now +declares its own date in frontmatter: + +```yaml +lastmod: 2026-07-28 # optional; omitted -> no tag at all +``` -> **Persistence caveat.** Overrides are a JSON file on the container filesystem. -> On Render's free tier that is ephemeral: changes survive until the next deploy. -> Point `PAGE_VISIBILITY_FILE` at a persistent disk if they must outlive one. +The initial values are each file's real `git log -1 --format=%cs` date. **Never +script these from file mtimes** — those reset on every Docker build and would +re-invent exactly the lie the floor exists to end. When you edit a page's +prose, bump its `lastmod` in the same commit. + +**Both heads must agree on identity.** `pages/markdown.py` passes the full +record (`title`, `image_url`, `schema_type`, `lastmod`) through to +`register_page_metadata`, because the `dash.register_page` call above it is +what a browser reads and this one is what a crawler reads. Until it did, the +crawler document carried no `og:image` at all and typed every documentation +page as a bare schema.org `WebPage`. + +> **Still pending: normalized favicon art.** The fleet's standard layout wants +> source art at `cdn.2plot.ai/github_assets/favicons/leaflet.png` regenerated +> through the boilerplate's `scripts/make_favicons.py` (not present in this +> repo). Not blocking: discovery already serves the existing `favicon_io/` art, +> which is this site's own and not the template's, and +> `assets/favicon_io/site.webmanifest` already carries leaflet's name, +> description and `#2f9e44` theme colour. + +### The person→agent handoff + +`GET /api/agent-key` (`lib/agent_key.py`) turns the browser's Clerk session +into a portable `?key=` for copied `llms.txt` URLs, so a link pasted into an +assistant still resolves a gated document — the assistant's fetch arrives with +no cookie. `assets/llms_copy.js` calls it lazily, on the first copy click. + +- **204, no body** — anonymous, Clerk off, or the hub declined. The copy button + falls back to the plain URL, which is what an anonymous reader gets anyway. +- **200 `{"key": "k2p_…"}`** with `Cache-Control: private, no-store`. The key is + never embedded in page HTML, so nothing can cache it and hand it to the next + visitor. + +This satellite holds **no key material**: it cannot mint and cannot verify +offline. The hub verifies the Clerk token against Clerk's JWKS and pins +`scope=auth`, so a satellite can never mint an admin key. ## Post-deploy checklist @@ -292,6 +432,11 @@ is meant to be gated. 3. Sign in from the site — you should bounce to 2plot.ai and land **back here**, not on the primary's home page. 4. `/admin/control-board` shows the page table with **no** dev-mode banner. +4b. The gate's boot line reads `access wiring ON` (see "Shipping dark, then + flipping"). With `PAGE_DEFAULT_TIER=public` that is the dark launch: + `GET /pointer-events` serves docs, `GET /pointer-events/llms.txt` serves + prose, `GET /api/agent-key` answers 204 signed out and 200 with + `Cache-Control: private, no-store` signed in. 5. The 2plot.ai `/traffic` dashboard grows a `leaflet` series within one `SATELLITE_REPORT_INTERVAL_S`. 6. An ad slot appears in the aside on a page with a table of contents. diff --git a/assets/auth_gate.css b/assets/auth_gate.css new file mode 100644 index 0000000..1faf1ce --- /dev/null +++ b/assets/auth_gate.css @@ -0,0 +1,6 @@ +/* Teaser demos inside the auth gate card (lib/auth_demos.py) reuse the docs' + exec examples verbatim — some carry fixed pixel widths sized for the full + docs page. Clamp them to the card. */ +.auth-gate-demo > * { + max-width: 100% !important; +} diff --git a/assets/auth_gate.js b/assets/auth_gate.js new file mode 100644 index 0000000..1cec4f2 --- /dev/null +++ b/assets/auth_gate.js @@ -0,0 +1,63 @@ +/** + * Auth gate card — Clerk sign-in / sign-up triggers. + * + * The interactive gate (lib/gate_layouts.py) renders "Sign in" / + * "Create free account" buttons with static IDs. Clicks are delegated at the + * document level so the listeners survive Dash re-renders. + * + * Satellite mode: Clerk FORBIDS its modal here ("This operation is not + * allowed on a satellite domain") — it only auto-syncs an existing session, + * it never initiates one. So on a satellite we NAVIGATE to the primary + * (window.dashClerkAuth.buildSatelliteRedirect(), dash-clerk-auth ≥ 0.9.2: + * primary /onboarding?returnTo=), which signs the user in and sends + * them straight back. The modal remains the non-satellite/local-dev path. + * + * Coexistence: lib/auth.py's capture-phase fixup owns #clerk-login-button + * (the package's own widget); this file owns only #auth-gate-*. Disjoint + * selectors, same destination — both return to the current page. + */ +document.addEventListener("click", (e) => { + const signup = e.target.closest("#auth-gate-signup"); + const signin = e.target.closest("#auth-gate-signin"); + if (!signup && !signin) return; + + const here = window.location.href; + const dca = window.dashClerkAuth; + if (dca && dca.isSatellite) { + // Needs no ClerkJS at all — works even while clerk.browser.js loads. + const dest = dca.buildSatelliteRedirect + ? dca.buildSatelliteRedirect(signup ? { mode: "signup" } : {}) + : null; + if (dest) { + window.location.assign(dest); + return; + } + if (window.Clerk && window.Clerk.redirectToSignIn) { + if (signup && window.Clerk.redirectToSignUp) { + window.Clerk.redirectToSignUp({ signUpForceRedirectUrl: here }); + } else { + window.Clerk.redirectToSignIn({ signInForceRedirectUrl: here }); + } + return; + } + console.warn("[auth-gate] satellite mode but no redirect target and Clerk JS not loaded"); + return; + } + + if (!window.Clerk) { + console.warn("[auth-gate] Clerk JS not loaded — cannot open auth flow"); + return; + } + + const opts = { + redirectUrl: here, + afterSignInUrl: here, + afterSignUpUrl: here, + }; + + if (signup) { + window.Clerk.openSignUp(opts); + } else { + window.Clerk.openSignIn(opts); + } +}); diff --git a/assets/llms_copy.js b/assets/llms_copy.js index 3f4d424..5f39497 100644 --- a/assets/llms_copy.js +++ b/assets/llms_copy.js @@ -58,6 +58,33 @@ document.addEventListener('DOMContentLoaded', function () { return p.endsWith('/') ? p.slice(0, -1) : p; } + /** + * The signed-in visitor's agent key, or "" when anonymous. + * + * These URLs get pasted into Claude or ChatGPT, which fetch them with no + * cookie — so a gated document needs its authority in the URL or the + * agent gets the gate page instead of the docs. /api/agent-key + * (lib/agent_key.py) returns the key bound to the current Clerk session + * (204 when signed out), which is why the key is never embedded in the + * page HTML: nothing can cache it and hand it to the next visitor. + * + * Fetched lazily on the first click and remembered for the page view — + * every call is a hub round trip, so never fetch on render. Any failure + * falls through to the plain URL: copying something that works for + * public pages beats copying nothing. + */ + let agentKeyPromise = null; + + function getAgentKey() { + if (agentKeyPromise === null) { + agentKeyPromise = fetch('/api/agent-key', { credentials: 'same-origin' }) + .then((r) => (r.status === 200 ? r.json() : null)) + .then((data) => (data && data.key) || '') + .catch(() => ''); + } + return agentKeyPromise; + } + function flashButton(button, originalText, message, color) { button.textContent = message; button.style.color = color; @@ -81,7 +108,11 @@ document.addEventListener('DOMContentLoaded', function () { e.stopPropagation(); try { - const url = `${window.location.origin}${cleanPagePath()}/llms.txt`; + // Carry the agent key when signed in so the pasted link + // works in an assistant that has no session. + const agentKey = await getAgentKey(); + const url = `${window.location.origin}${cleanPagePath()}/llms.txt` + + (agentKey ? `?key=${encodeURIComponent(agentKey)}` : ''); const ok = await copyToClipboard(url); const original = button.textContent; if (ok) { diff --git a/docs/attribution/attribution.md b/docs/attribution/attribution.md index f3c0fd6..8ed4869 100644 --- a/docs/attribution/attribution.md +++ b/docs/attribution/attribution.md @@ -5,6 +5,7 @@ endpoint: "/attribution" package: dash-leaflet2 category: "Dash integration" icon: "tabler:license" +lastmod: 2026-07-28 --- .. llms_copy::Attribution diff --git a/docs/canvas-overlay/canvas-overlay.md b/docs/canvas-overlay/canvas-overlay.md index 956cdc0..00b5ab3 100644 --- a/docs/canvas-overlay/canvas-overlay.md +++ b/docs/canvas-overlay/canvas-overlay.md @@ -5,6 +5,7 @@ endpoint: "/canvas-overlay" package: dash-leaflet2 category: "v2 capabilities" icon: "tabler:chart-dots" +lastmod: 2026-07-28 --- .. llms_copy::Canvas Renderer diff --git a/docs/compare-lab/compare-lab.md b/docs/compare-lab/compare-lab.md index d927d2c..faa5ede 100644 --- a/docs/compare-lab/compare-lab.md +++ b/docs/compare-lab/compare-lab.md @@ -5,6 +5,7 @@ endpoint: "/compare-lab" package: dash-leaflet2 category: "Controls (compiled dl2.*)" icon: "tabler:flip-horizontal" +lastmod: 2026-07-28 --- .. llms_copy::Compare Lab diff --git a/docs/easy-button/easy-button.md b/docs/easy-button/easy-button.md index 0cac4fd..8239857 100644 --- a/docs/easy-button/easy-button.md +++ b/docs/easy-button/easy-button.md @@ -5,6 +5,7 @@ endpoint: "/easy-button" package: dash-leaflet2 category: "Controls (compiled dl2.*)" icon: "tabler:hand-click" +lastmod: 2026-07-28 --- .. llms_copy::Easy Button diff --git a/docs/edit-control-measurement/edit-control-measurement.md b/docs/edit-control-measurement/edit-control-measurement.md index 7cd96e9..3ff426f 100644 --- a/docs/edit-control-measurement/edit-control-measurement.md +++ b/docs/edit-control-measurement/edit-control-measurement.md @@ -5,6 +5,7 @@ endpoint: "/edit-control-measurement" package: dash-leaflet2 category: "Controls (compiled dl2.*)" icon: "tabler:ruler-measure" +lastmod: 2026-07-28 --- .. llms_copy::Edit Control + Measurement diff --git a/docs/edit-control/edit-control.md b/docs/edit-control/edit-control.md index 7cc86b9..e9c2474 100644 --- a/docs/edit-control/edit-control.md +++ b/docs/edit-control/edit-control.md @@ -5,6 +5,7 @@ endpoint: "/edit-control" package: dash-leaflet2 category: "Controls (compiled dl2.*)" icon: "tabler:edit" +lastmod: 2026-07-28 --- .. llms_copy::Draw & Edit diff --git a/docs/emoji-iconify/emoji-iconify.md b/docs/emoji-iconify/emoji-iconify.md index fbfd53a..5d149b1 100644 --- a/docs/emoji-iconify/emoji-iconify.md +++ b/docs/emoji-iconify/emoji-iconify.md @@ -5,6 +5,7 @@ endpoint: "/emoji-iconify" package: dash-leaflet2 category: "Markers" icon: "tabler:mood-smile" +lastmod: 2026-07-28 --- .. llms_copy::Emoji & Iconify diff --git a/docs/events-python/events-python.md b/docs/events-python/events-python.md index 3c80633..12b0cf2 100644 --- a/docs/events-python/events-python.md +++ b/docs/events-python/events-python.md @@ -5,6 +5,7 @@ endpoint: "/events-python" package: dash-leaflet2 category: "Dash integration" icon: "tabler:bolt" +lastmod: 2026-07-28 --- .. llms_copy::Events → Python diff --git a/docs/flight-sim/flight-sim.md b/docs/flight-sim/flight-sim.md index 4a6aa1d..2c9b9b0 100644 --- a/docs/flight-sim/flight-sim.md +++ b/docs/flight-sim/flight-sim.md @@ -5,6 +5,7 @@ endpoint: "/flight-sim" package: dash-leaflet2 category: "Rotation & Sims" icon: "tabler:plane" +lastmod: 2026-07-28 --- .. llms_copy::Flight Sim diff --git a/docs/flyto/flyto.md b/docs/flyto/flyto.md index 7ed84b0..0677ce4 100644 --- a/docs/flyto/flyto.md +++ b/docs/flyto/flyto.md @@ -5,6 +5,7 @@ endpoint: "/flyto" package: dash-leaflet2 category: "Dash integration" icon: "tabler:plane-departure" +lastmod: 2026-07-28 --- .. llms_copy::FlyTo diff --git a/docs/geojson-cluster/geojson-cluster.md b/docs/geojson-cluster/geojson-cluster.md index bb70904..cd5da86 100644 --- a/docs/geojson-cluster/geojson-cluster.md +++ b/docs/geojson-cluster/geojson-cluster.md @@ -5,6 +5,7 @@ endpoint: "/geojson-cluster" package: dash-leaflet2 category: "Layers" icon: "tabler:circles-relation" +lastmod: 2026-07-28 --- .. llms_copy::GeoJSON clustering diff --git a/docs/home/home.md b/docs/home/home.md index bf9a210..4c5f2c2 100644 --- a/docs/home/home.md +++ b/docs/home/home.md @@ -5,6 +5,7 @@ endpoint: "/" package: dash-leaflet2 category: "Start here" icon: "tabler:home" +lastmod: 2026-08-01 --- .. llms_copy::Home diff --git a/docs/layer-group/layer-group.md b/docs/layer-group/layer-group.md index fe30ac0..5923b88 100644 --- a/docs/layer-group/layer-group.md +++ b/docs/layer-group/layer-group.md @@ -5,6 +5,7 @@ endpoint: "/layer-group" package: dash-leaflet2 category: "Layers" icon: "tabler:stack-2" +lastmod: 2026-07-28 --- .. llms_copy::LayerGroup & FeatureGroup diff --git a/docs/layers-control/layers-control.md b/docs/layers-control/layers-control.md index 6554f8d..8a95916 100644 --- a/docs/layers-control/layers-control.md +++ b/docs/layers-control/layers-control.md @@ -5,6 +5,7 @@ endpoint: "/layers-control" package: dash-leaflet2 category: "Controls (compiled dl2.*)" icon: "tabler:stack-2" +lastmod: 2026-07-28 --- .. llms_copy::Layers Control diff --git a/docs/map-pro-props/map-pro-props.md b/docs/map-pro-props/map-pro-props.md index 3906468..3f3090a 100644 --- a/docs/map-pro-props/map-pro-props.md +++ b/docs/map-pro-props/map-pro-props.md @@ -5,6 +5,7 @@ endpoint: "/map-pro-props" package: dash-leaflet2 category: "Layers" icon: "tabler:viewport-narrow" +lastmod: 2026-07-28 --- .. llms_copy::Map pro props diff --git a/docs/minimap/minimap.md b/docs/minimap/minimap.md index b3e9b33..97c5c9b 100644 --- a/docs/minimap/minimap.md +++ b/docs/minimap/minimap.md @@ -5,6 +5,7 @@ endpoint: "/minimap" package: dash-leaflet2 category: "Controls (compiled dl2.*)" icon: "tabler:map-pin" +lastmod: 2026-07-28 --- .. llms_copy::MiniMap diff --git a/docs/pointer-events/pointer-events.md b/docs/pointer-events/pointer-events.md index 1924509..491cc82 100644 --- a/docs/pointer-events/pointer-events.md +++ b/docs/pointer-events/pointer-events.md @@ -5,6 +5,7 @@ endpoint: "/pointer-events" package: dash-leaflet2 category: "v2 capabilities" icon: "tabler:pointer" +lastmod: 2026-07-28 --- .. llms_copy::Pointer Events diff --git a/docs/resize-observer/resize-observer.md b/docs/resize-observer/resize-observer.md index fbdc93a..4595457 100644 --- a/docs/resize-observer/resize-observer.md +++ b/docs/resize-observer/resize-observer.md @@ -5,6 +5,7 @@ endpoint: "/resize-observer" package: dash-leaflet2 category: "v2 capabilities" icon: "tabler:resize" +lastmod: 2026-07-28 --- .. llms_copy::ResizeObserver Sizing diff --git a/docs/rotation-basic/rotation-basic.md b/docs/rotation-basic/rotation-basic.md index 1f54aec..d714da6 100644 --- a/docs/rotation-basic/rotation-basic.md +++ b/docs/rotation-basic/rotation-basic.md @@ -5,6 +5,7 @@ endpoint: "/rotation-basic" package: dash-leaflet2 category: "Rotation & Sims" icon: "tabler:rotate-360" +lastmod: 2026-07-28 --- .. llms_copy::Basic Rotation diff --git a/docs/scale-fullscreen-image/scale-fullscreen-image.md b/docs/scale-fullscreen-image/scale-fullscreen-image.md index 70b2e93..215e563 100644 --- a/docs/scale-fullscreen-image/scale-fullscreen-image.md +++ b/docs/scale-fullscreen-image/scale-fullscreen-image.md @@ -5,6 +5,7 @@ endpoint: "/scale-fullscreen-image" package: dash-leaflet2 category: "Controls (compiled dl2.*)" icon: "tabler:tool" +lastmod: 2026-07-28 --- .. llms_copy::Scale, FullScreen, ImageOverlay diff --git a/docs/subclassing/subclassing.md b/docs/subclassing/subclassing.md index c037739..023bd9d 100644 --- a/docs/subclassing/subclassing.md +++ b/docs/subclassing/subclassing.md @@ -5,6 +5,7 @@ endpoint: "/subclassing" package: dash-leaflet2 category: "v2 capabilities" icon: "tabler:hierarchy" +lastmod: 2026-07-28 --- .. llms_copy::ES6 Subclassing diff --git a/docs/text-marker/text-marker.md b/docs/text-marker/text-marker.md index ebc07bf..500ad40 100644 --- a/docs/text-marker/text-marker.md +++ b/docs/text-marker/text-marker.md @@ -5,6 +5,7 @@ endpoint: "/text-marker" package: dash-leaflet2 category: "Markers" icon: "mdi:format-text" +lastmod: 2026-07-28 --- .. llms_copy::TextMarker diff --git a/docs/tile-layers-pro/tile-layers-pro.md b/docs/tile-layers-pro/tile-layers-pro.md index dc6ba75..ef02664 100644 --- a/docs/tile-layers-pro/tile-layers-pro.md +++ b/docs/tile-layers-pro/tile-layers-pro.md @@ -5,6 +5,7 @@ endpoint: "/tile-layers-pro" package: dash-leaflet2 category: "Controls (compiled dl2.*)" icon: "tabler:layers-difference" +lastmod: 2026-07-28 --- .. llms_copy::Tile Layers (Pro) diff --git a/docs/tile-selector/tile-selector.md b/docs/tile-selector/tile-selector.md index 4dcb195..af467a6 100644 --- a/docs/tile-selector/tile-selector.md +++ b/docs/tile-selector/tile-selector.md @@ -5,6 +5,7 @@ endpoint: "/tile-selector" package: dash-leaflet2 category: "Controls (compiled dl2.*)" icon: "tabler:grid-4x4" +lastmod: 2026-07-28 --- .. llms_copy::Tile Selector diff --git a/docs/tilelayer-pro-props/tilelayer-pro-props.md b/docs/tilelayer-pro-props/tilelayer-pro-props.md index 71d9079..b1bd539 100644 --- a/docs/tilelayer-pro-props/tilelayer-pro-props.md +++ b/docs/tilelayer-pro-props/tilelayer-pro-props.md @@ -5,6 +5,7 @@ endpoint: "/tilelayer-pro-props" package: dash-leaflet2 category: "Layers" icon: "tabler:map-2" +lastmod: 2026-07-28 --- .. llms_copy::TileLayer pro props diff --git a/docs/vector-layers/vector-layers.md b/docs/vector-layers/vector-layers.md index 5c70b42..cbb280e 100644 --- a/docs/vector-layers/vector-layers.md +++ b/docs/vector-layers/vector-layers.md @@ -5,6 +5,7 @@ endpoint: "/vector-layers" package: dash-leaflet2 category: "Layers" icon: "tabler:vector-triangle" +lastmod: 2026-07-28 --- .. llms_copy::Vector Layers diff --git a/docs/walking-sim/walking-sim.md b/docs/walking-sim/walking-sim.md index 7729d9f..bda76a9 100644 --- a/docs/walking-sim/walking-sim.md +++ b/docs/walking-sim/walking-sim.md @@ -5,6 +5,7 @@ endpoint: "/walking-sim" package: dash-leaflet2 category: "Rotation & Sims" icon: "tabler:walk" +lastmod: 2026-07-28 --- .. llms_copy::Walking Sim diff --git a/lib/access.py b/lib/access.py new file mode 100644 index 0000000..0ec9f05 --- /dev/null +++ b/lib/access.py @@ -0,0 +1,346 @@ +"""The access policy this site hands to dash-improve-my-llms. + +Ported from the boilerplate and adapted for the one thing this repo has that +the template does not: a working, live control board. So the local half of +every verdict is resolved from THREE inputs, in this order (see +:func:`local_tier`): + + control-board override lib.page_visibility, persisted to /var/data + frontmatter registration lib.page_tiers, declared by `tier:`/`visibility:` + the hub's ceiling lib.hub_client, applied with `more_restrictive` + +The override goes first because that is what the board is for: a toggle has to +apply on the next render, without a restart. The hub goes last and only ever +restricts — a satellite may lock a page down further, never loosen what the +network gated. + +Two functions matter, and they answer different questions: + +:func:`check` is the MACHINE lane — "may this fetch of a text file proceed?". +It honours ``?key=`` and the ``llms_public`` axis, and the package calls it for +``//llms.txt``, the crawler document and the prerender. + +:func:`resolve_page_access` is the INTERACTIVE lane — "what does a browser get +instead of this page?". Keys never unlock layouts and ``llms_public`` says +nothing about the interactive experience, so it deliberately ignores both. + +:func:`check`'s *ordering* is the rest of the design: + + tier -> short-circuit public/hidden + -> local Clerk session (a person in a browser) + -> hub verification of ?key= (an agent, later, elsewhere) + +**Local session first, hub only as a fallback.** A signed-in visitor is +resolved entirely on this host, so the hub being down gates nothing for them. +Only the agent path — a fetch with no cookie, carrying a key in the URL — +needs the hub at all. Reversing this order would couple every satellite's +availability to one host for no benefit: it is the single most consequential +line in this file. + +Two questions get asked of a documentation site, and only one has a session +attached. Clerk answers "who is this human?". It can never answer "may this +fetch of a text file proceed?", because when someone pastes +``https://host/guide/llms.txt`` into an assistant, the fetch arrives with no +cookie. That is why authority has to be able to travel in the URL, and why +both mechanisms exist rather than one. + +Unwired by default in the template; wired unconditionally here. +:func:`configure` skips itself when every tier is public, which is the right +default for a fork — but this deployment flips its gate by environment +variable, and the verdict plumbing (including the prerender's use of it) has +to be live BEFORE the flip, not installed by it. run.py therefore passes +``force=True``. See its boot line for the resulting state. +""" + +from __future__ import annotations + +import logging +from typing import Optional + +from lib import auth, hub_client, page_tiers, page_visibility + +logger = logging.getLogger(__name__) + +# Set by configure(). lib.page_visibility asks, because its legacy llms.txt +# stub swap is the fallback for a boot where nothing could be wired. +_CONFIGURED = False + + +def configured() -> bool: + """True once the package holds this module's policy.""" + return _CONFIGURED + + +def _request_key() -> str: + """The ``?key=`` on the current request, or "". Never raises, never logs.""" + try: + from flask import request, has_request_context + + if not has_request_context(): + return "" + return (request.args.get("key") or "").strip() + except Exception: + return "" + + +# --------------------------------------------------------------------------- +# The local half: one resolver, three inputs +# --------------------------------------------------------------------------- + + +def local_tier(path: str) -> str: + """This page's tier before the hub ceiling — override, then frontmatter. + + ``page_visibility.tier_override`` answers None for a page the board never + touched, which is the distinction that makes this work: an untouched page + falls through to what its frontmatter declared, rather than to the + override store's own unknown-path default. Both ledgers speak the same + four-value vocabulary, so no mapping is needed. + """ + override = page_visibility.tier_override(path) + if override is not None: + return override + return page_tiers.local_tier(path) + + +def llms_public(path: str) -> bool: + """The machine-surface axis, resolved the same way as the tier. + + Same precedence, same reason. ``LLMS_PUBLIC_DEFAULT`` still governs every + page that neither the board nor the frontmatter pinned, so the phase-4 + agent flip stays one environment change. + """ + override = page_visibility.llms_public_override(path) + if override is not None: + return override + return page_tiers.get_llms_public(path) + + +def _raw_tier(path: str) -> str: + """Local tier with the hub's ceiling applied, no degradation.""" + hub_tier = hub_client.hub_tiers().get(path) + local = local_tier(path) + return page_tiers.more_restrictive(local, hub_tier) if hub_tier else local + + +def effective_tier(path: str) -> str: + """This page's tier, with the hub ceiling applied and degradation handled.""" + tier = _raw_tier(path) + if not auth.clerk_enabled(): + # No way to identify anyone: everything except `hidden` falls open. + # See lib/page_tiers.degraded_tier for why that is the right trade + # for reading documentation, and why admin surfaces don't rely on it. + return page_tiers.degraded_tier(tier) + return tier + + +def check(path: str) -> str: + """``allow`` | ``gated`` | ``deny`` for the current request. + + Runs inside the request, on every request, on paths that may be hot — so + it stays cheap: a dict lookup, and at most one cached hub call for the + agent path. + """ + tier = effective_tier(path) + + if tier == "hidden": + return "deny" + if tier == "public": + return "allow" + + # The second axis: an `auth` page whose machine twin stays open. This is + # the window posture — humans meet the sign-in card (resolve_page_access, + # the interactive lane) while llms.txt, crawler HTML and the prerender + # keep serving prose so the crawl-demand dataset survives. The phase-4 + # agent flip is LLMS_PUBLIC_DEFAULT=0, which turns this branch off + # everywhere at once; admin/hidden never reach it. The exemption applies + # ONLY to a locally declared gate: when the hub's ceiling is what raised + # this page above public, the machine lane must stay bound too — a + # satellite's env default cannot loosen what the network restricted. + if tier == "auth" and llms_public(path): + hub_tier = hub_client.hub_tiers().get(path) + if hub_tier not in ("auth", "admin", "hidden"): + return "allow" + + # A signed-in reader in a browser. Resolved here, without the hub. + user = auth.current_user() + if user is not None: + if tier == "admin": + return "allow" if auth.is_admin_user(user) else "gated" + return "allow" + + # No session: an agent, or an anonymous browser. Only a key can help now. + key = _request_key() + if not key: + return "gated" + return hub_client.verify(key, path, tier) + + +def resolve_page_access(path: str) -> str: + """``allow`` | ``sign_in`` | ``forbidden`` | ``hidden`` — the INTERACTIVE + verdict, for browser layouts (lib/gate_layouts wraps every docs page in + it). + + Distinct from :func:`check` on purpose: the machine lane answers "may + this fetch of a text file proceed" and honours ``?key=`` and + ``llms_public``; a browser layout is a different question — keys never + unlock layouts, and ``llms_public`` says nothing about the interactive + experience. + + Fail postures are the boilerplate's, NOT the ones the retired + ``page_visibility.resolve_access`` had: with Clerk unavailable, ``auth`` + pages fall OPEN (documentation must never brick on a missing credential) + while ``admin`` pages stay CLOSED unless ``ALLOW_UNGATED_ADMIN`` says this + is a local box. The old function fell open for both, which was survivable + only because no admin surface ever went through it — ``/admin/control-board`` + has always gated itself. Do not restore that behaviour here. + + Uses the raw effective tier — not ``degraded_tier()``, which maps admin to + public and would fall the wrong way. + """ + tier = _raw_tier(path) + + if tier == "hidden": + return "hidden" + if tier == "public": + return "allow" + + if not auth.clerk_enabled(): + if tier == "admin": + return "allow" if auth.admin_access_open() else "forbidden" + return "allow" + + user = auth.current_user() + if user is None: + return "sign_in" + if tier == "admin" and not auth.is_admin_user(user): + return "forbidden" + return "allow" + + +def gate_doc(path: str) -> str: + """The Markdown an unauthorised reader receives at 200. + + Deliberately useful rather than a wall: it names the page, says what would + unlock it and where to sign in. An agent that fetches a gated document + should come away knowing what it is and how its user gets access — that is + the difference between a gate and a dead end, and it is why `gated` keeps + the URL listed in the index and sitemap. + """ + tier = effective_tier(path) + name = _page_name(path) or path + sign_in = _sign_in_url() + + lines = [ + f"# {name}", + "", + "> This document exists but is not public.", + "", + "---", + "", + ] + if tier == "admin": + lines += ["Reading it requires an administrator account on this site."] + else: + lines += ["Reading it requires a signed-in account."] + lines += [ + "", + "**If you are a person:** sign in" + + (f" at {sign_in}" if sign_in else "") + + ", then reload this page.", + "", + "**If you are an assistant:** ask whoever gave you this URL to copy it " + "again while signed in. The copied link carries a key that authorises " + "this document, and it will work when pasted here.", + "", + "The page's existence is public; only its content is restricted.", + "", + ] + return "\n".join(lines) + + +def link_suffix() -> str: + """Carry authority to the links generated in *this* response. + + An agent handed an authorised URL must be able to follow the site-index + and per-page links inside it, or the catalogue is untraversable one hop + from the document it was given. The package appends this to same-origin + generated URLs only — never to canonical tags, the sitemap, or peer hosts, + because a capability should not travel to another origin. + """ + key = _request_key() + return f"?key={key}" if key else "" + + +def _page_name(path: str) -> Optional[str]: + try: + import dash + + for entry in dash.page_registry.values(): + if page_tiers.normalize(entry.get("path", "")) == page_tiers.normalize(path): + return entry.get("name") + except Exception: + pass + return None + + +def sign_in_url() -> Optional[str]: + """Where a reader signs in: the network bulletin's word first, env second. + + The bulletin travels with the hub's announcements, so a network-wide + sign-in move is one hub edit instead of N satellite env changes; the env + remains the local override and the offline fallback. + """ + try: + from dash_improve_my_llms.bulletin import get_bulletin + + url = ((get_bulletin() or {}).get("network") or {}).get("sign_in_url") + if isinstance(url, str) and url.strip(): + return url.strip() + except Exception: + pass + import os + + return (os.getenv("CLERK_SIGN_IN_URL") or "").strip() or None + + +# Old private name — gate_doc() predates the bulletin-aware lookup. +_sign_in_url = sign_in_url + + +def gating_configured() -> bool: + """True when at least one page is not public. + + A site whose pages are all public gains nothing from a per-request check + and pays for it on every hot path, so the wiring stays off until a tier + says otherwise. Control-board overrides count: a board that gated a page + on a previous boot has that override on disk before this is called. + """ + if any(tier != "public" for tier in page_tiers.registered().values()): + return True + return any( + page_visibility.tier_override(path) not in (None, "public") + for path in page_visibility.controllable_pages() + ) + + +def configure(force: bool = False) -> bool: + """Hand the policy to the package. Returns True when access control is on. + + Call after the pages are registered — the decision depends on their tiers. + """ + global _CONFIGURED + if not (force or gating_configured()): + return False + + from dash_improve_my_llms import configure_access, configure_viewer_identity + + configure_access(check, gate_doc=gate_doc, link_suffix=link_suffix) + configure_viewer_identity(auth.viewer_identity) + _CONFIGURED = True + logger.info( + "access control ON (clerk=%s, hub=%s)", + auth.clerk_enabled(), + hub_client.enabled(), + ) + return True diff --git a/lib/agent_key.py b/lib/agent_key.py new file mode 100644 index 0000000..15174e8 --- /dev/null +++ b/lib/agent_key.py @@ -0,0 +1,99 @@ +"""``GET /api/agent-key`` — the person→agent handoff, satellite side. + +A signed-in reader clicks "Copy for LLM" and gets a URL that still works +after it is pasted into an assistant, because the assistant's fetch arrives +with no cookie. This route turns the browser's Clerk session into that +portable authority: it reads the ``__session`` cookie and asks the hub to +mint (or return) the reader's current agent key via +:func:`lib.hub_client.current_key` — the hub verifies the token against +Clerk's JWKS and pins ``scope=auth``, so a satellite can never mint an admin +key and never asserts an identity of its own. + +Contract (mirror of pip-docs+ ``/api/agent-key``): + +- 204, no body — anonymous request, Clerk off, or the hub declined/was + unreachable. The caller copies the plain URL, which is exactly what an + anonymous reader gets anyway. +- 200 ``{"key": "k2p_…"}`` with ``Cache-Control: private, no-store`` — the + key must never enter a shared cache; it is why the key is fetched on click + rather than embedded in the page HTML. + +Consumed by ``assets/llms_copy.js`` (lazily, on the first copy click — every +call is a hub round trip and each mint is recorded hub-side). + +No ``from __future__ import annotations`` here, deliberately: PEP 563 turns +the FastAPI handler's ``request: Request`` into a string that FastAPI then +tries to resolve from module globals — where the locally imported ``Request`` +does not exist — and the parameter silently becomes a required query field +(422 on every call). +""" + +_NO_STORE = "private, no-store" + + +def _mint() -> str | None: + """The current request's agent key, or None. Never raises.""" + try: + from flask import request + + token = (request.cookies.get("__session") or "").strip() + except Exception: + return None + return _mint_from_token(token) + + +def _mint_from_token(token: str) -> str | None: + if not token: + return None + try: + from lib import auth, hub_client + + if not auth.clerk_enabled(): + return None + return hub_client.current_key(token) + except Exception: + return None + + +def register_agent_key_route(app, backend: str) -> None: + """Mount ``/api/agent-key`` on whichever backend is running.""" + server = app.server + + if backend == "fastapi": + from fastapi import Request + from fastapi.responses import JSONResponse, Response + + @server.get("/api/agent-key") + def _agent_key(request: Request): # sync: runs in the threadpool + token = (request.cookies.get("__session") or "").strip() + key = _mint_from_token(token) + if not key: + return Response(status_code=204, + headers={"Cache-Control": _NO_STORE}) + return JSONResponse({"key": key}, + headers={"Cache-Control": _NO_STORE}) + + elif backend == "quart": + from quart import jsonify, request + + @server.get("/api/agent-key") + async def _agent_key(): # pragma: no cover — quart runtime + token = (request.cookies.get("__session") or "").strip() + key = _mint_from_token(token) + if not key: + return "", 204, {"Cache-Control": _NO_STORE} + resp = jsonify({"key": key}) + resp.headers["Cache-Control"] = _NO_STORE + return resp + + else: + from flask import jsonify + + @server.get("/api/agent-key") + def _agent_key(): + key = _mint() + if not key: + return "", 204, {"Cache-Control": _NO_STORE} + resp = jsonify({"key": key}) + resp.headers["Cache-Control"] = _NO_STORE + return resp diff --git a/lib/auth.py b/lib/auth.py index f0a573b..b28e140 100644 --- a/lib/auth.py +++ b/lib/auth.py @@ -149,6 +149,56 @@ def is_admin_user(user=None) -> bool: return bool(user_id and user_id in admin_ids) +# --------------------------------------------------------------------------- +# Viewer identity for the llms.txt banner +# --------------------------------------------------------------------------- + +# session_id -> first ISO timestamp this process saw it. Deliberately NOT the +# Clerk token's `iat`: the session token is refreshed roughly every 60 seconds, +# so `iat` is the age of the *token*, not of the sign-in — wiring it renders a +# "signed in since" clock that resets every minute. +# +# Process-local and unbounded-by-design is wrong, so it is capped. Losing an +# entry costs a slightly-late timestamp, nothing more. +_SESSION_FIRST_SEEN: dict[str, str] = {} +_SESSION_CACHE_MAX = 2048 + + +def _first_seen(session_id: str) -> str: + from datetime import datetime, timezone + + stamp = _SESSION_FIRST_SEEN.get(session_id) + if stamp is None: + if len(_SESSION_FIRST_SEEN) >= _SESSION_CACHE_MAX: + _SESSION_FIRST_SEEN.clear() + stamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + _SESSION_FIRST_SEEN[session_id] = stamp + return stamp + + +def viewer_identity() -> dict | None: + """For ``configure_viewer_identity``. None when nobody is signed in. + + Rendered in the HTML viewer's banner only — the Markdown variant of the + same URL is byte-identical with and without it, so this can never reach an + agent, a crawler or an index. + """ + user = current_user() + if not user: + return None + try: + identity = {"name": getattr(user, "email", None) or getattr(user, "user_id", "")} + session_id = getattr(user, "session_id", None) + if session_id: + identity["since"] = _first_seen(session_id) + plan = getattr(user, "plan", None) + if plan: + identity["plan"] = plan + return identity if identity.get("name") else None + except Exception: + return None + + # --------------------------------------------------------------------------- # Registration — MUST run BEFORE Dash() is constructed # --------------------------------------------------------------------------- diff --git a/lib/auth_demos.py b/lib/auth_demos.py new file mode 100644 index 0000000..e993efb --- /dev/null +++ b/lib/auth_demos.py @@ -0,0 +1,94 @@ +"""Teaser demos for the authentication gate cards. + +Each auth-gated docs page can register ONE live example that renders inside +the sign-in card (lib.gate_layouts.sign_in_layout) — an interactive taste of +what's behind the gate, with no code and no surrounding docs. + +The modules referenced here are the same ``.. exec::`` example modules the +docs pages use (they expose a module-level ``component``), so they're already +imported — and their callbacks already registered — when pages/markdown.py +parses the docs at startup. Only one layout (gate card OR full docs) renders +per request, so sharing the component instances never duplicates IDs. + +The table ships EMPTY here on purpose, and the gate card renders fine that +way. A leaflet example module is a full docs block — header, live map, code +panel — so dropping one in unedited puts a code listing inside the sign-in +card, which is the opposite of a teaser. Pick a page, look at the card, then +enable one entry (one is plenty — this is a funnel, not a gallery). + +Entries: + endpoint -> { + "module": dotted path of the example module, + "caption": short label shown next to the "Live demo" badge, + "max_height": px cap for the demo viewport inside the card, + "height": optional explicit px height — needed by components that + size to their container, + } +""" +from __future__ import annotations + +import importlib +import logging + +logger = logging.getLogger(__name__) + +DEMOS: dict[str, dict] = { + # "/flyto": { + # "module": "docs.flyto.example", + # "caption": "Fly between cities — Leaflet 2, no react-leaflet", + # "max_height": 420, + # # Maps size to their container, so a card demo usually needs this. + # "height": 380, + # }, +} + + +def build_demo(path: str): + """Return the teaser demo block for ``path``, or None. + + Import/attribute failures degrade to the plain (demo-less) card — a broken + example must never take down the sign-in funnel. + """ + spec = DEMOS.get(path) + if spec is None: + return None + try: + module = importlib.import_module(spec["module"]) + component = getattr(module, "component") + except Exception as e: + logger.warning("Auth-gate demo %s failed to load (%s) — card renders " + "without it", spec.get("module"), e) + return None + + import dash_mantine_components as dmc + from dash_iconify import DashIconify + + return dmc.Box( + [ + dmc.Group( + [ + dmc.Badge( + "Live demo — try it", + variant="light", + color="teal", + leftSection=DashIconify(icon="tabler:hand-click", width=13), + ), + dmc.Text(spec.get("caption", ""), size="sm", c="dimmed"), + ], + justify="space-between", + px="md", + pt="md", + ), + dmc.Box( + component, + p="md", + className="auth-gate-demo", + style={ + "maxHeight": f"{spec.get('max_height', 420)}px", + "overflowY": "auto", + "overflowX": "hidden", + **({"height": f"{spec['height']}px"} if "height" in spec else {}), + }, + ), + ] + ) diff --git a/lib/gate_layouts.py b/lib/gate_layouts.py new file mode 100644 index 0000000..65685bc --- /dev/null +++ b/lib/gate_layouts.py @@ -0,0 +1,179 @@ +"""The interactive gate — what a browser sees instead of a page it may not read. + +Ported from pip-docs+ (`lib/page_visibility.py`, the gate-layout half) with the +boilerplate's fail postures: the verdict comes from +:func:`lib.access.resolve_page_access`, which falls OPEN for ``auth`` docs when +Clerk is unconfigured and CLOSED for ``admin`` surfaces — do not "fix" either +direction, both are deliberate (see that function's docstring). + +The gate is a card at HTTP 200, not a redirect and not a 404: the URL stays +shareable, the machine twin at ``//llms.txt`` keeps its own verdict, and +the card is the account-creation funnel. When ``lib/auth_demos.py`` registers a +teaser for the page, a live interactive example renders at the top of the card +so the visitor sees exactly what an account unlocks. + +Buttons carry the static ids ``#auth-gate-signup`` / ``#auth-gate-signin``, +handled by ``assets/auth_gate.js`` (satellite mode navigates to the primary +with a returnTo; local dev opens the Clerk modal). Those selectors are +deliberately disjoint from ``#clerk-login-button``, which the package's own +handler and ``lib/auth.py``'s capture-phase fixup already own. +""" +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) + + +def _sign_in_destination() -> str: + from lib import access + + return access.sign_in_url() or "https://2plot.ai" + + +def _card(icon: str, color: str, title: str, body: str, extra=None): + import dash_mantine_components as dmc + from dash_iconify import DashIconify + + children = [ + DashIconify(icon=icon, width=56, color=f"var(--mantine-color-{color}-5)"), + dmc.Title(title, order=3, ta="center"), + dmc.Text(body, c="dimmed", ta="center", maw=440), + ] + if extra is not None: + children.append(extra) + return dmc.Center( + dmc.Paper( + dmc.Stack(children, align="center", gap="md", p="xl"), + withBorder=True, radius="lg", shadow="md", p="xl", mt="10vh", maw=560, + ) + ) + + +def sign_in_layout(page_name: str, path: str | None = None): + """Account-creation funnel card shown to signed-out visitors. + + With a registered teaser demo (lib.auth_demos) a live example renders at + the top — no code, no docs — so the visitor sees what an account unlocks. + """ + import dash_mantine_components as dmc + from dash_iconify import DashIconify + + demo = None + if path: + try: + from lib.auth_demos import build_demo + + demo = build_demo(path) + except Exception: # a broken demo table must never break the funnel + demo = None + + if demo is not None: + intro = ( + f"You're looking at a live preview of {page_name}. Create a free " + "account to unlock the full documentation — every interactive " + "example and the complete API reference." + ) + else: + intro = ( + f"“{page_name}” is available to registered users — a free account " + "unlocks every component's live examples and full API docs." + ) + + message = dmc.Stack( + [ + dmc.ThemeIcon( + DashIconify(icon="tabler:lock", width=28), + size=54, radius="xl", variant="light", color="teal", + ), + dmc.Title("Authentication required", order=3, ta="center"), + dmc.Text(intro, c="dimmed", ta="center", maw=460), + dmc.Group( + [ + dmc.Button( + "Create free account", + id="auth-gate-signup", + size="md", + variant="gradient", + gradient={"from": "teal", "to": "cyan"}, + leftSection=DashIconify(icon="tabler:user-plus", width=18), + ), + dmc.Button( + "Sign in", + id="auth-gate-signin", + size="md", + variant="default", + leftSection=DashIconify(icon="tabler:login-2", width=18), + ), + ], + justify="center", + gap="sm", + mt="xs", + ), + dmc.Text( + "Free forever — you'll be redirected straight back to this " + f"page. Accounts live at {_sign_in_destination()}.", + size="xs", c="dimmed", ta="center", + ), + ], + align="center", + gap="md", + p="xl", + ) + + children = [message] if demo is None else [demo, dmc.Divider(), message] + + return dmc.Center( + dmc.Paper( + children, + withBorder=True, + radius="lg", + shadow="xl", + p=0, + mt="4vh" if demo is not None else "10vh", + mb="4vh", + w="100%", + maw=780 if demo is not None else 560, + style={"overflow": "hidden"}, + ), + px="md", + ) + + +def forbidden_layout(page_name: str): + return _card( + "tabler:shield-lock", "red", "Restricted documentation", + f"“{page_name}” is limited to administrator accounts.", + ) + + +def hidden_layout(): + return _card( + "tabler:eye-off", "gray", "404 — Page not available", + "This page is not currently published.", + ) + + +def gated_layout(path: str, page_name: str, build_layout): + """Wrap a page layout in the interactive gate. + + ``build_layout`` is the prebuilt component tree (or a zero-arg callable + returning one). The returned function becomes the Dash page layout, so + the verdict runs on every render — an env flip applies on the next + navigation, no rebuild. ``**kwargs`` is required: Dash Pages forwards + query params (including Clerk's ``?__clerk_handshake=``) into layout + callables. + """ + def layout(**kwargs): + from lib import access + + verdict = access.resolve_page_access(path) + if verdict == "hidden": + return hidden_layout() + if verdict == "sign_in": + return sign_in_layout(page_name, path) + if verdict == "forbidden": + return forbidden_layout(page_name) + return build_layout() if callable(build_layout) else build_layout + + return layout diff --git a/lib/hub_client.py b/lib/hub_client.py new file mode 100644 index 0000000..776e86c --- /dev/null +++ b/lib/hub_client.py @@ -0,0 +1,312 @@ +"""Client for the network hub's agent-key and page-tier endpoints. + +Three calls, all satellite → hub, never browser → hub:: + + POST {hub}/api/agent-key/current -> {"key": "k2p_..."} for the copy button + POST {hub}/api/agent-key/verify -> {"verdict": ..., "ttl": ...} + POST {hub}/api/page-tiers -> {"tiers": {path: tier}, "ttl": ...} + +Why a hub call exists at all +---------------------------- +A signed-in visitor is resolved **locally** by Clerk and never reaches this +module. Only an agent fetch does: someone pastes a document URL into an +assistant, and it arrives with no cookie, carrying `?key=` instead. Only the +hub can validate that key, because only the hub holds the secret it was +derived from. + +That asymmetry is the point of the design. A satellite holds **no key +material** — it cannot mint, and it cannot verify offline. Sharing the hub's +`SESSION_SECRET` would let any satellite verify locally, but anything that can +verify can also mint, including `scope=admin`; twenty deployments holding a +network-wide admin-minting secret is precisely the failure this avoids. + +Authenticating the caller +------------------------- +The hub must know which satellite is asking — a verify endpoint open to the +internet is a key-guessing oracle. This reuses the network's existing +signed-webhook scheme (`CROSS_APP_WEBHOOK_SECRET`, HMAC-SHA256 over +`"{timestamp}." + body`), the same one `lib/satellite_reporter` already uses. +That secret authenticates a caller; it does not derive keys, so holding it +grants a satellite nothing it could not already do. + +Failure behaviour +----------------- +Every failure path — no secret, timeout, DNS, 5xx, non-JSON, wrong shape — +returns ``gated``. Never ``allow``: a hub outage must not publish restricted +prose. Never ``deny``: an outage must not black-hole every document. ``gated`` +leaks nothing and keeps the surface answering, which is the same fail-safe the +package applies when an app's own check raises. + +Nothing here logs a key, or anything derived from one. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import logging +import os +import time +from typing import Dict, Optional, Tuple + +logger = logging.getLogger(__name__) + +DEFAULT_HUB_URL = "https://2plot.dev" + +# Allow verdicts outlive deny verdicts on purpose. A brief hub blip should not +# gate a reader who was fine a minute ago, whereas a revoked key should stop +# working promptly — so the cost of being slightly stale is paid by the side +# that fails safe. The hub may override both per response. +ALLOW_TTL_S = 900.0 +DENY_TTL_S = 60.0 + +# key-hash + path -> (verdict, expires_at) +_VERDICT_CACHE: Dict[Tuple[str, str], Tuple[str, float]] = {} +_CACHE_MAX = 4096 + + +def hub_url() -> str: + return (os.getenv("NETWORK_HUB_URL") or DEFAULT_HUB_URL).rstrip("/") + + +def _secret() -> Optional[str]: + return os.getenv("CROSS_APP_WEBHOOK_SECRET") or None + + +def app_id() -> str: + """This satellite's identity to the hub. Same key the reporter uses. + + ``leaflet`` is this app's 2plot network-directory key — the same value + render.yaml sets for ``SATELLITE_APP_KEY`` and ``AD_APP_ID``, and the one + the hub labels its series and its page-tier ceilings by. The literal is + the fallback for a local run with neither variable set; getting it wrong + would make the hub answer for a different app's pages. + """ + return os.getenv("SATELLITE_APP_KEY") or os.getenv("AD_APP_ID") or "leaflet" + + +def enabled() -> bool: + """False when this deployment cannot talk to the hub at all. + + With no shared secret there is nobody to authenticate as, so every verify + would fail anyway. Reporting that up front lets the caller skip the round + trip and go straight to the fail-safe. + """ + return bool(_secret()) + + +def _fingerprint(key: str) -> str: + """A cache handle for a key that is not the key. + + The cache lives in process memory and may be dumped by a debugger, a heap + profiler or an error reporter. Storing a hash means none of those ever + contain a capability. + """ + return hashlib.sha256(key.encode("utf-8", "replace")).hexdigest()[:32] + + +def _cache_get(fingerprint: str, path: str) -> Optional[str]: + entry = _VERDICT_CACHE.get((fingerprint, path)) + if not entry: + return None + verdict, expires_at = entry + if expires_at < time.time(): + _VERDICT_CACHE.pop((fingerprint, path), None) + return None + return verdict + + +def _cache_put(fingerprint: str, path: str, verdict: str, ttl: float) -> None: + if len(_VERDICT_CACHE) >= _CACHE_MAX: + _VERDICT_CACHE.clear() + _VERDICT_CACHE[(fingerprint, path)] = (verdict, time.time() + max(0.0, ttl)) + + +def clear_cache() -> None: + _VERDICT_CACHE.clear() + clear_tiers_cache() + + +def _post(route: str, payload: dict, timeout: float) -> Optional[dict]: + """Signed POST to the hub. Returns the decoded body, or None on any failure.""" + secret = _secret() + if not secret: + return None + + import requests + + from lib.constants import internal_ua + + # Sign the exact bytes sent — serialise once, sign that. + body = json.dumps(payload).encode() + ts = str(int(time.time())) + signature = hmac.new( + secret.encode(), f"{ts}.".encode() + body, hashlib.sha256 + ).hexdigest() + + try: + response = requests.post( + f"{hub_url()}{route}", + data=body, + timeout=timeout, + headers={ + "Content-Type": "application/json", + "X-AI-Canvas-Timestamp": ts, + "X-AI-Canvas-Signature": signature, + "X-Satellite-App": app_id(), + # Internal-traffic contract (lib/constants.INTERNAL_UA): these + # are satellite→hub machine calls, and a key verification is + # not a reader of 2plot.dev's documentation. + "User-Agent": internal_ua("hub-client"), + }, + ) + except Exception as exc: # noqa: BLE001 — DNS, TLS, timeouts all land here + logger.debug("hub %s unreachable: %r", route, exc) + return None + + if response.status_code != 200: + logger.debug("hub %s returned HTTP %s", route, response.status_code) + return None + + try: + decoded = response.json() + except Exception: + # A hub whose catch-all serves an HTML app shell answers 200 with a + # page. Treating that as a verdict would be worse than a timeout. + logger.debug("hub %s did not return JSON", route) + return None + + return decoded if isinstance(decoded, dict) else None + + +def verify(key: str, path: str, tier: str, timeout: float = 3.0) -> str: + """``allow`` | ``gated`` | ``deny`` for an agent fetch carrying ``key``. + + Cached on (key fingerprint, path). Anything unexpected is ``gated``. + """ + if not key: + return "gated" + + fingerprint = _fingerprint(key) + cached = _cache_get(fingerprint, path) + if cached is not None: + return cached + + if not enabled(): + logger.debug("hub verify skipped: no CROSS_APP_WEBHOOK_SECRET") + return "gated" + + decoded = _post( + "/api/agent-key/verify", + {"key": key, "path": path, "tier": tier, "app": app_id()}, + timeout, + ) + if decoded is None: + return "gated" + + verdict = str(decoded.get("verdict") or "").strip().lower() + if verdict not in ("allow", "gated", "deny"): + logger.debug("hub verify returned an unrecognised verdict") + return "gated" + + # The hub may set the TTL; fall back to ours. allow outlives deny. + default_ttl = ALLOW_TTL_S if verdict == "allow" else DENY_TTL_S + try: + ttl = float(decoded.get("ttl", default_ttl)) + except (TypeError, ValueError): + ttl = default_ttl + _cache_put(fingerprint, path, verdict, ttl) + return verdict + + +def current_key(token: str, timeout: float = 3.0) -> Optional[str]: + """This user's current agent key, minted by the hub if needed. + + For the "copy for LLM" button: a signed-in reader gets a URL that still + works after it is pasted into an assistant. ``token`` is the browser's + Clerk session token — server-side, the ``__session`` cookie value on a + Clerk-authenticated request. The hub verifies it against Clerk's JWKS and + mints at ``scope=auth``, never admin. + + The hub 401s any caller-asserted identity (a ``user_id`` in the payload is + the forgery path — this satellite could claim to be anyone), which is why + the token travels instead: only Clerk's signature says who the reader is. + + Two rules from the hub session: call this on copy-button CLICK, never on + page render — every call is a hub round trip and each mint is recorded + hub-side; and ``None`` degrades to copying the plain URL, which is what an + anonymous reader gets anyway. None when the hub is unreachable or auth is + off. + """ + if not token or not enabled(): + return None + + decoded = _post( + "/api/agent-key/current", + {"token": token, "app": app_id()}, + timeout, + ) + if decoded is None: + return None + + key = decoded.get("key") + return key if isinstance(key, str) and key else None + + +# hub_tiers cache: (tiers, expires_at). One entry — the feed is per-app, and +# this process is one app. A failed fetch caches {} briefly so an outage costs +# one timeout per FAILURE_TTL_S, not one per request (access.check calls this +# on every non-public resolution). +TIERS_TTL_S = 900.0 +TIERS_FAILURE_TTL_S = 60.0 +_TIERS_CACHE: Tuple[Dict[str, str], float] = ({}, 0.0) + + +def clear_tiers_cache() -> None: + global _TIERS_CACHE + _TIERS_CACHE = ({}, 0.0) + + +def hub_tiers(timeout: float = 3.0) -> Dict[str, str]: + """Tiers published by the hub — the ceiling for this site's pages. + + Signed POST ``/api/page-tiers`` with ``{"app": app_id()}`` → + ``{"tiers": {path: tier}, "ttl": seconds}``, cached for the returned TTL. + Each fetch is recorded hub-side — the hub admin's "last pulled" indicator + is how a published ceiling is confirmed to have landed on this satellite. + + Every failure returns ``{}``, meaning "hub unknown", which + `page_tiers.effective_tier` resolves to the local value. The ceiling only + ever restricts, so an outage loosens nothing — and an outage that served + a ceiling a moment ago degrades to the local tier, never below it. + """ + global _TIERS_CACHE + tiers, expires_at = _TIERS_CACHE + if expires_at > time.time(): + return tiers + + if not enabled(): + # No secret -> nobody to authenticate as; don't re-check every request. + _TIERS_CACHE = ({}, time.time() + TIERS_FAILURE_TTL_S) + return {} + + decoded = _post("/api/page-tiers", {"app": app_id()}, timeout) + raw = decoded.get("tiers") if isinstance(decoded, dict) else None + if not isinstance(raw, dict): + _TIERS_CACHE = ({}, time.time() + TIERS_FAILURE_TTL_S) + return {} + + # Keep only well-formed entries; a junk value must not become a tier. + tiers = { + str(path): str(tier).strip().lower() + for path, tier in raw.items() + if isinstance(path, str) and isinstance(tier, str) + } + + try: + ttl = float(decoded.get("ttl", TIERS_TTL_S)) + except (TypeError, ValueError): + ttl = TIERS_TTL_S + _TIERS_CACHE = (tiers, time.time() + max(0.0, ttl)) + return tiers diff --git a/lib/network_directory.py b/lib/network_directory.py index f1244ab..eccea4f 100644 --- a/lib/network_directory.py +++ b/lib/network_directory.py @@ -41,25 +41,13 @@ # Only list hosts that are actually live. A directory entry pointing at a # subdomain with no site is a dead link an agent will follow once and then -# distrust the rest of the list for. muicharts.2plot.dev and -# flexlayout.2plot.dev have no docs site yet — add them in the same change -# that ships them, not before. -# UPSTREAM DIVERGENCE — the only edit this file carries, and it is temporary. -# -# pannellum.2plot.dev and emojimart.2plot.dev are commented out below because -# they are NXDOMAIN as of 2026-07-31 (verified via DNS, not just a failed -# request — email/flows returned 000 on a first curl too, but that was a Render -# free-tier cold start and they serve fine on retry). -# -# The rule this enforces is the module's own, three paragraphs up: "a directory -# entry pointing at a subdomain with no site is a dead link an agent will follow -# once and then distrust the rest of the list for." Shipping them would publish -# two dead links from a live docs site. -# -# The real fix belongs in dash-documentation-boilerplate, which is the single -# definition every satellite copies — otherwise each repo rediscovers this -# independently. Once those hosts resolve, delete this note and re-copy the file -# from the boilerplate rather than un-commenting by hand. +# distrust the rest of the list for. The full docs fleet went live on paid +# hosting 2026-08-19/20 — muicharts, flexlayout and llms joined in that +# window, and this file's temporary divergence (pannellum/emojimart +# commented out while NXDOMAIN) is resolved per its own instruction: +# re-copied from the boilerplate, the canonical definition every satellite +# syncs FROM, verbatim. Still deliberately absent until they deploy: +# excalidraw.2plot.dev and modelviewer.2plot.dev. PEERS: List[Dict[str, str]] = [ { "name": "2plot.ai", @@ -86,21 +74,36 @@ "url": "https://muischeduler.2plot.dev", "description": "MUI X Scheduler — calendars and event scheduling for Dash.", }, + { + "name": "dash-mui-charts", + "url": "https://muicharts.2plot.dev", + "description": "MUI X charts, tree views and time pickers for Dash.", + }, + { + "name": "flexlayout-dash", + "url": "https://flexlayout.2plot.dev", + "description": "IDE-style dockable, resizable and floatable window panels.", + }, + { + "name": "dash-improve-my-llms", + "url": "https://llms.2plot.dev", + "description": "The AI/LLM and SEO package every site in this network is built on.", + }, { "name": "dash-flows", "url": "https://flows.2plot.dev", "description": "Node-graph editors built on React Flow.", }, - # { - # "name": "dash-pannellum", - # "url": "https://pannellum.2plot.dev", - # "description": "360° panorama and virtual-tour viewer.", - # }, - # { - # "name": "dash-emoji-mart", - # "url": "https://emojimart.2plot.dev", - # "description": "Emoji picker component.", - # }, + { + "name": "dash-pannellum", + "url": "https://pannellum.2plot.dev", + "description": "360° panorama and virtual-tour viewer.", + }, + { + "name": "dash-emoji-mart", + "url": "https://emojimart.2plot.dev", + "description": "Emoji picker component.", + }, { "name": "dash-email", "url": "https://email.2plot.dev", @@ -108,12 +111,12 @@ }, ] +# pip-install-python.com is deliberately NOT here: the domain is retired +# network-wide (the fleet's retire-pip-install-python-domain sweep), and +# this repo's test_social_card pins its absence. A directory that keeps +# pointing agents at a retired origin re-teaches them the identity the +# network spent a release unlearning. AFFILIATED: List[Dict[str, str]] = [ - { - "name": "2plot.ai", - "url": "https://2plot.ai", - "description": "The original component documentation site.", - }, { "name": "Pirate's Bargain", "url": "https://piratesbargain.com", diff --git a/lib/page_tiers.py b/lib/page_tiers.py index 77c90f1..db2a5f1 100644 --- a/lib/page_tiers.py +++ b/lib/page_tiers.py @@ -17,9 +17,22 @@ tier: admin --- -Absent that, :data:`DEFAULT_TIER` applies — ``public``, because a -documentation site that defaults to gated is a documentation site nobody -reads. Override the default per deployment with ``PAGE_DEFAULT_TIER``. +Absent that, the default applies — ``public``, because a documentation site +that defaults to gated is a documentation site nobody reads. Override it per +deployment with ``PAGE_DEFAULT_TIER`` (or this site's older spelling +``PAGE_DEFAULT_VISIBILITY``, still accepted). + +``tier:`` is the canonical frontmatter key. ``visibility:`` is accepted as an +alias for it — the two vocabularies were always identical +(public/auth/admin/hidden), and pages/markdown.py feeds one declared value to +both this ledger and :mod:`lib.page_visibility`'s control-board rows, so the +board can never show a page a different tier from the one enforced. + +**This module is the enforcement engine.** :mod:`lib.page_visibility` still +owns the live control-board overrides and their ``/var/data`` persistence, and +:func:`lib.access.local_tier` consults those overrides ahead of what is +registered here — a board toggle must apply without a restart. What was +registered here is the declared baseline underneath them. Two rules make this safe to run before the hub exists: @@ -49,17 +62,48 @@ TIERS = ("public", "auth", "admin", "hidden") +# `PAGE_DEFAULT_VISIBILITY` is this site's own older spelling of the same +# knob and is already set on the live service (render.yaml). It is accepted as +# an ALIAS rather than migrated: dropping a variable a running deployment +# depends on is how a host silently changes posture between a git push and the +# next blueprint sync. PAGE_DEFAULT_TIER is canonical and wins when both are +# set. lib.page_visibility reads its baseline through this function too, so the +# board's rows and the enforced tier cannot disagree about the default. +_DEFAULT_TIER_ENV = ("PAGE_DEFAULT_TIER", "PAGE_DEFAULT_VISIBILITY") + + def _default_tier() -> str: - tier = (os.getenv("PAGE_DEFAULT_TIER") or "public").strip().lower() - if tier not in TIERS: - logger.warning("PAGE_DEFAULT_TIER=%r is not a tier — using 'public'", tier) - return "public" - return tier + for name in _DEFAULT_TIER_ENV: + raw = (os.getenv(name) or "").strip().lower() + if not raw: + continue + if raw not in TIERS: + logger.warning("%s=%r is not a tier — using 'public'", name, raw) + return "public" + return raw + return "public" # endpoint -> tier, populated from frontmatter as pages/markdown.py loads docs. _LOCAL_TIERS: Dict[str, str] = {} +# endpoint -> machine-surface openness, the SECOND axis. `tier` answers "who +# may use the page in a browser"; `llms_public` answers "does the machine +# twin (//llms.txt, crawler HTML, the prerender) stay open anyway". +# The split exists so the fleet can gate the interactive experience for +# humans while the 30-day crawl-demand window keeps measuring agents — and +# so the later agent flip is one env change (`LLMS_PUBLIC_DEFAULT=0`), not a +# code change. Only meaningful on `auth` pages: `public` needs no exemption, +# and `admin`/`hidden` must never leak through a machine surface. +_LOCAL_LLMS_PUBLIC: Dict[str, bool] = {} + +_FALSE_VALUES = ("0", "false", "no", "off") + + +def _default_llms_public() -> bool: + raw = (os.getenv("LLMS_PUBLIC_DEFAULT") or "").strip().lower() + return raw not in _FALSE_VALUES if raw else True + def normalize(path: str) -> str: """One canonical path form, so registration and lookup cannot disagree.""" @@ -69,8 +113,14 @@ def normalize(path: str) -> str: return path.rstrip("/") or "/" -def register(path: str, tier: Optional[str]) -> str: - """Record a page's locally declared tier. Returns the tier applied.""" +def register(path: str, tier: Optional[str], + llms_public: Optional[bool] = None) -> str: + """Record a page's locally declared tier. Returns the tier applied. + + ``llms_public`` pins the machine-surface axis for this page; ``None`` + (the overwhelmingly common case) defers to ``LLMS_PUBLIC_DEFAULT`` at + lookup time, so flipping the env flips every undeclared page at once. + """ resolved = (tier or _default_tier()).strip().lower() if resolved not in TIERS: logger.warning( @@ -79,9 +129,25 @@ def register(path: str, tier: Optional[str]) -> str: ) resolved = _default_tier() _LOCAL_TIERS[normalize(path)] = resolved + # Declarative: a registration fully describes the page, so None does not + # mean "keep whatever was pinned before" — it clears the pin and defers + # to the env default again. + if llms_public is None: + _LOCAL_LLMS_PUBLIC.pop(normalize(path), None) + else: + _LOCAL_LLMS_PUBLIC[normalize(path)] = bool(llms_public) return resolved +def get_llms_public(path: str) -> bool: + """Whether ``path``'s machine twin stays open to anonymous fetches. + + Read at verdict time, not registration time, so `LLMS_PUBLIC_DEFAULT` + governs every page that did not pin the axis in frontmatter. + """ + return _LOCAL_LLMS_PUBLIC.get(normalize(path), _default_llms_public()) + + def local_tier(path: str) -> str: return _LOCAL_TIERS.get(normalize(path), _default_tier()) diff --git a/lib/page_visibility.py b/lib/page_visibility.py index 79f3346..a7ecfbb 100644 --- a/lib/page_visibility.py +++ b/lib/page_visibility.py @@ -1,8 +1,23 @@ -"""Dynamic per-page visibility for the dash-leaflet2 documentation site. - -Four tiers, checked server-side at layout render time (i.e. on every request), -so a toggle from ``/admin/control-board`` applies immediately — no restart, no -redeploy: +"""The control board's override store — live per-page tiers, persisted. + +**Demoted, deliberately.** This module used to be the whole access system: +frontmatter defaults, verdicts, gate layouts and the llms.txt bridge. The +network stack now owns enforcement — :mod:`lib.page_tiers` holds the declared +baseline, :mod:`lib.access` resolves the verdict (adding the hub ceiling, the +``?key=`` agent lane and the ``llms_public`` axis), and +:mod:`lib.gate_layouts` renders the interactive gate. What stayed here is the +half that had no counterpart in the network stack and is working production +UX: the ``/admin/control-board`` model, its live toggles, and their JSON +persistence. + +So the reading order is: an override written here beats everything local (see +:func:`lib.access.local_tier`), because the point of the board is that a +toggle applies on the next render with no restart and no redeploy. Underneath +it sits the frontmatter registration in :mod:`lib.page_tiers`, and above both +sits the hub's ceiling, which only ever restricts. + +The four tiers are the network's, unchanged — they were always the same +vocabulary: - ``public`` — anyone - ``auth`` — any signed-in Clerk user @@ -11,23 +26,29 @@ Where the defaults come from ---------------------------- -Each ``docs//.md`` may declare ``visibility:`` in its frontmatter. -Absent that, :data:`DEFAULT_TIER` applies — ``public``, because this is a -component library's own documentation and the whole point is that people can -read it. Override the baseline per deployment with ``PAGE_DEFAULT_VISIBILITY``. +Each ``docs//.md`` declares ``tier:`` (or its older spelling +``visibility:``) in frontmatter, and ``pages/markdown.py`` feeds that one value +to both this store's board rows and ``lib.page_tiers``. Absent a declaration +the baseline comes from :func:`lib.page_tiers._default_tier`, i.e. +``PAGE_DEFAULT_TIER`` / ``PAGE_DEFAULT_VISIBILITY`` — read through that +function rather than re-derived here, so the board can never display a +different default from the one enforced. Persistence ----------- -Overrides live in ``page_visibility.json`` at the project root. This is a -deliberate simplification of the pip-docs+ original, which mirrors into -Postgres: a docs satellite has one writer and no shared database, so a JSON -file is the whole story. On an ephemeral container filesystem (Render's free -tier) an override survives until the next deploy — set -``PAGE_VISIBILITY_FILE`` to a path on a persistent disk if they must outlive it. - -Auth degrades gracefully: with the Clerk env keys absent (local dev without -credentials) every tier except ``hidden`` falls open to public and one warning -is logged. The site must never brick because a dev forgot a key. +Overrides live in ``page_visibility.json`` at the project root, or wherever +``PAGE_VISIBILITY_FILE`` points — in production that is the persistent +``/var/data`` disk, so a board toggle outlives a deploy. This is a deliberate +simplification of the pip-docs+ original, which mirrors into Postgres: a docs +satellite has one writer and no shared database, so a JSON file is the whole +story. + +What this module no longer does: resolve access (that is +``lib.access.resolve_page_access`` for browsers and ``lib.access.check`` for +machine surfaces, and their fail postures differ from the old +``resolve_access``'s — admin fails CLOSED now), and wrap page layouts (that is +``lib.gate_layouts.gated_layout``). The gate cards below are kept for +``/admin/control-board``, which gates itself. """ from __future__ import annotations @@ -37,15 +58,26 @@ import threading from pathlib import Path -from lib.auth import clerk_enabled, current_user, is_admin_user +from lib import page_tiers +from lib.auth import clerk_enabled, is_admin_user logger = logging.getLogger(__name__) -TIERS = ("public", "auth", "admin", "hidden") -DEFAULT_TIER = (os.environ.get("PAGE_DEFAULT_VISIBILITY") or "public").strip().lower() -if DEFAULT_TIER not in TIERS: - logger.warning("PAGE_DEFAULT_VISIBILITY=%r is not a tier — using 'public'", DEFAULT_TIER) - DEFAULT_TIER = "public" +TIERS = page_tiers.TIERS + + +def default_tier() -> str: + """The baseline for a page that declares nothing. + + Delegated to :mod:`lib.page_tiers` rather than re-read here. It used to be + a module constant computed from ``PAGE_DEFAULT_VISIBILITY`` at import time, + which meant two things this pilot cannot afford: the board's rows and the + enforced tier resolved the default independently (so they could disagree + once ``PAGE_DEFAULT_TIER`` was introduced), and a value read at import + could not be flipped by a test or a reload. + """ + return page_tiers._default_tier() + _STORE_PATH = Path(os.environ.get("PAGE_VISIBILITY_FILE") or "page_visibility.json") _lock = threading.Lock() @@ -55,7 +87,6 @@ # the control board wrote and always wins. _defaults: dict[str, dict] = {} _overrides: dict[str, dict] = {} -_warned_no_clerk = False def _load_overrides() -> None: @@ -85,19 +116,36 @@ def _persist() -> None: # --------------------------------------------------------------------------- def register_default(path: str, name: str, visibility: str | None = None, - llms_public: bool = True) -> None: - """Called once per page at registration time (frontmatter defaults).""" - tier = (visibility or DEFAULT_TIER).strip().lower() + llms_public: bool | None = None) -> None: + """Called once per page at registration time (frontmatter defaults). + + ``llms_public=None`` means "this page did not pin the axis" and is stored + as None rather than resolved here, so ``LLMS_PUBLIC_DEFAULT`` keeps + governing it — including in the board's own switches, which then show what + the site is actually doing rather than what it was doing at boot. + """ + fallback = default_tier() + tier = (visibility or fallback).strip().lower() if tier not in TIERS: - logger.warning("Page %s: unknown visibility %r — using %r", path, tier, DEFAULT_TIER) - tier = DEFAULT_TIER + logger.warning("Page %s: unknown visibility %r — using %r", path, tier, fallback) + tier = fallback _defaults[path] = {"visibility": tier, "llms_public": llms_public, "name": name} def get_settings(path: str) -> dict: - base = _defaults.get(path, {"visibility": "public", "llms_public": True, "name": path}) + """Baseline + override, with the unpinned machine axis resolved live. + + The board's model, not the resolver's — lib.access reads the override + accessors below instead, because a merged value cannot say whether an + operator chose it. + """ + base = _defaults.get(path) + if base is None: + base = {"visibility": default_tier(), "llms_public": None, "name": path} merged = dict(base) merged.update(_overrides.get(path, {})) + if merged.get("llms_public") is None: + merged["llms_public"] = page_tiers.get_llms_public(path) return merged @@ -130,49 +178,83 @@ def controllable_pages() -> dict[str, dict]: return {path: get_settings(path) for path in sorted(_defaults)} +def pin_default(path: str, visibility: str) -> None: + """Force a page's baseline tier after it registered. Board rows follow. + + run.py pins the funnel's front door public so ``PAGE_DEFAULT_TIER=auth`` + cannot gate it. That pin has to land on BOTH ledgers or the board would + display a tier the site does not enforce — the exact drift this pilot + unified the two systems to remove. An operator can still override the pin + from the board; that is the point of an override. + """ + if visibility not in TIERS: + raise ValueError(f"unknown tier {visibility!r}") + entry = _defaults.get(path) + if entry is None: + return + entry["visibility"] = visibility + + # --------------------------------------------------------------------------- -# Access resolution +# Overrides, read by lib.access # --------------------------------------------------------------------------- +# `get_settings` merges defaults and overrides, which is right for the board's +# table and wrong for the resolver: a merged read cannot tell "the operator +# set this page to public" from "nobody ever touched it". The resolver needs +# that difference, because an untouched page has to fall through to the +# frontmatter registration in lib.page_tiers rather than to this store's +# unknown-path default. Hence two accessors that answer None for "no override". -def resolve_access(path: str) -> str: - """Verdict for the current request: 'allow' | 'sign_in' | 'forbidden' | 'hidden'.""" - global _warned_no_clerk - tier = get_visibility(path) - if tier == "hidden": - return "hidden" - if tier == "public": - return "allow" - if not clerk_enabled(): - if not _warned_no_clerk: - logger.warning( - "Clerk env keys missing — visibility tier %r falls open to public. " - "Set CLERK_SECRET_KEY / CLERK_PUBLISHABLE_KEY in production.", tier, - ) - _warned_no_clerk = True - return "allow" - user = current_user() - if user is None: - return "sign_in" - if tier == "admin" and not is_admin_user(user): - return "forbidden" - return "allow" + +def tier_override(path: str) -> str | None: + """The tier the control board wrote for ``path``, or None.""" + 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.""" + value = (_overrides.get(path) or {}).get("llms_public") + return None if value is None else bool(value) # --------------------------------------------------------------------------- # llms.txt bridge # --------------------------------------------------------------------------- # dash-improve-my-llms stores each page's prose as a plain string in its own -# registry and reads it at request time, so a control-board toggle only takes -# effect if we push the new verdict back into that registry. We therefore keep -# the real prose here and re-register either it or a stub whenever the tier or -# the llms_public switch changes. - -_llms_docs: dict[str, tuple[str, str, str]] = {} # path -> (name, description, doc) - - -def register_llms_doc(path: str, name: str, description: str, doc: str) -> None: - """Record a page's llms.txt prose and push the current verdict.""" - _llms_docs[path] = (name, description, doc) +# registry and reads it at request time. This module used to swap that string +# for a stub whenever a page's verdict said "not public" — a second, parallel +# enforcement path. +# +# lib.access is the enforcement engine now, and it is strictly better at this +# job: the package asks it per request, so the answer can honour the hub +# ceiling, an agent's `?key=`, and the llms_public axis, and an unauthorised +# fetch gets `gate_doc()` (which names the page and says how to unlock it) +# instead of a bare stub. Registering the real prose once and letting the +# check decide is therefore the whole design — a stub swapped in underneath a +# working check would only mean a reader who IS authorised gets the stub. +# +# The stub swap survives as the fallback for one case: a boot where the +# package could not be handed a policy at all (`lib.access.configured()` is +# False). Then nothing else is standing between a hidden page and its prose. + +# path -> {"name", "description", "doc", "extra"}. `extra` is whatever else +# the page declares for dash-improve-my-llms — `lastmod`, `image_url`, +# `schema_type`. It has to be REMEMBERED rather than passed once, because +# `apply_llms_state` re-registers the whole record on every control-board +# toggle, and register_page_metadata MERGES: a re-registration that omitted +# these would leave the earlier values in place today and silently drop them +# the day the package's merge semantics change. Remembering them is the +# version of this that cannot rot. +_llms_docs: dict[str, dict] = {} + + +def register_llms_doc(path: str, name: str, description: str, doc: str, + **extra) -> None: + """Record a page's llms.txt prose (and its metadata) and push it.""" + _llms_docs[path] = { + "name": name, "description": description, "doc": doc, "extra": extra, + } apply_llms_state(path) @@ -199,28 +281,52 @@ def published_name(path: str, name: str) -> str: def apply_llms_state(path: str) -> None: - """Re-register this page's llms.txt body to match the current verdict.""" + """Register this page's llms.txt body. Real prose whenever a check is wired. + + Still called on every control-board toggle, and still worth calling: it is + what re-asserts :func:`published_name` for "/" (see that docstring — a + board flip of the home page would otherwise republish the site's identity + as "Home"). + """ entry = _llms_docs.get(path) if entry is None: return - name, description, doc = entry try: from dash_improve_my_llms import register_page_metadata except Exception: # optional dependency — nothing to sync return - name = published_name(path, name) - body = doc if llms_accessible(path) else ( - f"# {name}\n\n> This page is not publicly available.\n" + name = published_name(path, entry["name"]) + body = entry["doc"] + if not _enforcement_wired() and not llms_accessible(path): + body = f"# {name}\n\n> This page is not publicly available.\n" + register_page_metadata( + path=path, + name=name, + description=entry["description"], + llms_doc=body, + **entry["extra"], ) - register_page_metadata(path=path, name=name, description=description, llms_doc=body) + + +def _enforcement_wired() -> bool: + """Whether lib.access handed the package a policy. Imported lazily — + lib.access imports this module, so a top-level import would be a cycle.""" + try: + from lib import access + + return access.configured() + except Exception: + return False def llms_accessible(path: str) -> bool: - """Whether ``//llms.txt`` may serve this page's content. + """The legacy machine-surface verdict — the degraded-boot fallback only. - llms.txt intentionally bypasses the sign-in gate when ``llms_public`` is on - — AI/SEO friendliness is the site's premise. But ``hidden`` pages are always - excluded, and ``admin`` pages are never served to anonymous LLM traffic. + :func:`lib.access.check` is the real answer; this stays for the case where + no policy could be wired at all, so a hidden or admin page still cannot + publish prose on a boot with the package misconfigured. It knows nothing + about the hub ceiling or `?key=`, which is exactly why it is not the + engine any more. """ tier = get_visibility(path) if tier == "hidden": @@ -231,8 +337,17 @@ def llms_accessible(path: str) -> bool: # --------------------------------------------------------------------------- -# Gate layouts +# Gate layouts — the CONTROL BOARD's cards only # --------------------------------------------------------------------------- +# Docs pages moved to lib/gate_layouts.py, whose sign-in card carries the +# `#auth-gate-*` ids that assets/auth_gate.js handles. These three stay +# because /admin/control-board gates itself in `pages/control_board.layout` +# and needs something to render; one admin page is not worth a second +# dependency on the docs funnel's copy. +# +# Note the button here is `#clerk-login-button`, handled by lib/auth.py's +# capture-phase delegation. Deliberately the OTHER selector: the two handlers +# must stay disjoint or a single click runs both. def _card(icon: str, color: str, title: str, body: str, extra=None): import dash_mantine_components as dmc @@ -254,11 +369,18 @@ def _card(icon: str, color: str, title: str, body: str, extra=None): def sign_in_layout(page_name: str, path: str | None = None): - """Sign-in card for a visitor hitting an ``auth``-tier page. - - The buttons carry the ids dash-clerk-auth's satellite click-interceptor - looks for, so sign-in redirects to the 2plot.ai primary and lands the user - back on this page. + """Sign-in card for a signed-out visitor. Used by the control board. + + The primary button carries `#clerk-login-button`, the id lib/auth.py's + satellite click-interceptor looks for, so sign-in redirects to the + 2plot.ai primary and lands the user back on this page. + + The secondary "Sign in" anchor still points straight at + ``CLERK_SIGN_IN_URL`` with no ``returnTo``, which strands the visitor on + the primary after they authenticate. lib/gate_layouts.sign_in_layout is + the fixed version and is what every docs page renders; this copy is left + as-is because an administrator who lands here knows the board's URL. Fix + it here too if this card ever gets a second caller. """ import dash_mantine_components as dmc from dash_iconify import DashIconify @@ -325,26 +447,3 @@ def hidden_layout(): "tabler:eye-off", "gray", "404 — Page not available", "This page is not currently published.", ) - - -def gated_layout(path: str, page_name: str, build_layout): - """Wrap a page layout in the dynamic visibility gate. - - ``build_layout`` is a prebuilt component tree (or a zero-arg callable - returning one). The returned function becomes the Dash page layout, so the - check runs on every render and control-board toggles apply live. - - ``**kwargs`` is required: Dash Pages forwards query params — including - Clerk's ``?__clerk_handshake=`` — into layout callables. - """ - def layout(**kwargs): - verdict = resolve_access(path) - if verdict == "hidden": - return hidden_layout() - if verdict == "sign_in": - return sign_in_layout(page_name, path) - if verdict == "forbidden": - return forbidden_layout(page_name) - return build_layout() if callable(build_layout) else build_layout - - return layout diff --git a/lib/satellite_reporter.py b/lib/satellite_reporter.py index 95e75f0..c8aedad 100644 --- a/lib/satellite_reporter.py +++ b/lib/satellite_reporter.py @@ -26,14 +26,27 @@ whatever has accumulated since the restart — point ``TRAFFIC_ANALYTICS_FILE`` at mounted storage to avoid it. +A second, lighter thread posts a **presence ping** — ``{app, active}`` to +``/api/satellite/active`` roughly every minute — so the hub's board can show +"who is on this satellite right now" without waiting for a rollup. Presence +is display-only and ephemeral by contract (the hub keeps it in memory with a +~3-minute TTL and never writes it to the event log); the daily rollup stays +the single source of the board's daily numbers. Every presence failure is +swallowed silently: a hub that predates the endpoint 404s, and a failed ping +is not an error worth waking anyone for. + Env: CROSS_APP_WEBHOOK_SECRET shared HMAC secret (required — no secret, no reporting; the app runs on unaffected) SATELLITE_APP_KEY network directory key for this app (default "leaflet", this app's own directory key) - SATELLITE_TRAFFIC_URL override the hub endpoint (default 2plot.ai) + SATELLITE_TRAFFIC_URL override the hub endpoint (default 2plot.ai); + the presence URL derives from it SATELLITE_REPORT_INTERVAL_S seconds between reports (default 3600) SATELLITE_REPORT_DELAY_S delay before the first report (default 90) + SATELLITE_PRESENCE_INTERVAL_S seconds between presence pings (default + 60, floor 30 per the hub contract; 0 disables) + SATELLITE_PRESENCE_URL override the presence endpoint """ from __future__ import annotations @@ -58,6 +71,9 @@ DEFAULT_ENDPOINT = "https://2plot.ai/api/satellite/traffic" DEFAULT_INTERVAL_S = 3600 +PRESENCE_DEFAULT_INTERVAL_S = 60 +# The hub contract's floor: "do not post faster than every 30s". +PRESENCE_FLOOR_S = 30 # Re-post yesterday during the first hours of a new day so its final, # post-last-report hits are included. CLOSEOUT_HOUR = 3 @@ -67,6 +83,17 @@ def endpoint() -> str: return os.getenv("SATELLITE_TRAFFIC_URL") or DEFAULT_ENDPOINT +def presence_endpoint() -> str: + """Derived from the traffic endpoint so one URL override retargets both.""" + override = os.getenv("SATELLITE_PRESENCE_URL") + if override: + return override + base = endpoint() + if base.endswith("/traffic"): + return base[: -len("/traffic")] + "/active" + return base.rstrip("/") + "/active" + + def app_key() -> str: """This app's key in the hub's network directory — "leaflet". @@ -96,11 +123,24 @@ def _interval() -> int: return DEFAULT_INTERVAL_S +def _presence_interval() -> int: + """Seconds between presence pings; 0 disables the thread entirely.""" + try: + raw = int(os.getenv("SATELLITE_PRESENCE_INTERVAL_S", + PRESENCE_DEFAULT_INTERVAL_S)) + except ValueError: + return PRESENCE_DEFAULT_INTERVAL_S + if raw <= 0: + return 0 + return max(PRESENCE_FLOOR_S, raw) + + # ---------------------------------------------------------------- transport -- -def post_rollup(payload: dict, secret: str | None = None, timeout: float = 10.0): - """Sign and POST one rollup. Returns ``(ok, detail)``; never raises.""" +def _post_signed(url: str, payload: dict, ua_label: str, + secret: str | None = None, timeout: float = 10.0): + """Sign and POST one payload. Returns ``(ok, detail)``; never raises.""" secret = secret or _secret() if not secret: return False, "no CROSS_APP_WEBHOOK_SECRET" @@ -114,16 +154,16 @@ def post_rollup(payload: dict, secret: str | None = None, timeout: float = 10.0) hashlib.sha256).hexdigest() try: r = requests.post( - endpoint(), data=body, timeout=timeout, + url, data=body, timeout=timeout, headers={"Content-Type": "application/json", "X-AI-Canvas-Timestamp": ts, "X-AI-Canvas-Signature": sig, # The internal-traffic contract's outbound half: without - # this the hourly rollup arrives at 2plot.ai as + # this the POST arrives at 2plot.ai as # `python-requests/2.x` and the hub counts its own - # analytics pipeline as a bot visit, once per satellite - # per hour, forever. - "User-Agent": internal_ua("traffic-reporter")}, + # analytics pipeline as a bot visit, on every report, + # forever. + "User-Agent": internal_ua(ua_label)}, ) except Exception as e: return False, f"request failed: {e!r}" @@ -132,6 +172,12 @@ def post_rollup(payload: dict, secret: str | None = None, timeout: float = 10.0) return True, r.text[:200] +def post_rollup(payload: dict, secret: str | None = None, timeout: float = 10.0): + """Sign and POST one rollup. Returns ``(ok, detail)``; never raises.""" + return _post_signed(endpoint(), payload, "traffic-reporter", + secret=secret, timeout=timeout) + + # -------------------------------------------------------------------- lease -- @@ -141,7 +187,13 @@ def _lease_path() -> Path: return analytics_path().with_name(".satellite_report.lease") -def _claim(interval: int) -> bool: +def _presence_lease_path() -> Path: + from lib.analytics_tracker import analytics_path + + return analytics_path().with_name(".satellite_presence.lease") + + +def _claim(interval: int, path: Path | None = None) -> bool: """True for the one worker that should report this interval. The lease file holds the epoch of the last report. Whoever takes the lock @@ -150,7 +202,7 @@ def _claim(interval: int) -> bool: prevents most duplicates, and duplicates are harmless anyway (the hub overwrites on (app, date)). """ - path = _lease_path() + path = path or _lease_path() try: fh = open(path, "a+") except OSError: @@ -233,6 +285,51 @@ def _loop(interval: int, first_delay: float): time.sleep(max(60, interval / 4)) +# ----------------------------------------------------------------- presence -- + + +def build_presence_payload(app: str | None = None) -> dict: + """``{app, active}`` — distinct human visitors inside the session window. + + The exact mirror of the hub's own "active now" count (its board derives + the same number from its local sessions), honouring the one-measurement + rule: same ledger, same session gap, same bot exclusion. Presence never + carries hits/pages — those stay the rollup's job. + """ + from lib.analytics_tracker import tracker + from lib.traffic_rollup import SESSION_GAP_MIN, load_visits + + tracker.flush() + cutoff = datetime.now() - timedelta(minutes=SESSION_GAP_MIN) + active = { + v["vkey"] + for v in load_visits() + if v.get("device_type") != "bot" and v["dt"] >= cutoff + } + return {"app": app or app_key(), "active": len(active)} + + +def _presence_loop(interval: int): + # Short first delay: presence is the board's "it's alive" signal, so it + # should appear well before the first rollup (which waits ~90s + build). + time.sleep(20) + while True: + try: + if _claim(interval, path=_presence_lease_path()): + payload = build_presence_payload() + ok, detail = _post_signed(presence_endpoint(), payload, + "presence-beacon", timeout=5.0) + if not ok: + # Silently, by contract: a hub that predates /active 404s + # here, and a missed ping self-heals on the next one. + logger.debug("[satellite-presence] %s", detail) + except Exception: + logger.debug("[satellite-presence] cycle failed", exc_info=True) + # Wake at half the interval so the lease can move between workers + # without the board seeing a gap longer than one TTL. + time.sleep(max(15, interval / 2)) + + def start_reporter() -> bool: """Start the background reporter. No-op (and says so) without a secret.""" if not _secret(): @@ -248,6 +345,13 @@ def start_reporter() -> bool: name="satellite-traffic-reporter", daemon=True).start() print(f"[satellite-traffic] reporting app='{app_key()}' to {endpoint()} " f"every {interval}s") + + presence_interval = _presence_interval() + if presence_interval: + threading.Thread(target=_presence_loop, args=(presence_interval,), + name="satellite-presence-beacon", daemon=True).start() + print(f"[satellite-presence] pinging {presence_endpoint()} " + f"every {presence_interval}s (0 disables)") return True diff --git a/pages/markdown.py b/pages/markdown.py index b755a33..3076fe6 100644 --- a/pages/markdown.py +++ b/pages/markdown.py @@ -7,7 +7,7 @@ import dash_mantine_components as dmc import frontmatter from markdown2dash import Admonition, BlockExec, Divider, Image, create_parser -from pydantic import BaseModel +from pydantic import BaseModel, field_validator from lib.ad_client import inject_ad_into_aside from lib.constants import OG_IMAGE_URL, PAGE_TITLE_PREFIX, NAME_CONTENT_MAP @@ -16,7 +16,8 @@ from lib.directives.llms_copy import LlmsCopy from lib.directives.source import SC from lib.directives.toc import TOC -from lib.page_visibility import gated_layout, register_default, register_llms_doc +from lib.gate_layouts import gated_layout +from lib.page_visibility import register_default, register_llms_doc from lib.versions import substitute_versions logger = logging.getLogger(__name__) @@ -37,17 +38,62 @@ class Meta(BaseModel): package: str = "dash-leaflet2" category: Optional[str] = None icon: Optional[str] = None - # Baseline access tier for this page. Omitted → lib.page_visibility's - # DEFAULT_TIER ("public"). The admin control board's overrides always win. - visibility: Optional[str] = None - # The NETWORK's tier vocabulary (public | auth | admin | hidden), recorded - # in lib/page_tiers.py — the ledger the hub's page-tier ceilings compare - # against. Distinct from `visibility` above, which is this repo's own - # control-board gate; the two coexist until the fleet unifies them. + # Baseline access tier: public | auth | admin | hidden. Omitted → the + # deployment default (PAGE_DEFAULT_TIER / PAGE_DEFAULT_VISIBILITY). The + # control board's overrides always win over whatever is declared here. + # + # `tier` is canonical — it is the network's word, and the ledger the hub's + # ceilings compare against. `visibility` is this repo's older spelling of + # the SAME four values and is accepted as an alias; see `_declared_tier`. tier: Optional[str] = None - # Whether this page's prose may be served at //llms.txt to anonymous - # and AI traffic. Also live-toggleable from the control board. - llms_public: bool = True + visibility: Optional[str] = None + # Whether this page's prose may be served at //llms.txt (and in the + # crawler document and the prerender) to anonymous and AI traffic, even + # when `tier` gates the interactive page. Omitted → LLMS_PUBLIC_DEFAULT, + # so the fleet-wide agent flip is one env change rather than 28 edits. + # Also live-toggleable from the control board. + llms_public: Optional[bool] = None + # Sitemap , YYYY-MM-DD, emitted VERBATIM by dash-improve-my-llms + # >= 2.6.0 — and omitted entirely when absent. Truth or silence: set it + # when the page's prose genuinely changes, in the SAME commit as the + # prose. Never script it from file mtimes, which reset on every Docker + # build and would re-invent the every-page-changed-today sitemap that + # 2.6.0 exists to end. The initial values here are each page's real + # `git log -1 --format=%cs` date. + # + # The validator is not optional: YAML parses a bare `lastmod: 2026-07-28` + # into a datetime.date before pydantic ever sees it, and Optional[str] + # rejects that — every page would fail Meta validation at import. + lastmod: Optional[str] = None + + @field_validator("lastmod", mode="before") + @classmethod + def _lastmod_to_iso(cls, value): + return value.isoformat() if hasattr(value, "isoformat") else value + + +def _declared_tier(metadata: "Meta", source: str) -> Optional[str]: + """The one tier this page declares, from `tier:` or the `visibility:` alias. + + ONE value feeds both ledgers — lib.page_tiers (what the hub's ceiling + compares against and what lib.access enforces) and lib.page_visibility + (the control board's row). They were two independent frontmatter keys + until this pass, which meant a page could declare `visibility: auth` and + be enforced as public, with nothing to show for it but a board row that + lied. + + A page that sets both to the same value is fine and silent. A page that + sets them to DIFFERENT values is a bug in the document, so it warns and + `tier:` wins — the canonical key beats the alias, and a warning beats + guessing. + """ + if metadata.tier and metadata.visibility and metadata.tier != metadata.visibility: + logger.warning( + "%s declares tier=%r and visibility=%r — they are the same field. " + "Using tier=%r; drop the `visibility:` line.", + source, metadata.tier, metadata.visibility, metadata.tier, + ) + return metadata.tier or metadata.visibility _SOURCE_DIRECTIVE = re.compile(r'^\.\. source::(.+?)$', re.MULTILINE) @@ -137,17 +183,31 @@ def _build_llms_doc(name: str, description: str, expanded_markdown: str, path: s # no ad, and the call is fail-silent — an ad must never break registration. inject_ad_into_aside(layout, metadata.endpoint) - # Baseline tier from frontmatter; the control board overrides it live. + # ONE declared value, TWO ledgers. `declared` is None for a page that + # says nothing, which both registries read as "use the deployment + # default" — so PAGE_DEFAULT_TIER moves every undeclared page at once, + # which is what makes the dark launch and its flip a single env change. + declared = _declared_tier(metadata, str(file)) + + # The control board's row. Overrides written here win at resolution time + # (lib.access.local_tier), which is what makes a toggle apply live. register_default( metadata.endpoint, metadata.name, - visibility=metadata.visibility, + visibility=declared, llms_public=metadata.llms_public, ) - # register with dash. The layout goes through the visibility gate, which - # re-checks access on EVERY render — that is what makes a control-board - # toggle apply without a restart. + # The network ledger: what the hub's page-tier ceiling compares against + # and what lib.access enforces underneath any override. Registered BEFORE + # dash.register_page so no request can reach the layout ahead of the tier + # that is meant to gate it. + page_tiers.register(metadata.endpoint, declared, llms_public=metadata.llms_public) + + # register with dash. The layout goes through lib.gate_layouts, which + # re-resolves access on EVERY render — that is what makes a control-board + # toggle, a hub ceiling change and an env flip all apply without a + # restart, and what puts the sign-in card in front of a gated page. dash.register_page( metadata.name, metadata.endpoint, @@ -165,17 +225,39 @@ def _build_llms_doc(name: str, description: str, expanded_markdown: str, path: s icon=metadata.icon, ) - # Record the declared network tier before the prose is registered, so a - # gate can never be applied later than the content it is meant to gate. - page_tiers.register(metadata.endpoint, metadata.tier) - # Feed the expanded markdown into dash-improve-my-llms so //llms.txt - # serves the directive-expanded prose. Routed through page_visibility so - # the control board's llms.txt switch can swap the body for a stub. + # serves the directive-expanded prose. Still routed through + # page_visibility, which now registers the REAL prose and lets + # lib.access.check decide per request whether the fetch may have it — see + # that module's llms.txt-bridge comment for why the old stub swap went. + # + # The kwargs below are the CRAWLER document's record, and they must not + # describe the page differently from the `dash.register_page` call above: + # that call is what a browser reads, this one is what Googlebot reads, and + # a site whose two heads disagree is the shape of every SEO defect the + # network measured in 2026-08. Content may differ between the two + # documents; identity may not. Before this passed anything, the crawler + # document carried no og:image at all (browsers got one) and typed every + # documentation page as a bare schema.org WebPage. expanded = _expand_source_directives(content) register_llms_doc( metadata.endpoint, metadata.name, metadata.description, _build_llms_doc(metadata.name, metadata.description, expanded, metadata.endpoint), + # Same string dash.register_page got. Measured, not assumed: it does + # NOT double the brand — the package composes its own + # " · " only when no title is declared. + title=PAGE_TITLE_PREFIX + metadata.name, + image_url=OG_IMAGE_URL, + # TechArticle, not the package's WebPage default: every page here + # documents software, and "WebPage" tells a crawler nothing it could + # not already see. run.py declares the home page separately. + schema_type="TechArticle", + # None omits the sitemap tag on >= 2.6.0 (the floor in + # requirements.txt), which is the truth-or-silence half. `lastmod` + # only exists from 2.6.0; below the floor it is at best ignored, and + # the sitemap goes back to stamping every page "today" — which is why + # the floor is a floor and not a preference. + lastmod=metadata.lastmod, ) diff --git a/render.yaml b/render.yaml index b24c78d..80f2085 100644 --- a/render.yaml +++ b/render.yaml @@ -79,6 +79,13 @@ services: # PULSE_POLL_TARGETS=...,leaflet=https://leaflet.2plot.dev/healthz - key: CROSS_APP_WEBHOOK_SECRET sync: false + # 15 minutes, not the code default of hourly: the fleet is on paid + # instances now and the hub board reads near-real-time. The live + # "active now" number rides the separate presence beacon + # (SATELLITE_PRESENCE_INTERVAL_S, default 60s, 0 disables) — this knob + # only paces the daily rollup. + - key: SATELLITE_REPORT_INTERVAL_S + value: "900" # The series name this app reports under on 2plot.ai/traffic. "leaflet" # is this app's 2plot network-directory key — the hub labels and colours # the series from that directory. (The Gen-1 spelling SATELLITE_APP_ID @@ -164,17 +171,57 @@ services: - key: ADMIN_EMAILS sync: false - # --- Page visibility (lib/page_visibility.py) -------------------- - # Baseline tier for pages whose frontmatter does not set one. "public" - # keeps the component documentation readable without an account; the - # control board can still flip any individual page live. + # --- The interactive gate (lib/access.py) ------------------------ + # Baseline tier for pages whose frontmatter does not set one: + # public | auth | admin | hidden. + # + # THIS IS THE FLIP. The gate ships dark — the code is live and every + # verdict answers `allow` while this reads `public`. Changing it to + # `auth` here, in the dashboard, gates every documentation page behind + # a sign-in card on the next request. No redeploy, and the rollback is + # the same edit in reverse (see DEPLOYMENT.md's rollback rehearsal). + # + # `/`, `/llms-small.txt` and `/llms-full.txt` are pinned public in + # run.py and are NOT moved by this — the funnel's front door and the + # corpus documents are deliberate settings, never an ambient default. + # + # PAGE_DEFAULT_TIER is the network-standard name and the canonical one. + # PAGE_DEFAULT_VISIBILITY is this service's older spelling of the SAME + # knob and is still read (lib/page_tiers._DEFAULT_TIER_ENV) — kept + # because dropping a variable a running service depends on is how a + # host changes posture between a push and the next blueprint sync. Set + # ONE of them; if both are set the canonical name wins. + - key: PAGE_DEFAULT_TIER + value: public - key: PAGE_DEFAULT_VISIBILITY value: public - # Overrides are written here — on the analytics disk, so control-board - # changes now OUTLIVE a deploy (they used to live on the ephemeral - # container filesystem and reset with every one). + # The SECOND axis, deliberately UNSET for the 30-day window. + # + # `tier` says who may use a page in a browser; this says whether its + # machine twin — //llms.txt, the crawler document, the prerender + # — stays open anyway. Unset means open, which is the window posture: + # humans meet the sign-in card while the crawl-demand dataset keeps + # accruing. The phase-4 agent flip is adding this as `0`, once, which + # closes every page that did not pin the axis in frontmatter. + # + # Do not add it before then. A page can still be closed individually + # from the control board or with `llms_public: false` in frontmatter, + # which is how the pilot's canary leak check is run. + # - key: LLMS_PUBLIC_DEFAULT + # value: "0" + # + # Control-board overrides are written here — on the analytics disk, so + # they OUTLIVE a deploy (they used to live on the ephemeral container + # filesystem and reset with every one). An override written here beats + # both the frontmatter tier and PAGE_DEFAULT_TIER; only the hub's + # ceiling outranks it. - key: PAGE_VISIBILITY_FILE value: /var/data/page_visibility.json + # The hub's page-tier ceiling and the ?key= agent lane both need this + # (lib/hub_client.py). It is already set above for the traffic + # reporter — the same secret authenticates this satellite to + # /api/page-tiers and /api/agent-key/*. Without it the gate still + # works locally; only the network ceiling and agent keys go dark. # --- Optional ---------------------------------------------------- # MUI X Pro licence for the TreeViewPro tile browser on diff --git a/requirements.txt b/requirements.txt index 3c9882c..0594a1f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -46,20 +46,34 @@ docutils!=0.21 jsonpath>=0.82,<0.83 mistune>=3.0.1,<4.0.0 python-frontmatter -# 2.5.1 is the 2plot network standard floor (raised from 2.3.4 in the fleet's -# 1.3.x instrumentation sync), not a nice-to-have: it is the Tier-B SEO -# standard — `configure_seo`, the crawler carrying the site name, -# per-page title/image_url/schema_type reaching the crawler document, and -# /favicon.ico answered with a redirect instead of the app shell — plus the -# tiered corpus documents (/llms-small.txt, /llms-full.txt, >= 2.4.0) this -# repo now registers tiers for in run.py. -# -# Earlier steps on the same line: 2.3.4's `resolve_site_title` (stops a -# generic home-page name becoming the published identity), 2.2.0's merge -# semantics (the assign-vs-merge home-page stub), 2.3.3's directive-leakage -# fix and OAI/Anthropic robots taxonomy. The [flask] extra is just -# flask>=2.0, already present via Dash. -dash-improve-my-llms[flask]>=2.5.1 +# 2.6.0 is LOAD-BEARING here, not a routine bump: pages/markdown.py passes +# `lastmod=` unconditionally, and that argument only exists from 2.6.0. +# Two things arrive with it. +# +# SITEMAP HONESTY. Before 2.6.0 every <lastmod> was "today", regenerated on +# every crawl — a sitemap that claims 27 pages changed daily is a sitemap +# search engines learn to discard wholesale. 2.6.0 emits the frontmatter +# `lastmod:` VERBATIM and omits the tag entirely when a page declares none: +# truth or silence. Never script those dates from file mtimes, which reset on +# every Docker build and would re-invent exactly the lie this ends. +# +# ICON AUTODISCOVERY. This app never calls `configure_seo`, so until 2.6.0 +# Googlebot got a crawler document with ZERO icons while browsers got six +# from templates/index.html. 2.6.0 scans the assets tree — `favicon_io/` is +# one of its covered directory names — and this site's own art becomes its +# crawler-head identity with no declaration. tests/test_seo_icons.py pins +# both contracts; discovery WARNS rather than failing when it finds nothing, +# so a renamed favicon directory would otherwise be silent. +# +# Earlier steps on the same line, all still required: 2.5.1's Tier-B SEO +# standard (the crawler <title> carrying the site name, /favicon.ico answered +# with a redirect instead of the app shell), the tiered corpus documents +# (/llms-small.txt, /llms-full.txt, >= 2.4.0) run.py registers tiers for, +# 2.3.4's `resolve_site_title`, 2.2.0's merge semantics, and 2.3.3's +# directive-leakage fix. `configure_access` (2.3) is what lib/access.py +# hands the gate policy to. The [flask] extra is just flask>=2.0, already +# present via Dash. +dash-improve-my-llms[flask]>=2.6.0 python-dotenv>=1.0 pydantic>=2.0 requests>=2.31 diff --git a/run.py b/run.py index 35ac286..8a20ff9 100644 --- a/run.py +++ b/run.py @@ -19,11 +19,20 @@ lib/traffic_rollup.py (the hub's own daily definitions) + lib/satellite_reporter.py → 2plot.ai/api/satellite/traffic * auth — lib/auth.py → Clerk satellite of the 2plot.ai primary + * gate — lib/access.py (+ page_tiers / hub_client / gate_layouts) + → who may read which page * control — pages/control_board.py → /admin/control-board Every one of those is dormant without its env keys, so a plain `python run.py` gives you the same local docs site it always did. +The gate is wired unconditionally (`_access.configure(force=True)` below) and +ships DARK: with PAGE_DEFAULT_TIER=public every verdict answers `allow`, so the +whole verdict path — including the prerender's use of it — is exercised in +production before the flip that turns it on. Flipping that one environment +variable to `auth` gates the site; flipping it back is the rollback. Read +lib/access.py for the policy and DEPLOYMENT.md for the operational half. + Run: python run.py # FastAPI backend (default) DASH_BACKEND=flask python run.py # Flask fallback @@ -52,6 +61,7 @@ ) from lib import auth, bulletin, network_directory +from lib import hub_client as _hub_client from lib.analytics_tracker import tracker from lib.backend import get_backend_info, resolve_backend from lib.constants import ( @@ -82,7 +92,10 @@ # ---------------------------------------------------------------------------- CLERK_ENABLED = auth.register() if not CLERK_ENABLED: - print("[auth] Clerk dormant — every visibility tier falls open to public.") + # Precisely: every DOCS tier falls open, because documentation must not + # brick over a missing credential. Admin surfaces do not — they gate on + # `auth.admin_access_open()` and stay closed. See lib/access.py. + print("[auth] Clerk dormant — docs tiers fall open to public; admin stays closed.") # ---------------------------------------------------------------------------- # Leaflet 2 alpha delivery via dash.hooks — the same no-build-step contract @@ -270,19 +283,47 @@ async def track_visitor(): except Exception: pass +# ============================================================================ +# Access control. Reads the tiers the pages just declared, so it runs after +# they are registered and before the routes are attached. The policy and its +# reasoning live in lib/access.py; lib/page_visibility.py keeps the live +# control-board overrides it reads first. +# ============================================================================ + +from lib import access as _access # noqa: E402 +from lib import page_tiers as _page_tiers # noqa: E402 +from lib import page_visibility as _page_visibility # noqa: E402 + # Tiered corpus documents (dash-improve-my-llms >= 2.4.0). Pseudo-paths: # they never enter dash.page_registry, so they cannot leak into listings — # registering them here lets this satellite tier its compact briefing and -# full corpus via env (LLMS_SMALL_TIER / LLMS_FULL_TIER; unset = the -# default tier, i.e. public), and the hub can tighten either network-wide -# through its page-tier ceilings with no redeploy here. Inert on older -# package versions. (The boilerplate pairs this with lib/access.py -# enforcement, which has no counterpart in this repo yet — the registrations -# are the contract the hub reads either way.) -from lib import page_tiers as _page_tiers # noqa: E402 - -_page_tiers.register("/llms-small.txt", os.environ.get("LLMS_SMALL_TIER")) -_page_tiers.register("/llms-full.txt", os.environ.get("LLMS_FULL_TIER")) +# full corpus via env (LLMS_SMALL_TIER / LLMS_FULL_TIER), and the hub can +# tighten either network-wide through its page-tier ceilings with no redeploy +# here. The explicit `or "public"` matters: unset, these would inherit +# PAGE_DEFAULT_TIER, so flipping that env to gate the *interactive* site would +# silently gate the corpus documents too. Their tier is always a deliberate +# setting, never an ambient default. +_page_tiers.register("/llms-small.txt", + os.environ.get("LLMS_SMALL_TIER") or "public") +_page_tiers.register("/llms-full.txt", + os.environ.get("LLMS_FULL_TIER") or "public") + +# The funnel's front door stays public, always. docs/home/home.md declares no +# tier, so under PAGE_DEFAULT_TIER=auth the landing page would inherit the +# gate and a signed-out visitor would meet a sign-in card before they had any +# reason to want an account. Pinned on BOTH ledgers so the control board shows +# what the site enforces; an operator can still override it from the board. +_page_tiers.register("/", "public") +_page_visibility.pin_default("/", "public") + +# force=True, unconditionally — the deviation from the boilerplate, and the +# whole point of shipping dark. With every tier still public the auto-detect +# would skip the wiring entirely, so the gate would go live in the same change +# that first exercises it. Wiring it now means the verdict path, the gate +# document and the prerender's use of the check are all running (and +# answering `allow`) before PAGE_DEFAULT_TIER flips, and the flip is then an +# environment change against code that has been serving for a week. +ACCESS_ENABLED = _access.configure(force=True) # Wire up /llms.txt, /<page>/llms.txt, /robots.txt, /sitemap.xml + bot # middleware. dash-improve-my-llms auto-detects the active backend @@ -330,6 +371,31 @@ async def track_visitor(): app.layout = create_appshell(dash.page_registry.values()) server = app.server +# ============================================================================ +# The person->agent handoff: /api/agent-key turns the browser's Clerk session +# into a portable ?key= for copied llms.txt URLs (lib/agent_key.py, consumed +# by assets/llms_copy.js on the first copy click). 204 for everyone until +# Clerk AND the hub are both configured, so it is safe to mount always. +# ============================================================================ + +from lib.agent_key import register_agent_key_route # noqa: E402 + +register_agent_key_route(app, BACKEND) + +# One line, because every part of this is env-driven and invisible from +# inside the container. A dark launch that quietly failed to wire looks +# exactly like a dark launch that worked. +_non_public = sum(1 for t in _page_tiers.registered().values() if t != "public") +print( + "[dash-leaflet2] interactive gate: default tier " + f"'{_page_tiers._default_tier()}', {_non_public} non-public page(s), " + "machine surfaces " + f"{'GATED' if not _page_tiers.get_llms_public('/__probe__') else 'open'} " + f"by default (LLMS_PUBLIC_DEFAULT), access wiring " + f"{'ON' if ACCESS_ENABLED else 'off'}, hub " + f"{'reachable' if _hub_client.enabled() else 'off (no CROSS_APP_WEBHOOK_SECRET)'}." +) + # ============================================================================ # Analytics tracking (FastAPI) — added LAST on purpose. # Starlette runs the most recently added middleware outermost, so registering diff --git a/scripts/sync_from_rnd.py b/scripts/sync_from_rnd.py index 3f56c7a..3ee67d4 100644 --- a/scripts/sync_from_rnd.py +++ b/scripts/sync_from_rnd.py @@ -78,6 +78,15 @@ "lib/versions.py", "lib/auth.py", "lib/page_visibility.py", + # The gate pilot. These are adapted from dash-documentation-boilerplate, + # not from R&D — lib/access.py in particular resolves the control board's + # overrides ahead of the frontmatter tier, which the template has no + # counterpart for. A pull that overwrote it would silently drop the board. + "lib/access.py", + "lib/hub_client.py", + "lib/gate_layouts.py", + "lib/auth_demos.py", + "lib/agent_key.py", "lib/constants.py", "pages/markdown.py", "pages/control_board.py", diff --git a/tests/conftest.py b/tests/conftest.py index b17ed38..1620825 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,9 +12,11 @@ SECRETLESS, AND ORDER MATTERS. The suite runs against the app exactly as CI's zero-secret container does: no Clerk keys (auth falls open, non-public tiers -still deny), no `CROSS_APP_WEBHOOK_SECRET` (the traffic reporter never starts -a thread and nothing is ever POSTed to the hub), and the analytics ledger in -a temp dir. The zero-secret boot is itself the first invariant. +still deny), no `CROSS_APP_WEBHOOK_SECRET` (the hub client reports itself +disabled, the traffic reporter never starts a thread, and nothing is ever +POSTed to the hub), and the analytics ledger in a temp dir. The zero-secret +boot is itself the first invariant — every fail-closed assertion in +tests/test_access.py depends on it. The env block below therefore has to run BEFORE anything imports `run.py`, because run.py calls `load_dotenv()` at import time and a developer's local @@ -49,6 +51,16 @@ for _key in SECRET_ENV_KEYS: os.environ[_key] = "" +# --- 1b. Pin the gate's env knobs to the shipped-dark posture --------------- +# These are not secrets, but they change what every page IS, so a developer +# who exported PAGE_DEFAULT_TIER=auth to try the gate locally would otherwise +# see two dozen unrelated tests fail on a sign-in card. Blank means "unset" to +# `lib.page_tiers._default_tier`, i.e. public — the posture the pilot deploys +# with, and the one tests/test_access.py's inertness assertions describe. +for _key in ("PAGE_DEFAULT_TIER", "PAGE_DEFAULT_VISIBILITY", "LLMS_PUBLIC_DEFAULT", + "LLMS_SMALL_TIER", "LLMS_FULL_TIER"): + os.environ[_key] = "" + # --- 2. Keep app state out of the repo -------------------------------------- # Without this the suite appends its own hits to the checked-out # visitor_analytics.json, which then shows up in `git status` and, worse, in diff --git a/tests/test_access.py b/tests/test_access.py new file mode 100644 index 0000000..94d995b --- /dev/null +++ b/tests/test_access.py @@ -0,0 +1,654 @@ +"""Access control — the checks that justify this design over the simpler ones. + +The package already covers the six-surface gate matrix in its own suite. What +is new *here* is the resolution, which has three inputs on this site and two +on the boilerplate: a control-board override, a frontmatter registration, and +the hub's ceiling — then a local Clerk session answering for a person in a +browser, or a hub call answering for an agent with a key. So these tests +concentrate on the seams, and specifically on what happens when the hub is +unavailable. + +From AUTH-NETWORK.md, checks 1 and 3 are the point: + + 1. Signed-in browser, hub UNREACHABLE -> still allowed (local resolution) + 3. Agent with valid key, hub down -> gated, not 500, not prose + +If 1 fails, every satellite's availability is coupled to one host for no +benefit. If 3 fails, an outage either leaks prose or takes the site down. + +`configure_access` is process-wide, so `access_on` saves and restores it — +otherwise a gate configured here would leak into every later test in the +session and the failures would look like anything but the cause. Note the +teardown re-runs `configure(force=True)`: unlike the boilerplate, run.py wires +the policy unconditionally, so leaving the package reset would take the gate +off the app for every test that follows. +""" + +from __future__ import annotations + +import re + +import pytest + +from conftest import CRAWLER_UA +from lib import access, auth, hub_client, page_tiers, page_visibility +from lib.network_directory import peers_for + +# Two real documentation pages. GATED_PAGE is whatever the tests move around; +# PUBLIC_PAGE stays public so the "never reaches the session or the hub" +# assertions have something to prove it on. +GATED_PAGE = "/flyto" +PUBLIC_PAGE = "/pointer-events" +VALID_KEY = "k2p_testref_testsig" + +# Every 2plot origin except this one. A key must never travel to any of them. +# +# Full ORIGINS, not bare hosts: `2plot.dev` is a substring of this site's own +# `leaflet.2plot.dev`, so a host-substring match flags same-origin links — +# which legitimately DO carry the key (that is `access.link_suffix`, the reason +# an authorised agent can follow the index one hop). `https://2plot.dev` does +# not appear inside `https://leaflet.2plot.dev`, so the origin form separates +# the two cleanly. +PEER_ORIGINS = [p["url"].rstrip("/") for p in peers_for("https://leaflet.2plot.dev")] + + +def _snapshot(): + return ( + page_tiers.registered(), + dict(page_tiers._LOCAL_LLMS_PUBLIC), + {path: dict(entry) for path, entry in page_visibility._overrides.items()}, + ) + + +def _restore(snapshot): + tiers, llms, overrides = snapshot + page_tiers._LOCAL_TIERS.clear() + page_tiers._LOCAL_TIERS.update(tiers) + page_tiers._LOCAL_LLMS_PUBLIC.clear() + page_tiers._LOCAL_LLMS_PUBLIC.update(llms) + page_visibility._overrides.clear() + page_visibility._overrides.update(overrides) + + +@pytest.fixture +def restore_tiers(): + """Any test that registers a tier outside `access_on` must clean up, or the + inertness assertion below sees a gate a later reader cannot explain.""" + saved = _snapshot() + yield + _restore(saved) + + +class FakeUser: + """Stands in for ClerkUser. Only the attributes lib/auth reads.""" + + def __init__(self, email="reader@example.com", user_id="user_1", + session_id="sess_1", plan=None): + self.email = email + self.user_id = user_id + self.session_id = session_id + self.plan = plan + + +@pytest.fixture +def access_on(app_module, monkeypatch): + """Turn gating on for one test, then put the process back as it was.""" + import dash_improve_my_llms as pkg + from dash_improve_my_llms import access as pkg_access + + saved = _snapshot() + # llms_public=False: these tests exercise a machine lane that actually + # gates. The default-open axis (the data-window posture) has its own + # section below. + page_tiers.register(GATED_PAGE, "auth", llms_public=False) + hub_client.clear_cache() + access.configure(force=True) + try: + yield pkg + finally: + # The package ships reset() for exactly this; using it rather than + # restoring _config by hand means the teardown cannot drift from + # whatever configure_access sets next release. Then re-wire, because + # this app boots with the policy installed. + pkg_access.reset() + _restore(saved) + hub_client.clear_cache() + access.configure(force=True) + + +@pytest.fixture +def hub_down(monkeypatch): + """Every hub call fails, the way a real outage does: no exception escapes.""" + def unreachable(route, payload, timeout): + return None + + monkeypatch.setattr(hub_client, "_post", unreachable) + monkeypatch.setattr(hub_client, "enabled", lambda: True) + hub_client.clear_cache() + + +@pytest.fixture +def hub_allows(monkeypatch): + def allow(route, payload, timeout): + return {"verdict": "allow", "ttl": 60} + + monkeypatch.setattr(hub_client, "_post", allow) + monkeypatch.setattr(hub_client, "enabled", lambda: True) + hub_client.clear_cache() + + +def signed_in(monkeypatch, user=None): + monkeypatch.setattr(auth, "clerk_enabled", lambda: True) + monkeypatch.setattr(auth, "current_user", lambda: user or FakeUser()) + + +def anonymous(monkeypatch): + monkeypatch.setattr(auth, "clerk_enabled", lambda: True) + monkeypatch.setattr(auth, "current_user", lambda: None) + + +# --------------------------------------------------------------------------- +# The two checks that justify the design +# --------------------------------------------------------------------------- + + +def test_signed_in_browser_is_allowed_while_the_hub_is_unreachable( + access_on, hub_down, monkeypatch +): + """Check 1. The reason Clerk runs on satellites at all. + + Without local resolution every access decision needs the hub, so one host + being down gates every restricted document on all twenty subdomains. + """ + signed_in(monkeypatch) + assert access.check(GATED_PAGE) == "allow" + + +def test_agent_with_a_key_is_gated_when_the_hub_is_down( + access_on, hub_down, monkeypatch +): + """Check 3. Not 500, not prose — gated.""" + anonymous(monkeypatch) + monkeypatch.setattr(access, "_request_key", lambda: VALID_KEY) + assert access.check(GATED_PAGE) == "gated" + + +def test_agent_with_a_valid_key_is_allowed_when_the_hub_answers( + access_on, hub_allows, monkeypatch +): + """Check 2.""" + anonymous(monkeypatch) + monkeypatch.setattr(access, "_request_key", lambda: VALID_KEY) + assert access.check(GATED_PAGE) == "allow" + + +def test_the_hub_is_not_consulted_for_a_signed_in_reader(access_on, monkeypatch): + """Local-first is an ordering claim; this asserts the ordering itself. + + A passing check-1 could also be produced by a hub that happened to allow. + + "Zero hub calls" means the per-reader agent-key endpoints. The page-tiers + feed is different in kind: one cached per-app fetch per TTL, consulted for + every path (a hub ceiling must bind even on locally-public pages), and its + failure resolves to the local tier — so it never couples a signed-in + reader's access to hub availability, which is what this test protects. + """ + calls = [] + monkeypatch.setattr(hub_client, "_post", lambda *a, **k: calls.append(a) or None) + monkeypatch.setattr(hub_client, "enabled", lambda: True) + signed_in(monkeypatch) + assert access.check(GATED_PAGE) == "allow" + agent_key_calls = [c for c in calls if "/api/agent-key/" in c[0]] + assert agent_key_calls == [], "a signed-in reader triggered a per-reader hub call" + + +# --------------------------------------------------------------------------- +# Tiers, degradation and the hub ceiling +# --------------------------------------------------------------------------- + + +def test_public_pages_never_reach_the_session_or_the_hub(access_on, monkeypatch): + monkeypatch.setattr(auth, "current_user", lambda: pytest.fail("session consulted")) + monkeypatch.setattr(hub_client, "_post", lambda *a, **k: pytest.fail("hub consulted")) + assert access.check(PUBLIC_PAGE) == "allow" + + +def test_admin_tier_gates_a_signed_in_non_admin(access_on, monkeypatch): + page_tiers.register(GATED_PAGE, "admin") + signed_in(monkeypatch) + monkeypatch.setattr(auth, "is_admin_user", lambda user=None: False) + assert access.check(GATED_PAGE) == "gated" + monkeypatch.setattr(auth, "is_admin_user", lambda user=None: True) + assert access.check(GATED_PAGE) == "allow" + + +def test_hidden_is_deny_even_with_a_session(access_on, monkeypatch): + page_tiers.register(GATED_PAGE, "hidden") + signed_in(monkeypatch) + assert access.check(GATED_PAGE) == "deny" + + +def test_everything_but_hidden_falls_open_without_clerk(access_on, monkeypatch): + """Documentation must not brick over a missing credential. + + `hidden` still holds, because it means "there is nothing here" rather than + "not yet" — and admin surfaces gate on `admin_access_open()`, not on this. + """ + monkeypatch.setattr(auth, "clerk_enabled", lambda: False) + for tier, expected in (("auth", "allow"), ("admin", "allow"), ("hidden", "deny")): + page_tiers.register(GATED_PAGE, tier) + assert access.check(GATED_PAGE) == expected, tier + + +def test_the_hub_sets_the_ceiling(restore_tiers): + """A satellite may restrict further; it may never loosen.""" + page_tiers.register("/x", "public") + assert page_tiers.effective_tier("/x", "auth") == "auth", "hub ceiling did not hold" + page_tiers.register("/x", "admin") + assert page_tiers.effective_tier("/x", "auth") == "admin", "local restriction lost" + assert page_tiers.effective_tier("/x", None) == "admin", "hub outage changed the tier" + + +def test_a_hub_published_tier_gates_a_locally_public_page(access_on, monkeypatch): + """The ceiling, end to end through the real feed: this site says public, + the hub's /api/page-tiers says auth, an anonymous reader is gated.""" + anonymous(monkeypatch) + + def hub(route, payload, timeout): + assert route == "/api/page-tiers" + assert payload == {"app": hub_client.app_id()} + return {"tiers": {PUBLIC_PAGE: "auth"}, "ttl": 300} + + monkeypatch.setattr(hub_client, "_post", hub) + monkeypatch.setattr(hub_client, "enabled", lambda: True) + hub_client.clear_cache() + assert access.check(PUBLIC_PAGE) == "gated", "hub ceiling not applied" + + +def test_the_hub_ceiling_binds_a_control_board_override_too(access_on, monkeypatch): + """The board is the most local input there is, so it is the one that could + quietly outrank the network. It must not: an operator here may lock a page + down further, never open one the hub restricted.""" + anonymous(monkeypatch) + page_visibility._overrides[PUBLIC_PAGE] = {"visibility": "public"} + + monkeypatch.setattr(hub_client, "_post", + lambda *a, **k: {"tiers": {PUBLIC_PAGE: "admin"}, "ttl": 300}) + monkeypatch.setattr(hub_client, "enabled", lambda: True) + hub_client.clear_cache() + assert access.resolve_page_access(PUBLIC_PAGE) == "sign_in" + + +def test_the_tier_feed_is_fetched_once_per_ttl_not_per_request(monkeypatch): + calls = [] + + def hub(route, payload, timeout): + calls.append(route) + return {"tiers": {"/x": "auth"}, "ttl": 300} + + monkeypatch.setattr(hub_client, "_post", hub) + monkeypatch.setattr(hub_client, "enabled", lambda: True) + hub_client.clear_cache() + for _ in range(5): + assert hub_client.hub_tiers() == {"/x": "auth"} + assert len(calls) == 1, "every request paid a hub round trip" + + +def test_a_failed_tier_fetch_is_cached_and_loosens_nothing(restore_tiers, monkeypatch): + """An outage answers {} (hub unknown -> local tier holds) and is cached, + so a down hub costs one timeout per window, not one per request.""" + calls = [] + monkeypatch.setattr(hub_client, "_post", lambda *a, **k: calls.append(a) or None) + monkeypatch.setattr(hub_client, "enabled", lambda: True) + hub_client.clear_cache() + for _ in range(5): + assert hub_client.hub_tiers() == {} + assert len(calls) == 1, "a down hub was hammered per-request" + page_tiers.register("/x", "admin") + assert page_tiers.effective_tier("/x", hub_client.hub_tiers().get("/x")) == "admin" + + +def test_a_junk_tier_from_the_hub_cannot_loosen_a_local_tier(restore_tiers): + page_tiers.register("/x", "admin") + assert page_tiers.effective_tier("/x", "not-a-tier") == "admin" + assert page_tiers.effective_tier("/x", "public") == "admin" + + +# --------------------------------------------------------------------------- +# One resolver, three inputs — this repo's own seam +# --------------------------------------------------------------------------- + + +def test_a_control_board_override_beats_the_frontmatter_registration(restore_tiers): + """The whole reason the board survived the unification: a toggle applies on + the next render, with no restart and no redeploy.""" + page_tiers.register(GATED_PAGE, "public") + assert access.local_tier(GATED_PAGE) == "public" + page_visibility._overrides[GATED_PAGE] = {"visibility": "admin"} + assert access.local_tier(GATED_PAGE) == "admin" + + +def test_an_untouched_page_falls_through_to_its_frontmatter(restore_tiers): + """The distinction a merged read cannot make. `get_settings` answers + "public" for a page nobody configured, which would silently outrank a + frontmatter `tier: admin` if the resolver used it.""" + page_tiers.register(GATED_PAGE, "admin") + assert page_visibility.tier_override(GATED_PAGE) is None + assert access.local_tier(GATED_PAGE) == "admin" + + +def test_the_board_also_owns_the_machine_axis(restore_tiers): + page_tiers.register(GATED_PAGE, "auth", llms_public=True) + assert access.llms_public(GATED_PAGE) is True + page_visibility._overrides[GATED_PAGE] = {"llms_public": False} + assert access.llms_public(GATED_PAGE) is False + + +def test_the_default_tier_env_alias_is_honoured(monkeypatch, restore_tiers): + """PAGE_DEFAULT_VISIBILITY is this site's older spelling and is set on the + live service. Dropping it would change the deployment's posture silently.""" + monkeypatch.delenv("PAGE_DEFAULT_TIER", raising=False) + monkeypatch.setenv("PAGE_DEFAULT_VISIBILITY", "auth") + assert page_tiers._default_tier() == "auth" + assert page_visibility.default_tier() == "auth", "board and gate disagree" + monkeypatch.setenv("PAGE_DEFAULT_TIER", "public") + assert page_tiers._default_tier() == "public", "canonical key must win" + + +def test_frontmatter_accepts_tier_and_the_visibility_alias(): + """One declared value, two ledgers — and `tier:` wins a disagreement.""" + from pages.markdown import Meta, _declared_tier + + base = {"name": "x", "description": "d", "endpoint": "/x"} + assert _declared_tier(Meta(**base), "x.md") is None + assert _declared_tier(Meta(**base, tier="auth"), "x.md") == "auth" + assert _declared_tier(Meta(**base, visibility="auth"), "x.md") == "auth" + assert _declared_tier(Meta(**base, tier="admin", visibility="auth"), "x.md") == "admin" + + +# --------------------------------------------------------------------------- +# The mint path (nothing calls it on this deployment; forks will) +# --------------------------------------------------------------------------- + + +def test_current_key_sends_the_token_never_an_asserted_identity(monkeypatch): + """The hub 401s caller-asserted identity by design — a satellite POSTing + {"user_id": ...} could claim to be anyone. Only the Clerk session token + travels, because only Clerk's signature says who the reader is.""" + captured = {} + + def hub(route, payload, timeout): + captured.update({"route": route, "payload": payload}) + return {"key": "k2p_minted"} + + monkeypatch.setattr(hub_client, "_post", hub) + monkeypatch.setattr(hub_client, "enabled", lambda: True) + assert hub_client.current_key("clerk-session-token") == "k2p_minted" + assert captured["route"] == "/api/agent-key/current" + assert captured["payload"] == { + "token": "clerk-session-token", "app": hub_client.app_id() + } + assert "user_id" not in captured["payload"] + + +def test_the_satellite_identifies_itself_as_leaflet(monkeypatch): + """The hub labels its series, and scopes its page-tier ceilings, by this + key. A wrong one means this site enforces another app's tiers.""" + monkeypatch.delenv("SATELLITE_APP_KEY", raising=False) + monkeypatch.delenv("AD_APP_ID", raising=False) + assert hub_client.app_id() == "leaflet" + + +def test_current_key_degrades_to_none_when_the_hub_is_down(hub_down): + """None -> the copy button falls back to the plain URL.""" + assert hub_client.current_key("clerk-session-token") is None + assert hub_client.current_key("") is None + + +# --------------------------------------------------------------------------- +# What must never leak +# --------------------------------------------------------------------------- + + +def test_a_key_never_reaches_the_sitemap_or_canonical_tags(access_on, client): + """Authority is scoped to the response it arrived in. + + A key in a canonical tag or a sitemap entry would be published to every + crawler that reads them. + """ + sitemap = client.get(f"/sitemap.xml?key={VALID_KEY}").text + assert VALID_KEY not in sitemap + + html = client.get(f"{PUBLIC_PAGE}?key={VALID_KEY}", user_agent=CRAWLER_UA).text + canonicals = re.findall(r'rel="canonical"\s+href="([^"]*)"', html) + assert canonicals and all(VALID_KEY not in c for c in canonicals) + + +def test_a_key_never_reaches_a_peer_host(access_on, client): + """The directory points at other origins; a capability must not travel.""" + assert PEER_ORIGINS, "the peer list resolved empty — this test would prove nothing" + body = client.get(f"/llms.txt?key={VALID_KEY}").text + for line in body.splitlines(): + if any(origin in line for origin in PEER_ORIGINS): + assert VALID_KEY not in line, f"key leaked to a peer link: {line}" + + +def test_the_cache_never_stores_the_key_itself(): + hub_client.clear_cache() + hub_client._cache_put(hub_client._fingerprint(VALID_KEY), "/x", "allow", 60) + assert all(VALID_KEY not in str(k) for k in hub_client._VERDICT_CACHE) + + +# --------------------------------------------------------------------------- +# Identity +# --------------------------------------------------------------------------- + + +def test_markdown_is_byte_identical_with_and_without_a_signed_in_reader( + access_on, client, monkeypatch +): + """Check 5. Identity is chrome for people; it must cost an agent nothing.""" + anonymous(monkeypatch) + anonymous_body = client.get(f"{PUBLIC_PAGE}/llms.txt").text + signed_in(monkeypatch) + signed_in_body = client.get(f"{PUBLIC_PAGE}/llms.txt").text + assert anonymous_body == signed_in_body + + +def test_viewer_identity_uses_session_first_seen_not_the_token_iat(monkeypatch): + """A Clerk session token refreshes about every 60 seconds, so its `iat` + renders a "signed in since" clock that resets. This must be stable.""" + monkeypatch.setattr(auth, "clerk_enabled", lambda: True) + monkeypatch.setattr(auth, "current_user", lambda: FakeUser(session_id="sess_stable")) + auth._SESSION_FIRST_SEEN.clear() + first = auth.viewer_identity() + second = auth.viewer_identity() + assert first["since"] == second["since"] + assert first["name"] == "reader@example.com" + + +def test_viewer_identity_is_none_when_nobody_is_signed_in(monkeypatch): + monkeypatch.setattr(auth, "clerk_enabled", lambda: True) + monkeypatch.setattr(auth, "current_user", lambda: None) + assert auth.viewer_identity() is None + + +# --------------------------------------------------------------------------- +# Wiring +# --------------------------------------------------------------------------- + + +def test_the_policy_is_wired_at_boot_even_though_every_page_is_public(): + """The dark launch, asserted. The auto-detect would skip this deployment — + every tier is public in the test posture — so run.py forces it, and the + verdict path is live before PAGE_DEFAULT_TIER ever flips. + """ + assert not access.gating_configured(), "a test left a non-public tier behind" + assert access.configure() is False, "auto-detect should still decline" + assert access.configured(), "run.py must wire the policy regardless" + + +def test_a_check_that_raises_degrades_to_gated(access_on, monkeypatch): + """The package's fail-safe, asserted from this side of the contract. + + Not `allow` — a bug in this repo's policy must not publish gated prose. + Not `deny` — a bug must not black-hole every document on the site. + """ + from dash_improve_my_llms import access as pkg_access + + def boom(path): + raise RuntimeError("policy bug") + + monkeypatch.setattr(pkg_access._config, "check", boom) + assert pkg_access.resolve(GATED_PAGE) == "gated" + + +# --------------------------------------------------------------------------- +# The second axis: llms_public — "interactive gated, machine open" +# --------------------------------------------------------------------------- + + +def test_interactive_gated_machine_open_is_the_window_contract(access_on, monkeypatch): + """THE contract of the data-window posture, in one test: the same + anonymous request is gated in a browser and allowed on the machine lane. + Do not "fix" either half — the split is the design (the prose is + anonymously fetchable at /<page>/llms.txt by decision, while the + interactive experience funnels through the sign-in card).""" + page_tiers.register(GATED_PAGE, "auth") # llms_public -> env default: open + anonymous(monkeypatch) + assert access.check(GATED_PAGE) == "allow" + assert access.resolve_page_access(GATED_PAGE) == "sign_in" + + +def test_llms_public_false_gates_the_anonymous_machine_fetch(access_on, monkeypatch): + """The phase-4 posture, per page: axis pinned closed, anonymous machine + fetches meet the gate doc. (access_on registers llms_public=False.)""" + anonymous(monkeypatch) + assert access.check(GATED_PAGE) == "gated" + + +def test_llms_public_default_env_is_the_phase_4_flip(access_on, monkeypatch): + """LLMS_PUBLIC_DEFAULT=0 flips every page that did not pin the axis — + the whole agent flip is this env change, no code.""" + page_tiers.register(GATED_PAGE, "auth") # no pin -> follows the env + anonymous(monkeypatch) + assert access.check(GATED_PAGE) == "allow" + monkeypatch.setenv("LLMS_PUBLIC_DEFAULT", "0") + assert access.check(GATED_PAGE) == "gated" + + +def test_an_explicit_llms_public_pin_survives_the_env_flip(access_on, monkeypatch): + page_tiers.register(GATED_PAGE, "auth", llms_public=True) + monkeypatch.setenv("LLMS_PUBLIC_DEFAULT", "0") + anonymous(monkeypatch) + assert access.check(GATED_PAGE) == "allow" + + +def test_the_open_axis_never_loosens_a_hub_imposed_gate(access_on, monkeypatch): + """Hub ceiling says auth, local axis says open — the machine lane stays + gated. A satellite env default must not expose what the network + restricted; the exemption is for locally declared gates only.""" + anonymous(monkeypatch) + + def hub(route, payload, timeout): + return {"tiers": {PUBLIC_PAGE: "auth"}, "ttl": 300} + + monkeypatch.setattr(hub_client, "_post", hub) + monkeypatch.setattr(hub_client, "enabled", lambda: True) + hub_client.clear_cache() + assert access.check(PUBLIC_PAGE) == "gated" + + +def test_machine_surfaces_follow_the_axis_end_to_end(access_on, client, monkeypatch): + """Through the real routes: the gate doc when the axis is closed, the + prose when it is open — same page, same anonymous reader.""" + anonymous(monkeypatch) + gated_body = client.get(f"{GATED_PAGE}/llms.txt").text + assert "not public" in gated_body + page_tiers.register(GATED_PAGE, "auth", llms_public=True) + open_body = client.get(f"{GATED_PAGE}/llms.txt").text + assert "not public" not in open_body + assert open_body != gated_body + + +def test_the_prerender_carries_the_verdict_not_the_prose(access_on, client, monkeypatch): + """The leak check. A gated page's browser HTML embeds a prerendered body + for crawlers and copy-paste; if that block still held the prose, the gate + would be a client-side illusion and view-source would defeat it.""" + anonymous(monkeypatch) + html = client.get(GATED_PAGE).text + assert "not public" in html, "the gate doc never reached the prerender" + + page_tiers.register(GATED_PAGE, "auth", llms_public=True) + open_html = client.get(GATED_PAGE).text + assert "not public" not in open_html, "the open axis did not restore the prose" + + +def test_the_legacy_llms_stub_no_longer_shadows_the_check(access_on, client, monkeypatch): + """page_visibility used to swap a page's registered prose for a stub. With + a policy wired, the real prose is registered and the check decides — so an + AUTHORISED reader gets documentation, not the stub that used to be baked in + underneath the verdict.""" + signed_in(monkeypatch) + body = client.get(f"{GATED_PAGE}/llms.txt").text + assert "not publicly available" not in body, "the legacy stub is still in the registry" + + +# --------------------------------------------------------------------------- +# resolve_page_access — the interactive verdict +# --------------------------------------------------------------------------- + + +def test_resolve_page_access_anonymous_matrix(access_on, monkeypatch): + anonymous(monkeypatch) + for tier, expected in (("public", "allow"), ("auth", "sign_in"), + ("admin", "sign_in"), ("hidden", "hidden")): + page_tiers.register(GATED_PAGE, tier) + assert access.resolve_page_access(GATED_PAGE) == expected, tier + + +def test_resolve_page_access_signed_in_matrix(access_on, monkeypatch): + signed_in(monkeypatch) + monkeypatch.setattr(auth, "is_admin_user", lambda user=None: False) + page_tiers.register(GATED_PAGE, "auth") + assert access.resolve_page_access(GATED_PAGE) == "allow" + page_tiers.register(GATED_PAGE, "admin") + assert access.resolve_page_access(GATED_PAGE) == "forbidden" + monkeypatch.setattr(auth, "is_admin_user", lambda user=None: True) + assert access.resolve_page_access(GATED_PAGE) == "allow" + + +def test_admin_layouts_fail_closed_without_clerk_docs_fall_open(access_on, monkeypatch): + """The boilerplate's posture, pinned so the retired + `page_visibility.resolve_access` (which fell fully open without Clerk keys) + cannot come back through a later port: docs stay readable, admin stays + sealed.""" + monkeypatch.setattr(auth, "clerk_enabled", lambda: False) + monkeypatch.setattr(auth, "admin_access_open", lambda: False) + page_tiers.register(GATED_PAGE, "auth") + assert access.resolve_page_access(GATED_PAGE) == "allow" + page_tiers.register(GATED_PAGE, "admin") + assert access.resolve_page_access(GATED_PAGE) == "forbidden" + monkeypatch.setattr(auth, "admin_access_open", lambda: True) + assert access.resolve_page_access(GATED_PAGE) == "allow" + + +def test_a_key_never_unlocks_a_browser_layout(access_on, hub_allows, monkeypatch): + """Keys are machine-surface capabilities. A ?key= that opened layouts + would turn every copied URL into a shareable session.""" + page_tiers.register(GATED_PAGE, "auth", llms_public=False) + anonymous(monkeypatch) + monkeypatch.setattr(access, "_request_key", lambda: VALID_KEY) + assert access.check(GATED_PAGE) == "allow" # machine lane: yes + assert access.resolve_page_access(GATED_PAGE) == "sign_in" # layout: no + + +def test_run_py_pins_the_funnel_public(app_module): + """PAGE_DEFAULT_TIER=auth must never gate the funnel or the corpus + pseudo-paths — run.py pins them explicitly, and this is the regression net + for those pins. The home page matters most: it is the one page a + signed-out visitor has no reason to want an account for yet.""" + for path in ("/", "/llms-small.txt", "/llms-full.txt"): + assert page_tiers.local_tier(path) == "public", path + assert page_visibility.get_settings("/")["visibility"] == "public", \ + "the board would show the home page gated while the site serves it" diff --git a/tests/test_agent_key_route.py b/tests/test_agent_key_route.py new file mode 100644 index 0000000..f73edab --- /dev/null +++ b/tests/test_agent_key_route.py @@ -0,0 +1,88 @@ +"""/api/agent-key — the person→agent handoff (lib/agent_key.py). + +Contract pins: 204 for anonymous/Clerk-off/hub-down (the copy button falls +back to the plain URL), 200 + private,no-store for a minted key, the token +read from the __session cookie and never from the query string. +""" + +from __future__ import annotations + +import flask +import pytest + +from lib import agent_key + +NO_STORE = "private, no-store" + + +class _App: + def __init__(self, server): + self.server = server + + +@pytest.fixture +def route_client(): + server = flask.Flask(__name__) + agent_key.register_agent_key_route(_App(server), "flask") + return server.test_client() + + +def test_anonymous_gets_204_with_no_store(route_client): + r = route_client.get("/api/agent-key") + assert r.status_code == 204 + assert r.headers["Cache-Control"] == NO_STORE + + +def test_a_minted_key_returns_200_json_with_no_store(route_client, monkeypatch): + monkeypatch.setattr(agent_key, "_mint_from_token", + lambda t: "k2p_minted" if t == "tok" else None) + route_client.set_cookie("__session", "tok") + r = route_client.get("/api/agent-key") + assert r.status_code == 200 + assert r.get_json() == {"key": "k2p_minted"} + assert r.headers["Cache-Control"] == NO_STORE + + +def test_the_token_is_read_from_the_cookie_never_the_query(route_client, monkeypatch): + seen = [] + monkeypatch.setattr(agent_key, "_mint_from_token", + lambda t: seen.append(t) or None) + route_client.get("/api/agent-key?token=forged&__session=forged2") + assert seen == [""], "a query-string token reached the mint path" + + +def test_mint_is_none_when_clerk_is_off(monkeypatch): + from lib import auth + + monkeypatch.setattr(auth, "clerk_enabled", lambda: False) + assert agent_key._mint_from_token("tok") is None + + +def test_mint_passes_the_token_to_the_hub(monkeypatch): + from lib import auth, hub_client + + monkeypatch.setattr(auth, "clerk_enabled", lambda: True) + monkeypatch.setattr(hub_client, "current_key", + lambda tok: "k2p_x" if tok == "tok" else None) + assert agent_key._mint_from_token("tok") == "k2p_x" + assert agent_key._mint_from_token("") is None + + +def test_a_hub_failure_degrades_to_none_never_raises(monkeypatch): + from lib import auth, hub_client + + monkeypatch.setattr(auth, "clerk_enabled", lambda: True) + + def boom(tok): + raise RuntimeError("hub exploded") + + monkeypatch.setattr(hub_client, "current_key", boom) + assert agent_key._mint_from_token("tok") is None + + +def test_the_route_is_mounted_on_the_running_app(client): + """End to end on whichever backend the suite runs: Clerk is off in the + test env, so the route answers 204 — mounted, safe, and cache-proof.""" + r = client.get("/api/agent-key") + assert r.status == 204 + assert r.header("Cache-Control") == NO_STORE diff --git a/tests/test_gate_layouts.py b/tests/test_gate_layouts.py new file mode 100644 index 0000000..b0e889a --- /dev/null +++ b/tests/test_gate_layouts.py @@ -0,0 +1,107 @@ +"""The interactive gate's presentation layer (lib/gate_layouts.py). + +The verdict logic is tested in test_access.py; here the contract is the +wrapper itself: the right card per verdict, content only on allow, the +**kwargs tolerance Dash Pages requires, and the funnel surviving a broken +teaser demo. +""" + +from __future__ import annotations + +import pytest + +from lib import access, gate_layouts + + +def _ids(component, found=None): + """Every component id in a Dash tree.""" + found = found if found is not None else set() + comp_id = getattr(component, "id", None) + if isinstance(comp_id, str): + found.add(comp_id) + children = getattr(component, "children", None) + if isinstance(children, (list, tuple)): + for child in children: + _ids(child, found) + elif children is not None: + _ids(children, found) + return found + + +CONTENT = "the real page content" + + +@pytest.fixture +def wrapped(app_module): + return gate_layouts.gated_layout("/some-page", "Some Page", CONTENT) + + +def test_allow_returns_the_content(wrapped, monkeypatch): + monkeypatch.setattr(access, "resolve_page_access", lambda p: "allow") + assert wrapped() == CONTENT + + +def test_allow_calls_a_callable_layout(app_module, monkeypatch): + monkeypatch.setattr(access, "resolve_page_access", lambda p: "allow") + layout = gate_layouts.gated_layout("/p", "P", lambda: CONTENT) + assert layout() == CONTENT + + +def test_sign_in_renders_the_funnel_card_with_both_buttons(wrapped, monkeypatch): + monkeypatch.setattr(access, "resolve_page_access", lambda p: "sign_in") + card = wrapped() + ids = _ids(card) + assert "auth-gate-signup" in ids and "auth-gate-signin" in ids + assert CONTENT not in str(card) + + +def test_forbidden_and_hidden_render_cards_not_content(wrapped, monkeypatch): + for verdict in ("forbidden", "hidden"): + monkeypatch.setattr(access, "resolve_page_access", lambda p: verdict) + assert CONTENT not in str(wrapped()) + + +def test_the_layout_accepts_dash_pages_kwargs(wrapped, monkeypatch): + """Dash Pages forwards query params (incl. Clerk's ?__clerk_handshake=) + into layout callables — the wrapper must swallow them.""" + monkeypatch.setattr(access, "resolve_page_access", lambda p: "allow") + assert wrapped(__clerk_handshake="abc", utm_source="x") == CONTENT + + +def test_the_verdict_runs_per_render_not_per_registration(wrapped, monkeypatch): + """An env flip applies on the next navigation — nothing is baked in.""" + monkeypatch.setattr(access, "resolve_page_access", lambda p: "sign_in") + assert CONTENT not in str(wrapped()) + monkeypatch.setattr(access, "resolve_page_access", lambda p: "allow") + assert wrapped() == CONTENT + + +def test_a_broken_demo_never_breaks_the_funnel(app_module, monkeypatch): + """lib/auth_demos degrades to the demo-less card on any failure, and the + card itself tolerates build_demo raising — a broken example must never + take down the sign-in funnel.""" + from lib import auth_demos + + monkeypatch.setitem( + auth_demos.DEMOS, "/broken", + {"module": "docs.does_not_exist.nope", "caption": "x"}, + ) + # The table ships empty on this site, so this is also the only coverage + # the demo path gets until a page enables one. + assert auth_demos.build_demo("/broken") is None + + def boom(path): + raise RuntimeError("demo table bug") + + monkeypatch.setattr(auth_demos, "build_demo", boom) + card = gate_layouts.sign_in_layout("Page", "/broken") + assert "auth-gate-signup" in _ids(card) + + +def test_the_gate_card_names_the_sign_in_destination(app_module, monkeypatch): + """No hardcoded URLs: the destination comes from access.sign_in_url() + (bulletin first, env second), falling back to the network primary.""" + monkeypatch.setattr(access, "sign_in_url", lambda: "https://example.test/in") + assert "https://example.test/in" in str(gate_layouts.sign_in_layout("P")) + monkeypatch.setattr(access, "sign_in_url", lambda: None) + assert "https://2plot.ai" in str(gate_layouts.sign_in_layout("P")) diff --git a/tests/test_satellite_presence.py b/tests/test_satellite_presence.py new file mode 100644 index 0000000..f3cc605 --- /dev/null +++ b/tests/test_satellite_presence.py @@ -0,0 +1,133 @@ +"""The presence beacon (lib/satellite_reporter.py, presence half). + +Presence is display-only and fail-silent by contract: the payload mirrors +the hub's own "active now" derivation (distinct human visitor keys inside +the session window — one measurement rule), the interval respects the hub's +30s floor and 0-disables, and no failure of any kind escapes the loop. +""" + +from __future__ import annotations + +import json +from datetime import datetime, timedelta + +import pytest + +from lib import satellite_reporter as sr +from lib.constants import INTERNAL_UA_TOKEN + + +def _visit(path, *, dt, ip="1.1.1.1", ua="Mozilla/5.0 Chrome", + device_type="desktop"): + return { + "timestamp": dt.strftime("%Y-%m-%dT%H:%M:%S"), + "path": path, + "ip_address": ip, + "user_agent": ua, + "device_type": device_type, + } + + +@pytest.fixture +def ledger(tmp_path, monkeypatch): + path = tmp_path / "visitor_analytics.json" + + def write(visits): + path.write_text(json.dumps({"visits": visits})) + monkeypatch.setenv("TRAFFIC_ANALYTICS_FILE", str(path)) + # The suite shares one app whose tracker buffers the other tests' + # client hits; a real flush() would pour those into this ledger and + # the count under test would depend on test ordering. + from lib.analytics_tracker import tracker + + monkeypatch.setattr(tracker, "flush", lambda: None) + return path + + return write + + +def test_active_counts_distinct_humans_inside_the_session_window( + ledger, monkeypatch +): + now = datetime.now() + fresh, stale = now - timedelta(minutes=5), now - timedelta(minutes=90) + ledger([ + _visit("/a", dt=fresh, ip="1.1.1.1"), + _visit("/b", dt=fresh, ip="1.1.1.1"), # same visitor + _visit("/c", dt=fresh, ip="2.2.2.2"), # second visitor + _visit("/d", dt=stale, ip="3.3.3.3"), # outside window + _visit("/e", dt=fresh, ip="4.4.4.4", + ua="GPTBot/1.0", device_type="bot"), # bots never count + ]) + payload = sr.build_presence_payload(app="testapp") + assert payload == {"app": "testapp", "active": 2} + + +def test_an_empty_ledger_reports_zero_not_an_error(ledger): + ledger([]) + assert sr.build_presence_payload(app="t")["active"] == 0 + + +def test_interval_floor_and_disable(): + import os + + os.environ["SATELLITE_PRESENCE_INTERVAL_S"] = "5" + assert sr._presence_interval() == sr.PRESENCE_FLOOR_S + os.environ["SATELLITE_PRESENCE_INTERVAL_S"] = "0" + assert sr._presence_interval() == 0 + os.environ["SATELLITE_PRESENCE_INTERVAL_S"] = "junk" + assert sr._presence_interval() == sr.PRESENCE_DEFAULT_INTERVAL_S + del os.environ["SATELLITE_PRESENCE_INTERVAL_S"] + assert sr._presence_interval() == sr.PRESENCE_DEFAULT_INTERVAL_S + + +def test_presence_url_derives_from_the_traffic_override(monkeypatch): + """One SATELLITE_TRAFFIC_URL override retargets both endpoints — a + staging hub does not need a second env var.""" + monkeypatch.delenv("SATELLITE_PRESENCE_URL", raising=False) + monkeypatch.delenv("SATELLITE_TRAFFIC_URL", raising=False) + assert sr.presence_endpoint() == "https://2plot.ai/api/satellite/active" + monkeypatch.setenv("SATELLITE_TRAFFIC_URL", + "https://staging.example/api/satellite/traffic") + assert sr.presence_endpoint() == "https://staging.example/api/satellite/active" + monkeypatch.setenv("SATELLITE_PRESENCE_URL", "https://x.example/ping") + assert sr.presence_endpoint() == "https://x.example/ping" + + +def test_a_failed_post_is_swallowed_never_raised(monkeypatch): + import requests + + def boom(*args, **kwargs): + raise requests.ConnectionError("hub is down") + + monkeypatch.setattr(requests, "post", boom) + ok, detail = sr._post_signed("https://2plot.ai/api/satellite/active", + {"app": "t", "active": 1}, + "presence-beacon", secret="s") + assert ok is False and "request failed" in detail + + +def test_the_presence_post_sends_the_internal_token(monkeypatch): + """The internal-traffic contract's outbound half, presence edition — + without it the hub counts its own fleet pinging as bot traffic, once + per satellite per minute, forever.""" + import requests + + seen = {} + + def fake(*args, **kwargs): + seen.update(kwargs.get("headers") or {}) + raise RuntimeError("captured") + + monkeypatch.setattr(requests, "post", fake) + ok, _ = sr._post_signed(sr.presence_endpoint(), {"app": "t", "active": 0}, + "presence-beacon", secret="test-secret") + assert ok is False + assert INTERNAL_UA_TOKEN in seen.get("User-Agent", "") + assert "presence-beacon" in seen.get("User-Agent", "") + + +def test_rollup_and_presence_use_separate_leases(ledger): + ledger([]) + assert sr._lease_path() != sr._presence_lease_path() + assert sr._presence_lease_path().name == ".satellite_presence.lease" diff --git a/tests/test_seo_icons.py b/tests/test_seo_icons.py new file mode 100644 index 0000000..93fa0fd --- /dev/null +++ b/tests/test_seo_icons.py @@ -0,0 +1,154 @@ +"""dimll 2.6.0's SEO honesty features, pinned from this app's side. + +Adapted from the boilerplate's copy, and the adaptation is the point: the +reference host DECLARES its icons with `configure_seo(icons=[...])` and proves +discovery agrees with the declaration. **This app declares nothing.** It has +never called `configure_seo`, so until the 2.6.0 floor its crawler document +carried zero icons while browsers got six from `templates/index.html` — the +crawler/browser identity drift the whole Tier-B standard exists to close, in +its most complete form. + +So the contract here is one step earlier than the template's: discovery alone +has to find this site's OWN art, and the crawler head has to carry exactly +what discovery found. Discovery is a courtesy in the package — it returns [] +and logs at debug on anything unexpected — which means a renamed favicon +directory would take the icons away in silence. These tests are that silence's +alarm. + +Three contracts: + +1. **Discovery finds this site's own icons**, from `assets/favicon_io/`, and + the crawler head emits them. Not zero, and not another host's art. +2. **The sitemap tells the truth or says nothing.** `<lastmod>` is emitted + verbatim from frontmatter and omitted when unset. No date may appear that + no page declared — the invented daily "today" is the exact lie 2.6.0 ends. +3. **The two heads agree on identity.** Content may differ between the crawler + document and the browser document; `og:image` and the page's schema.org + type may not go missing from one of them. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from conftest import BROWSER_UA, CRAWLER_UA, SAMPLE_PAGE + +ICON_DIR = "assets/favicon_io" + + +def _hrefs(entries): + """href strings out of the package's mixed icon shapes (str | dict).""" + return {e if isinstance(e, str) else e["href"] for e in entries} + + +def test_discovery_finds_this_sites_own_icons(app): + from dash_improve_my_llms.seo import discover_icons + + found = _hrefs(discover_icons(app)) + assert found, ( + f"Discovery found nothing. {ICON_DIR}/ is one of the package's covered " + "directory names — if the folder was renamed, the crawler document " + "silently loses every icon, because discovery fails soft by design." + ) + stray = [h for h in found if ICON_DIR not in h] + assert not stray, f"icons discovered outside {ICON_DIR}/: {stray}" + + +def test_the_crawler_head_carries_them(app, client): + """The half that actually reaches Google. Discovery returning a good list + is not the same as the crawler document emitting it.""" + from dash_improve_my_llms.seo import discover_icons + + html = client.get(SAMPLE_PAGE, user_agent=CRAWLER_UA).text + emitted = set(re.findall( + r'<link[^>]*rel="(?:icon|apple-touch-icon)"[^>]*href="([^"]+)"', html + )) + assert emitted == _hrefs(discover_icons(app)), ( + "The crawler head and discovery disagree.\n" + f"head only: {sorted(emitted - _hrefs(discover_icons(app)))}\n" + f"discovery only: {sorted(_hrefs(discover_icons(app)) - emitted)}" + ) + + +def test_the_icons_actually_resolve(client): + """A head full of 404s is worse than a head with no icons: it looks fixed.""" + for name in ("favicon.ico", "favicon-32x32.png", "apple-touch-icon.png", + "android-chrome-192x192.png"): + r = client.get(f"/{ICON_DIR}/{name}") + assert r.ok, f"{name} -> HTTP {r.status}" + + +def _declared_lastmods() -> set[str]: + dates = set() + for md in Path("docs").glob("**/*.md"): + if md.name == "SKILL.md": + continue + text = md.read_text() + if not text.startswith("---"): + continue + head = text[3:].split("\n---", 1)[0] + m = re.search(r'^lastmod:\s*"?(\d{4}-\d{2}-\d{2})"?\s*$', head, re.MULTILINE) + if m: + dates.add(m.group(1)) + return dates + + +def test_sitemap_lastmod_is_verbatim_or_absent(client): + sitemap = client.get("/sitemap.xml").text + emitted = re.findall(r"<lastmod>([^<]+)</lastmod>", sitemap) + declared = _declared_lastmods() + + assert emitted, ( + "No <lastmod> anywhere — the frontmatter stamps were removed, or the " + "package fell below the 2.6.0 floor. Truth-or-silence permits silence " + "per page, but this docs set deliberately declares real git dates." + ) + undeclared = [d for d in emitted if d not in declared] + assert not undeclared, ( + f"Sitemap emits dates nobody declared: {sorted(set(undeclared))} — an " + "invented date is the lie that gets a whole sitemap discarded. Before " + "2.6.0 every entry read 'today', regenerated on every crawl." + ) + + +def test_every_page_declares_a_date(client): + """This site stamped all of its pages, so silence anywhere means a page + was added without one — worth hearing about while the set is small.""" + sitemap = client.get("/sitemap.xml").text + blocks = re.findall(r"<url>.*?</url>", sitemap, re.DOTALL) + assert blocks, "sitemap has no <url> entries at all" + bare = [re.search(r"<loc>([^<]+)</loc>", b).group(1) + for b in blocks if "<lastmod>" not in b] + assert not bare, ( + f"pages in the sitemap with no declared lastmod: {bare} — add " + "`lastmod: <git log -1 --format=%cs>` to their frontmatter." + ) + + +def test_the_two_heads_agree_on_identity(client): + """Content may differ between the crawler and browser documents; identity + may not. Both of these went missing from the crawler side until + pages/markdown.py started passing the full record through.""" + crawler = client.get(SAMPLE_PAGE, user_agent=CRAWLER_UA).text + browser = client.get(SAMPLE_PAGE, user_agent=BROWSER_UA).text + + def og_image(html): + return re.findall(r'property="og:image" content="([^"]*)"', html) + + assert og_image(crawler), "the crawler document declares no og:image" + assert og_image(crawler) == og_image(browser), ( + f"og:image differs — crawler {og_image(crawler)}, " + f"browser {og_image(browser)}" + ) + assert "TechArticle" in crawler, ( + 'the crawler document types this docs page as something other than ' + 'TechArticle (the package default, "WebPage", says nothing a crawler ' + "could not already see)" + ) + + titles = re.findall(r"<title>([^<]*)", crawler) + assert titles and titles == re.findall(r"([^<]*)", browser), ( + f"crawler and browser disagree: {titles} vs " + f"{re.findall(r'<title>([^<]*)', browser)}" + )