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..238ec3a7ca
--- /dev/null
+++ b/apps/geolibre-desktop/src/components/auth/ClerkGate.tsx
@@ -0,0 +1,136 @@
+import {
+ ClerkFailed,
+ ClerkLoaded,
+ ClerkLoading,
+ ClerkProvider,
+ Show,
+ SignIn,
+ 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 {
+ 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.
+ *
+ * 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. 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, 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();
+ // 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 (
+
+
+
+
+
+
+ {/* 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")}
+
+
+
+
+
+
+
+
+ {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.
+
+ )}
+
+
+
+ {children}
+
+
+
+
+
+
+ );
+}
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/apps/geolibre-desktop/src/lib/clerk-auth.ts b/apps/geolibre-desktop/src/lib/clerk-auth.ts
new file mode 100644
index 0000000000..c6ef3643b8
--- /dev/null
+++ b/apps/geolibre-desktop/src/lib/clerk-auth.ts
@@ -0,0 +1,48 @@
+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.
+ *
+ * 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. `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,
+ deploymentEnv?: EnvRecord,
+ buildEnv?: EnvRecord,
+): string | undefined {
+ 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 e557c2dc7b..fa964777bd 100644
--- a/apps/geolibre-desktop/src/main.tsx
+++ b/apps/geolibre-desktop/src/main.tsx
@@ -55,6 +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, resolveClerkWaitlistEnabled } from "./lib/clerk-auth";
installDiagnosticsCapture();
// In the desktop build, route geocoding (place search / reverse geocode)
@@ -95,6 +96,14 @@ 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();
+// "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 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).
@@ -138,18 +147,26 @@ 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/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 47a0956fd1..67fa1a6406 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
@@ -869,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-compose.yml b/docker-compose.yml
index 62aca5e98e..1e48be1f5f 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -13,6 +13,8 @@ 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:-}"
+ GEOLIBRE_CLERK_WAITLIST: "${GEOLIBRE_CLERK_WAITLIST:-}"
depends_on:
geolibre-server:
condition: service_healthy
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
index 41fc0dfe8c..6f65889c6d 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,52 @@ 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. 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)
+ # 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
+
+# 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.
@@ -262,6 +309,16 @@ if [ -n "${GEOLIBRE_EMBED_ORIGINS:-}" ]; then
echo "Embed postMessage API enabled for: $GEOLIBRE_EMBED_ORIGINS"
fi
+if [ -n "$(trim "${GEOLIBRE_CLERK_PUBLISHABLE_KEY:-}")" ]; then
+ # 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
+
# 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 +328,7 @@ fi
python -c '
import os
import re
+import base64
from urllib.parse import urlsplit
token = os.environ["GEOLIBRE_SIDECAR_TOKEN"]
@@ -292,10 +350,46 @@ 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.
+#
+# 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:
+ 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.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.")
+ 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..693ed86477 100644
--- a/docker/nginx.conf
+++ b/docker/nginx.conf
@@ -93,12 +93,18 @@ 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
# 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..c0d8656383 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -261,6 +261,62 @@ 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
+```
+
+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
+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
+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..7919b0db2b 100644
--- a/docs/self-hosting.md
+++ b/docs/self-hosting.md
@@ -141,6 +141,8 @@ 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_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/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..56fccdc5b8
--- /dev/null
+++ b/tests/clerk-auth.test.ts
@@ -0,0 +1,88 @@
+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", () => {
+ 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,
+ );
+ });
+});
+
+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);
+ });
+});