From ea1d8e007e89851da3d9acaab1dc065f016e3358 Mon Sep 17 00:00:00 2001 From: giswqs Date: Mon, 10 Aug 2026 18:20:42 -0400 Subject: [PATCH 1/7] feat: add optional Clerk access gate Allow hosted GeoLibre deployments to require individual Clerk sign-in while keeping public, native, and embedded builds unchanged. Load Clerk on demand and keep its chunk out of the default PWA precache. --- apps/geolibre-desktop/package.json | 1 + .../src/components/auth/ClerkGate.tsx | 41 +++++++++++ apps/geolibre-desktop/src/lib/clerk-auth.ts | 19 +++++ apps/geolibre-desktop/src/main.tsx | 17 +++-- apps/geolibre-desktop/vite.config.ts | 4 ++ docker-compose.yml | 1 + docker/entrypoint.sh | 41 +++++++++++ docker/nginx.conf | 2 +- docs/getting-started.md | 24 +++++++ docs/self-hosting.md | 1 + package-lock.json | 70 +++++++++++++++++++ tests/clerk-auth.test.ts | 41 +++++++++++ 12 files changed, 257 insertions(+), 5 deletions(-) create mode 100644 apps/geolibre-desktop/src/components/auth/ClerkGate.tsx create mode 100644 apps/geolibre-desktop/src/lib/clerk-auth.ts create mode 100644 tests/clerk-auth.test.ts diff --git a/apps/geolibre-desktop/package.json b/apps/geolibre-desktop/package.json index 3a8a037562..7f255060c7 100644 --- a/apps/geolibre-desktop/package.json +++ b/apps/geolibre-desktop/package.json @@ -19,6 +19,7 @@ "@anthropic-ai/sdk": "^0.115.0", "@carbonplan/zarr-layer": "^0.7.0", "@cereusdb/standard": "^0.2.0", + "@clerk/react": "^6.14.1", "@deck.gl/aggregation-layers": "9.3.7", "@deck.gl/core": "^9.3.7", "@deck.gl/geo-layers": "^9.3.7", diff --git a/apps/geolibre-desktop/src/components/auth/ClerkGate.tsx b/apps/geolibre-desktop/src/components/auth/ClerkGate.tsx new file mode 100644 index 0000000000..cbeb02360b --- /dev/null +++ b/apps/geolibre-desktop/src/components/auth/ClerkGate.tsx @@ -0,0 +1,41 @@ +import { ClerkLoaded, ClerkLoading, ClerkProvider, Show, SignIn, UserButton } from "@clerk/react"; +import type { ReactNode } from "react"; + +interface ClerkGateProps { + publishableKey: string; + children: ReactNode; +} + +/** + * Optional whole-app sign-in gate for hosted web deployments. + * + * This module is dynamically imported only when a Clerk key is configured, so + * normal web, Tauri, mobile, and embedded builds do not initialize Clerk. + */ +export function ClerkGate({ publishableKey, children }: ClerkGateProps) { + return ( + + +
+ + + + +
+ +
+
+ + {children} +
+ +
+
+
+ + ); +} diff --git a/apps/geolibre-desktop/src/lib/clerk-auth.ts b/apps/geolibre-desktop/src/lib/clerk-auth.ts new file mode 100644 index 0000000000..5d02d1d6d7 --- /dev/null +++ b/apps/geolibre-desktop/src/lib/clerk-auth.ts @@ -0,0 +1,19 @@ +import { readDeploymentEnvValue, type EnvRecord } from "./deployment-env"; + +export const CLERK_PUBLISHABLE_KEY_ENV = "VITE_GEOLIBRE_CLERK_PUBLISHABLE_KEY"; + +/** + * Resolve the optional Clerk publishable key for a web deployment. + * + * A missing key keeps authentication completely disabled. Native and embedded + * callers should pass `false` for `webApp` so a build-time environment variable + * cannot accidentally gate an offline application. + */ +export function resolveClerkPublishableKey( + webApp: boolean, + deploymentEnv?: EnvRecord, + buildEnv?: EnvRecord, +): string | undefined { + if (!webApp) return undefined; + return readDeploymentEnvValue(CLERK_PUBLISHABLE_KEY_ENV, deploymentEnv, buildEnv)?.trim(); +} diff --git a/apps/geolibre-desktop/src/main.tsx b/apps/geolibre-desktop/src/main.tsx index e557c2dc7b..62509394c9 100644 --- a/apps/geolibre-desktop/src/main.tsx +++ b/apps/geolibre-desktop/src/main.tsx @@ -55,6 +55,8 @@ import i18n, { i18nReady } from "./i18n"; import { installDiagnosticsCapture } from "./lib/diagnostics"; import { isTauri } from "./lib/is-tauri"; import { installStaleChunkReload } from "./lib/stale-chunk-reload"; +import { resolveClerkPublishableKey } from "./lib/clerk-auth"; +import { isEmbedded } from "./hooks/embedHost"; installDiagnosticsCapture(); // In the desktop build, route geocoding (place search / reverse geocode) @@ -95,6 +97,7 @@ if (isTauri()) { // Recover from chunks orphaned by a web redeploy (stale lazy import → 404). A // no-op in the desktop build, whose chunks are bundled locally. installStaleChunkReload(); +const clerkPublishableKey = resolveClerkPublishableKey(!isTauri() && !isEmbedded()); // Register the offline/PWA service worker (web build only). `registerSW` is a // no-op stub in the Tauri desktop and embedded Jupyter builds, where the plugin // is disabled (see vite.config.ts pwaPlugin). @@ -138,18 +141,24 @@ registerSW({ void Promise.all([ import("./App"), import("./components/common/error-boundaries"), + clerkPublishableKey ? import("./components/auth/ClerkGate") : Promise.resolve(null), // Gate the first render on i18next being initialized with the active locale's // (lazily loaded) catalog, so the UI never paints raw translation keys. i18nReady, ]) - .then(([{ default: App }, { AppErrorBoundary }]) => { + .then(([{ default: App }, { AppErrorBoundary }, clerkModule]) => { + const app = ; + const authenticatedApp = + clerkPublishableKey && clerkModule ? ( + {app} + ) : ( + app + ); ReactDOM.createRoot(document.getElementById("root")!).render( - - - + {authenticatedApp} , diff --git a/apps/geolibre-desktop/vite.config.ts b/apps/geolibre-desktop/vite.config.ts index 47a0956fd1..4c5901d12b 100644 --- a/apps/geolibre-desktop/vite.config.ts +++ b/apps/geolibre-desktop/vite.config.ts @@ -710,6 +710,10 @@ function pwaPlugin(): Plugin[] { // is auto-named `i18n-` and must stay precached, so this must NOT match // it. English is bundled there, so it stays precached and works offline. "**/i18n-locale-*.js", + // Optional hosted-web authentication. This chunk is requested only when a + // Clerk publishable key is configured, so public deployments should not + // download it during service-worker installation. + "**/ClerkGate-*.js", ]; // Note: the 4 KB public/pyodide/pyodide-worker.js shim is intentionally left // in the precache (revisioned, so no stale-after-deploy risk). The heavy diff --git a/docker-compose.yml b/docker-compose.yml index 62aca5e98e..505abe7a02 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,7 @@ services: # them with the public TLS origins when deploying behind an ingress. GEOLIBRE_SHARE_URL: "${GEOLIBRE_SHARE_URL:-http://localhost:8000}" GEOLIBRE_COLLAB_URL: "${GEOLIBRE_COLLAB_URL:-ws://localhost:8787}" + GEOLIBRE_CLERK_PUBLISHABLE_KEY: "${GEOLIBRE_CLERK_PUBLISHABLE_KEY:-}" depends_on: geolibre-server: condition: service_healthy diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 41fc0dfe8c..a79c4278e4 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -132,6 +132,7 @@ python -c ' import json import os import re +import base64 from urllib.parse import urlsplit deployment = {} @@ -139,6 +140,22 @@ if os.environ.get("GEOLIBRE_AI_URL"): deployment["VITE_GEOLIBRE_AI_URL"] = os.environ["GEOLIBRE_AI_URL"] deployment["VITE_GEOLIBRE_AI_MODEL"] = os.environ["GEOLIBRE_AI_MODEL"] +# Optional Clerk sign-in gate. The publishable key is intentionally public and +# is all the browser needs; Clerk secrets never enter the image or runtime +# config. Decode and validate the embedded Frontend API hostname now so an +# invalid key fails at container startup instead of leaving a blank login page. +clerk_key = os.environ.get("GEOLIBRE_CLERK_PUBLISHABLE_KEY", "").strip() +if clerk_key: + try: + encoded = clerk_key.split("_", 2)[2] + encoded += "=" * (-len(encoded) % 4) + clerk_fapi = base64.urlsafe_b64decode(encoded).decode().rstrip("$") + except (IndexError, ValueError, UnicodeDecodeError) as error: + raise SystemExit("ERROR: GEOLIBRE_CLERK_PUBLISHABLE_KEY is invalid.") from error + if not re.fullmatch(r"[A-Za-z0-9.-]+", clerk_fapi) or "." not in clerk_fapi: + raise SystemExit("ERROR: GEOLIBRE_CLERK_PUBLISHABLE_KEY contains an invalid Frontend API host.") + deployment["VITE_GEOLIBRE_CLERK_PUBLISHABLE_KEY"] = clerk_key + # Origins allowed to drive a framed app over the embed postMessage API. Unset # means the API stays off, so a public deployment can never be driven by the # page that frames it. "*" allows any origin: private networks only. @@ -262,6 +279,10 @@ if [ -n "${GEOLIBRE_EMBED_ORIGINS:-}" ]; then echo "Embed postMessage API enabled for: $GEOLIBRE_EMBED_ORIGINS" fi +if [ -n "$(trim "${GEOLIBRE_CLERK_PUBLISHABLE_KEY:-}")" ]; then + echo "Clerk sign-in gate enabled." +fi + # Render the nginx config from the immutable image template on every boot. The # template is never mutated, so a container *restart* (which re-runs this script # with a freshly generated token but keeps the writable layer) always writes a @@ -271,6 +292,7 @@ fi python -c ' import os import re +import base64 from urllib.parse import urlsplit token = os.environ["GEOLIBRE_SIDECAR_TOKEN"] @@ -292,10 +314,29 @@ if collab: raise SystemExit(f"ERROR: GEOLIBRE_COLLAB_URL is not a plain origin: {collab!r}.") collab_src = f" {origin}" +# Clerk loads its browser SDK from the Frontend API hostname encoded in the +# publishable key. Add only that exact hostname to script-src. The remaining +# documented Clerk requirements are fixed origins in the nginx template. +clerk_src = "" +clerk_frame_src = "" +clerk_key = os.environ.get("GEOLIBRE_CLERK_PUBLISHABLE_KEY", "").strip() +if clerk_key: + encoded = clerk_key.split("_", 2)[2] + encoded += "=" * (-len(encoded) % 4) + clerk_fapi = base64.urlsafe_b64decode(encoded).decode().rstrip("$") + if not re.fullmatch(r"[A-Za-z0-9.-]+", clerk_fapi) or "." not in clerk_fapi: + raise SystemExit("ERROR: Clerk Frontend API host is invalid.") + clerk_src = f" https://{clerk_fapi} https://challenges.cloudflare.com https://*.protect.clerk.com" + clerk_frame_src = " https://challenges.cloudflare.com https://*.protect.clerk.com" + src = open("/etc/nginx/nginx.conf.template").read() open("/etc/nginx/conf.d/default.conf", "w").write( src.replace("__GEOLIBRE_SIDECAR_TOKEN__", token).replace( "__GEOLIBRE_COLLAB_CONNECT_SRC__", collab_src + ).replace( + "__GEOLIBRE_CLERK_SCRIPT_SRC__", clerk_src + ).replace( + "__GEOLIBRE_CLERK_FRAME_SRC__", clerk_frame_src ) ) ' diff --git a/docker/nginx.conf b/docker/nginx.conf index a27e6474fb..b25e3285f1 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -98,7 +98,7 @@ server { # launched JupyterLab server in the Notebook panel. That is desktop-only # and intentionally NOT mirrored here: the web build embeds the # same-origin self-hosted JupyterLite site, already covered by 'self'. - add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https: data: blob: http://127.0.0.1:* http://localhost:* wss://collab.geolibre.app__GEOLIBRE_COLLAB_CONNECT_SRC__ ws://127.0.0.1:* ws://localhost:*; img-src 'self' data: blob: https:; media-src 'self' blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' blob: 'unsafe-eval' 'wasm-unsafe-eval' https://cdn.jsdelivr.net/npm/ https://cdn.jsdelivr.net/pyodide/ https://accounts.google.com; child-src 'self' https://accounts.google.com https://www.google.com; frame-src 'self' https://accounts.google.com https://www.google.com; worker-src blob: 'self'" always; + add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https: data: blob: http://127.0.0.1:* http://localhost:* wss://collab.geolibre.app__GEOLIBRE_COLLAB_CONNECT_SRC__ ws://127.0.0.1:* ws://localhost:*; img-src 'self' data: blob: https:; media-src 'self' blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' blob: 'unsafe-eval' 'wasm-unsafe-eval' https://cdn.jsdelivr.net/npm/ https://cdn.jsdelivr.net/pyodide/ https://accounts.google.com__GEOLIBRE_CLERK_SCRIPT_SRC__; child-src 'self' https://accounts.google.com https://www.google.com; frame-src 'self' https://accounts.google.com https://www.google.com__GEOLIBRE_CLERK_FRAME_SRC__; worker-src blob: 'self'" always; } # The service worker has a stable filename, so it must always revalidate; diff --git a/docs/getting-started.md b/docs/getting-started.md index 8e1061a43d..df0f0753aa 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -261,6 +261,30 @@ Also see the note in about dropping the `localhost` CSP allowances before exposing the image publicly. +#### Clerk sign-in gate (optional) + +For individual user accounts instead of one shared password, configure a Clerk +application for the deployment domain and pass its publishable key: + +```bash +docker run --rm -p 8080:80 \ + -e GEOLIBRE_CLERK_PUBLISHABLE_KEY='pk_live_...' \ + ghcr.io/opengeos/geolibre:latest +``` + +When the variable is unset, Clerk is not loaded and GeoLibre behaves exactly as +before. The gate applies only to the hosted web application; Tauri, mobile, and +embedded/Jupyter builds remain available offline. Control who may register or +sign in through the Clerk Dashboard. Configure TLS and the deployment domain in +Clerk before using a production key. + +This client-side gate controls access to the GeoLibre interface but is not a +server authorization boundary by itself. Keep `/sidecar`, `/ai`, and any other +sensitive upstream service behind nginx authentication, Cloudflare Access, or a +backend that verifies Clerk session tokens on every request. Use the existing +`GEOLIBRE_AUTH_USER` and `GEOLIBRE_AUTH_PASSWORD` variables as well when the +whole container must be protected before its assets are served. + #### Subpath and onboarding build arguments For deployments under a URL subpath, pass the app base at build time: diff --git a/docs/self-hosting.md b/docs/self-hosting.md index e55eb63a6f..624834b8a0 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -141,6 +141,7 @@ Settings that matter for a private deployment: | `GEOLIBRE_SHARE_URL` | `off`, or your own server | `off` removes Share and the Project Gallery entirely, so no project can be published to `share.geolibre.app` by accident. A URL points both at your own [projects server](server-api.md). | | `GEOLIBRE_COLLAB_URL` | unset, or your own relay | Unset leaves [live collaboration](collaboration.md) dark. Set it to a `wss://` relay you run if you want multiplayer editing without the hosted relay. | | `GEOLIBRE_AUTH_USER` / `GEOLIBRE_AUTH_PASSWORD` | set, for a quick single credential | nginx Basic Auth over the app and the `/sidecar` API. One shared credential, not accounts. Use a real auth proxy for multi-user or SSO. | +| `GEOLIBRE_CLERK_PUBLISHABLE_KEY` | unset, or a Clerk publishable key | Unset keeps the app public and does not load Clerk. A key requires individual users to sign in before the web interface renders; protect server APIs separately. | | `GEOLIBRE_CONVERSION_ROOTS` | `/data` (the image default) | Confines every sidecar read and write to the mounted directory. | | `GEOLIBRE_POSTGIS_HOSTS` | unset unless needed | The sidecar's PostGIS endpoints refuse every destination until this names the allowed databases, so a caller cannot aim them at hosts only the container can reach. | | `GEOLIBRE_DISABLE_SIDECAR` | `1` if you do not need it | Runs nginx only. | diff --git a/package-lock.json b/package-lock.json index 75bcc656d2..b6ef718d95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "@anthropic-ai/sdk": "^0.115.0", "@carbonplan/zarr-layer": "^0.7.0", "@cereusdb/standard": "^0.2.0", + "@clerk/react": "^6.14.1", "@deck.gl/aggregation-layers": "9.3.7", "@deck.gl/core": "^9.3.7", "@deck.gl/geo-layers": "^9.3.7", @@ -2645,6 +2646,50 @@ "node": ">=16.0.0" } }, + "node_modules/@clerk/react": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/@clerk/react/-/react-6.14.1.tgz", + "integrity": "sha512-rSFJUyfOuMeySbmkTv+g4DpPp492lpgVKERtrzm0wo+PwnWXoGjVkrpWb6mZMKLQAvq2dU2LnKitljcbLDKUXA==", + "license": "MIT", + "dependencies": { + "@clerk/shared": "^4.28.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", + "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" + } + }, + "node_modules/@clerk/shared": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-4.28.1.tgz", + "integrity": "sha512-OhpUczN6t8CYJ+g8HdzN1O4SFC2EANOZRDwlE3rXxKu13eNtyFbLspt+d6Nhb/PmcThS/fIAKYnQQrwv0vIEwg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "^5.100.6", + "dequal": "2.0.3", + "glob-to-regexp": "0.4.1", + "js-cookie": "3.0.7" + }, + "engines": { + "node": ">=20.9.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", + "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/@cloudflare/kv-asset-handler": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", @@ -8176,6 +8221,16 @@ "tailwindcss": "4.3.3" } }, + "node_modules/@tanstack/query-core": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/react-virtual": { "version": "3.14.9", "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.9.tgz", @@ -14543,6 +14598,12 @@ "node": ">=10.13.0" } }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "license": "BSD-2-Clause" + }, "node_modules/global": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz", @@ -16061,6 +16122,15 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-cookie": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.7.tgz", + "integrity": "sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/tests/clerk-auth.test.ts b/tests/clerk-auth.test.ts new file mode 100644 index 0000000000..8f6940fc1e --- /dev/null +++ b/tests/clerk-auth.test.ts @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + CLERK_PUBLISHABLE_KEY_ENV, + resolveClerkPublishableKey, +} from "../apps/geolibre-desktop/src/lib/clerk-auth"; + +describe("optional Clerk authentication", () => { + it("stays disabled when no publishable key is configured", () => { + assert.equal(resolveClerkPublishableKey(true, {}, {}), undefined); + }); + + it("prefers the Docker runtime key over the build-time key", () => { + assert.equal( + resolveClerkPublishableKey( + true, + { [CLERK_PUBLISHABLE_KEY_ENV]: " pk_live_runtime " }, + { [CLERK_PUBLISHABLE_KEY_ENV]: "pk_test_build" }, + ), + "pk_live_runtime", + ); + }); + + it("falls back to the build-time key", () => { + assert.equal( + resolveClerkPublishableKey(true, {}, { [CLERK_PUBLISHABLE_KEY_ENV]: "pk_test_build" }), + "pk_test_build", + ); + }); + + it("never gates native or embedded applications", () => { + assert.equal( + resolveClerkPublishableKey( + false, + { [CLERK_PUBLISHABLE_KEY_ENV]: "pk_live_runtime" }, + { [CLERK_PUBLISHABLE_KEY_ENV]: "pk_test_build" }, + ), + undefined, + ); + }); +}); From 4681a1c3f9b17336f9f7340a503f3b2b13bec209 Mon Sep 17 00:00:00 2001 From: giswqs Date: Mon, 10 Aug 2026 18:32:16 -0400 Subject: [PATCH 2/7] Address Claude review feedback - Decide the Clerk gate from the build target, not the request: main.tsx no longer consults isEmbedded(), whose `?embed=1` query parameter is client-controlled and let any visitor render a Clerk-gated deployment with no sign-in wall. A new __GEOLIBRE_EMBED_BUILD__ define (GEOLIBRE_EMBED=1) keeps the Jupyter embed wheel ungated as intended, and documents the constraint on resolveClerkPublishableKey and in docs/getting-started.md. - Give the CSP block in docker/entrypoint.sh its own try/except around the publishable-key base64 decode instead of depending on `set -e` and the earlier block having already validated the key, so a reorder or extraction cannot turn an invalid key into a raw traceback. --- apps/geolibre-desktop/src/lib/clerk-auth.ts | 4 +++- apps/geolibre-desktop/src/main.tsx | 8 ++++++-- apps/geolibre-desktop/src/vite-env.d.ts | 8 ++++++++ apps/geolibre-desktop/vite.config.ts | 1 + docker/entrypoint.sh | 16 +++++++++++++--- docs/getting-started.md | 6 ++++-- 6 files changed, 35 insertions(+), 8 deletions(-) diff --git a/apps/geolibre-desktop/src/lib/clerk-auth.ts b/apps/geolibre-desktop/src/lib/clerk-auth.ts index 5d02d1d6d7..45d0d78630 100644 --- a/apps/geolibre-desktop/src/lib/clerk-auth.ts +++ b/apps/geolibre-desktop/src/lib/clerk-auth.ts @@ -7,7 +7,9 @@ export const CLERK_PUBLISHABLE_KEY_ENV = "VITE_GEOLIBRE_CLERK_PUBLISHABLE_KEY"; * * A missing key keeps authentication completely disabled. Native and embedded * callers should pass `false` for `webApp` so a build-time environment variable - * cannot accidentally gate an offline application. + * cannot accidentally gate an offline application. `webApp` must be derived from + * the build target alone — a runtime signal the visitor controls (a query + * parameter such as `?embed=1`) would let anyone switch the gate off. */ export function resolveClerkPublishableKey( webApp: boolean, diff --git a/apps/geolibre-desktop/src/main.tsx b/apps/geolibre-desktop/src/main.tsx index 62509394c9..0f18eac5ce 100644 --- a/apps/geolibre-desktop/src/main.tsx +++ b/apps/geolibre-desktop/src/main.tsx @@ -56,7 +56,6 @@ import { installDiagnosticsCapture } from "./lib/diagnostics"; import { isTauri } from "./lib/is-tauri"; import { installStaleChunkReload } from "./lib/stale-chunk-reload"; import { resolveClerkPublishableKey } from "./lib/clerk-auth"; -import { isEmbedded } from "./hooks/embedHost"; installDiagnosticsCapture(); // In the desktop build, route geocoding (place search / reverse geocode) @@ -97,7 +96,12 @@ if (isTauri()) { // Recover from chunks orphaned by a web redeploy (stale lazy import → 404). A // no-op in the desktop build, whose chunks are bundled locally. installStaleChunkReload(); -const clerkPublishableKey = resolveClerkPublishableKey(!isTauri() && !isEmbedded()); +// "Web app" here means the *build*, never anything the visitor controls: the +// desktop shell and the Jupyter embed wheel are compiled without the gate, but a +// hosted deployment gates every request. In particular this must NOT consult +// `isEmbedded()` — that returns true for a plain `?embed=1` query parameter, so +// any visitor could disable a configured sign-in wall by typing a URL. +const clerkPublishableKey = resolveClerkPublishableKey(!isTauri() && !__GEOLIBRE_EMBED_BUILD__); // Register the offline/PWA service worker (web build only). `registerSW` is a // no-op stub in the Tauri desktop and embedded Jupyter builds, where the plugin // is disabled (see vite.config.ts pwaPlugin). diff --git a/apps/geolibre-desktop/src/vite-env.d.ts b/apps/geolibre-desktop/src/vite-env.d.ts index 30342f2757..340f5df88c 100644 --- a/apps/geolibre-desktop/src/vite-env.d.ts +++ b/apps/geolibre-desktop/src/vite-env.d.ts @@ -13,6 +13,14 @@ declare const __GEOLIBRE_STORE_BUILD__: boolean; // UI compiles them out. false in every other build. See vite.config.ts. declare const __GEOLIBRE_MAS_BUILD__: boolean; +// True only in the Jupyter embed wheel build (GEOLIBRE_EMBED=1), which is served +// from inside a notebook and must never render a hosted deployment's sign-in +// gate. false in every other build. Deliberately a *build* flag: the runtime +// `isEmbedded()` heuristic accepts a `?embed=1` query parameter, which a visitor +// controls and so cannot decide whether authentication applies. See +// vite.config.ts. +declare const __GEOLIBRE_EMBED_BUILD__: boolean; + // jsDelivr URLs for the PGlite engine and its PostGIS extension, injected by // vite.config.ts. Only the embed (Jupyter wheel) build reads them, from // pglite-loader.cdn.ts; web/desktop builds bundle PGlite and never reference diff --git a/apps/geolibre-desktop/vite.config.ts b/apps/geolibre-desktop/vite.config.ts index 4c5901d12b..67fa1a6406 100644 --- a/apps/geolibre-desktop/vite.config.ts +++ b/apps/geolibre-desktop/vite.config.ts @@ -873,6 +873,7 @@ export default defineConfig({ __GEOLIBRE_VERSION__: JSON.stringify(APP_VERSION), __GEOLIBRE_STORE_BUILD__: JSON.stringify(IS_STORE_BUILD), __GEOLIBRE_MAS_BUILD__: JSON.stringify(IS_MAS_BUILD), + __GEOLIBRE_EMBED_BUILD__: JSON.stringify(IS_EMBED), __PGLITE_CDN_URL__: JSON.stringify(PGLITE_CDN_URL), __PGLITE_POSTGIS_CDN_URL__: JSON.stringify(PGLITE_POSTGIS_CDN_URL), __CEREUS_WASM_CDN_URL__: JSON.stringify(CEREUS_WASM_CDN_URL), diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index a79c4278e4..e9e0ca9e1e 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -317,13 +317,23 @@ if collab: # Clerk loads its browser SDK from the Frontend API hostname encoded in the # publishable key. Add only that exact hostname to script-src. The remaining # documented Clerk requirements are fixed origins in the nginx template. +# +# The runtime-config block above already decoded and validated this same key +# (`set -e` means we never get here if it rejected one), but the decode is +# repeated with its own try/except rather than relying on that ordering: this is +# a separate `python -c` process, so an edit that reorders, extracts, or drops +# the earlier block would otherwise turn an invalid key into a raw traceback +# instead of the clean ERROR message. clerk_src = "" clerk_frame_src = "" clerk_key = os.environ.get("GEOLIBRE_CLERK_PUBLISHABLE_KEY", "").strip() if clerk_key: - encoded = clerk_key.split("_", 2)[2] - encoded += "=" * (-len(encoded) % 4) - clerk_fapi = base64.urlsafe_b64decode(encoded).decode().rstrip("$") + try: + encoded = clerk_key.split("_", 2)[2] + encoded += "=" * (-len(encoded) % 4) + clerk_fapi = base64.urlsafe_b64decode(encoded).decode().rstrip("$") + except (IndexError, ValueError, UnicodeDecodeError) as error: + raise SystemExit("ERROR: GEOLIBRE_CLERK_PUBLISHABLE_KEY is invalid.") from error if not re.fullmatch(r"[A-Za-z0-9.-]+", clerk_fapi) or "." not in clerk_fapi: raise SystemExit("ERROR: Clerk Frontend API host is invalid.") clerk_src = f" https://{clerk_fapi} https://challenges.cloudflare.com https://*.protect.clerk.com" diff --git a/docs/getting-started.md b/docs/getting-started.md index df0f0753aa..c89f433f9b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -273,8 +273,10 @@ docker run --rm -p 8080:80 \ ``` When the variable is unset, Clerk is not loaded and GeoLibre behaves exactly as -before. The gate applies only to the hosted web application; Tauri, mobile, and -embedded/Jupyter builds remain available offline. Control who may register or +before. The gate applies only to the hosted web application; the separately built +Tauri, mobile, and embedded/Jupyter builds remain available offline. It is a +property of the build, not of the request, so framing the gated deployment or +loading it with `?embed=1` still requires sign-in. Control who may register or sign in through the Clerk Dashboard. Configure TLS and the deployment domain in Clerk before using a production key. From 527d4d1c3b4bb480c478aacd5a9540b676307554 Mon Sep 17 00:00:00 2001 From: giswqs Date: Mon, 10 Aug 2026 18:40:00 -0400 Subject: [PATCH 3/7] Address review feedback - Validate the full Clerk publishable-key shape in docker/entrypoint.sh: require a pk_test_/pk_live_ prefix (so a secret key pasted into GEOLIBRE_CLERK_PUBLISHABLE_KEY can never be published in the runtime config instead of failing the boot), decode with validate=True so stray characters are an error rather than silently dropped, and require the decoded payload's trailing "$" delimiter. Mirrored in the CSP block for the same separate-process reason already documented there. - Note in ClerkGate's doc comment that it gates rendering only and is not a server authorization boundary, pointing at the deployment guidance in docs/getting-started.md. - Say in docs/getting-started.md that Clerk stays disabled only when neither the runtime nor the build-time key is set, and that the runtime one wins. --- .../src/components/auth/ClerkGate.tsx | 5 ++++ docker/entrypoint.sh | 28 ++++++++++++++++--- docs/getting-started.md | 6 ++-- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/apps/geolibre-desktop/src/components/auth/ClerkGate.tsx b/apps/geolibre-desktop/src/components/auth/ClerkGate.tsx index cbeb02360b..a1c2273f09 100644 --- a/apps/geolibre-desktop/src/components/auth/ClerkGate.tsx +++ b/apps/geolibre-desktop/src/components/auth/ClerkGate.tsx @@ -11,6 +11,11 @@ interface ClerkGateProps { * * This module is dynamically imported only when a Clerk key is configured, so * normal web, Tauri, mobile, and embedded builds do not initialize Clerk. + * + * It gates *rendering* only, and is not a server authorization boundary: the + * deployment must still validate Clerk sessions (or another credential) at the + * reverse proxy for `/sidecar`, `/ai`, and any other upstream service. See the + * Clerk section of docs/getting-started.md. */ export function ClerkGate({ publishableKey, children }: ClerkGateProps) { return ( diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index e9e0ca9e1e..7f619194cd 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -142,16 +142,29 @@ if os.environ.get("GEOLIBRE_AI_URL"): # Optional Clerk sign-in gate. The publishable key is intentionally public and # is all the browser needs; Clerk secrets never enter the image or runtime -# config. Decode and validate the embedded Frontend API hostname now so an -# invalid key fails at container startup instead of leaving a blank login page. +# config. A publishable key is `pk_test_`/`pk_live_` + base64url of the Frontend +# API hostname with a trailing "$" delimiter, so check the whole shape now: the +# prefix rejects a secret key pasted into this variable (which would otherwise be +# published to every visitor in the runtime config), and decoding the hostname +# makes an invalid key fail at container startup instead of leaving a blank +# login page. clerk_key = os.environ.get("GEOLIBRE_CLERK_PUBLISHABLE_KEY", "").strip() if clerk_key: + if not clerk_key.startswith(("pk_test_", "pk_live_")): + raise SystemExit( + "ERROR: GEOLIBRE_CLERK_PUBLISHABLE_KEY must be a Clerk publishable key (pk_test_... or pk_live_...)." + ) try: encoded = clerk_key.split("_", 2)[2] encoded += "=" * (-len(encoded) % 4) - clerk_fapi = base64.urlsafe_b64decode(encoded).decode().rstrip("$") + # validate=True so stray characters are an error rather than silently + # discarded, which would decode a malformed key into a plausible host. + clerk_fapi = base64.b64decode(encoded, altchars="-_", validate=True).decode() except (IndexError, ValueError, UnicodeDecodeError) as error: raise SystemExit("ERROR: GEOLIBRE_CLERK_PUBLISHABLE_KEY is invalid.") from error + if not clerk_fapi.endswith("$"): + raise SystemExit("ERROR: GEOLIBRE_CLERK_PUBLISHABLE_KEY is invalid.") + clerk_fapi = clerk_fapi[:-1] if not re.fullmatch(r"[A-Za-z0-9.-]+", clerk_fapi) or "." not in clerk_fapi: raise SystemExit("ERROR: GEOLIBRE_CLERK_PUBLISHABLE_KEY contains an invalid Frontend API host.") deployment["VITE_GEOLIBRE_CLERK_PUBLISHABLE_KEY"] = clerk_key @@ -328,12 +341,19 @@ clerk_src = "" clerk_frame_src = "" clerk_key = os.environ.get("GEOLIBRE_CLERK_PUBLISHABLE_KEY", "").strip() if clerk_key: + if not clerk_key.startswith(("pk_test_", "pk_live_")): + raise SystemExit( + "ERROR: GEOLIBRE_CLERK_PUBLISHABLE_KEY must be a Clerk publishable key (pk_test_... or pk_live_...)." + ) try: encoded = clerk_key.split("_", 2)[2] encoded += "=" * (-len(encoded) % 4) - clerk_fapi = base64.urlsafe_b64decode(encoded).decode().rstrip("$") + clerk_fapi = base64.b64decode(encoded, altchars="-_", validate=True).decode() except (IndexError, ValueError, UnicodeDecodeError) as error: raise SystemExit("ERROR: GEOLIBRE_CLERK_PUBLISHABLE_KEY is invalid.") from error + if not clerk_fapi.endswith("$"): + raise SystemExit("ERROR: GEOLIBRE_CLERK_PUBLISHABLE_KEY is invalid.") + clerk_fapi = clerk_fapi[:-1] if not re.fullmatch(r"[A-Za-z0-9.-]+", clerk_fapi) or "." not in clerk_fapi: raise SystemExit("ERROR: Clerk Frontend API host is invalid.") clerk_src = f" https://{clerk_fapi} https://challenges.cloudflare.com https://*.protect.clerk.com" diff --git a/docs/getting-started.md b/docs/getting-started.md index c89f433f9b..361f0bbdb9 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -272,8 +272,10 @@ docker run --rm -p 8080:80 \ ghcr.io/opengeos/geolibre:latest ``` -When the variable is unset, Clerk is not loaded and GeoLibre behaves exactly as -before. The gate applies only to the hosted web application; the separately built +Clerk is not loaded and GeoLibre behaves exactly as before when neither this +variable nor the build-time `VITE_GEOLIBRE_CLERK_PUBLISHABLE_KEY` is set; the +runtime variable wins when both are. +The gate applies only to the hosted web application; the separately built Tauri, mobile, and embedded/Jupyter builds remain available offline. It is a property of the build, not of the request, so framing the gated deployment or loading it with `?embed=1` still requires sign-in. Control who may register or From 3fd834a217d1bb6f7f0fc84fbe747c7f9f5d77dc Mon Sep 17 00:00:00 2001 From: giswqs Date: Mon, 10 Aug 2026 18:47:23 -0400 Subject: [PATCH 4/7] Address Claude review feedback - Use the same "GEOLIBRE_CLERK_PUBLISHABLE_KEY contains an invalid Frontend API host." message in both entrypoint.sh Clerk blocks, so the duplicated validation cannot report the same failure two different ways. - Note in docker/nginx.conf why the Clerk CSP hosts are deliberately not mirrored into the Tauri CSP (the gate is compiled out of the desktop and embed builds), so the file's own mirror-to-Tauri rule stays accurate. --- docker/entrypoint.sh | 2 +- docker/nginx.conf | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 7f619194cd..9f4a1f48d1 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -355,7 +355,7 @@ if clerk_key: raise SystemExit("ERROR: GEOLIBRE_CLERK_PUBLISHABLE_KEY is invalid.") clerk_fapi = clerk_fapi[:-1] if not re.fullmatch(r"[A-Za-z0-9.-]+", clerk_fapi) or "." not in clerk_fapi: - raise SystemExit("ERROR: Clerk Frontend API host is invalid.") + raise SystemExit("ERROR: GEOLIBRE_CLERK_PUBLISHABLE_KEY contains an invalid Frontend API host.") clerk_src = f" https://{clerk_fapi} https://challenges.cloudflare.com https://*.protect.clerk.com" clerk_frame_src = " https://challenges.cloudflare.com https://*.protect.clerk.com" diff --git a/docker/nginx.conf b/docker/nginx.conf index b25e3285f1..693ed86477 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -93,6 +93,12 @@ server { # URI image -- a KML/KMZ , a deck.gl icon atlas -- is # matched against connect-src, not img-src. Dropping it makes those # layers fail to load with an opaque "Load failed" (issue #1463). + # __GEOLIBRE_CLERK_SCRIPT_SRC__/__GEOLIBRE_CLERK_FRAME_SRC__ are likewise + # replaced at boot with the Clerk Frontend API host encoded in + # GEOLIBRE_CLERK_PUBLISHABLE_KEY plus Clerk's fixed bot-protection origins + # (empty when unset). These are deliberately NOT mirrored to the Tauri CSP: + # the sign-in gate is compiled out of the desktop and embed builds + # (main.tsx gates it on the build target), so Clerk never loads there. # The Tauri CSP additionally allows http://127.0.0.1:* / http://localhost:* # in frame-src/child-src so the desktop app can embed its locally # launched JupyterLab server in the Notebook panel. That is desktop-only From 66eb0b7d76d4663042495db09273186817556f38 Mon Sep 17 00:00:00 2001 From: giswqs Date: Mon, 10 Aug 2026 21:13:26 -0400 Subject: [PATCH 5/7] feat: add an optional Clerk waitlist screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restricted (invite-only) sign-up needs no configuration here, but waitlist mode does: Clerk renders a "Join the waitlist" link inside the sign-in card that has nowhere to go until the app mounts a and points waitlistUrl at it. Serve it at the #/waitlist fragment of the gate's own page, behind a new GEOLIBRE_CLERK_WAITLIST opt-in. Hash routing keeps the move between the two screens a same-document navigation, and gives a prefix its own sub-steps (#/factor-one, #/sso-callback) cannot collide with. Off by default: on a restricted instance the form would take submissions nobody can approve. Setting it without GEOLIBRE_CLERK_PUBLISHABLE_KEY fails at container startup rather than silently serving a public app, and an unrecognized value is rejected instead of read as false. No CSP change — the waitlist form talks to the same Frontend API host, and its bot check uses the challenges.cloudflare.com origin already allowed. --- .../src/components/auth/ClerkGate.tsx | 65 +++++++++++++++++-- apps/geolibre-desktop/src/lib/clerk-auth.ts | 27 ++++++++ apps/geolibre-desktop/src/main.tsx | 10 ++- docker-compose.yml | 1 + docker/entrypoint.sh | 22 ++++++- docs/getting-started.md | 28 ++++++++ docs/self-hosting.md | 1 + tests/clerk-auth.test.ts | 47 ++++++++++++++ 8 files changed, 192 insertions(+), 9 deletions(-) diff --git a/apps/geolibre-desktop/src/components/auth/ClerkGate.tsx b/apps/geolibre-desktop/src/components/auth/ClerkGate.tsx index a1c2273f09..2cd0511dc2 100644 --- a/apps/geolibre-desktop/src/components/auth/ClerkGate.tsx +++ b/apps/geolibre-desktop/src/components/auth/ClerkGate.tsx @@ -1,11 +1,53 @@ -import { ClerkLoaded, ClerkLoading, ClerkProvider, Show, SignIn, UserButton } from "@clerk/react"; -import type { ReactNode } from "react"; +import { + ClerkLoaded, + ClerkLoading, + ClerkProvider, + Show, + SignIn, + UserButton, + Waitlist, +} from "@clerk/react"; +import { useSyncExternalStore, type ReactNode } from "react"; interface ClerkGateProps { publishableKey: string; + /** + * Whether to serve Clerk's waitlist form at {@link WAITLIST_HASH}. Off unless + * the deployment opts in, because it only makes sense for a Clerk instance in + * waitlist sign-up mode. + */ + waitlist?: boolean; children: ReactNode; } +// The gate lives on a single page with no router, so the two signed-out screens +// are told apart by the URL hash. `` owns the root hash +// and writes its own sub-steps (`#/factor-one`, `#/sso-callback`) there, so the +// waitlist takes a distinct prefix that those can never collide with. +const WAITLIST_HASH = "#/waitlist"; +const SIGN_IN_HASH = "#/"; + +function subscribeToHash(onStoreChange: () => void): () => void { + window.addEventListener("hashchange", onStoreChange); + return () => window.removeEventListener("hashchange", onStoreChange); +} + +function readHash(): string { + return window.location.hash; +} + +/** + * Track the hash so Clerk's own cross-links between the two screens work. + * + * Both links are plain same-document navigations (`#/waitlist` ⇄ `#/`), which + * fire `hashchange` rather than reloading — reloading would re-download the + * whole bundle just to swap one card. + */ +function useOnWaitlistRoute(): boolean { + const hash = useSyncExternalStore(subscribeToHash, readHash, () => ""); + return hash.startsWith(WAITLIST_HASH); +} + /** * Optional whole-app sign-in gate for hosted web deployments. * @@ -15,9 +57,14 @@ interface ClerkGateProps { * It gates *rendering* only, and is not a server authorization boundary: the * deployment must still validate Clerk sessions (or another credential) at the * reverse proxy for `/sidecar`, `/ai`, and any other upstream service. See the - * Clerk section of docs/getting-started.md. + * Clerk section of docs/getting-started.md. That holds for the waitlist too — + * approving someone in the Clerk Dashboard decides who sees the interface, not + * who can reach the APIs behind it. */ -export function ClerkGate({ publishableKey, children }: ClerkGateProps) { +export function ClerkGate({ publishableKey, waitlist = false, children }: ClerkGateProps) { + // Read unconditionally: hooks cannot be called behind a prop check, and the + // subscription is inert when the waitlist is off. + const onWaitlistRoute = useOnWaitlistRoute(); return ( @@ -31,7 +78,15 @@ export function ClerkGate({ publishableKey, children }: ClerkGateProps) {
- + {waitlist && onWaitlistRoute ? ( + + ) : ( + // `waitlistUrl` fills the "Join the waitlist" link Clerk renders + // inside the sign-in card when the instance is in waitlist mode. + // Left unset otherwise, so a restricted (invite-only) deployment + // shows no route to a form nobody can act on. + + )}
diff --git a/apps/geolibre-desktop/src/lib/clerk-auth.ts b/apps/geolibre-desktop/src/lib/clerk-auth.ts index 45d0d78630..c6ef3643b8 100644 --- a/apps/geolibre-desktop/src/lib/clerk-auth.ts +++ b/apps/geolibre-desktop/src/lib/clerk-auth.ts @@ -2,6 +2,12 @@ import { readDeploymentEnvValue, type EnvRecord } from "./deployment-env"; export const CLERK_PUBLISHABLE_KEY_ENV = "VITE_GEOLIBRE_CLERK_PUBLISHABLE_KEY"; +export const CLERK_WAITLIST_ENV = "VITE_GEOLIBRE_CLERK_WAITLIST"; + +// Values that turn the waitlist screen on, matching the "1"/"true" convention +// of the other opt-in deployment envs (see onboarding-suppression.ts). +const WAITLIST_ENABLED_VALUES = new Set(["1", "true"]); + /** * Resolve the optional Clerk publishable key for a web deployment. * @@ -19,3 +25,24 @@ export function resolveClerkPublishableKey( if (!webApp) return undefined; return readDeploymentEnvValue(CLERK_PUBLISHABLE_KEY_ENV, deploymentEnv, buildEnv)?.trim(); } + +/** + * Whether the sign-in gate should also offer Clerk's waitlist form. + * + * Opt-in, and only meaningful alongside a publishable key: the gate renders the + * waitlist screen only when the deployment asks for it *and* the Clerk instance + * is in waitlist sign-up mode, so an operator running invite-only ("restricted") + * access never shows visitors a form that implies self-service access. + * + * `webApp` carries the same meaning as in {@link resolveClerkPublishableKey} — + * a build-time fact, never a runtime signal the visitor controls. + */ +export function resolveClerkWaitlistEnabled( + webApp: boolean, + deploymentEnv?: EnvRecord, + buildEnv?: EnvRecord, +): boolean { + if (!webApp) return false; + const value = readDeploymentEnvValue(CLERK_WAITLIST_ENV, deploymentEnv, buildEnv); + return WAITLIST_ENABLED_VALUES.has(value?.trim().toLowerCase() ?? ""); +} diff --git a/apps/geolibre-desktop/src/main.tsx b/apps/geolibre-desktop/src/main.tsx index 0f18eac5ce..fa964777bd 100644 --- a/apps/geolibre-desktop/src/main.tsx +++ b/apps/geolibre-desktop/src/main.tsx @@ -55,7 +55,7 @@ import i18n, { i18nReady } from "./i18n"; import { installDiagnosticsCapture } from "./lib/diagnostics"; import { isTauri } from "./lib/is-tauri"; import { installStaleChunkReload } from "./lib/stale-chunk-reload"; -import { resolveClerkPublishableKey } from "./lib/clerk-auth"; +import { resolveClerkPublishableKey, resolveClerkWaitlistEnabled } from "./lib/clerk-auth"; installDiagnosticsCapture(); // In the desktop build, route geocoding (place search / reverse geocode) @@ -101,7 +101,9 @@ installStaleChunkReload(); // hosted deployment gates every request. In particular this must NOT consult // `isEmbedded()` — that returns true for a plain `?embed=1` query parameter, so // any visitor could disable a configured sign-in wall by typing a URL. -const clerkPublishableKey = resolveClerkPublishableKey(!isTauri() && !__GEOLIBRE_EMBED_BUILD__); +const isHostedWebApp = !isTauri() && !__GEOLIBRE_EMBED_BUILD__; +const clerkPublishableKey = resolveClerkPublishableKey(isHostedWebApp); +const clerkWaitlistEnabled = resolveClerkWaitlistEnabled(isHostedWebApp); // Register the offline/PWA service worker (web build only). `registerSW` is a // no-op stub in the Tauri desktop and embedded Jupyter builds, where the plugin // is disabled (see vite.config.ts pwaPlugin). @@ -154,7 +156,9 @@ void Promise.all([ const app = ; const authenticatedApp = clerkPublishableKey && clerkModule ? ( - {app} + + {app} + ) : ( app ); diff --git a/docker-compose.yml b/docker-compose.yml index 505abe7a02..1e48be1f5f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,7 @@ services: GEOLIBRE_SHARE_URL: "${GEOLIBRE_SHARE_URL:-http://localhost:8000}" GEOLIBRE_COLLAB_URL: "${GEOLIBRE_COLLAB_URL:-ws://localhost:8787}" GEOLIBRE_CLERK_PUBLISHABLE_KEY: "${GEOLIBRE_CLERK_PUBLISHABLE_KEY:-}" + GEOLIBRE_CLERK_WAITLIST: "${GEOLIBRE_CLERK_WAITLIST:-}" depends_on: geolibre-server: condition: service_healthy diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 9f4a1f48d1..527a3f01af 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -169,6 +169,23 @@ if clerk_key: raise SystemExit("ERROR: GEOLIBRE_CLERK_PUBLISHABLE_KEY contains an invalid Frontend API host.") deployment["VITE_GEOLIBRE_CLERK_PUBLISHABLE_KEY"] = clerk_key +# Optional waitlist screen, for a Clerk instance whose sign-up mode is +# "Waitlist": visitors request access and an admin approves each one from the +# Clerk Dashboard. Off by default, because on a "Restricted" (invite-only) +# instance the form would take submissions nobody can approve. +clerk_waitlist = os.environ.get("GEOLIBRE_CLERK_WAITLIST", "").strip().lower() +if clerk_waitlist in ("1", "true"): + # Refuse rather than ignore: an operator who set this expects visitors to be + # able to request access, and silently serving a public app instead would be + # the opposite of what they asked for. + if not clerk_key: + raise SystemExit( + "ERROR: GEOLIBRE_CLERK_WAITLIST needs GEOLIBRE_CLERK_PUBLISHABLE_KEY; the waitlist is part of the Clerk sign-in gate." + ) + deployment["VITE_GEOLIBRE_CLERK_WAITLIST"] = "1" +elif clerk_waitlist not in ("", "0", "false"): + raise SystemExit("ERROR: GEOLIBRE_CLERK_WAITLIST must be 1/true or 0/false.") + # Origins allowed to drive a framed app over the embed postMessage API. Unset # means the API stays off, so a public deployment can never be driven by the # page that frames it. "*" allows any origin: private networks only. @@ -293,7 +310,10 @@ if [ -n "${GEOLIBRE_EMBED_ORIGINS:-}" ]; then fi if [ -n "$(trim "${GEOLIBRE_CLERK_PUBLISHABLE_KEY:-}")" ]; then - echo "Clerk sign-in gate enabled." + case "$(trim "${GEOLIBRE_CLERK_WAITLIST:-}")" in + 1 | true | TRUE | True) echo "Clerk sign-in gate enabled, with the waitlist screen." ;; + *) echo "Clerk sign-in gate enabled." ;; + esac fi # Render the nginx config from the immutable image template on every boot. The diff --git a/docs/getting-started.md b/docs/getting-started.md index 361f0bbdb9..c0d8656383 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -282,6 +282,34 @@ loading it with `?embed=1` still requires sign-in. Control who may register or sign in through the Clerk Dashboard. Configure TLS and the deployment domain in Clerk before using a production key. +##### Approving users + +Who may sign in is decided in the Clerk Dashboard, not by GeoLibre: + +- **Restrictions → Restricted** turns off self-service sign-up. You add people + by invitation, through an enterprise connection, or by creating the user + manually. This needs no extra configuration here. +- **Waitlist** lets visitors request access, which you approve one at a time + (**Waitlist** page → the menu next to a person → **Invite**, or **Revoke** to + decline). Enable the matching screen in GeoLibre so the sign-in card offers a + "Join the waitlist" link instead of a dead end: + + ```bash + docker run --rm -p 8080:80 \ + -e GEOLIBRE_CLERK_PUBLISHABLE_KEY='pk_live_...' \ + -e GEOLIBRE_CLERK_WAITLIST=1 \ + ghcr.io/opengeos/geolibre:latest + ``` + + The waitlist form lives at the `#/waitlist` fragment of the same page, so + moving between it and the sign-in card never reloads the app. It is off + unless you set this variable, because on a restricted instance the form would + collect requests that cannot be approved. Set the Clerk instance's sign-up + mode to **Waitlist** as well — the variable adds the screen, the Dashboard + decides whether Clerk accepts submissions to it. Setting it without + `GEOLIBRE_CLERK_PUBLISHABLE_KEY` is an error rather than a silently public + app. + This client-side gate controls access to the GeoLibre interface but is not a server authorization boundary by itself. Keep `/sidecar`, `/ai`, and any other sensitive upstream service behind nginx authentication, Cloudflare Access, or a diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 624834b8a0..7919b0db2b 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -142,6 +142,7 @@ Settings that matter for a private deployment: | `GEOLIBRE_COLLAB_URL` | unset, or your own relay | Unset leaves [live collaboration](collaboration.md) dark. Set it to a `wss://` relay you run if you want multiplayer editing without the hosted relay. | | `GEOLIBRE_AUTH_USER` / `GEOLIBRE_AUTH_PASSWORD` | set, for a quick single credential | nginx Basic Auth over the app and the `/sidecar` API. One shared credential, not accounts. Use a real auth proxy for multi-user or SSO. | | `GEOLIBRE_CLERK_PUBLISHABLE_KEY` | unset, or a Clerk publishable key | Unset keeps the app public and does not load Clerk. A key requires individual users to sign in before the web interface renders; protect server APIs separately. | +| `GEOLIBRE_CLERK_WAITLIST` | unset, or `1` alongside a Clerk key | Adds Clerk's waitlist form to the sign-in screen, so visitors can request access and you approve each one from the Clerk Dashboard. Leave unset for an invite-only ("restricted") instance, where nothing would act on a request. | | `GEOLIBRE_CONVERSION_ROOTS` | `/data` (the image default) | Confines every sidecar read and write to the mounted directory. | | `GEOLIBRE_POSTGIS_HOSTS` | unset unless needed | The sidecar's PostGIS endpoints refuse every destination until this names the allowed databases, so a caller cannot aim them at hosts only the container can reach. | | `GEOLIBRE_DISABLE_SIDECAR` | `1` if you do not need it | Runs nginx only. | diff --git a/tests/clerk-auth.test.ts b/tests/clerk-auth.test.ts index 8f6940fc1e..56fccdc5b8 100644 --- a/tests/clerk-auth.test.ts +++ b/tests/clerk-auth.test.ts @@ -2,7 +2,9 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { CLERK_PUBLISHABLE_KEY_ENV, + CLERK_WAITLIST_ENV, resolveClerkPublishableKey, + resolveClerkWaitlistEnabled, } from "../apps/geolibre-desktop/src/lib/clerk-auth"; describe("optional Clerk authentication", () => { @@ -39,3 +41,48 @@ describe("optional Clerk authentication", () => { ); }); }); + +describe("optional Clerk waitlist", () => { + it("stays off unless the deployment opts in", () => { + assert.equal(resolveClerkWaitlistEnabled(true, {}, {}), false); + }); + + it("accepts the documented truthy spellings, case-insensitively", () => { + for (const value of ["1", "true", "TRUE", " True "]) { + assert.equal(resolveClerkWaitlistEnabled(true, { [CLERK_WAITLIST_ENV]: value }, {}), true); + } + }); + + it("treats any other value as off", () => { + for (const value of ["0", "false", "off", "no", "yes", "waitlist"]) { + assert.equal(resolveClerkWaitlistEnabled(true, { [CLERK_WAITLIST_ENV]: value }, {}), false); + } + }); + + it("prefers the Docker runtime flag over the build-time flag", () => { + assert.equal( + resolveClerkWaitlistEnabled( + true, + { [CLERK_WAITLIST_ENV]: "0" }, + { [CLERK_WAITLIST_ENV]: "1" }, + ), + false, + ); + assert.equal( + resolveClerkWaitlistEnabled( + true, + { [CLERK_WAITLIST_ENV]: "1" }, + { [CLERK_WAITLIST_ENV]: "0" }, + ), + true, + ); + }); + + it("falls back to the build-time flag", () => { + assert.equal(resolveClerkWaitlistEnabled(true, {}, { [CLERK_WAITLIST_ENV]: "1" }), true); + }); + + it("never applies to native or embedded applications", () => { + assert.equal(resolveClerkWaitlistEnabled(false, { [CLERK_WAITLIST_ENV]: "1" }, {}), false); + }); +}); From 3ef213160f9462d4814a14fb97cab5ff8276e371 Mon Sep 17 00:00:00 2001 From: giswqs Date: Mon, 10 Aug 2026 21:17:27 -0400 Subject: [PATCH 6/7] Address Claude review feedback - Keep the beforeunload unsaved-work guard alive while signed out. mounts it, so ending a session unmounted it: the project survived (the Zustand store is module-scope, so signing back in re-renders it), but the tab could then be closed or reloaded with unsaved changes and no "Leave site?" prompt. Mount it in ClerkGate too, which covers exactly the window cannot. --- .../geolibre-desktop/src/components/auth/ClerkGate.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/geolibre-desktop/src/components/auth/ClerkGate.tsx b/apps/geolibre-desktop/src/components/auth/ClerkGate.tsx index 2cd0511dc2..c01314e307 100644 --- a/apps/geolibre-desktop/src/components/auth/ClerkGate.tsx +++ b/apps/geolibre-desktop/src/components/auth/ClerkGate.tsx @@ -8,6 +8,7 @@ import { Waitlist, } from "@clerk/react"; import { useSyncExternalStore, type ReactNode } from "react"; +import { useBeforeUnloadGuard } from "../../hooks/useBeforeUnloadGuard"; interface ClerkGateProps { publishableKey: string; @@ -65,6 +66,15 @@ export function ClerkGate({ publishableKey, waitlist = false, children }: ClerkG // Read unconditionally: hooks cannot be called behind a prop check, and the // subscription is inert when the waitlist is off. const onWaitlistRoute = useOnWaitlistRoute(); + // Keep the unsaved-work prompt alive across the signed-out screens. + // mounts the same guard, but it unmounts the moment the session ends — on an + // expiry or revocation as much as on a sign-out click. The project itself + // survives that (useAppStore is module-scope, so signing back in re-renders + // the same state), but without this the tab could then be closed or reloaded + // with unsaved changes and no "Leave site?" prompt, which is where the work + // would actually be lost. Duplicated while signed in, where both listeners + // read the same isDirty and the browser shows one prompt. + useBeforeUnloadGuard(); return ( From ed24b5a90b1297d65691d0e03862dea96bb43d0b Mon Sep 17 00:00:00 2001 From: giswqs Date: Mon, 10 Aug 2026 21:20:46 -0400 Subject: [PATCH 7/7] Address Claude review feedback - Render Clerk's error status instead of a blank page. ClerkLoading and ClerkLoaded both return null when Clerk's status is "error" (a key that no longer resolves, an unreachable Frontend API, an outage), so the gate left nothing on screen and no way to tell a stuck deployment from a slow one. Add a branch with a message and a reload action. - Lower-case the boot-log check for GEOLIBRE_CLERK_WAITLIST. The Python validator compares after .lower(), so a spelling like `TRue` enabled the waitlist but logged the plain sign-in gate line. --- .../src/components/auth/ClerkGate.tsx | 25 +++++++++++++++++++ .../geolibre-desktop/src/i18n/locales/en.json | 5 ++++ docker/entrypoint.sh | 7 ++++-- 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/apps/geolibre-desktop/src/components/auth/ClerkGate.tsx b/apps/geolibre-desktop/src/components/auth/ClerkGate.tsx index c01314e307..238ec3a7ca 100644 --- a/apps/geolibre-desktop/src/components/auth/ClerkGate.tsx +++ b/apps/geolibre-desktop/src/components/auth/ClerkGate.tsx @@ -1,4 +1,5 @@ import { + ClerkFailed, ClerkLoaded, ClerkLoading, ClerkProvider, @@ -7,7 +8,10 @@ import { UserButton, Waitlist, } from "@clerk/react"; +import { Button } from "@geolibre/ui"; +import { AlertTriangle } from "lucide-react"; import { useSyncExternalStore, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; import { useBeforeUnloadGuard } from "../../hooks/useBeforeUnloadGuard"; interface ClerkGateProps { @@ -63,6 +67,7 @@ function useOnWaitlistRoute(): boolean { * who can reach the APIs behind it. */ export function ClerkGate({ publishableKey, waitlist = false, children }: ClerkGateProps) { + const { t } = useTranslation(); // Read unconditionally: hooks cannot be called behind a prop check, and the // subscription is inert when the waitlist is off. const onWaitlistRoute = useOnWaitlistRoute(); @@ -85,6 +90,26 @@ export function ClerkGate({ publishableKey, waitlist = false, children }: ClerkG />
+ {/* Clerk reports a distinct "error" status (a key that no longer resolves, + an unreachable Frontend API, an outage). Both ClerkLoading and + ClerkLoaded render null in that state, so without this branch the gate + leaves a blank page with no way to tell a stuck deployment from a slow + one. */} + +
+ +
+

{t("auth.unavailableTitle")}

+

+ {t("auth.unavailableDescription")} +

+
+ +
+
diff --git a/apps/geolibre-desktop/src/i18n/locales/en.json b/apps/geolibre-desktop/src/i18n/locales/en.json index 999b1e0991..8e76a59eb9 100644 --- a/apps/geolibre-desktop/src/i18n/locales/en.json +++ b/apps/geolibre-desktop/src/i18n/locales/en.json @@ -1599,6 +1599,11 @@ "exportCsv": "Export CSV", "exportGeoParquet": "Export GeoParquet" }, + "auth": { + "unavailableTitle": "Sign-in is unavailable", + "unavailableDescription": "GeoLibre could not reach the sign-in service, so it cannot tell whether you are signed in. This is usually temporary; if it persists, this deployment's authentication settings may need attention.", + "retry": "Try again" + }, "basemapExtract": { "title": "Extract Offline Basemap", "url": "Basemap URL", diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 527a3f01af..6f65889c6d 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -310,8 +310,11 @@ if [ -n "${GEOLIBRE_EMBED_ORIGINS:-}" ]; then fi if [ -n "$(trim "${GEOLIBRE_CLERK_PUBLISHABLE_KEY:-}")" ]; then - case "$(trim "${GEOLIBRE_CLERK_WAITLIST:-}")" in - 1 | true | TRUE | True) echo "Clerk sign-in gate enabled, with the waitlist screen." ;; + # Lower-cased to match the Python validator above, which compares after + # `.lower()` — so a spelling like `TRue` enables the screen and must not then + # be logged as a plain sign-in gate. + case "$(trim "${GEOLIBRE_CLERK_WAITLIST:-}" | tr '[:upper:]' '[:lower:]')" in + 1 | true) echo "Clerk sign-in gate enabled, with the waitlist screen." ;; *) echo "Clerk sign-in gate enabled." ;; esac fi