Maintenance Notes¶
This page collects the repository rules that are easy to break silently —
hand-written mirrors of unexported upstream constants, coverage and audit gates,
publishing layouts, and generated files. CLAUDE.md links here rather than
restating any of it.
If you are just getting set up, read Contributing first.
Dependency bumps that need a manual check¶
Several features depend on values or DOM contracts that upstream packages do not export, so GeoLibre mirrors them by hand. Drift usually produces no build error — the feature just stops working. After bumping any of the packages below (including Dependabot PRs), do the listed check and run the frontend suite.
geolibre-wasm (packages/processing/package.json)¶
- Processing menu catalog.
ProcessingMenu.tsxrenders from a checked-in, auto-generated catalog,apps/geolibre-desktop/src/lib/whitebox-menu-catalog.ts(do not hand-edit). Runnode scripts/gen-whitebox-menu-catalog.mjsand commit the result, or new/renamed WASM tools silently miss the menu. The Processing dialog lists tools dynamically, so the gap only shows in the menu. Whitebox translations are optional external packs inopengeos/geolibre-language-packs, not entries generated into GeoLibre's bundled locale JSON. MAX_VECTOR_PMTILES_ZOOM(packages/processing/src/wasm-convert.ts) mirrors the deepest zoomvector_to_pmtilesaccepts (18 — past it the tool exits withvalidation error: max_zoom must be <= 18). The cap lives inside the WASM binary and is not exported. If it drifts, the browser's Vector to PMTiles either refuses a zoom the tiler would now accept, or accepts one it will reject after the user has waited. This is not the sidecar's cap: freestiler allows 24 (MAX_PMTILES_ZOOMinConversionDialog.tsx, mirroringbackend/geolibre_server/geolibre_server/app/conversion.py), and the dialog validates against whichever engine is about to run.tests/wasm-convert.test.tsfails if the mirror drifts.DISTANCE_SEGMENTS/NON_DISTANCE_NAMES(apps/geolibre-desktop/src/lib/whitebox-distance-params.ts) decide, by parameter name, which Whitebox parameters are ground distances and so get the Processing dialog's metric unit picker (GeoLibre#1540). The segments are generic (tolerance,radius,length,resolution), so a tool can carry a matching name that is not a length —corridor_toleranceis a 0–1 fraction. Those are safe today only because the picker is confined to tools whose every dataset input is a vector layer, and the colliding names happen to sit on imagery/LiDAR tools; that is a coincidence, not a guarantee. Scan the new catalog for adoublematching the rule whose description reads as a fraction, ratio, angle or weight, and add it toNON_DISTANCE_NAMES. If one is missed, that tool's field offers metres and silently converts a dimensionless number as if it were a distance.
maplibre-gl¶
GLOBE_CONTROL_TOGGLE_SELECTOR(packages/map/src/globe-control-toggle.ts) mirrors the class names MapLibre's ownGlobeControlputs on its toggle button —maplibregl-ctrl-globeandmaplibregl-ctrl-globe-enabled, swapped on every projection change.MapCanvaspersists a projection change from a click on that button rather than from theprojectiontransitionevent, because style initialization and project reconciliation emit that event too and a stale one overwrites the projection of a project that has just loaded.tests/globe-control-toggle.test.tsbuilds a realGlobeControland fails if the mirror stops matching.- Per-layer blend modes (
packages/map/src/layer-blend-modes.ts) wrap three unexportedmaplibre-glinternals, because MapLibre renders every layer into one canvas and ships no per-layer blend API (upstream draft: maplibre/maplibre-gl-js#8073). The wrappers arePainter.prototype.renderLayer(brackets one layer's draws),Painter.prototype.useProgram(tells the layer-opacity composite draw from the draws feeding it), andContext.prototype.setColorMode(the single place every draw resolves GL blend state). Fill and line layers additionally getfill-layer-opacity/line-layer-opacitypinned just under 1 bystyle-mapper, which elects MapLibre 6's render-to-texture composite so a layer blends as a whole rather than once per overlapping polygon.installLayerBlendModesfeature-detects every seam and disables the feature (hiding the Style-panel control) rather than breaking the map, so drift fails quietly — which is whytests/layer-blend-modes.test.tsasserts the seams ande2e/blend-modes.spec.tsasserts real pixels. Run both on a bump.
See Adding a blend mode before extending the list.
@maplibre/maplibre-gl-style-spec¶
propertySpecFor (packages/core/src/expressions.ts) fabricates the
unexported StylePropertySpecification shape that createExpression uses for
expected-result-type enforcement (the Expression Builder's filter → boolean /
color checks). The cast hides any contract change from the compiler, so run the
frontend suite — the "enforces an expected result type" test in
tests/expressions.test.ts fails if the shape stops being honored.
cssColor (packages/map/src/cesium-feature-style.ts) turns the Color
object a compiled colour expression evaluates to into CSS by reading its
toString() and accepting an rgba( or # prefix. That format is how the
spec's Color happens to print, not a documented contract: if a bump changes
it, the globe silently paints every classified feature the flat fallback colour
rather than failing. The "classifies by a categorical field" test in
tests/cesium-feature-style.test.ts goes through a real compiled expression,
so run the frontend suite after a bump and re-verify a categorized layer on the
globe.
SPEC_DEFAULT_COLOR (packages/map/src/mapbox-style-import.ts) mirrors the
spec's default for fill-color, line-color and circle-color — #000000
for all three — which is the colour a stacked class layer naming none is
imported as.
It is hard-coded rather than read from the spec because @geolibre/map is a
published package and does not depend on it (only packages/core does). The
frontend suite guards both directions: a test in
tests/mapbox-style-import.test.ts asserts the spec still says
SPEC_DEFAULT_COLOR, and the behavioural tests beside it assert an imported
colourless class renders that colour, so a change to either side fails.
COLOR_OVERRIDING_PAINT in the same file mirrors the other half of that
lookup: the paint properties that draw the feature themselves, so that the
colour default does not apply. The spec encodes it as line-color's
requires: [{ "!": "line-pattern" }], which the same test asserts. fill-pattern
and line-gradient are listed for the same reason but the spec does not encode
it, so only the line-pattern entry is a mirror a test can guard; the other two
rest on how MapLibre renders them. A class using any of them is not imported as
black — it declines the stack.
Web Services control packages (packages/plugins/package.json)¶
- Docked panel DOM bridge.
dockable-map-control.tsadaptsmaplibre-gl-fema-wms,maplibre-gl-nasa-earthdata,maplibre-gl-enviroatlas, andmaplibre-gl-national-mapby calling the control lifecycle directly and moving the panel element thatonAdd()appends to the map container into GeoLibre's native right-panel host. Vantor returns a wrapper instead, so the bridge selects its.vantor-paneldescendant. The scoped CSS inindex.cssmirrors each package's panel, header, toggle, close, and resize-handle class names. After bumping any of these packages, activate every migrated Web Services plugin and verify that its catalog renders, resizing the GeoLibre dock preserves the content, and no vendor panel remains under the map container.
maplibre-gl-components (packages/plugins/package.json)¶
MAP_PANEL_SELECTOR(apps/geolibre-desktop/src/components/layout/RecordVideoDialog.tsx) mirrors the rendered control class names —maplibre-gl-html-control,maplibre-gl-legend,maplibre-gl-colorbar— so Record Video's "Include map panels" option can rasterize those on-map overlays into the recording. These are the display elements, deliberately not the*-gui-controlauthoring editors. If a class drifts, the option silently stops burning that panel into the video (or the checkbox never appears) with no build error.- The PMTiles control's layer ids (
pmtilesControlLayerId/pmtilesIdsForSourceLayers/pmtilesIdNamesSourceLayer,packages/map/src/pmtiles-layer.ts, read fromlayer-sync.tsandpackages/plugins/src/plugins/maplibre-components.ts) mirror an unexported fact aboutPMTilesLayerControl: it names its MapLibre layers${sourceId}-${name}-${kind}from the raw source-layer name, wherepmtilesVectorLayerIdpercent-encodes it. The two agree for every name needing no encoding, so a store layer carrying the control's own ids — the archive kept whole, or a split part, which keeps the ids naming its own source layer — is recognised under the encoded scheme alone until a name holds a/, a space or non-ASCII. Thenlayer-syncdecides the source layer has no native layer and adds a second fill/line/circle trio on top of the control's: drawn twice, and only the control's copy answers the panel. Both schemes are therefore matched, and only ids naming a source layer the store actually holds are kept.
What the user ticked is deliberately not inferred from those ids:
selectedSourceLayers is a documented field of the exported
PMTilesLayerControlState handed to every handler, so pmtilesLayerOptions
reads it and the compiler checks it — the rules for a stale selection, and for
the archive ids the control reuses across a panel close, are written at that
function and at addPMTilesArchive. A reused id is the one case GeoLibre cannot
repair: two archives then name one MapLibre source, the first to sync wins it
and the other draws nothing, so addPMTilesArchive warns rather than pretending
otherwise — while an archive that takes a layer over outright is drawn
correctly, keeping the name, folder and styling of the one it replaced.
tests/pmtiles-control-contract.test.ts drives a real control against a
real archive, through the real layeradd handler into the store, and fails if
the id scheme moves or the selection stops reaching the handler.
maplibre-gl-basemap-control (packages/plugins/package.json)¶
BASEMAP_PANEL_SELECTOR / BASEMAP_ROW_SELECTOR / BASEMAP_ROW_ID_ATTR
(packages/plugins/src/plugins/basemap-thumbnails.ts) mirror the DOM the control
renders — .basemap-control-panel, .basemap-control-result, data-basemap-id —
which the Basemaps panel's thumbnails hook into to find rows and join each one
back to its catalog entry. That package exports only
BasemapControl/BasemapDefinition, so a renamed class fails nothing at build
time: the queries stop matching and thumbnails silently stop appearing.
tests/basemap-thumbnails.test.ts builds a real control and asserts its rendered
panel against the mirror.
The same file's hasUnresolvedPlaceholder deliberately matches the complement
of the tile tokens it substitutes rather than mirroring that package's credential
placeholders ({api-key}, {aws-region}), so a new provider's placeholder is
skipped instead of being fetched literally. Keep it that way rather than
enumerating placeholder names.
maplibre-gl-vector (packages/plugins/package.json)¶
MAX_VECTOR_BYTES (packages/plugins/src/plugins/remote-file-formats.ts) mirrors
MAX_REMOTE_FILE_BYTES, an internal, unexported constant in that package
(2 GiB — DuckDB-WASM holds remote file sizes in 32 bits). It cannot be imported,
so re-check src/lib/utils/remote.ts in that package and update the mirror if it
moved. If it drifts, the remote-browse panels (Source Cooperative, Hugging Face)
silently block GeoParquet the engine could now open, or offer an Add that is
certain to fail. Updating the constant is enough: the limit the user is shown is
rendered from it, not written into the copy.
remote-file-formats.ts is the single home for this and the other
format/reader/size rules those panels share — a per-panel copy would miss this
check, so add new browse panels against that module rather than duplicating it
(source-coop-api.ts re-exports it under its own names for compatibility).
maplibre-gl-raster — checked by the compiler¶
GeoLibreCogRenderEngine (packages/plugins/src/types.ts) mirrors the
RenderEngine union that package exports (maplibre-gl-raster |
cog-tiler-wasm | titiler). It is hand-written rather than imported because
types.ts is the public plugin-API surface and importing there would make that
package's types a hard dependency of every external plugin. Unlike the mirrors
above this one is checked by the compiler:
CogRenderEngineMirrorIsExact in
packages/plugins/src/plugins/maplibre-raster.ts asserts both directions of
assignability against the real imported type, so a renamed or dropped engine
identifier fails npm run typecheck. Nothing extra to do on a bump beyond letting
the build run.
tauri-plugin-persisted-scope — private on-disk format¶
PersistedScopeState (apps/geolibre-desktop/src-tauri/src/lib.rs) mirrors the
plugin's private bincode Scope structure so GeoLibre can remove legacy
per-photo grants before the plugin synchronously replays them at startup. On a
tauri-plugin-persisted-scope bump, compare the upstream struct's field order
and types against this mirror and run the Rust scope-cleanup tests. Bincode
encodes fields positionally, so an upstream layout change is not compiler
checked.
The bincode dependency itself is pinned to the 1.x line and Dependabot is
configured (.github/dependabot.yml) to skip its major bumps: the plugin writes
the file with bincode 1, so GeoLibre must decode and re-encode it with the same
wire format. Only move when tauri-plugin-persisted-scope moves. (bincode 3.0.0
is additionally a deliberately unbuildable release — its whole source is
compile_error!("https://xkcd.com/2347/").)
@tauri-apps/plugin-http — two upstream behaviors, not APIs¶
createNativeSidecarFetch
(apps/geolibre-desktop/src/lib/sidecar-fetch.ts) routes Windows sidecar traffic
through the plugin's native fetch and hardens it with two options whose effect
comes from reqwest's implementation rather than from any documented contract.
The option names are compiler-checked — NativeFetchInit is derived from
typeof import("@tauri-apps/plugin-http").fetch, so a renamed or dropped option
fails npm run typecheck — but the semantics are not, and both fail silently:
maxRedirections: 0maps toreqwest::redirect::Policy::none()(tauri-plugin-http/src/commands.rs). Without it the native client follows redirects, andreqwestonly stripsAuthorization/Cookieacross hosts, so the per-launchX-GeoLibre-Tokenwould be replayed to whatever a 3xx pointed at.proxy: { all: { url, noProxy: "*" } }is how the sidecar reaches the loopback directly. The plugin has no "disable proxy" switch, but anyClientBuilder::proxycall setsauto_sys_proxy = false(reqwest/src/async_impl/client.rs), andNoProxy::from_string("*")matches every host (hyper-util/src/client/proxy/matcher.rs), so the supplied proxy never intercepts either. This matters because reqwest'ssystem-proxyfeature is in the resolved graph (confirm withcargo tree -e features -i reqwest; the plugin's defaultmacos-system-configurationfeature pulls it in), and hyper-util's Windows reader copies the registryProxyOverridelist verbatim — it never expands the<local>token Windows writes for "bypass proxy server for local addresses". Drop this and a corporate-proxied Windows machine sends the sidecar request body and token to the proxy.
tests/sidecar-fetch.test.ts pins the options the adapter passes, which catches a
careless edit here but cannot exercise the Rust side. On a plugin bump, re-check
both behaviors against the sources above; the failure modes are a leaked token and
a sidecar that is unreachable only for proxied users, neither of which shows up in
CI or on an unproxied dev machine.
cesium / @cesium/engine / @cesium/widgets — runtime assets fetched by URL¶
The 3D globe pane loads its code from @cesium/engine but its runtime
assets from the cesium wrapper. Both are dependencies and both must move
together: cesium@1.x pins the matching @cesium/engine, and the prebuilt
Workers/Assets staged from the wrapper have to match the engine running them.
@cesium/widgets is a third dependency on the same clock — it depends on
@cesium/engine itself — and it supplies the globe's home, scene-mode and
fullscreen buttons (packages/map/src/cesium-widget-controls.ts).
The code imports the engine and the widgets directly rather than through the
cesium barrel, which re-exports both and defeats tree-shaking — the base-layer
picker, geocoder, info box and Knockout all shipped in the lazy chunk even when
the pane built a bare CesiumWidget. Reverting to import("cesium") adds
~340 KB back with no build error and no test failure.
After a bump, check all five — none of these fail the build:
- Asset copy.
vite-plugins/copy-cesium-assets.tscopiesAssets,ThirdParty,WidgetsandWorkersout ofcesium/Build/Cesiumintopublic/cesium/, keyed on the installed version. The copy is gitignored, so a stale one is refreshed automatically. A directory renamed or removed upstream fails loudly —cpSyncthrowsENOENTfrombuildStart, so the dev server and the build both stop. The silent case is the opposite one: a runtime directory added upstream is simply not inRUNTIME_DIRS, so it is never copied and only surfaces as a 404 when the globe reaches for it. CESIUM_BASE_URL.CesiumCanvas.tsxderives it from the app'sBASE_URL, not a hardcoded/cesium, so a sub-path deploy (the/demo/build) still resolves. Cesium reads it at import time; if the Workers 404 the render loop dies with no error boundary.- The stylesheets.
CesiumCanvas.tsxlinks four paths by hand (CESIUM_CSS_PATHS) rather than the 32 KBWidgets/widgets.css:Widgets/CesiumWidget/CesiumWidget.cssfor the canvas sizing, credit container and error panel;Widgets/shared.cssfor the.cesium-buttonchrome both toolbar widgets are built from; andWidgets/SceneModePicker/SceneModePicker.cssplusWidgets/FullscreenButton/FullscreenButton.cssfor those two widgets. Check every one after a bump — a<link>to a path upstream moved 404s silently, and the failure is cosmetic in a way no assertion catches: the globe still mounts, but its canvas stops filling the pane, or a toolbar button loses its size and renders 0x0. (The home button has no stylesheet of its own;shared.cssis all it needs.) - The widget classes and view models.
cesium-widget-controls.tsbuildsHomeButton,SceneModePickerandFullscreenButtondirectly and writes their tooltips throughviewModel.tooltip/tooltip2D/tooltip3D/tooltipColumbusView.index.cssthen restyles them through Cesium's own class names (.cesium-button,.cesium-toolbar-button,.cesium-sceneModePicker-wrapper). A renamed observable leaves the English default in place; a renamed class leaves Cesium's dark-blue chrome on a GeoLibre toolbar. Neither fails the build. Tooltip assertions live ine2e/cesium-primary-renderer.spec.ts, alongside control mounting and alignment checks. The fullscreen button is the fragile one: its tooltip is a read-only computed, so the translated string is written onto the element from afullscreenchangelistener and survives only because DOM listeners fire in registration order. If a bump makes the widget update its own title differently — batched on a microtask, or bound to another target — the label reverts to English after the first toggle, which is why the spec toggles fullscreen and re-reads the title rather than checking it once at mount.
Scene-mode lifetime: the 2D/3D/Columbus picker controls the current
CesiumWidget only. Recreating the widget, including switching to MapLibre
and back or reopening a project, starts in 3D. The project retains its
rendering engine and camera, but does not serialize the Cesium scene mode.
Scene-mode persistence is outside the toolbar integration's scope; adding it
requires an explicit project/store field and restoration for each Cesium pane.
- PWA globs. **/cesium-* / **/Cesium-* in vite.config.ts keep the
chunk out of the app-shell precache and CacheFirst-cache it instead. A chunk
renamed out of that pattern would be precached, adding megabytes to first load.
e2e/cesium-globe.spec.ts mounts the real engine keyless and drags the globe,
so it catches a broken CESIUM_BASE_URL or a dead chunk.
e2e/cesium-primary-renderer.spec.ts adds the toolbar: it asserts the three
buttons mount, carry translated tooltips, and share a right edge, which catches a
renamed view-model observable and the 0x0-button case of a missing stylesheet.
Neither catches the rest of the CSS regression or the bundle-size one — check
those by eye and in the build output.
Adding a blend mode¶
Do not add a blend mode without checking it in the browser. MapLibre's blend
state covers the alpha channel too, and it composites a blended layer as one
viewport-filling quad, so any mode that does not reduce to "leave the destination
alone" at zero source alpha repaints the whole map. That is what disqualified
darken (a MIN equation erased the entire basemap to transparent black) and
subtract (a reverse subtract left the canvas at dstA - srcA, showing the page
through the layer). The shipped list is BLEND_MODES in @geolibre/core, and
both the unit test's blend simulator and the e2e spec pin their exclusion.
Only fill and line have a *-layer-opacity in the style spec, so only they
blend as a whole layer; circle and fill-extrusion blend per symbol and
visibly double-darken where symbols overlap on screen (measured under Multiply:
rgb(23, 77, 220) in the overlap vs rgb(76, 136, 222) on a single symbol). That
is upstream's limitation, documented in
Managing Layers; the test "has a layer-level composite for
fill and line only" fails if a bump adds one of the missing properties, at which
point extend COMPOSITE_LAYER_TYPES and style-mapper together and drop the
caveat.
The Style-panel control (blendModeControl in StylePanel.tsx, rendered in each
of its terminal branches) is gated on !pluginOwnsPaint && !controlRendersLayer:
blending only reaches layers GeoLibre itself paints, so anything a control
renders or paints (3D Tiles, Gaussian splats, LiDAR, the COG raster control, and
Add Vector Layer, which sets customLayerType and controlOwnsPaint) is
excluded — layer-sync never applies fillPaint/linePaint to those, so the
*-layer-opacity that elects the composite never lands and a Blend menu there
would silently do nothing. Keep docs/user-guide/layers.md and
tests/layer-blend-modes.test.ts ("the layer kinds the Blend control is offered
for") in step with that gate; build the test's mocks the way the real controls
build their metadata, or they pass on shapes that never occur.
Coverage floors¶
The :coverage test variants run the same suites and print a coverage summary;
CI runs them so every build reports coverage. They are gated on a floor:
test:frontend:coverage fails below 78% lines / 78% branches / 63% functions, and
test:backend:coverage fails below 55% (--cov-fail-under). The floors sit a few
points under the current numbers as a ratchet — regressions fail CI, and when
coverage rises comfortably above a floor, raise the floor to lock in the gain.
The frontend report only counts files a test actually imports, so a module with no
test does not appear at all rather than as 0%. That is the part that bites:
writing the first test for a large untested module reads as a coverage
regression, because the module and everything it imports enter the denominator
at once. GeoLibre#1784 added a test that imported usePlugins.ts and so pulled in
the whole built-in plugin registry, 39 files, dropping function coverage 72.90% →
60.36% and reddening main. The fix is to test against a leaf module rather than
to lower the floor (GeoLibre#1888 extracted lib/plugin-layer-queries.ts;
geo-editor-geometry.ts in @geolibre/plugins is the same pattern). Check what a
new test transitively imports before assuming a coverage drop means the code got
worse.
test:frontend:coverage runs through scripts/coverage-check.mjs rather than
calling node --test directly. Node still enforces all three floors; the wrapper
only re-measures once when line coverage alone comes up short with every test
passing. Line coverage is nondeterministic on CI (GeoLibre#1889: two runs over
byte-identical sources reported 81.82% and 76.47%, 114 of 444 files differing on
lines and none on branches or functions), and it is not reproducible locally on
either Node 22 or 26. Branch and function shortfalls, and any test failure, fail on
the spot with no retry, so a real regression still fails fast. classify() is
exported and covered by tests/coverage-check.test.ts — change the retry policy
there, not by loosening a floor. If the retry starts firing regularly, fix the
measurement instead of widening the mitigation.
The backend coverage run (and npm run ci, which calls the :coverage variants)
needs pytest-cov from the backend dev extra. Install the test extra to
run the full backend suite — without the optional engines
(geopandas/rasterio/sedona/httpx) the vector/raster/SQL/ML tests skip themselves
and CI is green but hollow: pip install -e "backend/geolibre_server[test]".
Dependency updates and the audit allowlist¶
Dependencies are watched two ways: Dependabot (.github/dependabot.yml) opens
grouped weekly update PRs for npm, pip (backend + python/), cargo, and Actions,
and the CI audit job runs npm run audit:ci (blocking) plus a non-blocking
pip-audit of the resolved backend environment.
audit:ci is scripts/audit-check.mjs, a thin wrapper over npm audit
--omit=dev that still fails on every high/critical advisory except the ones
listed in its ALLOWLIST. The wrapper exists because plain npm audit cannot
accept a single finding, so one unpatchable transitive advisory reddens every PR
until upstream ships a fix — which for an unmaintained leaf package may be never.
Only allowlist an advisory when there is no patched version to upgrade to and
the vulnerable code is unreachable from a GeoLibre runtime path, and say why on
both counts in the entry. Anything upgradeable gets upgraded instead. Stale entries
print a warning rather than failing, since the advisory database is a live service
and a transient omission must not redden an unrelated PR.
Publishing @geolibre/core and @geolibre/map¶
Both are published to npm by .github/workflows/publish-packages.yml on each
GitHub Release, alongside @geolibre/embed. Their checked-in
main/types/exports point at TypeScript source, because that is how the
monorepo consumes them: Vite, tsc and tsx all resolve ./src/index.ts through
the package's own exports, so npm run dev and
node --import tsx --test tests/<name>.test.ts need no build step.
The npm tarball ships dist instead, and npm cannot express that split on its
own: unlike pnpm and Yarn it deliberately ignores entry fields nested under
publishConfig (npm/cli#7586), so a manifest that only states its dist entries
there publishes ./src/index.ts to consumers who never receive src. The
published entries therefore live under publishConfig, and
scripts/prepare-npm-package.mjs hoists them (and pins the "*"
@geolibre/core dependency to the release version) just before npm publish.
Point those top-level fields at dist and every frontend test that imports a
@geolibre/map subpath fails with ERR_MODULE_NOT_FOUND, because dist is
gitignored and nothing builds it before the suite.
tests/prepare-npm-package.test.ts guards both halves, including that each
published path is one the package's own tsdown entries actually emit
(--format esm --dts writes <entry>.mjs and <entry>.d.mts, not .d.ts).
The release workflow does build both packages, but a green build proves only that
the bundles were written, not that every path the manifest publishes names one of
them, so nothing else would notice that drift.
The bundled sidecar lockfile¶
backend/geolibre_server/uv.lock is committed (the root .gitignore ignores
uv.lock everywhere else and negates it for this one path). That project is
bundled into the desktop installers and launched with
uv run --frozen --project <resource dir> from src-tauri/src/lib.rs — a
directory the user cannot write (C:\Program Files\…,
/usr/lib/GeoLibre Desktop/…). Ship it lockless and uv resolves, then tries to
write uv.lock there, fails with "Permission denied" and exits 2 — which reaches
the user as "Jupyter server exited before it was ready (exit code: 2)" with the
cause invisible.
So: any edit to that pyproject.toml's dependencies must land with a refreshed
lock (uv lock --project backend/geolibre_server). CI's "Check the bundled sidecar
lockfile is in sync" step (uv lock --check) fails if they drift.
Credentials must never reach a redistributable build¶
Anything we hand to someone else — the Jupyter wheel above all — must carry no credential of ours. Three properties of this build make that easy to get wrong, and every guard below blocks one of them.
apps/geolibre-desktop/vite.config.tsbridges bare shell vars into theirVITE_names —GOOGLE_MAPS_API_KEY→VITE_GOOGLE_MAPS_API_KEY, and the same forMAPBOX_TOKENandCESIUM_TOKEN. Convenient for local testing; it also means the build machine's shell is build input.- Something in the graph reads
import.meta.envas a whole object. Vite cannot tell which keys such a read wants, so it stops replacing per key and inlines the entire env record — everyVITE_var on the build machine — into every chunk that read reaches. Ours waspackages/core/src/runtime-env.ts; the one we cannot fix is@clerk/shared'sgetEnvVariable.mjs, which doesimport.meta.env[name]with a computed name, so the inlined record lands in theClerkGate-*.jschunk. python/hatch_build.pyskips the JS build whenstatic/appalready exists andGEOLIBRE_FORCE_JS_BUILDis unset. A localpython -m buildtherefore packages whatever an earliernpm run build:embedleft staged, with no JavaScript running at all.
The rules now¶
BUILD_ENV_KEYSinvite.config.tsis the allowlist ofVITE_names that may reach a bundle.pruneBuildEnv()deletes every otherVITE_var fromprocess.envbefore Vite reads it. Adding a new build-time var means adding it here — otherwise it silently resolves to undefined.CREDENTIAL_ENV_KEYSis the subset that authenticates as, and bills to, whoever ran the build. In a redistributable build these are blanked to""(blanked, not deleted, so a.envfile cannot reintroduce them). A build is redistributable whenGEOLIBRE_EMBED=1(the Jupyter wheel) orGEOLIBRE_STRIP_CREDENTIALS=1. The web deploy is not redistributable: it is our own site using our own referrer-restricted keys, and it keeps them.- Public-by-design identifiers stay in every build: the Clerk publishable key,
the Auth0 client ID and domain, the GEE OAuth client ID, the GA measurement ID.
publish-python.ymldeliberately injects the GEE client ID into the wheel. - Prefer
getBuildEnvironment()from@geolibre/coreover readingimport.meta.envyourself. A whole-object read re-opens cause (2) for every chunk it reaches, and nothing in the type system will tell you.
Each stripped credential resolves through getRuntimeEnvironment(), which
overlays window.__GEOLIBRE_RUNTIME_ENV__ from Settings → Environment variables.
So a wheel user supplies their own token and the affected surfaces degrade as
documented: Mapbox prompts in the basemap API-keys view, the 3D globe loses
terrain and Ion imagery (the pane itself still works), Protomaps basemaps are
hidden.
The scan¶
scripts/scan-credentials.mjs verifies the output. A build run by hand can
satisfy every rule above and still be wrong, and a third-party asset dropped into
the tree can arrive with a key already inside it — neither is visible from the
config. It runs in two places:
scripts/build-embed.mjs, before stagingdist-embedinto the Python package.python/hatch_build.py, before packaging any wheel or sdist — including the stale-assets path (3) above, which no JS-side guard can cover. This one needs no Node.
Both read scripts/credential-patterns.json, so the JS and Python scanners
cannot drift; the file is force-included into the sdist so an sdist → wheel build
is gated too. tests/credential-scan.test.ts covers it.
To scan any built directory by hand:
node scripts/scan-credentials.mjs apps/geolibre-desktop/dist-embed
When the scan fires after a dependency bump¶
allowedValueHashes in credential-patterns.json holds the SHA-256 of values
that match a pattern but are public by design — currently CesiumJS's built-in
default Ion token, which ships inside cesium and appears in every build. A
Cesium upgrade changes that token, so the guard will fire on the new one.
That is intended. Decode the payload before doing anything: CesiumJS's has
sub: "CesiumJS" and iss: "https://api.cesium.com". Only once you have
confirmed it is the vendor's own token, replace the hash. Never add a hash to
silence a finding you have not decoded — the whole point of the list is that it
is short and every entry was checked.
Bundled plugin drop-ins under apps/geolibre-desktop/public/plugins/<id>/ are
scanned too. They are third-party build artifacts that are not committed here, so
a hardcoded key inside one is fixed in that plugin's own repository, never by
allowlisting it. Note that removing such a drop-in does not clean a dist/ built
while it was present: Vite copies public/ into the output and only clears that
output when a build actually runs. Rebuild, or delete the stale directory.
Generated files and cross-file sync¶
- Processing tool metadata. Names, descriptions, group labels, parameter
labels/help and select options live in registries with no i18n access, so the
dialogs resolve them through
apps/geolibre-desktop/src/lib/processing-tool-i18n.tsand fall back to the registry's own English string. For the four small bundled registries,en.json'sprocessing.toolMeta/processing.toolGroupsubtrees are the generated baseline translators work from. After adding or renaming one of those tools, parameters, or select options, runnpm run i18n:toolsand commit the result; CI fails on drift. Whitebox's much larger metadata is deliberately absent from all bundled locales and comes from optional, validated packs atlanguages.geolibre.app(or local file import); do not addprocessing.toolMeta.whitebox,processing.whitebox.categories,menuTool, ormenuSubcategoryback to a bundled locale. - The agent skill.
skills/geolibre/is a user-facing agent skill — aSKILL.mdplusreferences/that teaches an external AI agent to author.geolibre.jsonprojects throughgeolibre-mcp, the Python package, or hand-written JSON. It is not for contributors working on GeoLibre itself. It restates things that live elsewhere: the MCP tool surface (python/src/geolibre/mcp/server.py), the basemap/color-ramp/legend catalogs (python/src/geolibre/basemaps.py,color_ramp.py,legends.py), the project schema (Project Format), and the embed parameters (Embedding).python/tests/test_agent_skill.pyguards the parts that can be checked mechanically: every registered MCP tool must appear in the tool reference, every tool andMapmethod the skill names must exist, and the basemap, color-ramp, legend-preset, layer-type, frontmatter, and reference-file lists must match their sources. It cannot check prose, so a changed size cap, limit, or behavioral caveat still has to be carried over by hand — update the skill in the same PR. A stale caveat sends an agent down a path that no longer works, with no failure anywhere.