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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ DASH_DEBUG=true
# 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.
#
# THE TWO VALUES BELOW ARE LOCAL-ONLY. Do not copy this block into a hosted
# service's environment. They feed every canonical link, og:url, /sitemap.xml
# entry and /llms.txt URL, so a deployed host carrying them publishes a whole
# site of localhost addresses while looking perfectly healthy from the inside.
# lib/constants.py already defaults to https://leaflet.2plot.dev, so a hosted
# deploy is better off with NEITHER variable set than with these. If it happens
# anyway, `lib.constants.base_url_misconfigured()` warns at boot and /healthz
# reports the origin actually being advertised.
# APP_BASE_URL=http://localhost:8050
DASH_LEAFLET2_BASE_URL=http://localhost:8050

Expand Down
47 changes: 46 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Nothing yet.

---

## [0.2.2] — 2026-08-01
## [0.2.2] — 2026-08-05

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
Expand Down Expand Up @@ -71,11 +71,56 @@ changed.

### Changed

- **A hosted deploy advertising `http://localhost` now says so, loudly.**
Production was serving `/llms.txt`, `/sitemap.xml` and every canonical link
pointing at `http://localhost:8050`, and nothing looked wrong: the site
rendered, `/healthz` returned 200, and `tests/test_network_surfaces.py`
passed because it asserts sitemap URLs start with `BASE_URL` — comparing the
deployed value against itself, which is just as true when both sides are
localhost. The code default was never the problem (it is already
`https://leaflet.2plot.dev`); a loopback value can only come from
`APP_BASE_URL` or `DASH_LEAFLET2_BASE_URL` being *explicitly* set to one, and
`.env.example` ships exactly those values uncommented for local use.
Three changes, none of which self-heal — auto-filling Render's
`RENDER_EXTERNAL_URL` would just swap one wrong canonical origin
(`*.onrender.com`) for another: `lib.constants.base_url_misconfigured()`
returns an actionable message when a hosted service resolves BASE_URL to a
loopback origin, naming which of the two variables is at fault; `run.py`
prints the resolved base URL at boot and that warning after it; and
`/healthz` now reports `base_url`, so the origin a satellite *advertises* is
checkable from outside it with one curl. `.env.example` says plainly that its
values are local-only.
- **`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.
- **`dash-emoji-mart` and `flexlayout-dash` install from PyPI**, replacing the
vendored tarballs now that their working builds are published. Both keep a
load-bearing floor — `dash-emoji-mart>=0.0.5` (0.0.3 errors on init) and
`flexlayout-dash>=1.1.0` (1.1.0 renamed the import to `flexlayout_dash`, which
`docs/walking-sim/example.py` imports directly) — so a too-old resolve fails
at install rather than at page render. They also re-enter CI's `pip-audit`
job, which skips `./vendor/` lines because pip-audit can only assess PyPI
dists. `vendor/` is down to the single Clerk tarball.
- **`dash-clerk-auth` 0.9.1 → 1.0.0**, and `lib/auth.py` stops hand-patching the
satellite. Both fixes it used to inject are upstream: 0.9.1 stamps
`data-clerk-domain` onto the ClerkJS script tag, and 0.9.2 replaced the
`Clerk.openSignIn()` modal — which ClerkJS forbids on a satellite — with a
navigation to the primary. What stays is one *delegated* capture-phase
listener on `#clerk-login-button`: the package binds that id inside its
`DOMContentLoaded` handler, so the header control is covered but the sign-in
card in `lib/page_visibility.py`, which a page callback renders later, would
otherwise have no listener at all. It now defers to the package's own
`window.dashClerkAuth.buildSatelliteRedirect()` (0.9.2's page-JS surface,
opt-in via `CLERK_SATELLITE_SIGN_IN_REDIRECT`) and falls back to the same
`redirectToSignIn()` call upstream makes.

1.0.0 raises `requires-python` to `>=3.10` — `clerk-backend-api` 5.x
publishes no 3.9 build, so the old `>=3.9` claim was never installable. That
binds the **docs site** only: Docker is 3.12 and the CI docs matrix is
3.10/3.12/3.13. The `dash_leaflet2` package keeps `requires-python >=3.9`,
which the `package-python-range` CI job proves against the built wheel.

