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); + }); +});