diff --git a/.env.example b/.env.example index 9b07a80..e616c79 100644 --- a/.env.example +++ b/.env.example @@ -94,6 +94,21 @@ DASH_LEAFLET2_BASE_URL=http://localhost:8050 # Production primary (2plot.ai on its custom Clerk domains): # CLERK_SIGN_IN_URL=https://accounts.2plot.ai/sign-in # CLERK_SIGN_UP_URL=https://accounts.2plot.ai/sign-up +# +# THE RETURN TRIP — required in production, and the one Clerk setting that +# fails with no error anywhere. Set, the Sign In button navigates to +# https://2plot.ai/onboarding?returnTo=, a page on the hub's own +# app that validates returnTo and sends the user home. Unset, sign-in falls +# back to the Clerk-hosted Account Portal, the hub's app never sees the +# request, and the user authenticates successfully and stays on 2plot.ai. +# A DESTINATION, not a flag. It must be an absolute http(s) URL — the value +# is concatenated as `?returnTo=`, so `true` or a bare host +# resolves against THIS site and the button 404s, which is worse than +# leaving it unset. dash-clerk-auth only logs a warning and uses a bad +# value anyway; lib/auth.py warns at boot for both mistakes. +# Leave it unset LOCALLY (localhost is not a satellite); render.yaml sets +# it in production. +# CLERK_SATELLITE_SIGN_IN_REDIRECT=https://2plot.ai/onboarding # CLERK_FRONTEND_API=https://clerk.2plot.ai # CLERK_IS_SATELLITE=false # SESSION_SECRET= diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 446740a..f0b1fb2 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -147,6 +147,7 @@ is logged. | `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. | +| `CLERK_SATELLITE_SIGN_IN_REDIRECT` | — | **Required in production.** `https://2plot.ai/onboarding`. Unset, sign-in reaches the primary and never returns — see the next section. | | `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.** | @@ -183,6 +184,81 @@ instance and cannot host satellites): All five are already literals in `render.yaml`; only `CLERK_SECRET_KEY` and `CLERK_PUBLISHABLE_KEY` need entering in the dashboard. +### The sign-in return trip — the failure with no error + +**Symptom:** the visitor clicks Sign In, authenticates successfully on the +primary, and is left there. Nothing is logged on either host, no console error, +no failed request. Observed live on this service 2026-08-20. + +**Cause:** `CLERK_SATELLITE_SIGN_IN_REDIRECT` unset. There are two paths, and +only one of them belongs to this network: + +| | Set to `https://2plot.ai/onboarding` | Unset (the fallback) | +|---|---|---| +| Where the button goes | `2plot.ai/onboarding?returnTo=` | `CLERK_SIGN_IN_URL` → `accounts.2plot.ai`, Clerk's **hosted** Account Portal | +| Who validates the return | 2plot.ai's own Dash app, against `lib.auth.allowed_redirect_origins()` | Clerk's dashboard allowed-redirect list | +| Sign-up button | `&mode=signup` opens the sign-**up** modal | no equivalent | +| When it breaks | the hub's whitelist rejects the origin — visible in that repo | ClerkJS silently drops `signInForceRedirectUrl` and uses the portal default | + +The fallback is why this is so quiet: the hub's Dash app is **never in the +loop**, so none of its `returnTo` machinery — the whitelist check, the +force-redirect, the auto-open — ever executes. `accounts.2plot.ai` is Clerk's +property, not ours. + +**It is a destination, not a flag.** The value must be an absolute `http(s)` +URL, because `buildSatelliteRedirect()` builds `?returnTo=` by +concatenation. A truthy non-URL is the worst of the three states: + +| Value | Where the Sign In button goes | +|---|---| +| `https://2plot.ai/onboarding` | `https://2plot.ai/onboarding?returnTo=…` — correct | +| *(unset)* | Clerk Account Portal — the stranding above | +| `true`, or a bare `2plot.ai/onboarding` | `https://leaflet.2plot.dev/true?returnTo=…` — resolved against **this** host, 404, sign-in impossible | + +`dash-clerk-auth` does not reject a bad value: it emits one `logger.warning` +and uses it regardless. `lib/auth.py` therefore prints its own `[auth] ⚠️` +line for both mistakes, alongside the rest of the boot diagnostics. + +`dash-clerk-auth` reads the variable from the environment itself, so +`lib/auth.py` does not pass it through; setting it on the service is the whole +fix. `lib/auth.py` now **warns at boot** whenever satellite mode is on and this +is unset, because the original note here said to set it "once 2plot.ai has a +page that honours `?returnTo=`" — 2plot.ai gained that page, and nobody came +back. + +**The other half to check when this misbehaves** lives in the *2plotai* repo, +not here: `allowed_redirect_origins()` is env-first, so if +`CLERK_ALLOWED_REDIRECT_ORIGINS` is set on the hub's service it **replaces** the +built-in list rather than extending it. `https://leaflet.2plot.dev` is in the +built-in default; confirm it survived any override. + +### Blueprint vs dashboard drift + +`render.yaml` declares the contract; Render applies `envVars` on a **blueprint +sync**, not on an autoDeploy from a git push. So the two drift, silently, and +the code cannot tell. As measured on 2026-08-20 the live service was missing: + +| Missing on the service | Consequence | +|---|---| +| `CLERK_SATELLITE_SIGN_IN_REDIRECT` | The sign-in return trip above. **Fix this one first.** | +| `PAGE_VISIBILITY_FILE` | Control-board overrides land on the container filesystem instead of `/var/data`, so every toggle resets on the next deploy. | +| `AD_SERVER_URL` | The ad slot never renders (the client's circuit breaker just keeps it hidden). | +| `ANALYTICS_GEO_LOOKUP` | Re-enables the per-visit ip-api.com lookup that Cloudflare's country header already answers. | +| `PAGE_DEFAULT_TIER` | Harmless today — `PAGE_DEFAULT_VISIBILITY` is set and is an accepted alias — but the gate's flip lever should be the canonical name. | +| `LLMS_SMALL_TIER` / `LLMS_FULL_TIER` | Harmless (`run.py` defaults both to `public` explicitly), but the knob is invisible on the dashboard. | +| `WEB_CONCURRENCY` | gunicorn falls back to its own worker default. | + +And carrying two variables **nothing reads any more** — Gen-1 leftovers retired +with the single-module tracker; delete them so the dashboard stops implying +they do something: + +- `SATELLITE_ANALYTICS_DRY_RUN` +- `SATELLITE_APP_ID` (the trio reads `SATELLITE_APP_KEY`) + +`PYTHONUNBUFFERED` belongs in the **Dockerfile**, not the dashboard — it is set +there already (`ENV PYTHONUNBUFFERED=1`), which is why it is correctly absent +from the service's variable list. + ### Registering the satellite on the primary — two separate lists Easy to conflate, and each fails differently. @@ -429,8 +505,10 @@ offline. The hub verifies the Clerk token against Clerk's JWKS and pins the environment table. 2. `GET /llms.txt`, `/robots.txt`, `/sitemap.xml` all 200, and the sitemap URLs 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. +3. Sign in from the site — you should bounce to **`2plot.ai/onboarding`** (not + `accounts.2plot.ai`) and land **back here**, not on the primary's home page. + Landing on the primary means `CLERK_SATELLITE_SIGN_IN_REDIRECT` is unset; + the boot log warns about it, and "The sign-in return trip" above is the fix. 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: diff --git a/lib/auth.py b/lib/auth.py index b28e140..fbfa9ae 100644 --- a/lib/auth.py +++ b/lib/auth.py @@ -30,14 +30,29 @@ CLERK_SATELLITE_DOMAIN "leaflet.2plot.dev" (host only, no scheme) CLERK_SIGN_UP_URL https://2plot.ai/sign-up CLERK_SATELLITE_SIGN_IN_REDIRECT - OPTIONAL (dash-clerk-auth >= 0.9.2). An absolute URL - on the PRIMARY that the Sign In button navigates to, - with this page carried in ?returnTo=. Read by the - package itself, so we do not pass it through. Unset, - sign-in falls back to Clerk.redirectToSignIn() with - this page forced as the return — the behaviour this - site already ships. Only set it once 2plot.ai has a - page that honours ?returnTo=. + REQUIRED in production, despite reading as optional. + An absolute URL on the PRIMARY that the Sign In + button navigates to, with this page carried in + ?returnTo=. Read by dash-clerk-auth from the + environment itself, so we do not pass it through. + Set to https://2plot.ai/onboarding (render.yaml). + + It was left unset here while the note below said + "only set it once 2plot.ai has a page that honours + ?returnTo=". 2plot.ai gained exactly that page — + /onboarding, which validates returnTo against its + own satellite whitelist and force-redirects home — + and nobody came back to set it. The result, live on + 2026-08-20: sign-in succeeded on the primary and + stranded the user there. + + Unset, the fallback is Clerk.redirectToSignIn(), + which hops to CLERK_SIGN_IN_URL — the Clerk-HOSTED + Account Portal. The hub's Dash app never sees that + request, so none of its returnTo handling runs and + the return depends solely on the Clerk dashboard's + own allowed-redirect list. That failure is silent + on both sides. See the boot warning in register(). Admin allowlist (drives /admin/control-board): ADMIN_EMAILS comma-separated, case-insensitive @@ -263,6 +278,33 @@ def register() -> bool: f"CLERK_SATELLITE_DOMAIN={SATELLITE_HOST} (and CLERK_IS_SATELLITE=true) " "or ClerkJS will fail with 'satellite needs a domain'." ) + sat_redirect = (os.getenv("CLERK_SATELLITE_SIGN_IN_REDIRECT") or "").strip() + if is_satellite and not sat_redirect: + # The one Clerk misconfiguration with NO error on either side: the + # user signs in successfully and is simply left on the primary. It + # shipped that way here for weeks because nothing said so. Now + # something does. + print( + "[auth] ⚠️ Satellite mode with CLERK_SATELLITE_SIGN_IN_REDIRECT " + "unset — sign-in will hop to the Clerk Account Portal instead of " + "the hub, and users may not be returned to this site at all. " + "Set it to https://2plot.ai/onboarding (see render.yaml)." + ) + elif is_satellite and not sat_redirect.startswith(("http://", "https://")): + # It is a URL, not a flag, and the package does not reject a bad one: + # it logs a warning and uses the value anyway. `buildSatelliteRedirect` + # concatenates `?returnTo=`, so a non-absolute value is + # resolved against THIS host — CLERK_SATELLITE_SIGN_IN_REDIRECT=true + # sends the Sign In button to https:///true?returnTo=..., + # which is strictly worse than leaving it unset. The package's own + # warning goes to `logger`, several screens up in the boot output; + # this one prints where the rest of the [auth] diagnostics are. + print( + f"[auth] ⚠️ CLERK_SATELLITE_SIGN_IN_REDIRECT={sat_redirect!r} is not " + "an absolute http(s) URL. It is a DESTINATION, not a flag — the " + "Sign In button will navigate to a path on THIS host and 404. " + "Set it to https://2plot.ai/onboarding." + ) if is_satellite and sat_domain: _install_satellite_signin_delegation() diff --git a/render.yaml b/render.yaml index 80f2085..a3c76b4 100644 --- a/render.yaml +++ b/render.yaml @@ -142,6 +142,39 @@ services: value: https://accounts.2plot.ai/sign-in - key: CLERK_SIGN_UP_URL value: https://accounts.2plot.ai/sign-up + # THE RETURN TRIP. Without this, sign-in reaches the primary and the + # user never comes back — the exact failure observed on this host + # 2026-08-20. + # + # Two different paths exist, and only one of them is ours: + # + # SET (this): the Sign In button NAVIGATES to + # https://2plot.ai/onboarding?returnTo=. + # That is a page on the hub's own Dash app, which + # injects a script that validates returnTo against + # lib.auth.allowed_redirect_origins() (leaflet.2plot.dev + # is in that list) and then force-redirects home. The + # network owns and can debug every hop. + # + # UNSET: dash-clerk-auth falls back to + # Clerk.redirectToSignIn(), which goes to + # CLERK_SIGN_IN_URL — accounts.2plot.ai, Clerk's HOSTED + # Account Portal. The hub's Dash app is never in the + # loop, so none of its returnTo machinery runs, and the + # return depends entirely on the Clerk dashboard's own + # allowed-redirect list. When that does not name this + # host, ClerkJS silently drops signInForceRedirectUrl + # and uses the portal's default after-sign-in URL: the + # user authenticates successfully and lands on 2plot.ai. + # No error, client-side or server-side. + # + # dash-clerk-auth reads this name from the environment itself + # (satellite_sign_in_redirect's env fallback), so lib/auth.py does not + # pass it through. `assets/auth_gate.js` appends &mode=signup for the + # "Create free account" button, which /onboarding reads to open the + # sign-UP modal instead — so this one value serves both buttons. + - key: CLERK_SATELLITE_SIGN_IN_REDIRECT + value: https://2plot.ai/onboarding # REQUIRED in satellite mode. A production custom-domain instance cannot # derive this from CLERK_SIGN_IN_URL — it must be set explicitly. - key: CLERK_FRONTEND_API