---

Expand Down
16 changes: 11 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,17 @@ python scripts/sync_from_rnd.py # dry-run the pull from ../dash-leaflet2
python -m build --wheel # PyPI-installable wheel in dist/
```

Two docs-only deps are NOT on PyPI and are vendored in `vendor/` (installed by
`requirements.txt`): `dash_emoji_mart` 0.0.5 (PyPI's 0.0.3 errors on init) for
`/emoji-iconify` + `/easy-button`, and `flexlayout_dash` 1.1.0 for `/walking-sim`.
Neither is needed by the `dash_leaflet2` package itself — that needs only
`dash>=4.1`.
Two docs-only deps come from PyPI with load-bearing version floors:
`dash-emoji-mart>=0.0.5` (0.0.3 errors on init) for `/emoji-iconify` +
`/easy-button`, and `flexlayout-dash>=1.1.0` (1.1.0 renamed the import to
`flexlayout_dash`) for `/walking-sim`. Both were vendored tarballs until their
working builds reached PyPI. Neither is needed by the `dash_leaflet2` package
itself — that needs only `dash>=4.1`.

`vendor/` now holds exactly one tarball: `dash_clerk_auth-1.0.0.tar.gz`, which
is vendored across every 2plot satellite rather than published to PyPI. It
requires Python >=3.10, which binds the docs site only (Docker is 3.12); the
package keeps `requires-python >=3.9`.

## Critical Leaflet 2.0.0-alpha.1 facts (these cost real debugging time)

Expand Down
62 changes: 46 additions & 16 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,19 @@ marked `sync: false` so you fill them in the dashboard.
| `DASH_BACKEND` | `flask` | `flask` \| `fastapi` \| `quart`. Keep `flask` under gunicorn — the ASGI backends need uvicorn. |
| `PORT` / `HOST` | `8050` / `0.0.0.0` | Bind address. Render injects `PORT`. |
| `WEB_CONCURRENCY` | `2` | gunicorn workers. Drop to 1 on a 512 MB free instance. |
| `DASH_LEAFLET2_BASE_URL` | `https://leaflet.2plot.dev` | Canonical origin for `sitemap.xml` and `llms.txt`. |
| `APP_BASE_URL` | `https://leaflet.2plot.dev` | Canonical origin for `sitemap.xml`, `llms.txt` and every `<link rel="canonical">` / `og:url`. Network-standard name, **read first**. |
| `DASH_LEAFLET2_BASE_URL` | `https://leaflet.2plot.dev` | This repo's own spelling of the same thing, read second. An alias, never a rename — keep both set on a live service. |

> **Never carry `.env.example`'s values for those two into a hosted service.**
> They are `http://localhost:8050` for local development. `lib/constants.py`
> already defaults to `https://leaflet.2plot.dev`, so a hosted deploy with
> **neither** variable set is correct — one set to localhost is not, and that is
> the only way this site can end up publishing localhost URLs. It renders fine
> in that state and `/healthz` returns 200, so the symptom is invisible from
> inside; `/healthz`'s `base_url` field and the boot-log warning from
> `lib.constants.base_url_misconfigured()` are what surface it. Render's
> blueprint declares the right values but does **not** overwrite a variable
> already edited in the dashboard, so fix it there.
| `MUI_PRO_API_KEY` | — | MUI X Pro licence for the TreeViewPro tile browser on `/tile-layers-pro`. Absent → that one control is watermarked. |

