diff --git a/.env.example b/.env.example index 7367919..9b03fe6 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,12 @@ DASH_BACKEND=flask PORT=8050 HOST=127.0.0.1 DASH_DEBUG=true +# APP_BASE_URL is the network-standard name and is read FIRST; +# DASH_LEAFLET2_BASE_URL is this repo's own spelling and remains supported. +# An alias, never a rename — both are set on the live service, because +# dropping one of two env names from a running host is how it starts +# advertising the wrong canonical origin and deindexes itself quietly. +# APP_BASE_URL=http://localhost:8050 DASH_LEAFLET2_BASE_URL=http://localhost:8050 # --- Optional: MUI X Pro ---------------------------------------------------- @@ -16,13 +22,28 @@ DASH_LEAFLET2_BASE_URL=http://localhost:8050 # --- Optional: 2plot.dev ad network ----------------------------------------- # AD_SERVER_URL=https://2plot.dev -# AD_APP_ID=dash-leaflet2 +# AD_APP_ID=leaflet # --- Optional: 2plot.ai satellite analytics --------------------------------- # Without CROSS_APP_WEBHOOK_SECRET nothing is reported; /healthz still serves. # Set DRY_RUN while developing so you never beacon the live hub. # CROSS_APP_WEBHOOK_SECRET= +# `leaflet` is the hub's directory key — NOT the package name `dash-leaflet2`, +# which the hub only still accepts as a legacy id. Both spellings of the env +# var are honoured; SATELLITE_APP_KEY is the network-standard name. # SATELLITE_APP_ID=leaflet +# SATELLITE_APP_KEY=leaflet + +# --- Optional: 2plot.dev network bulletin ----------------------------------- +# The hub's announcements + tips, rendered in this site's llms.txt viewer +# header. Unset means the feature is off and the viewer still renders — the +# tell for an unwired host is "No announcements." plus ONE generic tip where +# the hub publishes two. +# +# NOTE: the dash-improve-my-llms package never reads this variable. lib/bulletin +# does, and passes it to configure_bulletin(). Setting it in an app without that +# wiring does nothing, silently. +# NETWORK_BULLETIN_URL=https://2plot.dev/api/network/bulletin SATELLITE_ANALYTICS_DRY_RUN=1 # --- Optional: Clerk auth (satellite of the 2plot.ai primary) --------------- diff --git a/CHANGELOG.md b/CHANGELOG.md index e6486df..2e7aaff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,70 @@ Nothing yet. --- +## [0.2.2] — 2026-08-01 + +The rest of the 2plot network standard, from the checklist's "found on the +email pass" — the items that each bit a satellite which already looked +finished. Documentation site and network wiring only; no `dl2.*` component +changed. + +> **Deploy note.** `og:image` now declares 1200×630, and the battery reads the +> CDN file's real pixels after every deploy. The new card +> (`scripts/make_social_card.py`) must be uploaded to +> `cdn.2plot.ai/github_assets/leaflet.2plot.dev.png` **before** this ships, or +> `social_card_real_pixels` fails the deploy — deliberately. + +### Fixed + +- **The network bulletin was never wired.** The hub publishes announcements and + tips at `2plot.dev/api/network/bulletin`, and every satellite renders them in + its llms.txt viewer header. This host had no `lib/bulletin.py` at all, so it + showed "No announcements." and one generic tip where the hub publishes two — + and an unwired host still renders both panels, which is why nobody noticed. + Note that `dash_improve_my_llms/bulletin.py` never reads + `NETWORK_BULLETIN_URL`: setting that variable without this code does nothing, + silently. `run.py` now prints which of the two states it booted in. +- **The social card was the wrong shape, and the wrong image.** 1280×515 + (2.49:1) is wider than both the Open Graph ideal and Twitter's 2:1 slot, so + every platform cropped it — and the file was the 2plot network wordmark + rather than a card for this site. Replaced with a generated 1200×630 card, + and the battery now reads the served PNG's IHDR so a re-upload at a different + size cannot pass while every offline test stays green. +- **`dash-clerk-auth` 0.9.0 renders a dead avatar on satellites** — the header + control appears and never resolves the signed-in user. This host is a + satellite of the 2plot.ai primary, so it is exactly the affected shape. + Vendored 0.9.1. +- **`markdown2dash` was installed without `--no-deps` in two places** — + `scripts/compat_matrix.py` and the README quickstart. In the matrix that + meant every per-Dash-version venv booted an app with no documentation pages, + so the compatibility run measured nothing. +- **`AD_APP_ID` was the package name, not the directory key.** The hub lists + `dash-leaflet2` under `legacy_ids` and folds it in at ingest specifically + "until leaflet's own network-standard pass sets `AD_APP_ID=leaflet`". It now + does, and `SATELLITE_APP_KEY` is set alongside `SATELLITE_APP_ID`. + +### Added + +- **The Control Board appears in the nav, to admins only** — its own section in + both the desktop navbar and the mobile drawer, hidden by default and revealed + server-side by the same predicate the page itself uses. The link is cosmetic: + `/admin/control-board` gates itself on every render and again in its mutating + callback, and fails closed without Clerk. +- `lib/bulletin.py`, `scripts/make_social_card.py`, `tests/test_bulletin.py`, + `tests/test_admin_nav.py`, and `social_card_real_pixels` in the battery. +- `SITE_SHORT_NAME` (with `PAGE_TITLE_PREFIX` derived from it rather than typed + twice) and `OG_IMAGE_TYPE`. + +### Changed + +- **`BASE_URL` accepts `APP_BASE_URL` first**, falling back to this repo's + `DASH_LEAFLET2_BASE_URL`. An alias, never a rename — both are set in + `render.yaml`, because removing one of two env names from a live service is + how a host starts advertising the wrong canonical origin and deindexes + itself quietly. + +--- + ## [0.2.1] — 2026-07-31 Brings this satellite onto the **2plot network standard** that 2plot.ai (root), diff --git a/README.md b/README.md index dcf4b9f..378f80f 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,10 @@ You can also run the docs site locally: ```bash pip install -r requirements.txt +# markdown2dash pins gunicorn<22, against the CVE-driven gunicorn>=23 floor +# in requirements.txt. Its real dependencies are listed there, so it installs +# without its dependency graph — the docs pages need it. +pip install --no-deps markdown2dash==0.1.2 python run.py # open http://127.0.0.1:8050 ``` diff --git a/components/navbar.py b/components/navbar.py index 8f01083..1162938 100644 --- a/components/navbar.py +++ b/components/navbar.py @@ -10,6 +10,7 @@ from collections import OrderedDict import dash_mantine_components as dmc +from dash import ALL, Input, Output, callback, ctx, html from dash_iconify import DashIconify CATEGORY_ORDER = [ @@ -25,11 +26,19 @@ EXCLUDED_LINKS: set[str] = { "/404", "/not-found", - # Admin surfaces are not documentation — reachable by URL for allowlisted - # accounts, never advertised in the docs nav. + # Admin surfaces are not documentation, so they never join the categorised + # docs sections. `/admin/control-board` gets its own section instead, which + # is hidden by default and revealed server-side to allowlisted accounts — + # see `create_admin_section` below. "/admin/control-board", } +# The control board's nav entry. Rendered into both the desktop navbar and the +# mobile drawer, so its id is pattern-matched: two components may not share a +# plain string id, and the reveal callback has to reach both. +ADMIN_NAV_ID = "admin-nav-section" +_HIDDEN = {"display": "none"} + def create_nav_link(icon: str, text: str, href: str, external: bool = False): return dmc.Anchor( @@ -64,6 +73,75 @@ def create_nav_section(title: str, links: list): ) +def create_admin_section(loc: str): + """The owner-only Control Board link, hidden until the server says otherwise. + + Hidden by DEFAULT, and revealed by `_reveal_admin_nav` below rather than by + anything the browser knows. The distinction matters: `clerk-auth-store` + lives in the page and a determined visitor can put whatever they like in + it, so the decision is made server-side against the real session. + + Even so, this link is cosmetic. `/admin/control-board` gates itself twice — + `pages/control_board.layout()` re-checks on every render, and the mutating + callback re-checks before it will change anything — and it fails CLOSED + when Clerk is unavailable. Revealing this link grants nothing; hiding it + stops the board being advertised to readers it would only reject. + """ + return html.Div( + id={"type": ADMIN_NAV_ID, "loc": loc}, + style=_HIDDEN, + children=dmc.Stack( + [ + dmc.Divider(mt="md", mb="sm"), + create_nav_section( + "Admin", + [ + create_nav_link( + "tabler:adjustments-cog", + "Control Board", + "/admin/control-board", + ) + ], + ), + ], + gap="xs", + ), + ) + + +@callback( + Output({"type": ADMIN_NAV_ID, "loc": ALL}, "style"), + Input("url", "pathname"), + # The app sets `prevent_initial_callbacks=True` globally, so without this + # the section would stay hidden until the visitor navigated somewhere — + # including for the owner, on the page they signed in to. + prevent_initial_call=False, +) +def _reveal_admin_nav(_pathname): + """Show the Admin section only to accounts the control board would admit. + + Deliberately the SAME predicate the page itself uses (`is_admin_user`, or + `admin_access_open` when Clerk is off) rather than a bare comparison + against the owner's address. A nav that used a narrower rule would hide the + board from an ADMIN_EMAILS account that can still open it by URL — a link + that lies about access is worse than no link. + + With ADMIN_EMAILS unset, `is_admin_user` reduces to the owner's address + alone, which is exactly the owner-only behaviour wanted here. + + `url.pathname` is the trigger rather than `clerk-auth-store` because the + store only exists when Clerk is running; a callback with a missing Input + never fires, which would silently disable this everywhere Clerk is off. + Satellite sign-in round-trips through Clerk's hosted pages and returns as a + full page load, so this re-evaluates at exactly the right moment anyway. + """ + from lib.auth import admin_access_open, clerk_enabled, is_admin_user + + visible = is_admin_user() if clerk_enabled() else admin_access_open() + style = {} if visible else _HIDDEN + return [style] * len(ctx.outputs_list) + + def _categorize(data) -> "OrderedDict[str, list]": """Bucket page registry entries by their `category` field, preserving the intentional CATEGORY_ORDER and folding unknown categories into 'Other'.""" @@ -88,7 +166,7 @@ def _categorize(data) -> "OrderedDict[str, list]": return OrderedDict((k, v) for k, v in buckets.items() if v) -def create_content(data): +def create_content(data, loc: str = "navbar"): buckets = _categorize(data) sections = [] for i, (title, links) in enumerate(buckets.items()): @@ -129,6 +207,8 @@ def create_content(data): ) ) + sections.append(create_admin_section(loc)) + return dmc.ScrollArea( offsetScrollbars=True, type="scroll", @@ -139,7 +219,7 @@ def create_content(data): def create_navbar(data): return dmc.AppShellNavbar( - children=create_content(data), + children=create_content(data, loc="navbar"), style={"borderRight": "1px solid var(--mantine-color-gray-3)"}, ) @@ -153,7 +233,7 @@ def create_navbar_drawer(data): radius="md", withCloseButton=True, size="280px", - children=create_content(data), + children=create_content(data, loc="drawer"), trapFocus=False, position="left", ) diff --git a/dash_leaflet2/package-info.json b/dash_leaflet2/package-info.json index 3ceeac8..17e7328 100644 --- a/dash_leaflet2/package-info.json +++ b/dash_leaflet2/package-info.json @@ -1,6 +1,6 @@ { "name": "dash-leaflet2", - "version": "0.2.1", + "version": "0.2.2", "description": "Leaflet 2-native Dash components. A from-scratch wrapper around Leaflet 2 core (no react-leaflet), built for Dash 4.", "main": "src/ts/index.ts", "repository": { diff --git a/lib/bulletin.py b/lib/bulletin.py new file mode 100644 index 0000000..31b5554 --- /dev/null +++ b/lib/bulletin.py @@ -0,0 +1,103 @@ +"""Network bulletin — hub-published tips and announcements. + +NETWORK FILE: copied from dash-email, which took it from +dash-documentation-boilerplate 1.2.4. Only ``app_id()`` differs, and it is +derived rather than typed — see the note there, because this repo is the one +where the obvious import is wrong twice over. + +The hub (2plot.dev) serves one JSON document at ``/api/network/bulletin`` and +every satellite renders it in the header of its llms.txt viewer. That is the +whole point: a twenty-site network says "here is what changed" once, in one +place, instead of in twenty repositories that immediately drift. + +WHY THIS FILE EXISTS RATHER THAN FOUR LINES IN run.py +----------------------------------------------------- +On the boilerplate it WAS four lines in ``run.py``, and they were **commented +out** — with a note saying the hub did not serve the endpoint yet. The hub +started serving it, the comment did not change, and ``NETWORK_BULLETIN_URL`` +was set in production for a while against code that never read it. Nothing +failed: ``configure_bulletin`` is opt-in, so an unwired app makes no request +and the viewer header renders perfectly well with the package's built-in +defaults. The only symptom was an announcement that never appeared, which is +not a symptom anyone notices. + +So the wiring is a function that returns whether it wired, ``run.py`` prints +that, and ``tests/test_bulletin.py`` exercises it directly — no commented-out +code, and a boot log line that says which of the two states you are in. + +Env: + NETWORK_BULLETIN_URL the hub endpoint. Absent -> feature off, silently. + NETWORK_BULLETIN_TTL_S seconds a cached bulletin stays fresh (default 900) +""" + +from __future__ import annotations + +import os +from typing import Optional + +DEFAULT_TTL_S = 900.0 + +# The hub endpoint. Not a default — `configure()` requires the env var to be +# set, because a satellite that silently starts calling a hub it was never +# pointed at is the kind of surprise the network must not ship. This is here so +# `.env.example` and render.yaml have one place to copy from. +HUB_BULLETIN_URL = "https://2plot.dev/api/network/bulletin" + + +def url() -> Optional[str]: + return os.environ.get("NETWORK_BULLETIN_URL") or None + + +def _ttl() -> float: + try: + return max(60.0, float(os.environ.get("NETWORK_BULLETIN_TTL_S", + DEFAULT_TTL_S))) + except (TypeError, ValueError): + return DEFAULT_TTL_S + + +def app_id() -> str: + """This app's key in the hub's network directory — "leaflet". + + Derived from ``lib.satellite_analytics`` rather than hard-coded, so the key + this app announces itself with and the key it reports traffic under cannot + drift apart. A satellite left announcing itself as "boilerplate" would + receive the template's announcements, and the hub's "who is rendering the + bulletin" view would count it as that repo. + + TWO IMPORTS THAT LOOK RIGHT AND ARE NOT, both specific to this repo: + + * ``lib.satellite_reporter`` is what every other satellite uses, and it + does not exist here — a verbatim copy of the sibling file raises + ModuleNotFoundError on the first bulletin fetch. This app keeps the same + value in ``lib.satellite_analytics.APP_ID`` (env ``SATELLITE_APP_ID``). + * ``AD_APP_ID`` is the obvious one and the wrong one. It is the ad + network's identifier, historically the long ``dash-leaflet2``, whereas + the directory key is ``leaflet``. Announcing the long form would file + this host under a name the hub's directory does not use. + """ + from lib.satellite_analytics import APP_ID + + return APP_ID + + +def configure() -> bool: + """Point the package at the hub's bulletin. Returns whether it did. + + Fail-open by design, in both directions. With no URL the feature is off and + the viewer header still renders — the package ships default tips and an + empty state for announcements. With a URL that is unreachable, the + package's client degrades silently rather than failing a page render: a hub + outage must not take the documentation down with it. + """ + endpoint = url() + if not endpoint: + return False + + try: + from dash_improve_my_llms import configure_bulletin + except ImportError: # pragma: no cover - older releases lack the feature + return False + + configure_bulletin(url=endpoint, ttl=_ttl(), app_id=app_id()) + return True diff --git a/lib/constants.py b/lib/constants.py index a5a6030..6e9f6d4 100644 --- a/lib/constants.py +++ b/lib/constants.py @@ -1,10 +1,9 @@ import os -PAGE_TITLE_PREFIX = "dash-leaflet2 | " PRIMARY_COLOR = "green" # Keep in step with pyproject.toml and package.json when cutting a release. -APP_VERSION = "0.2.1" +APP_VERSION = "0.2.2" LEAFLET_VERSION = "2.0.0-alpha.1" # --------------------------------------------------------------------------- @@ -34,6 +33,13 @@ # - "Pip Install Python" is the byline (who made it), never the site name. SITE_BRAND = "dash-leaflet2 — Leaflet 2 maps for Dash" +# The short form, for places a full brand line does not fit: the installed +# app's home-screen label and the per-page prefix. Derived rather than +# typed twice — PAGE_TITLE_PREFIX used to be its own literal, which is one +# rename away from a site whose tab titles disagree with its brand. +SITE_SHORT_NAME = "dash-leaflet2" +PAGE_TITLE_PREFIX = f"{SITE_SHORT_NAME} | " + SITE_DESCRIPTION = ( "dash-leaflet2 — Leaflet 2 (alpha) mapping components for Plotly Dash 4. " "Wraps Leaflet 2 core directly instead of react-leaflet, exposing unified " @@ -44,7 +50,18 @@ # Public origin, used for canonical URLs, the sitemap and llms.txt. Override per # deployment; the default is the 2plot network subdomain this site ships to. -BASE_URL = os.environ.get("DASH_LEAFLET2_BASE_URL", "https://leaflet.2plot.dev").rstrip("/") +# APP_BASE_URL first, this repo's own spelling second. `BASE_URL` is a REQUIRED +# NAME, not a preference: the shared scripts/ and tests/ import it on every +# host in the network. The env var behind it is where hosts differ, and the +# rule from the email pass is an ALIAS, never a rename — render.yaml sets the +# legacy name on a live service, and removing one of two env names from a +# running host is how it starts advertising the wrong canonical origin, which +# deindexes it silently. +BASE_URL = ( + os.environ.get("APP_BASE_URL") + or os.environ.get("DASH_LEAFLET2_BASE_URL") + or "https://leaflet.2plot.dev" +).rstrip("/") # --------------------------------------------------------------------------- # The social card @@ -63,12 +80,19 @@ # assets-derived one, so passing it at `register_page` time fixes every page at # the source instead of fighting tag order inside templates/index.html. # -# 1280x515 (2.49:1) rather than the 1.91:1 the card specs ask for, so previews -# centre-crop roughly 100px off the top and bottom. The wordmark sits in the -# middle band and survives the crop. +# 1200x630 = exactly 1.91:1, the Open Graph documented ideal, which also +# degrades cleanly into Twitter's 2:1 `summary_large_image` slot. The file that +# lived at this URL until 0.2.2 was 1280x515 (2.49:1) — wider than both, so +# every platform cropped roughly 100px off the top and bottom — and it was the +# 2plot network wordmark rather than a card for this site at all. Regenerate +# with `python scripts/make_social_card.py`, then upload BY HAND; the values +# below must match the file's real IHDR, and +# `scripts/network_smoke.py::social_card_real_pixels` reads those bytes after +# every deploy precisely so a re-upload at a different size cannot pass. OG_IMAGE_URL = "https://cdn.2plot.ai/github_assets/leaflet.2plot.dev.png" -OG_IMAGE_WIDTH = 1280 -OG_IMAGE_HEIGHT = 515 +OG_IMAGE_WIDTH = 1200 +OG_IMAGE_HEIGHT = 630 +OG_IMAGE_TYPE = "image/png" OG_IMAGE_ALT = "dash-leaflet2 — Leaflet 2 maps for Dash, at leaflet.2plot.dev" # --------------------------------------------------------------------------- diff --git a/package-info.json b/package-info.json index 0bb1bec..46b587b 100644 --- a/package-info.json +++ b/package-info.json @@ -1,6 +1,6 @@ { "name": "dash-leaflet2", - "version": "0.2.1", + "version": "0.2.2", "description": "Leaflet 2-native Dash components. A from-scratch wrapper around Leaflet 2 core (no react-leaflet), built for Dash 4.", "main": "src/ts/index.ts", "repository": { diff --git a/package.json b/package.json index 3ceeac8..17e7328 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dash-leaflet2", - "version": "0.2.1", + "version": "0.2.2", "description": "Leaflet 2-native Dash components. A from-scratch wrapper around Leaflet 2 core (no react-leaflet), built for Dash 4.", "main": "src/ts/index.ts", "repository": { diff --git a/pyproject.toml b/pyproject.toml index cedc2dc..6b97c04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "dash-leaflet2" -version = "0.2.1" +version = "0.2.2" description = "Leaflet 2-native Dash components — a from-scratch wrapper around Leaflet 2 core (no react-leaflet), built for Dash 4." readme = "README.md" # 3.9, not 3.8. Dash 4.4.1 itself requires >=3.9, so a 3.8 user would be diff --git a/render.yaml b/render.yaml index 07b9d05..ac18614 100644 --- a/render.yaml +++ b/render.yaml @@ -40,6 +40,16 @@ services: - key: WEB_CONCURRENCY value: "2" # Public origin for canonical URLs, sitemap.xml and llms.txt. + # + # BOTH spellings, deliberately. APP_BASE_URL is the network-standard name + # that the shared scripts/ and tests/ expect; DASH_LEAFLET2_BASE_URL is + # what this service has been running with. lib/constants reads the + # standard one first and falls back to the legacy one, so either alone + # works — but dropping the old name from a LIVE service is how a host + # starts advertising the wrong canonical origin, which deindexes it + # quietly. Keep both until the service is confirmed on the new name. + - key: APP_BASE_URL + value: https://leaflet.2plot.dev - key: DASH_LEAFLET2_BASE_URL value: https://leaflet.2plot.dev @@ -49,8 +59,13 @@ services: - key: AD_SERVER_URL value: https://2plot.dev # This app's identity in the ad network's /admin/ad-board tables. + # The hub's directory key, NOT the package name. `canonical_app_id()` on + # 2plot.dev folds the legacy `dash-leaflet2` in at ingest, so the change + # is safe mid-flight — but the hub's directory entry lists `dash-leaflet2` + # under `legacy_ids` precisely "until leaflet's own network-standard pass + # sets AD_APP_ID=leaflet". This is that pass. - key: AD_APP_ID - value: dash-leaflet2 + value: leaflet # --- 2plot.ai satellite analytics (lib/satellite_analytics.py) ---- # The shared HMAC secret every satellite holds. Set it in the Render @@ -70,6 +85,24 @@ services: # only POSTs when the day's numbers actually changed. - key: SATELLITE_REPORT_INTERVAL_S value: "1800" + # The same key under the network-standard spelling. lib/satellite_analytics + # reads SATELLITE_APP_ID; the shared tooling and the rest of the fleet say + # SATELLITE_APP_KEY. Both are set because removing one of two env names on + # a live service is how a host starts reporting under the wrong id. + - key: SATELLITE_APP_KEY + value: leaflet + + # --- 2plot.dev network bulletin (lib/bulletin.py) ----------------- + # The hub's announcement feed, rendered in the header of this site's + # llms.txt viewer. Opt-in — unset means the feature is simply off. + # + # NOTE: Render applies blueprint envVars on a BLUEPRINT SYNC, not on an + # autoDeploy from a git push. Adding this line alone leaves the variable + # absent on the service and the panel empty, which is exactly how + # email.2plot.dev shipped wired code and an unwired deployment. Sync the + # blueprint or add it in the dashboard. + - key: NETWORK_BULLETIN_URL + value: https://2plot.dev/api/network/bulletin # --- Clerk satellite auth (lib/auth.py) -------------------------- # All of these are OPTIONAL: with them absent the site runs fully public diff --git a/requirements.txt b/requirements.txt index 0e27a45..e2f1054 100644 --- a/requirements.txt +++ b/requirements.txt @@ -95,10 +95,13 @@ dash-mui-charts>=1.2.3 # and redeploy; /admin/control-board fails CLOSED without Clerk, so nothing is # left exposed by doing so. # -# 0.9.0 carries the two satellite fixes lib/auth.py depends on; it is not on -# PyPI, and is vendored the same way across 2plotai, 2plotxyz and HoneyComb. +# 0.9.1, not 0.9.0. Both carry the two satellite fixes lib/auth.py depends on, +# but 0.9.0 renders a DEAD AVATAR on a satellite — the header control appears +# and never resolves the signed-in user. This host is a satellite of the +# 2plot.ai primary, so it is exactly the affected shape. Not on PyPI; vendored +# the same way across the network (0.9.1 is what the boilerplate ships). # Used by: lib/auth.py, /admin/control-board -./vendor/dash_clerk_auth-0.9.0.tar.gz +./vendor/dash_clerk_auth-0.9.1.tar.gz clerk-backend-api>=5.0.0,<6 # --- Deployment ------------------------------------------------------------ diff --git a/run.py b/run.py index 9821533..254e781 100644 --- a/run.py +++ b/run.py @@ -49,7 +49,7 @@ register_page_metadata, ) -from lib import auth, network_directory, satellite_analytics +from lib import auth, bulletin, network_directory, satellite_analytics from lib.backend import get_backend_info, resolve_backend from lib.constants import ( APP_VERSION, @@ -222,6 +222,22 @@ # firing, a page has genuinely lost its prose — which is worth hearing about. add_llms_routes(app, LLMSConfig(warn_missing_llms_doc=True)) +# The hub's announcement feed, rendered in the header of this site's llms.txt +# viewer. Opt-in: with NETWORK_BULLETIN_URL unset the feature is simply off and +# the viewer still renders, so the failure mode is an announcement that never +# appears — which nobody notices. +# +# Hence a function that RETURNS whether it wired, and a boot line that says so. +# The boilerplate shipped four commented-out lines here for weeks, against a +# hub endpoint that was already serving, and the only symptom was silence. An +# unwired host still renders both banner panels; the tell is one generic tip +# where the hub publishes two. +print( + "[dash-leaflet2] network bulletin: " + + (f"wired -> {bulletin.url()}" if bulletin.configure() + else "off (NETWORK_BULLETIN_URL unset)") +) + # ---------------------------------------------------------------------------- # 2plot.ai satellite analytics: /healthz for the hub's hourly health sweep, # per-request + SPA page-view tracking, and the signed traffic rollup POSTed to diff --git a/scripts/compat_matrix.py b/scripts/compat_matrix.py index ec93750..0db51ac 100644 --- a/scripts/compat_matrix.py +++ b/scripts/compat_matrix.py @@ -181,6 +181,21 @@ def make_venv(version: str) -> tuple[Path, Path] | None: log(f"{version}: requirements install FAILED\n{r.stderr[-1500:]}") return None + # markdown2dash is deliberately NOT in requirements.txt: it declares + # `gunicorn>=21.2,<22`, which pip cannot resolve against the CVE-driven + # `gunicorn>=23` floor (CVE-2024-6827, CVE-2024-1135 — request smuggling). + # Its real dependencies are listed there instead, and it installs without + # its dependency graph. `pages/markdown.py` imports it, so without this + # every venv in the matrix boots an app with no documentation pages and + # the whole run measures nothing. Same pair as the Dockerfile, ci.yml and + # release.yml. + log(f"{version}: installing markdown2dash (--no-deps)") + r = run([str(py), "-m", "pip", "install", "-q", "--no-deps", + "markdown2dash==0.1.2"], cwd=PROJECT_ROOT) + if r.returncode: + log(f"{version}: markdown2dash install FAILED\n{r.stderr[-1500:]}") + return None + # Confirm the resolver did not quietly upgrade Dash to satisfy something. r = run([str(py), "-c", "import dash; print(dash.__version__)"]) actual = r.stdout.strip() diff --git a/scripts/make_social_card.py b/scripts/make_social_card.py new file mode 100644 index 0000000..6e31ffe --- /dev/null +++ b/scripts/make_social_card.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +"""Render the 1200x630 social card for a 2plot satellite. + + python scripts/make_social_card.py # defaults, this site + python scripts/make_social_card.py --open # ...and preview it + python scripts/make_social_card.py \ + --artwork assets/logo.png --brand "dash-email" \ + --tagline "email components for Dash" --domain email.2plot.dev + +TEMPLATE FILE: satellites copy this verbatim and pass their own values, so +every card in the network is framed identically instead of being hand-made +once per site and drifting. + +Output goes to `build/social-cards/<domain>.png`, which is gitignored. The +card is NOT served by the app — publish it to the CDN: + + https://cdn.2plot.ai/github_assets/<domain>.png + +That is deliberate and is the network rule. A card served by the app itself +is fetched by the scraper at unfurl time, and on a cold free-tier container +that request lands mid-wake and times out — the preview renders blank, once, +permanently, because platforms cache the miss. The CDN has no cold start. + +WHY 1200x630 and not leaflet's 1280x515 +--------------------------------------- +1200x630 is exactly 1.91:1, the Open Graph documented ideal, and it degrades +cleanly into Twitter's 2:1 `summary_large_image` slot. leaflet.2plot.dev's is +1280x515 = 2.49:1, which is wider than both and gets cropped on each — and +what sits at that URL today is the 2plot wordmark rather than a per-site card +at all. This is the shape to converge on, not that one. + +Pillow is a build-time dependency only. It is deliberately absent from +requirements.txt: nothing at runtime renders images, and a docs site should +not carry an image library into production to support a script run by hand +every few months. +""" +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT)) + +try: + from PIL import Image, ImageDraw, ImageFont +except ImportError: # pragma: no cover - the one dependency, named clearly + sys.exit("This script needs Pillow:\n pip install Pillow") + +# Card geometry. WIDTH/HEIGHT are the contract; everything else is derived so +# a fork can change the padding without recomputing a layout by hand. +WIDTH, HEIGHT = 1200, 630 +PAD = 72 +ART_BOX = 430 # the square the artwork is fitted inside, right-hand side +RULE_W = 6 # the accent bar under the brand + +# Palette, from assets/favicon/site.webmanifest so the card, the browser +# chrome and the install splash cannot disagree. +BG_TOP = (26, 27, 30) # #1a1b1e — manifest background_color +BG_BOTTOM = (17, 20, 26) # a shade deeper, for a gradient with a direction +ACCENT = (18, 184, 134) # #12B886 — manifest theme_color +TEXT = (245, 246, 247) +MUTED = (150, 158, 168) + +# Font families in preference order. `truetype` is tried on each until one +# loads: macOS ships the first group, Debian/Ubuntu CI images the second. +# There is no bundled font on purpose — shipping a licensed TTF in a template +# every satellite forks is a licensing question nobody wants to answer. +FONT_CANDIDATES = { + "bold": [ + "/System/Library/Fonts/Supplemental/Arial Bold.ttf", + "/System/Library/Fonts/HelveticaNeue.ttc", + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", + ], + "regular": [ + "/System/Library/Fonts/Supplemental/Arial.ttf", + "/System/Library/Fonts/Helvetica.ttc", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + ], + "mono": [ + "/System/Library/Fonts/Menlo.ttc", + "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + "/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf", + ], +} + + +def load_font(kind: str, size: int): + for path in FONT_CANDIDATES[kind]: + if Path(path).exists(): + try: + return ImageFont.truetype(path, size) + except OSError: + continue + # Pillow >= 10.1 scales its built-in font; older ones give a 10px bitmap + # and the card looks broken rather than merely plain. Say so. + print(f"[card] WARNING: no {kind} system font found — falling back to " + "Pillow's built-in, which will look wrong. Install DejaVu or " + "Liberation fonts.", file=sys.stderr) + try: + return ImageFont.load_default(size=size) + except TypeError: # pragma: no cover - Pillow < 10.1 + return ImageFont.load_default() + + +def vertical_gradient(size, top, bottom): + """A one-pixel-wide gradient stretched across the canvas. + + Cheaper and smoother than filling row by row on the full-width image, and + the resample keeps the banding invisible at this height. + """ + w, h = size + strip = Image.new("RGB", (1, h)) + for y in range(h): + t = y / max(1, h - 1) + strip.putpixel((0, y), tuple( + round(top[i] + (bottom[i] - top[i]) * t) for i in range(3) + )) + return strip.resize((w, h), Image.BILINEAR) + + +def wrap(draw, text, font, max_width): + """Greedy word wrap against measured pixel width, not a character count.""" + words, lines, current = text.split(), [], "" + for word in words: + trial = f"{current} {word}".strip() + if draw.textlength(trial, font=font) <= max_width or not current: + current = trial + else: + lines.append(current) + current = word + if current: + lines.append(current) + return lines + + +def build_card(artwork: Path, brand: str, tagline: str, domain: str) -> Image.Image: + card = vertical_gradient((WIDTH, HEIGHT), BG_TOP, BG_BOTTOM).convert("RGBA") + draw = ImageDraw.Draw(card) + + # --- artwork, right ---------------------------------------------------- + # `thumbnail` preserves aspect ratio, so a square-ish logo and a wide one + # both land inside the same box without being stretched. The alpha bbox is + # cropped first: assets/ddb.png carries ~66px of transparent margin, which + # would otherwise be centred as if it were part of the image. + art = Image.open(artwork).convert("RGBA") + bbox = art.getchannel("A").getbbox() + if bbox: + art = art.crop(bbox) + art.thumbnail((ART_BOX, ART_BOX), Image.LANCZOS) + art_x = WIDTH - PAD - ART_BOX + (ART_BOX - art.width) // 2 + art_y = (HEIGHT - art.height) // 2 + card.alpha_composite(art, (art_x, art_y)) + + # --- text, left -------------------------------------------------------- + text_width = WIDTH - (PAD * 2) - ART_BOX - 48 + + brand_font = load_font("bold", 62) + tagline_font = load_font("regular", 29) + domain_font = load_font("mono", 25) + + brand_lines = wrap(draw, brand, brand_font, text_width) + # Shrink once rather than overflow: a three-line brand at 62px collides + # with the domain strip below. + if len(brand_lines) > 2: + brand_font = load_font("bold", 50) + brand_lines = wrap(draw, brand, brand_font, text_width) + + tagline_lines = wrap(draw, tagline, tagline_font, text_width)[:3] + + brand_lh, tagline_lh = 74, 40 + block_h = (len(brand_lines) * brand_lh) + 26 + (len(tagline_lines) * tagline_lh) + y = (HEIGHT - block_h - 60) // 2 + + # Accent rule, aligned to the top of the brand block. + draw.rounded_rectangle( + [PAD, y + 6, PAD + RULE_W, y + block_h - 10], radius=RULE_W // 2, fill=ACCENT + ) + text_x = PAD + RULE_W + 28 + + for line in brand_lines: + draw.text((text_x, y), line, font=brand_font, fill=TEXT) + y += brand_lh + y += 26 + for line in tagline_lines: + draw.text((text_x, y), line, font=tagline_font, fill=MUTED) + y += tagline_lh + + # Domain, bottom left — the one string a reader uses to decide whether the + # link goes where they think it does. + draw.text((text_x, HEIGHT - PAD - 26), domain, font=domain_font, fill=ACCENT) + + return card.convert("RGB") + + +def main() -> int: + from lib.constants import BASE_URL, SITE_BRAND + + default_domain = BASE_URL.split("://", 1)[-1].rstrip("/") + + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--artwork", default="assets/ddb.png", + help="source image, transparent PNG (default: %(default)s)") + ap.add_argument("--brand", default=SITE_BRAND.split(" — ")[0], + help="headline (default: the brand, minus its tagline)") + ap.add_argument("--tagline", + default="The markdown-driven documentation template every " + "*.2plot.dev component site is forked from.") + ap.add_argument("--domain", default=default_domain) + ap.add_argument("--out", default=None, + help="default: build/social-cards/<domain>.png") + ap.add_argument("--open", action="store_true", help="preview when done (macOS)") + args = ap.parse_args() + + artwork = (REPO_ROOT / args.artwork) if not Path(args.artwork).is_absolute() \ + else Path(args.artwork) + if not artwork.exists(): + return print(f"artwork not found: {artwork}", file=sys.stderr) or 1 + + out = Path(args.out) if args.out else \ + REPO_ROOT / "build" / "social-cards" / f"{args.domain}.png" + out.parent.mkdir(parents=True, exist_ok=True) + + card = build_card(artwork, args.brand, args.tagline, args.domain) + # optimize=True typically halves the file; scrapers fetch this on every + # cold unfurl and some give up on slow responses. + card.save(out, "PNG", optimize=True) + + kb = out.stat().st_size // 1024 + print(f"[card] {out.relative_to(REPO_ROOT)} {card.width}x{card.height} {kb} KB") + print(f"[card] ratio {card.width / card.height:.2f}:1") + print(f"[card] publish to: https://cdn.2plot.ai/github_assets/{args.domain}.png") + print("[card] then update OG_IMAGE_URL / OG_IMAGE_WIDTH / OG_IMAGE_HEIGHT " + "in lib/constants.py") + + if args.open and sys.platform == "darwin": + subprocess.run(["open", str(out)], check=False) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/network_smoke.py b/scripts/network_smoke.py index 96881a8..54f51e8 100644 --- a/scripts/network_smoke.py +++ b/scripts/network_smoke.py @@ -91,6 +91,8 @@ # URL is served from the 2plot CDN so a sleeping free-tier container never # costs a preview. Keep in step with lib/constants.OG_IMAGE_URL. OG_IMAGE_URL = "https://cdn.2plot.ai/github_assets/leaflet.2plot.dev.png" +OG_IMAGE_WIDTH = 1200 +OG_IMAGE_HEIGHT = 630 # --- robots.txt fingerprint, and this site's DELIBERATE divergence ----------- # pip metadata is invisible from outside a running host, so the robots.txt @@ -126,12 +128,17 @@ class SmokeFailure(Exception): pass -def fetch(url: str, ua: str = UA, method: str = "GET", - body: bytes | None = None, headers: dict | None = None, - timeout: int = TIMEOUT, retries: int = 3): - """(status, headers, text) — HTTP errors are results, not exceptions; +def fetch_raw(url: str, ua: str = UA, method: str = "GET", + body: bytes | None = None, headers: dict | None = None, + timeout: int = TIMEOUT, retries: int = 3): + """(status, headers, BYTES) — HTTP errors are results, not exceptions; network errors raise AFTER retries. + Bytes rather than text, because one caller needs them: the social card is a + PNG and its real dimensions live in the IHDR chunk at bytes 16..24. A + decode with `errors="replace"` substitutes U+FFFD for every invalid byte + and is one-way, so the header would be gone before it could be read. + Response headers come back lower-cased: gunicorn sends `content-type`, proxies often re-case it — callers must not care. """ @@ -146,15 +153,28 @@ def fetch(url: str, ua: str = UA, method: str = "GET", try: with urllib.request.urlopen(req, timeout=timeout) as r: return (r.status, {k.lower(): v for k, v in r.headers.items()}, - r.read().decode("utf-8", "replace")) + r.read()) except urllib.error.HTTPError as e: return (e.code, {k.lower(): v for k, v in e.headers.items()}, - e.read().decode("utf-8", "replace")) + e.read()) except Exception as exc: # timeout, reset, truncated read, … last_exc = exc raise last_exc +def fetch(url: str, ua: str = UA, method: str = "GET", + body: bytes | None = None, headers: dict | None = None, + timeout: int = TIMEOUT, retries: int = 3): + """(status, headers, text) — `fetch_raw` with the body decoded. + + A thin delegate ON PURPOSE. The in-process test patches ONE transport, and + if these were two independent implementations the card check would keep + reaching the real CDN from a unit test while everything else was stubbed. + """ + status, hdrs, raw = fetch_raw(url, ua, method, body, headers, timeout, retries) + return status, hdrs, raw.decode("utf-8", "replace") + + def record(name: str, verdict: str, detail: str = "") -> None: _RESULTS.append((name, verdict, detail)) print(f"[{verdict:>4}] {name}" + (f" — {detail}" if detail else ""), flush=True) @@ -305,11 +325,33 @@ def social_card_is_shareable(): expect(bool(twitter) and all(t.strip() for t in twitter), "twitter:image is missing or empty") - # The image has to actually exist. A 404 here is the whole card. - img_status, img_headers, _ = fetch(OG_IMAGE_URL) + expect("/assets/" not in images[0], + "the app is serving its own card — a cold container blanks the " + "preview, and the platform caches the miss") + + # The file has to exist AND be the shape the tags promise. Read the + # BYTES, not the decoded text: PNG stores its dimensions in the IHDR + # chunk at bytes 16..24, which a lossy decode destroys. + # + # This is the check that catches a re-upload at a different size — + # every offline test stays green while the platform reserves the box + # the tags declare and crops the image into it. It is how the previous + # card (1280x515, 2.49:1, and the 2plot wordmark rather than a per-site + # card at all) went unnoticed. + img_status, img_headers, raw = fetch_raw(OG_IMAGE_URL) expect(img_status == 200, f"the og:image URL returns {img_status}") expect(img_headers.get("content-type", "").startswith("image/"), f"og:image serves {img_headers.get('content-type')!r}") + # 24 bytes is exactly the signature plus the IHDR width/height, which + # is all this reads — `>` rather than `>=` would reject a perfectly + # readable header for being minimal. + expect(raw[1:4] == b"PNG" and len(raw) >= 24, + "og:image is not a PNG (or is truncated)") + actual_w = int.from_bytes(raw[16:20], "big") + actual_h = int.from_bytes(raw[20:24], "big") + expect((actual_w, actual_h) == (OG_IMAGE_WIDTH, OG_IMAGE_HEIGHT), + f"the CDN file is {actual_w}x{actual_h}, the tags declare " + f"{OG_IMAGE_WIDTH}x{OG_IMAGE_HEIGHT}") def installable_as_an_app(): """The manifest, and whether a browser could offer to install this. @@ -347,7 +389,7 @@ def installable_as_an_app(): ("crawler_gets_prose", crawler_gets_prose), ("agents_and_browsers_get_different_types", agents_and_browsers_get_different_types), - ("social_card_is_shareable", social_card_is_shareable), + ("social_card_real_pixels", social_card_is_shareable), ("installable_as_an_app", installable_as_an_app), ): check(name, fn) diff --git a/templates/index.html b/templates/index.html index 5d5b314..e02b9fa 100644 --- a/templates/index.html +++ b/templates/index.html @@ -67,8 +67,8 @@ asserted by tests/test_social_card.py. --> <meta property="og:image:secure_url" content="https://cdn.2plot.ai/github_assets/leaflet.2plot.dev.png"> <meta property="og:image:type" content="image/png"> - <meta property="og:image:width" content="1280"> - <meta property="og:image:height" content="515"> + <meta property="og:image:width" content="1200"> + <meta property="og:image:height" content="630"> <meta property="og:image:alt" content="dash-leaflet2 — Leaflet 2 maps for Dash, at leaflet.2plot.dev"> <meta name="twitter:image:alt" content="dash-leaflet2 — Leaflet 2 maps for Dash, at leaflet.2plot.dev"> diff --git a/tests/test_admin_nav.py b/tests/test_admin_nav.py new file mode 100644 index 0000000..b3b3eb7 --- /dev/null +++ b/tests/test_admin_nav.py @@ -0,0 +1,238 @@ +"""The Control Board's navigation entry, and what it is allowed to imply. + +The link is cosmetic and must stay that way. `/admin/control-board` gates +itself twice — `pages/control_board.layout()` re-checks on every render and the +mutating callback re-checks before changing anything — and it fails CLOSED when +Clerk is unavailable. So these tests are not about access control; they are +about a link that would otherwise either advertise an admin surface to every +reader, or hide it from someone who can legitimately open it. + +The default asserted here is HIDDEN, in the same zero-secret posture CI's +container runs: no Clerk, `admin_access_open()` false. A test that only checked +the visible case would pass just as happily against a section hard-coded open. +""" + +from __future__ import annotations + +import json + +from components.navbar import ADMIN_NAV_ID, _reveal_admin_nav, create_content + + +def _walk(component): + """Every component in a layout tree, depth-first.""" + yield component + children = getattr(component, "children", None) + if children is None: + return + if not isinstance(children, (list, tuple)): + children = [children] + for child in children: + if hasattr(child, "children") or hasattr(child, "id"): + yield from _walk(child) + + +def _admin_nodes(tree): + return [ + c for c in _walk(tree) + if isinstance(getattr(c, "id", None), dict) + and c.id.get("type") == ADMIN_NAV_ID + ] + + +def _hrefs(tree): + return [h for h in (getattr(c, "href", None) for c in _walk(tree)) if h] + + +# --------------------------------------------------------------- the section -- + + +def test_the_admin_section_exists_in_both_shells(pages): + """The desktop navbar and the mobile drawer each render their own copy. + + They cannot share a plain string id, which is why the id is a dict. If the + two ever collide, Dash raises a duplicate-id error at layout validation and + the whole site fails to render — so this also guards the boot. + """ + entries = [entry for _p, _n, entry in pages] + navbar_nodes = _admin_nodes(create_content(entries, loc="navbar")) + drawer_nodes = _admin_nodes(create_content(entries, loc="drawer")) + + assert len(navbar_nodes) == 1, "the navbar has no Admin section" + assert len(drawer_nodes) == 1, "the drawer has no Admin section" + assert navbar_nodes[0].id != drawer_nodes[0].id, "both shells share one id" + + +def test_the_admin_section_is_hidden_before_any_callback_runs(pages): + """The rendered default, which is what an anonymous visitor is served. + + If this were ever `{}`, every reader would see a Control Board link until + the reveal callback happened to run — and on a page load that beats the + callback, they would see it permanently. + """ + entries = [entry for _p, _n, entry in pages] + for loc in ("navbar", "drawer"): + node = _admin_nodes(create_content(entries, loc=loc))[0] + assert node.style == {"display": "none"}, f"{loc} ships the link visible" + + +def test_the_control_board_is_not_in_the_documentation_sections(pages): + """It must appear once, in its own gated section — never among the docs.""" + entries = [entry for _p, _n, entry in pages] + content = create_content(entries, loc="navbar") + admin_node = _admin_nodes(content)[0] + + everywhere = _hrefs(content).count("/admin/control-board") + inside_gate = _hrefs(admin_node).count("/admin/control-board") + assert everywhere == 1, f"the board is linked {everywhere} times" + assert inside_gate == 1, "the board's link is outside the gated section" + + +# --------------------------------------------------------------- the reveal -- + + +def _run_reveal(monkeypatch, *, n_outputs=2): + """Invoke the callback with a stubbed `ctx.outputs_list`. + + The callback returns one style per matched component, and outside a real + request `ctx.outputs_list` is empty — which would make it return `[]` and + every assertion below vacuous. + """ + import components.navbar as navbar + + class _Ctx: + outputs_list = [ + {"id": {"type": ADMIN_NAV_ID, "loc": loc}, "property": "style"} + for loc in ("navbar", "drawer")[:n_outputs] + ] + + monkeypatch.setattr(navbar, "ctx", _Ctx) + return _reveal_admin_nav("/") + + +def test_it_stays_hidden_with_no_clerk_and_no_override(monkeypatch): + """The zero-secret default — the posture CI's container boots in. + + `admin_access_open()` is false unless ALLOW_UNGATED_ADMIN is set, so the + link stays hidden on a stock deploy with no Clerk keys. That matches the + page, which fails closed in exactly the same situation. + """ + monkeypatch.delenv("ALLOW_UNGATED_ADMIN", raising=False) + assert _run_reveal(monkeypatch) == [{"display": "none"}] * 2 + + +def test_the_ungated_override_reveals_it(monkeypatch): + """Proves the predicate is consulted rather than the style hard-coded. + + Without this, a section that was simply never revealed would pass every + other test in this file. + """ + monkeypatch.setenv("ALLOW_UNGATED_ADMIN", "1") + assert _run_reveal(monkeypatch) == [{}] * 2 + + +def test_it_reveals_for_an_admin_user_and_hides_for_everyone_else(monkeypatch): + """The signed-in case, with Clerk reporting enabled. + + `is_admin_user` is patched rather than a Clerk session faked: the point + being pinned is that the callback delegates to the SAME predicate the + control board uses, not that Clerk works. + """ + import components.navbar as navbar + import lib.auth as auth + + monkeypatch.setattr(auth, "clerk_enabled", lambda: True) + + monkeypatch.setattr(auth, "is_admin_user", lambda user=None: True) + assert _run_reveal(monkeypatch) == [{}] * 2 + + monkeypatch.setattr(auth, "is_admin_user", lambda user=None: False) + assert _run_reveal(monkeypatch) == [{"display": "none"}] * 2 + assert navbar.ADMIN_NAV_ID # module still importable after patching + + +def test_the_owner_email_is_always_an_admin(): + """`is_admin_user` folds OWNER_EMAIL in, so the nav is owner-only by default. + + With ADMIN_EMAILS unset — the state this deployment is in — the allowlist + reduces to the owner's address alone, which is the requested behaviour. + Using the page's own predicate rather than a literal comparison means + adding an ADMIN_EMAILS entry later updates the nav and the gate together, + instead of leaving a link that lies about access. + """ + from lib.auth import OWNER_EMAIL, is_admin_user + + class _User: + def __init__(self, email): + self.email = email + self.user_id = "" + + assert is_admin_user(_User(OWNER_EMAIL)) is True + assert is_admin_user(_User(OWNER_EMAIL.upper())) is True, "must be case-insensitive" + assert is_admin_user(_User("someone-else@example.com")) is False + assert is_admin_user(None) is False + + +# ------------------------------------------------------------- the real gate -- + + +def test_the_board_itself_still_fails_closed(client): + """The link is cosmetic; this is the check that actually protects anything. + + In the zero-secret suite Clerk is off and ALLOW_UNGATED_ADMIN is unset, so + the board must not render its controls to an anonymous visitor. + """ + response = client.get("/admin/control-board") + assert response.ok, "the route should answer, then refuse" + assert "cb-vis" not in response.text, ( + "the control board served its visibility switches to an anonymous " + "visitor — the page is no longer failing closed" + ) + + +def test_the_board_is_still_hidden_from_agents(client): + """Unchanged by this work, and worth re-asserting beside it.""" + assert client.get("/admin/control-board/llms.txt").status == 404 + + +def _reveal_dependency(app): + """The reveal callback's entry in the serialised dependency list. + + `prevent_initial_call` is recorded on `app._callback_list`, NOT in + `app.callback_map` — the map's values carry the handler and its I/O spec + and have no such key, so reading it there returns None for every callback + and an assertion against it can only ever fail. Both structures are also + empty until `_setup_server()` runs, which is why these tests take `client`: + module-level `@callback` registers into Dash's global registry and only + transfers to the app on the first request. + """ + matches = [ + cb for cb in app._callback_list + if ADMIN_NAV_ID in json.dumps(cb.get("output")) + ] + assert matches, "the reveal callback is not registered" + assert len(matches) == 1, f"expected one reveal callback, found {len(matches)}" + return matches[0] + + +def test_the_reveal_callback_fires_on_first_load(app, client): + """The app sets `prevent_initial_callbacks=True` globally. + + Without the per-callback opt-out the section stays hidden until the visitor + navigates — including for the owner, on the page they signed in to, which + is the page they are most likely to be looking at. + """ + client.get("/") # force _setup_server() before inspecting the registry + assert _reveal_dependency(app).get("prevent_initial_call") is False, ( + "the reveal callback would not fire on first load" + ) + + +def test_the_pattern_output_covers_every_shell(app, client): + """One callback, both copies — an ALL output rather than two callbacks.""" + client.get("/") + output = _reveal_dependency(app)["output"] + assert "ALL" in json.dumps(output), ( + f"{output} is not a pattern-matching output; the drawer's copy would " + "never be revealed" + ) diff --git a/tests/test_bulletin.py b/tests/test_bulletin.py new file mode 100644 index 0000000..ce4237a --- /dev/null +++ b/tests/test_bulletin.py @@ -0,0 +1,94 @@ +"""The network bulletin — wired, or off, and never a comment. + +NETWORK FILE: adapted from dash-documentation-boilerplate 1.2.4, where this +existed because the wiring had sat COMMENTED OUT in run.py for weeks against a +hub endpoint that was already serving. Nothing failed. `configure_bulletin` is +opt-in, so an unwired app makes no request at all and the viewer header renders +perfectly well on the package's built-in tips and an "No announcements." empty +state. The only symptom was an announcement that never appeared — which nobody +goes looking for. + +The load-bearing test is the last one: commented-out wiring cannot define the +name it asserts on, so it fails the moment somebody comments it out again. +""" + +from __future__ import annotations + +import pytest + +from conftest import REPO_ROOT + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch): + """conftest pins NETWORK_BULLETIN_URL to "" for the whole session; these + tests set it themselves and must not leak it into the others.""" + monkeypatch.delenv("NETWORK_BULLETIN_URL", raising=False) + monkeypatch.delenv("NETWORK_BULLETIN_TTL_S", raising=False) + + +def test_no_url_means_the_feature_is_simply_off(): + from lib import bulletin + + assert bulletin.url() is None + assert bulletin.configure() is False + + +def test_configure_reports_that_it_wired(monkeypatch): + from lib import bulletin + + monkeypatch.setenv("NETWORK_BULLETIN_URL", bulletin.HUB_BULLETIN_URL) + + seen = {} + + def fake_configure_bulletin(**kwargs): + seen.update(kwargs) + + import dash_improve_my_llms + + monkeypatch.setattr(dash_improve_my_llms, "configure_bulletin", + fake_configure_bulletin) + + assert bulletin.configure() is True + assert seen["url"] == bulletin.HUB_BULLETIN_URL + assert seen["app_id"] == "leaflet" + + +def test_the_app_id_is_the_directory_key_not_a_second_opinion(): + """One id on every hub surface. + + Two imports look right here and are not, both specific to this repo: + `lib.satellite_reporter` is what every sibling satellite uses and does not + exist in this one, and `AD_APP_ID` is the ad network's identifier — + historically the long `dash-leaflet2`, where the hub's directory key is + `leaflet`. Announcing the long form would file this host under a name the + directory does not use. + """ + from lib import bulletin + from lib.satellite_analytics import APP_ID + + assert bulletin.app_id() == APP_ID == "leaflet" + + +def test_a_bad_ttl_falls_back_rather_than_crashing_the_boot(monkeypatch): + from lib import bulletin + + monkeypatch.setenv("NETWORK_BULLETIN_TTL_S", "not-a-number") + assert bulletin._ttl() == bulletin.DEFAULT_TTL_S + monkeypatch.setenv("NETWORK_BULLETIN_TTL_S", "5") + assert bulletin._ttl() == 60.0, "a too-short TTL would hammer the hub" + + +def test_run_py_wires_it_rather_than_leaving_it_commented_out(): + """The regression this file exists for. + + Commented-out wiring cannot define the name it asserts on, so requiring a + real call here is what makes commenting it out fail loudly. + """ + source = (REPO_ROOT / "run.py").read_text() + live = "\n".join( + line for line in source.splitlines() if not line.strip().startswith("#") + ) + assert "bulletin.configure()" in live, ( + "run.py no longer calls bulletin.configure() outside a comment" + ) diff --git a/tests/test_network_smoke.py b/tests/test_network_smoke.py index 0e6dd68..4c8a1d7 100644 --- a/tests/test_network_smoke.py +++ b/tests/test_network_smoke.py @@ -52,25 +52,41 @@ def wired(battery, client, monkeypatch): """ seen_agents = [] - def fetch(url, ua=battery.UA, method="GET", body=None, headers=None, - timeout=None, retries=1): + def _png_header(width: int, height: int) -> bytes: + """The 24 bytes the card check actually reads. + + PNG signature (8) + length/type of the IHDR chunk (8) + width and + height as big-endian uint32 (8). The battery reads bytes 16..24 and + nothing else, so a synthetic header is a faithful stand-in for a real + image — and it keeps the suite off the network. + """ + return (b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\rIHDR" + + width.to_bytes(4, "big") + height.to_bytes(4, "big")) + + def fetch_raw(url, ua=battery.UA, method="GET", body=None, headers=None, + timeout=None, retries=1): assert method == "GET", f"the satellite battery issued a {method}" seen_agents.append(ua) accept = (headers or {}).get("Accept") # Off-host URLs — today just the CDN-hosted social card — resolve to a - # stub. Reaching the real CDN from a unit test would make the suite - # depend on another service being up; that the asset genuinely - # resolves is the DEPLOYED battery's job, which is where the check - # earns its keep. + # stub at the DECLARED size, so the check passes here and still has to + # be earned against the real CDN after a deploy. Reaching the real CDN + # from a unit test would make the suite depend on another service. if not url.startswith(BASE) and "://" in url: - return 200, {"content-type": "image/png"}, "" + return (200, {"content-type": "image/png"}, + _png_header(battery.OG_IMAGE_WIDTH, battery.OG_IMAGE_HEIGHT)) path = url[len(BASE):] if url.startswith(BASE) else url response = client.get(path or "/", user_agent=ua, accept=accept) - return response.status, dict(response.headers), response.text - - monkeypatch.setattr(battery, "fetch", fetch) + return response.status, dict(response.headers), response.text.encode() + + # `fetch_raw`, NOT `fetch`. The card check reads PNG bytes, and `fetch` is + # a thin decoding delegate — patching it would leave `fetch_raw` reaching + # the real CDN from a unit test, and patching only `fetch` in a repo where + # they were separate implementations is how the boilerplate's copy of this + # test silently kept hitting the network. + monkeypatch.setattr(battery, "fetch_raw", fetch_raw) monkeypatch.setattr(battery, "_RESULTS", []) battery.seen_agents = seen_agents return battery diff --git a/tests/test_social_card.py b/tests/test_social_card.py index 76d4a4a..affca80 100644 --- a/tests/test_social_card.py +++ b/tests/test_social_card.py @@ -34,6 +34,7 @@ from lib.constants import ( OG_IMAGE_ALT, OG_IMAGE_HEIGHT, + OG_IMAGE_TYPE, OG_IMAGE_URL, OG_IMAGE_WIDTH, SITE_BRAND, @@ -118,7 +119,34 @@ def test_the_auxiliary_image_tags_match_the_constants(client): assert _meta(html, "property", "og:image:width") == [str(OG_IMAGE_WIDTH)] assert _meta(html, "property", "og:image:height") == [str(OG_IMAGE_HEIGHT)] assert _meta(html, "property", "og:image:alt") == [OG_IMAGE_ALT] - assert _meta(html, "property", "og:image:secure_url") == [OG_IMAGE_URL] + assert _meta(html, "property", "og:image:type") == [OG_IMAGE_TYPE] + assert _meta(html, "property", "og:image:secure_url") == [OG_IMAGE_URL], ( + "secure_url must be the same file as og:image, not a stale copy" + ) + + +def test_the_declared_ratio_suits_a_large_image_card(): + """`summary_large_image` wants roughly 1.91:1. + + The card this replaced was 1280x515 = 2.49:1 — wider than both the Open + Graph ideal and Twitter's 2:1 slot, so every platform cropped roughly + 100px off the top and bottom. It was also the 2plot wordmark rather than a + per-site card at all. + """ + ratio = OG_IMAGE_WIDTH / OG_IMAGE_HEIGHT + assert 1.7 <= ratio <= 2.05, f"{OG_IMAGE_WIDTH}x{OG_IMAGE_HEIGHT} is {ratio:.2f}:1" + + +def test_the_card_is_hosted_off_the_app(): + """The card must be on the CDN, not served by this app. + + Not a style rule. A card the app serves is fetched by the scraper at + unfurl time; on a cold free-tier container that request lands mid-wake and + times out, the preview renders blank ONCE, and the platform caches the + miss — so the first person to share the link poisons it for everyone. + """ + assert OG_IMAGE_URL.startswith("https://cdn.2plot.ai/github_assets/") + assert "/assets/" not in OG_IMAGE_URL, "the app is serving its own card again" def test_the_twitter_card_is_a_large_image(client): diff --git a/vendor/dash_clerk_auth-0.9.0.tar.gz b/vendor/dash_clerk_auth-0.9.0.tar.gz deleted file mode 100644 index 290b84e..0000000 Binary files a/vendor/dash_clerk_auth-0.9.0.tar.gz and /dev/null differ diff --git a/vendor/dash_clerk_auth-0.9.1.tar.gz b/vendor/dash_clerk_auth-0.9.1.tar.gz new file mode 100644 index 0000000..adb8d6b Binary files /dev/null and b/vendor/dash_clerk_auth-0.9.1.tar.gz differ