Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
111 changes: 111 additions & 0 deletions apps/geolibre-desktop/src/components/auth/ClerkGate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import {
ClerkLoaded,
ClerkLoading,
ClerkProvider,
Show,
SignIn,
UserButton,
Waitlist,
} from "@clerk/react";
import { useSyncExternalStore, type ReactNode } from "react";
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
Comment on lines +19 to +28

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: low. ClerkFailed, Show (with when="signed-in"/"signed-out" string props), and Waitlist/waitlistUrl are a less commonly documented part of Clerk's React surface — most existing Clerk integrations use <SignedIn>/<SignedOut> wrapper components instead of a generic <Show when=...> primitive. Since @clerk/react is a brand-new dependency for this repo (this PR adds it), it's worth double-checking these exports/props against the actual installed @clerk/react@6.14.1 type declarations to make sure tsc -b is actually type-checking real APIs here rather than something that happens to compile loosely. If this was confirmed via the "production build" step in the test plan, feel free to disregard.

// are told apart by the URL hash. `<SignIn routing="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.
*/
Comment thread
giswqs marked this conversation as resolved.
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();
// Keep the unsaved-work prompt alive across the signed-out screens. <App />
// 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 (
<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">
{waitlist && onWaitlistRoute ? (
<Waitlist signInUrl={SIGN_IN_HASH} />
) : (
// `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.
<SignIn routing="hash" waitlistUrl={waitlist ? WAITLIST_HASH : undefined} />
)}
Comment thread
giswqs marked this conversation as resolved.
</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>
);
}
48 changes: 48 additions & 0 deletions apps/geolibre-desktop/src/lib/clerk-auth.ts
Original file line number Diff line number Diff line change
@@ -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() ?? "");
}
25 changes: 21 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, resolveClerkWaitlistEnabled } from "./lib/clerk-auth";

installDiagnosticsCapture();
// In the desktop build, route geocoding (place search / reverse geocode)
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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 = <App />;
const authenticatedApp =
clerkPublishableKey && clerkModule ? (
<clerkModule.ClerkGate publishableKey={clerkPublishableKey} waitlist={clerkWaitlistEnabled}>
{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
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
91 changes: 91 additions & 0 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -132,13 +132,60 @@ 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. 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
Comment thread
giswqs marked this conversation as resolved.
Comment thread
giswqs marked this conversation as resolved.
Comment thread
giswqs marked this conversation as resolved.

# 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.
Expand Down Expand Up @@ -262,6 +309,13 @@ if [ -n "${GEOLIBRE_EMBED_ORIGINS:-}" ]; then
echo "Embed postMessage API enabled for: $GEOLIBRE_EMBED_ORIGINS"
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." ;;
*) echo "Clerk sign-in gate enabled." ;;
esac
Comment thread
giswqs marked this conversation as resolved.
Outdated
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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 +325,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 +347,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"
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
Loading
Loading