### Ad network → 2plot.dev
Expand Down Expand Up @@ -121,22 +133,23 @@ is logged.
| `CLERK_FRONTEND_API` | `https://<app>.clerk.accounts.dev` | **Required in satellite mode.** A production custom-domain instance cannot derive this from the sign-in URL. |
| `CLERK_SATELLITE_DOMAIN` | `2plot.dev` — the registered satellite, **not** the served host | Host only, no scheme. Must match the deployed domain exactly. |
| `CLERK_IS_SATELLITE` | `true` | Leave false locally — Clerk rejects satellites on `localhost`. |
| `CLERK_SATELLITE_SIGN_IN_REDIRECT` | (unset) | Optional, dash-clerk-auth ≥ 0.9.2. Absolute URL on the **primary** that Sign In navigates to, with this page in `?returnTo=`. Read by the package itself. Unset, sign-in falls back to `Clerk.redirectToSignIn()` forcing this page as the return — what this site ships today. Only set it once `2plot.ai` honours `?returnTo=`. |
| `SESSION_SECRET` | (generated) | Signs the session + `__dca_identity` cookies. Without it dash-clerk-auth uses a **public dev default**. |
| `ADMIN_EMAILS` | `a@b.com,c@d.com` | Allowlist for `/admin/control-board`. `OWNER_EMAIL` always counts. |
| `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 0.9.0 build
> carrying the satellite fixes is not on 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.
> **`dash-clerk-auth` is not a dependency of this project.** The 1.0.0 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.
>
> That is safe for the documentation itself, which is public anyway. It is not
> safe for `/admin/control-board`, so that page fails **closed**: without Clerk
> it returns a 404-style response and its save callback refuses writes, rather
> than handing an open admin panel to anyone who guesses the URL.
>
> **Clerk is enabled** — `vendor/dash_clerk_auth-0.9.0.tar.gz` is committed and
> **Clerk is enabled** — `vendor/dash_clerk_auth-1.0.0.tar.gz` is committed and
> active in `requirements.txt`. One operational risk to know before debugging a
> dead site: the package registers a `[dash_hooks]` entry point that Dash
> auto-imports at **every** `Dash()` construction, so it sits in the boot path
Expand Down Expand Up @@ -202,18 +215,29 @@ primary refuses to redirect back here after sign-in.
> `2plotai/lib/auth.py` and a redeploy, which is less error-prone if you are not
> already overriding the list.

**Two satellite fixes** are applied in `lib/auth.py` for dash-clerk-auth 0.9.0.
They are the difference between a working satellite and a broken one:
**Satellite fixes: now upstream.** `lib/auth.py` used to hand-apply two of them
against dash-clerk-auth 0.9.0. Both ship in the package as of 1.0.0 and the
local copies are gone:

1. clerk-js@5 reads `domain` as a **constructor** option, from the script tag's
`data-clerk-domain` — not as a `load()` option. Without stamping it,
`load({isSatellite: true})` throws *"a satellite application needs to specify
a domain or a proxyUrl"*.
2. The package binds sign-in to `Clerk.openSignIn()`, a modal on the current
domain. On a satellite that POSTs to the satellite FAPI and 403s. We intercept
the click in the capture phase and call `redirectToSignIn()` with
`signInForceRedirectUrl` set to this page, so the primary sends the user back
here rather than to its own home.
a domain or a proxyUrl"*. **Fixed upstream in 0.9.1.**
2. The package bound sign-in to `Clerk.openSignIn()`, a modal on the current
domain. On a satellite that POSTs to the satellite FAPI and 403s. **Fixed
upstream in 0.9.2**, which navigates to the primary instead.

What `lib/auth.py` still installs is one **delegated** capture-phase listener on
`#clerk-login-button`. The package binds that id inside its `DOMContentLoaded`
handler, once — fine for the header control, which is part of the app shell, but
the sign-in card in `lib/page_visibility.py` is rendered by a page callback when
a visitor reaches an `auth`-tier page, well after that handler ran. Without
delegation its button has no listener and the click does nothing. The listener
defers to the package's own `window.dashClerkAuth.buildSatelliteRedirect()` when
`CLERK_SATELLITE_SIGN_IN_REDIRECT` is set, and otherwise makes the same
`redirectToSignIn()` call upstream does.

> Downgrading below 0.9.2 means restoring both fixes in `lib/auth.py`.

**Satellite registration is not automatic.** `leaflet.2plot.dev` must also appear
in the primary's `CLERK_ALLOWED_REDIRECT_ORIGINS` (see `lib/auth.py` in the
Expand Down Expand Up @@ -259,10 +283,16 @@ is meant to be gated.

