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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/geolibre-desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
41 changes: 41 additions & 0 deletions apps/geolibre-desktop/src/components/auth/ClerkGate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { ClerkLoaded, ClerkLoading, ClerkProvider, Show, SignIn, UserButton } from "@clerk/react";
Comment thread
giswqs marked this conversation as resolved.
Outdated
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.
*/
Comment thread
giswqs marked this conversation as resolved.
export function ClerkGate({ publishableKey, children }: ClerkGateProps) {
return (
<ClerkProvider publishableKey={publishableKey}>
<ClerkLoading>
<div className="flex min-h-screen items-center justify-center bg-background">
<div
aria-hidden="true"
className="h-8 w-8 animate-spin rounded-full border-2 border-muted border-t-primary"
/>
</div>
</ClerkLoading>
<ClerkLoaded>
<Show when="signed-out">
<main className="flex min-h-screen items-center justify-center bg-background p-4">
<SignIn routing="hash" />
</main>
</Show>
<Show when="signed-in">
Comment thread
giswqs marked this conversation as resolved.
{children}
Comment thread
giswqs marked this conversation as resolved.
<div className="fixed end-2 top-2 z-[100]">
<UserButton />
</div>
</Show>
Comment thread
giswqs marked this conversation as resolved.
</ClerkLoaded>
</ClerkProvider>
);
}
21 changes: 21 additions & 0 deletions apps/geolibre-desktop/src/lib/clerk-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
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. `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();
}
21 changes: 17 additions & 4 deletions apps/geolibre-desktop/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 } from "./lib/clerk-auth";

installDiagnosticsCapture();
// In the desktop build, route geocoding (place search / reverse geocode)
Expand Down Expand Up @@ -95,6 +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();
// "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).
Expand Down Expand Up @@ -138,18 +145,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 = <App />;
const authenticatedApp =
clerkPublishableKey && clerkModule ? (
<clerkModule.ClerkGate publishableKey={clerkPublishableKey}>{app}</clerkModule.ClerkGate>
) : (
app
);
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<I18nextProvider i18n={i18n}>
<AppErrorBoundary>
<TooltipProvider delayDuration={200}>
<App />
</TooltipProvider>
<TooltipProvider delayDuration={200}>{authenticatedApp}</TooltipProvider>
</AppErrorBoundary>
</I18nextProvider>
</React.StrictMode>,
Expand Down
8 changes: 8 additions & 0 deletions apps/geolibre-desktop/src/vite-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions apps/geolibre-desktop/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,10 @@ function pwaPlugin(): Plugin[] {
// is auto-named `i18n-<hash>` 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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confidence: medium. This single glob assumes the whole import("./components/auth/ClerkGate") subtree (ClerkGate.tsx + @clerk/react + @clerk/shared + @tanstack/query-core + js-cookie + glob-to-regexp, none of which match a manualChunks rule) collapses into one ClerkGate-*.js chunk.

That's not guaranteed — this very file documents a case where it wasn't (a few lines up): the cesium dynamic-import boundary needed two ignore patterns, **/cesium-* and **/Cesium-*, because Rollup emitted a differently-cased facade chunk that the first glob missed. If Rollup splits any of the Clerk-only deps into a separately-named chunk (or emits a facade), it would silently slip back into the PWA precache, defeating the point of this change for public (non-Clerk) deployments.

Worth confirming against the actual dist/assets output of a production build (or better, adding an automated check, since the PR's verification of this was a manual step) rather than relying on the single glob.

];
// 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
Expand Down Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -132,13 +132,30 @@ python -c '
import json
import os
import re
import base64
from urllib.parse import urlsplit

deployment = {}
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
Comment thread
giswqs marked this conversation as resolved.
Comment thread
giswqs marked this conversation as resolved.
Comment thread
giswqs marked this conversation as resolved.

# 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.
Expand Down Expand Up @@ -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
Expand All @@ -271,6 +292,7 @@ fi
python -c '
import os
import re
import base64
from urllib.parse import urlsplit

token = os.environ["GEOLIBRE_SIDECAR_TOKEN"]
Expand All @@ -292,10 +314,39 @@ 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:
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.")
Comment thread
giswqs marked this conversation as resolved.
Outdated
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"
Comment thread
giswqs marked this conversation as resolved.

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
)
)
'
Expand Down
2 changes: 1 addition & 1 deletion docker/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
giswqs marked this conversation as resolved.
Comment thread
giswqs marked this conversation as resolved.
}

# The service worker has a stable filename, so it must always revalidate;
Expand Down
26 changes: 26 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,32 @@ 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; the separately built
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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.

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:
Expand Down
1 change: 1 addition & 0 deletions docs/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
Loading
Loading