## Post-deploy checklist

1. `GET /healthz` → `{"ok": true, "app": "leaflet", "version": "…", "reporting": true}`.
1. `GET /healthz` → `{"ok": true, "app": "leaflet", "version": "…",
"base_url": "https://leaflet.2plot.dev", "reporting": true}`.
`reporting: false` means `CROSS_APP_WEBHOOK_SECRET` is missing.
**`base_url` coming back `http://localhost:8050` is a live incident**: the
service has `APP_BASE_URL` or `DASH_LEAFLET2_BASE_URL` set to a loopback
origin, so every canonical link, `og:url`, sitemap entry and llms.txt URL is
unreachable — while the site itself renders perfectly. See the note under
the environment table.
2. `GET /llms.txt`, `/robots.txt`, `/sitemap.xml` all 200, and the sitemap URLs
use `leaflet.2plot.dev` (i.e. `DASH_LEAFLET2_BASE_URL` is set).
use `leaflet.2plot.dev` (i.e. `APP_BASE_URL` is set correctly).
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.
Expand Down
6 changes: 4 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@
WORKDIR /app

# Install Python deps first so this layer is cached across app-code changes.
# vendor/ must come along: requirements.txt installs two docs-only packages
# from local tarballs there (dash-emoji-mart 0.0.5, flexlayout-dash 1.1.0).
# vendor/ must come along: requirements.txt installs dash-clerk-auth 1.0.0 from
# a local tarball there (it is vendored across the 2plot network, not on PyPI).
# dash-emoji-mart and flexlayout-dash used to live here too and now come from
# PyPI, so this is down to the single Clerk tarball.
COPY requirements.txt ./
COPY vendor/ ./vendor/
RUN pip install --no-cache-dir -r requirements.txt
Expand All @@ -45,4 +47,4 @@

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

Check warning on line 50 in Dockerfile

View workflow job for this annotation

GitHub Actions / docker image · boot · battery

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

JSONArgsRecommended: JSON arguments recommended for CMD to prevent unintended behavior related to OS signals More info: https://docs.docker.com/go/dockerfile/rule/json-args-recommended/
5 changes: 1 addition & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
<div align="center">

<a href="https://2plot.ai">
<img src="https://cdn.2plot.ai/github_assets/android-chrome-512x512.png" alt="dash-leaflet2" width="120">
<img src="https://cdn.2plot.ai/github_assets/dark_mode_2plot.png" alt="2plot.ai" width="300">
</a>

<br/>
<br/>

<img src="https://cdn.2plot.ai/github_assets/android-chrome-512x512.png" alt="dash-leaflet2" width="120">

# dash-leaflet2

Expand Down
14 changes: 12 additions & 2 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,10 +246,20 @@ of a bare slug. The ad network's `/admin/ad-board` keys off `AD_APP_ID`

### 3.5 Post-deploy checklist

1. `GET /healthz` → `{"ok": true, "app": "leaflet", "version": "0.2.1", "reporting": true}`.
1. `GET /healthz` → `{"ok": true, "app": "leaflet", "version": "0.2.2",
"base_url": "https://leaflet.2plot.dev", "reporting": true}`.
`reporting: false` means `CROSS_APP_WEBHOOK_SECRET` is missing.
**`base_url` is the check that matters most here** — if it comes back
`http://localhost:8050`, the service has `APP_BASE_URL` or
`DASH_LEAFLET2_BASE_URL` set to a loopback origin in its dashboard
environment, and every canonical link, `og:url`, sitemap entry and llms.txt
URL the site publishes is dead. This has happened on a live deploy. Fix it
in the Render dashboard (a blueprint does not overwrite a dashboard-edited
variable) and redeploy; the boot log warns about it too.
2. `/llms.txt`, `/robots.txt`, `/sitemap.xml` all 200, and sitemap URLs use
`leaflet.2plot.dev` (i.e. `DASH_LEAFLET2_BASE_URL` took effect).
`leaflet.2plot.dev` (i.e. `APP_BASE_URL` took effect). Note the test suite
cannot catch this for you — it compares sitemap URLs against the deployed
`BASE_URL`, so it passes when both are wrong in the same way.
3. **Flip the theme toggle on three pages and confirm the basemap changes.**
This is the one thing no automated check covers end to end — the smoke test
proves the JS parses, not that the tiles swap in a browser.
Expand Down
13 changes: 8 additions & 5 deletions components/header.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,14 @@ def create_clerk_avatar():
deploy without the keys renders the header exactly as before rather than
erroring on a missing component.

The package renders `#clerk-login-button` inside this widget, which is the
id `lib.auth._install_satellite_fixups` intercepts in the capture phase to
call `Clerk.redirectToSignIn()`. That indirection is required on a satellite
domain: the package's own handler calls `openSignIn()`, a modal that POSTs
to the satellite FAPI and 403s with "not allowed on a satellite domain".
The package renders `#clerk-login-button` inside this widget. Since
dash-clerk-auth 0.9.2 the package's own handler is already satellite-safe
(it navigates to the primary rather than opening `openSignIn()`, a modal
that POSTs to the satellite FAPI and 403s), so this button needs nothing
from us. `lib.auth._install_satellite_signin_delegation` still intercepts
the id in the capture phase — not for this button, but for the sign-in card
in `lib.page_visibility`, which Dash renders after the package has already
bound its listeners.
"""
if not clerk_enabled():
return None
Expand Down
13 changes: 9 additions & 4 deletions dash_leaflet2/AttributionControl.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,22 @@
import typing # noqa: F401
from typing_extensions import TypedDict, NotRequired, Literal # noqa: F401
from dash.development.base_component import Component, _explicitize_args
try:
from dash.types import NumberType # noqa: F401
except ImportError:
# Backwards compatibility for dash<=4.1.0
if typing.TYPE_CHECKING:
raise
NumberType = typing.Union[ # noqa: F401
typing.SupportsFloat, typing.SupportsInt, typing.SupportsComplex
]

ComponentSingleType = typing.Union[str, int, float, Component, None]
ComponentType = typing.Union[
ComponentSingleType,
typing.Sequence[ComponentSingleType],
]

NumberType = typing.Union[
typing.SupportsFloat, typing.SupportsInt, typing.SupportsComplex
]


class AttributionControl(Component):
"""An AttributionControl component.
Expand Down
13 changes: 9 additions & 4 deletions dash_leaflet2/BaseLayer.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,22 @@
import typing # noqa: F401
from typing_extensions import TypedDict, NotRequired, Literal # noqa: F401
from dash.development.base_component import Component, _explicitize_args
try:
from dash.types import NumberType # noqa: F401
except ImportError:
# Backwards compatibility for dash<=4.1.0
if typing.TYPE_CHECKING:
raise
NumberType = typing.Union[ # noqa: F401
typing.SupportsFloat, typing.SupportsInt, typing.SupportsComplex
]

ComponentSingleType = typing.Union[str, int, float, Component, None]
ComponentType = typing.Union[
ComponentSingleType,
typing.Sequence[ComponentSingleType],
]

NumberType = typing.Union[
typing.SupportsFloat, typing.SupportsInt, typing.SupportsComplex
]


class BaseLayer(Component):
"""A BaseLayer component.
Expand Down
13 changes: 9 additions & 4 deletions dash_leaflet2/Circle.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,22 @@
import typing # noqa: F401
from typing_extensions import TypedDict, NotRequired, Literal # noqa: F401
from dash.development.base_component import Component, _explicitize_args
try:
from dash.types import NumberType # noqa: F401
except ImportError:
# Backwards compatibility for dash<=4.1.0
if typing.TYPE_CHECKING:
raise
NumberType = typing.Union[ # noqa: F401
typing.SupportsFloat, typing.SupportsInt, typing.SupportsComplex
]

ComponentSingleType = typing.Union[str, int, float, Component, None]
ComponentType = typing.Union[
ComponentSingleType,
typing.Sequence[ComponentSingleType],
]

NumberType = typing.Union[
typing.SupportsFloat, typing.SupportsInt, typing.SupportsComplex
]


class Circle(Component):
"""A Circle component.
Expand Down
Loading
Loading