digidem / digidem/comapeo-cloud-app
feat(map): add pinned GeoLibre embed bridge and AuthoredLayer project round-trip
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 0
- Forks
- 0
- Avg merge
- 1d 7h
- Merged PRs (30d)
- 29
Description
feat(map): add pinned GeoLibre embed bridge and AuthoredLayer project round-trip
Status: implementation-ready
Parent tracker: #223
Depends on: #279 merged/green.
Goal
Create the independently testable GeoLibre integration boundary: a pinned/self-hosted embedded GeoLibre build, supported/versioned host bridge, pure project↔#279 AuthoredLayer adapter, privacy/egress profile, immutable release identity, and real two-origin round-trip tests.
This issue intentionally ships no production Edit style / Advanced editor screen. The user-facing MapScreen flow, history/Done/Cancel UX, SavedMap commit destination, staging promotion, and production enablement belong to #282.
Core strategy
CoMapeo owns the durable map. GeoLibre is an optional advanced editor, not a copied component library or persistence system. Prefer stock pinned GeoLibre; when the required generic host capability is absent, apply only the exact small content-addressed upstreamable patch specified below and submit the same change upstream. A broader fork/private protocol/parallel store is a hard architecture stop, not an implementation shortcut.
Parent product context
The following product decisions come from parent tracker #223 and describe the behavior that #281 primitives must enable. #282 implements the user-facing entry points, Done/Cancel, and editor shell; #281 implements only the bridge/adapter/security primitives required by those decisions.
- The normal CoMapeo map authoring flow remains the default and must continue working without GeoLibre or network access.
- Existing Add layer remains the simple entry point.
- Every authored layer gets Edit style, which opens a full-screen GeoLibre editor with the whole current map loaded and that layer selected for styling.
- The layer section also gets a general Advanced editor action that opens the same full-screen workspace without requiring a specific layer.
- The GeoLibre surface works intentionally on desktop, tablet, and mobile; it is not desktop-only.
- GeoLibre edits authored layers, not the CoMapeo basemap. The current basemap is supplied as visual context when supported, but basemap changes made inside GeoLibre are not committed back.
- On Done, all supported authored-layer results come back automatically: layer data, names, visibility, opacity, order, styles, and newly created/derived layers.
- GeoLibre-only application state does not become CoMapeo state: plugins, dashboards, widgets, story maps, notebook state, workspace layout, collaboration state, and similar project extras are ignored.
- V1 centers on layer addition/creation and styling. Editing imported source geometry is not a primary CoMapeo workflow or acceptance requirement.
- Online sources may be used as temporary editing/reference material only from a fixed set of remote origins explicitly approved in the deployed CoMapeo GeoLibre profile; V1 has no per-session/dynamic egress expansion. Adding another permitted origin is a reviewed deployment-profile change that reruns the full bridge/privacy/security gates. Anything committed back to a saved CoMapeo map must still satisfy #279's offline-packageability rules.
- GeoLibre editing uses a temporary working copy. Done atomically applies a validated result to the current CoMapeo authoring draft; Cancel discards the GeoLibre session; navigating away with actual uncommitted changes requires a discard confirmation.
Dependency on #279
#281 imports #279's exact AuthoredLayer type, schema version, source/render allowlists, prepare boundary, runtime ID semantics, and canonical vector/raster fixtures. It does not persist anything and does not widen #279 when GeoLibre supports a richer source/style. Unsupported outputs are classified rejected or remain temporary editing state.
Key implementation files
| Path | Phase | Purpose |
|---|---|---|
src/lib/map/geolibre-client.ts |
#281 | GeoLibre embed transport, handshake, exact-origin/source security, protocol/version handling |
src/lib/map/geolibre-adapter.ts |
#281 | Pure GeoLibre project <-> #279 AuthoredLayer translation, prepareGeoLibreWorkingProject, translateGeoLibreProjectToAuthoredLayerCandidates, capability table, dirty-state canonicalization |
src/lib/map/geolibre-config.ts |
#281/#282 | Validate/expose VITE_GEOLIBRE_ORIGIN + VITE_GEOLIBRE_RELEASE_ID; feature disabled unless both are valid |
tests/unit/lib/map/geolibre-adapter.test.ts + tests/fixtures/geolibre/style-bundle-golden.ts |
#281 | Exhaustive capability/translation/version tests plus five canonical fragment-role golden cases |
docs/geolibre-integration.md |
#281, maintained by #282 | Single source of truth for resolved upstream contract, pinned app/embed versions or commits, downstream-patch/upstream-retirement status, capability decisions, and upgrade procedure |
src/lib/schemas/geolibre-profile.ts + ops/geolibre/profile.json |
#281, reviewed on change | Canonical versioned security/embed profile: trusted CoMapeo host origins, fixed remote egress origins, CSP/header directives, iframe sandbox/Permissions expectations, disabled PWA/persistence/upload surfaces |
src/lib/map/geolibre-release-manifest.ts + tests/fixtures/geolibre/release-manifest-v1.* |
#281 | Shared browser/Node release-manifest schema + RFC 8785 canonicalization/hash helper and golden vectors |
scripts/geolibre-dist-manifest.ts |
#281/#282 | Copy/validate the canonical profile into dist, generate bound deployment-header config, and generate deterministic metadata+profile+file content manifest/release ID through the shared helper |
scripts/geolibre-bridge-changes.ts + tests/unit/scripts/geolibre-bridge-changes.test.ts |
#281 | Exact security-sensitive path classifier; tests prove every profile/release/workflow/client/adapter/editor/schema/package-manager/deployment path forces the full bridge job |
scripts/check-geolibre-prerequisites.ts |
#281, extended by #282 | CI preflight for required #279/#281/#282 base-branch contracts; CLI is `--phase=bridge |
.github/workflows/geolibre-bridge.yml |
#281 | Required real GeoLibre bridge job + prerequisite preflight |
GeoLibre integration strategy
Production ownership
Use an unmodified upstream GeoLibre web build wherever possible:
- self-host it under Digital Democracy/CoMapeo-controlled infrastructure (prefer a dedicated static Cloudflare Pages deployment or equivalent static hosting);
- pin an explicit tested GeoLibre release/commit rather than following
latest; - pin the matching
@geolibre/embedpackage version in CoMapeo; - upgrade intentionally with integration tests;
- prefer small generic upstream contributions when a required embed capability is missing;
- do not copy GeoLibre React components into this repository or create a long-lived CoMapeo fork unless upstream integration proves impossible and a separate architectural decision explicitly approves that maintenance cost.
The public web.geolibre.app deployment may be used for investigation but is not a production dependency. #281 does not depend on permanent DNS/cloud infrastructure. For ordinary asset smoke checks, npx serve remains acceptable, but the real bridge/security test must use scripts/serve-geolibre-profile.ts, a tiny repository-owned test static server that serves the built dist and applies the exact CORS/CSP/frame headers generated from the active GeoLibre profile using the same shared header-generation helper as the production Pages _headers artifact. The test uses fixed origins http://127.0.0.1:4173 for CoMapeo, http://127.0.0.1:4174 for GeoLibre, and http://127.0.0.1:4175 as an untrusted host probe. tests/fixtures/geolibre/profile.local.json is the base bridge fixture: it adds only http://127.0.0.1:4173 to trustedHostOrigins, corsAllowedOrigins, and cspDirectives.frame-ancestors and keeps approvedEgressOrigins: []. tests/fixtures/geolibre/profile.local-egress.json is the only egress variant: it is identical except it adds the fixed data origin http://127.0.0.1:4176 to approvedEgressOrigins/required CSP fetch directives. The canonical ops/geolibre/profile.json must reject/omit localhost/127.0.0.1 origins. CI asserts the test server's emitted header values are byte/semantic-equivalent to the shared profile->Pages-header generator, so local success cannot hide a deployment-header mismatch. The GeoLibre deployment itself is required to serve the generated CSP/CORS headers on HTML and applicable assets; CoMapeo host code is not treated as capable of imposing CSP on the cross-origin iframe. Bridge tests fetch the GeoLibre HTML/manifest directly and assert Content-Security-Policy, Access-Control-Allow-Origin, Vary, and frame-ancestors values match the active profile. The localhost proof validates profile/header generation, browser CSP/CORS/frame behavior, protocol, storage, and adapter invariants but is not accepted as production-origin proof. #282 must redeploy the exact manifested artifact/profile to real CoMapeo-controlled staging domains and rerun the same CSP/CORS/cookie/sandbox/Permissions/network/privacy/bridge suite there before production promotion. Production-domain cookies/credentials are forbidden on the GeoLibre origin; staging tests assert no unexpected cookies are set or sent. #282 provisions/uses the actual CoMapeo-controlled staging/production GeoLibre deployment before production integration is complete.
Verified upstream baseline (August 2026)
Pinned #281 starting baseline: upstream repository opengeos/GeoLibre commit f7fe0818eae41d6d9da6f81bcbe1213f5e86c75b. At this exact commit both root package.json and packages/embed/package.json report 2.6.0; #281 must use this commit/version pair unless this issue spec is explicitly amended and re-reviewed before implementation. GitHub's tagged-release cadence is not used as an implicit selector. CI fetches both package files from the pinned checkout, requires root version === embed version === EXPECTED_GEOLIBRE_CONTRACT.embedVersion, and fails before patch/build on mismatch.
GeoLibre 2.4 documents a versioned v2 postMessage API and @geolibre/embed client. The API is disabled unless trusted host origins are configured and the typed client performs exact-origin filtering/request correlation.
Current documented methods include loadProject, setView, highlightFeature, openTool, setLayerVisibility, listLayers, setFilter, getViewport, addLayer, addData, and exportImage.
GeoLibre's current project format treats each entry in layers[] as one logical GIS layer with one stable layer id; styling is attached to that logical layer (style / the project styles map), and the Style panel exposes polygon fill + stroke, line stroke, point, label, zoom, and raster styling under that single selected layer. CoMapeo therefore maps one AuthoredLayer to one GeoLibre logical layer; CoMapeo's multiple MapLibre render.layers[] fragments are collapsed/reconstructed as that logical layer's style bundle rather than emitted as separate GeoLibre project layers.
The current standalone host embed docs do not expose a method that returns the complete live edited .geolibre.json project/style state or an atomic in-memory project setter. However, the same upstream repository already ships a stronger full-app Jupyter embed: npm run build:embed creates the embedded web bundle, GEOLIBRE_EMBED=1 disables PWA/service-worker registration, the Jupyter Map(layout='full') can render the complete UI, and the Python widget synchronizes one complete .geolibre.json project both ways over window.postMessage (load_project pushes state; to_project reads current UI-edited state). Therefore the missing CoMapeo capability is an exposure/profile gap at the generic host API boundary, not evidence that GeoLibre lacks a complete in-memory project synchronization path.
#281 pins one exact upstream commit and uses its build:embed / embedded-full-app path as the baseline rather than the standalone PWA build. Before implementation, re-check the official embedding/Jupyter/project/self-hosting documentation and that commit. If the pinned upstream host API already exposes all capabilities below, consume it directly. Otherwise apply the small generic downstream patch defined by this spec to that pinned source tree and submit the same change upstream; CoMapeo's build record stores the patch file SHA-256 and upstream PR/commit reference when available. #281 records in docs/geolibre-integration.md the exact upstream repository URL, release/commit, package manager/runtime version, build:embed command/output, patch SHA/status, and matching @geolibre/embed version. CI checks out exactly that commit, verifies the patch applies with no fuzz to the expected files when needed, and uses the upstream lockfile/commit as the dependency-cache key. Do not rely on an unpinned latest artifact, a public GeoLibre deployment, or undocumented local build steps.
Primary references:
- https://geolibre.app/user-guide/embedding/
- https://geolibre.app/project-format/
- https://geolibre.app/self-hosting/
- https://github.com/opengeos/GeoLibre/blob/main/docs/python.md (evidence that full two-way project synchronization already exists for the Jupyter integration)
Required integration spike / upstream gate
Treat this issue #281 as the separately mergeable bridge/upstream-gate implementation unit. #281 may add src/lib/map/geolibre-client.ts for embed transport/handshake/origin+iframe-source enforcement, src/lib/map/geolibre-adapter.ts for pure project/AuthoredLayer translation, version/config plumbing, self-hosted embedded-build configuration, the exact generic downstream patch described below when stock upstream lacks the capabilities, and deterministic integration tests needed to prove the bridge, but it must not add the production Edit style / Advanced editor entry points yet. Keep transport/security concerns out of the adapter so translation can be unit-tested without an iframe. #282 does not start until #281 and #280 are both green/merged.
Before building the production UI, prove a supported round trip on the exact pinned GeoLibre build:
CoMapeo authored-layer state -> GeoLibre -> user style/add-layer edit -> complete live project result -> CoMapeo authored-layer state
The proof must include:
- current CoMapeo map view/bbox;
- current basemap as visual context without transferring basemap ownership;
- at least two existing authored layers with stable IDs, order, visibility, and non-default styles, using #279's exact
AUTHORED_VECTOR_LAYER_FIXTUREandAUTHORED_RASTER_LAYER_FIXTURE; the vector fixture is deliberately mixed-geometry and multi-fragment (polygon fill + polygon outline + LineString stroke + Point circle), so #281 must prove logical-style collapse and deterministic fragment reconstruction rather than only single-fragment cases; - opening with one requested layer selected for styling;
- changing that layer’s style;
- adding or creating a new layer in GeoLibre;
- returning the full relevant live project/layer state to the host;
- translating back to #279 and rendering the result in the normal CoMapeo authoring canvas.
If the pinned stock host API still lacks the required CoMapeo operations, #281 carries one generic downstream patch against that exact upstream commit. The patch is deliberately defined here so implementation does not depend on a future fork spec or upstream review timing. It may only expose/configure capabilities already consistent with GeoLibre's embedded/Jupyter architecture:
-
setProject(project)/ equivalent: atomically replace the in-memory.geolibre.jsonproject without using a public URL/recent-project flow and return{ projectRevision: number }; -
getProject()/ equivalent: return{ project, projectRevision }for the complete current live project; host-sidegeolibre-client.tscomposes this with events into the boundedgetConsistentProject()primitive specified below; -
selectLayer(layerId, { openStyle?: boolean })/ equivalent: focus the logical layer and, when requested, acknowledge only after its Style surface is mounted/focused; -
a versioned
projectChangedevent whose payload is exactly{ projectRevision: number }; the revision is a finite safe integer and strictly increases after every project mutation that can affect the returned project. Missing/non-safe/duplicate/out-of-order revisions are protocol errors that immediately mark the client session poisoned: unsubscribe/ignore further child events, reject all pending/future RPCs withBRIDGE_PROTOCOL_MISMATCH, and require the responsive-clear-or-frame-termination teardown path before a fresh retry; no revision resynchronization is attempted inside the same session; -
one generic public
configureEmbedProfile(profile)capability acknowledged before the firstsetProject(), with host-facing shape:type GeoLibreEmbedProfile = { persistence: 'memory-only'; serviceWorker: 'disabled'; disabledSurfaces: readonly ('plugins'|'share'|'gallery'|'collaboration'|'ai'|'telemetry')[]; remoteSources: { mode: 'allowlist'; origins: readonly string[] }; profileVersion: number; profileSha256: string; };geolibre-client.tsderives this runtime object from the already-verified deployment profile/manifest with one exact mapping: deploymentprofileVersion -> profileVersion; manifest/profile SHA ->profileSha256; deploymentapprovedEgressOrigins -> remoteSources.origins;disableServiceWorker:true -> serviceWorker:'disabled'; project-bearing entries from deploymentdisabledSurfaces -> disabledSurfaces; and runtimepersistence:'memory-only'is mandatory for this profile version. Deployment-only fields (trustedHostOrigins,corsAllowedOrigins,cspDirectives, iframe sandbox tokens, Permissions Policy) are enforced by host/static-response configuration and are not duplicated into the runtime app profile.The acknowledgement echoes
{ profileVersion, profileSha256 }and is subject toGEOLIBRE_RPC_TIMEOUT_MS = 10_000; there is no automatic retry after timeout/malformed acknowledgement because configuration state would be unknown. The client marks that session failed, emitsBRIDGE_TIMEOUT/BRIDGE_PROTOCOL_MISMATCH, and #282 must tear it down and create a fresh session for Retry.profileSha256means exactlySHA-256of the exact UTF-8 bytes of the validated checked-inops/geolibre/profile.jsoncopied byte-for-byte into the dist; it is not the release-manifest hash. V1 remote-source policy is always allowlist: empty origins means remote URL/service addition is disabled and the ephemeral GeoLibre UI must hide/disable direct Add remote URL/service affordances. With non-empty origins the UI may expose them but app logic rejects origins outside the list before fetch. Acknowledgingpersistence:'memory-only'is not treated as proof that GeoLibre obeyed it. The real browser A/B differential storage/cross-context audit later in this spec is the enforcement oracle on every pinned/patched build; any project-bearing durable state means the profile is ineffective and the bridge cannot merge. Fix it within the bounded upstreamable patch or take branch 3. If stock upstream lacks this generic configuration hook, the bounded patch adds it. If it cannot be implemented within the bounded patch surface, branch 3 applies; never broaden CSP or add a CoMapeo-private store/protocol.
When a downstream patch is needed, #281 creates it reproducibly from a temporary worktree checked out at the exact pinned commit: make only the generic changes permitted by this spec, run upstream targeted tests/typecheck, generate git diff --binary --full-index <pinned-commit> -- <allowed patch paths> into ops/geolibre/patches/<commit>-comapeo-embed.patch, and review that patch file as a first-class PR artifact. scripts/geolibre-dist-manifest.ts hashes the checked-in patch bytes directly; CI requires that hash to equal EXPECTED_GEOLIBRE_CONTRACT.downstreamPatchSha256, applies those same bytes to a clean pinned checkout, and builds from that checkout in the same job. No generated/uncommitted replacement patch may participate in the build. The PR/docs link the corresponding upstream PR when one is opened.
The pinned upstream commit is immutable, so there is no patch-application fallback. If git apply --check fails against that exact commit, CI fails before build; correct the reviewed patch artifact so it applies cleanly to the same pinned commit, or—if doing so requires exceeding the bounded patch contract—take decision-tree branch 3. Never use fuzz, three-way application, a moving upstream ref, or an automatic rebase. The patch lives as a normal reviewable patch artifact at exactly ops/geolibre/patches/<upstreamCommit>-comapeo-embed.patch (V1 permits zero or one patch file; multiple patch files fail validation), is SHA-256-bound into the #281 build/release record, applies with git apply --check and then git apply without fuzz/three-way fallback, and is applied only to the pinned upstream source checkout. #281 also opens/submits the same generic change upstream when upstream does not already contain it. CoMapeo may temporarily build the pinned upstream commit + this exact patch without calling that checkout a maintained product fork; no unrelated GeoLibre customization, rebasing onto moving main, or private comapeo:* message protocol is permitted. The generated GeoLibre dist manifest records both upstreamCommit and downstreamPatchSha256 (null only when the pinned upstream commit already contains the complete required contract), and upgrade CI fails if the patch no longer applies exactly or its behavior tests change.
The monthly .github/workflows/geolibre-upstream-watch.yml defined below actively rechecks the recorded upstream PR and newer tagged releases; patch retention is therefore not dependent only on incidental upgrades. If the upstream PR has no maintainer response for 90 days, the same deduplicated dependency-review issue is marked upstream-stale for CoMapeo maintainer review; this does not authorize an automatic fork/repin and the verified exact patch remains the supported pin.
The patch has a retirement guard: docs/geolibre-integration.md records the upstream PR/commit when available, and every intentional GeoLibre upgrade first checks whether the pinned upstream target contains equivalent versioned capabilities. If yes, the same PR must remove the downstream patch and prove identical bridge/privacy tests against stock upstream. If upstream rejects, delays, or redesigns the API, the already-reviewed exact pinned patch has no time-based expiry and remains the supported contract until upstream contains an equivalent reviewed API or CoMapeo maintainers approve a separate replacement architecture/update issue. Rejection alone does not trigger a fork or branch 3, and the patch surface may not expand opportunistically.
#281 follows this executable decision tree and does not wait indefinitely on external upstream review:
- Pinned upstream already supports the complete contract above: record the methods/profile, set
downstreamPatchSha256: null, and proceed. - Pinned upstream lacks one or more items, but the exact generic patch above can implement them by exposing/configuring the existing full-project embedded synchronization without copying UI/components or creating parallel stores: apply the content-addressed patch, submit it upstream, record its SHA/reference, and proceed immediately with the real bridge/privacy tests. Upstream merge/release is not a #281 prerequisite. Upstream rejection or delayed review alone does not trigger branch 3 and does not authorize a broader fork: #281 may continue shipping only the exact content-addressed pinned patch already proven by the bridge/privacy suite with no time-based expiry. Branch 3 is triggered only when the required functionality/privacy cannot be satisfied inside that bounded patch surface. Replacing the pinned patch, widening its scope, or moving to a maintained fork requires a separate explicit architecture/update issue and full review.
- Implementing any required item would need a broader divergent fork, copied React components, undocumented CoMapeo-only store access, a second project model, or cannot satisfy the ephemeral/privacy tests: the patch boundary has failed. Do not widen it. Park/close the incomplete bridge PR as appropriate, open a linked architecture-decision issue containing the spike evidence and alternatives, and stop. CoMapeo maintainers are the decision authority; automation/implementation agents may not choose the replacement strategy. There is no automatic timeout/fallback: #281/#282 remain blocked until maintainers record an explicit approved strategy in that issue and the affected specs are updated/re-reviewed.
Do not silently downgrade to partial-state return, make projectChanged best-effort, weaken ephemeral storage, or copy components.
State translation contract — #281 adapter contract, consumed by #282
Implement a narrow adapter boundary between the #279 CoMapeo model and the supported GeoLibre project format. Keep translation code isolated and runtime-validated.
CoMapeo -> GeoLibre
Pass only the editing context needed for this feature:
- current map viewport/bbox;
- optional basemap visual context under this exact V1 rule: only when the current CoMapeo map is
type:'style',styleUrlis an anonymous absolute HTTPS URL whose origin is present in the active profile'sapprovedEgressOrigins, and it contains no credentials/query/signed-token material, set the GeoLibre project'sbasemapStyleUrlto that canonical URL. Raster basemaps, relative/blob URLs, or credentialed/query/signed-token style URLs are omitted asCONTEXT_BASEMAP_UNSUPPORTED. A syntactically safe anonymous HTTPS style URL whose origin is not approved is omitted asCONTEXT_OMITTED_EGRESSwithorigins: [thatOrigin]; this code is never emitted with an empty origins array. Both cases are context-only metadata for #282; omission never blocks layer editing. Returned GeoLibre basemap state is always ignored on commit; - ordered authored layers;
- stable layer identity;
- names;
- visibility/opacity;
- vector data / supported raster source metadata;
- style/render state that GeoLibre can represent.
Use GeoLibre's native logical project layer id for identity preservation: when loading an existing #279 AuthoredLayer, set the GeoLibre logical layer id exactly to the bare AuthoredLayer.id; do not add custom metadata solely to carry CoMapeo identity and do not create one GeoLibre project layer per MapLibre render fragment. This GeoLibre project ID is intentionally distinct from #279's derived MapLibre runtime IDs such as comapeo-authored:${layer.id}:source / :layer:${n} and must never use that reserved prefix. Unit/bridge tests assert every emitted GeoLibre logical ID equals a bare UUID and none begins with comapeo-authored:.
Identity semantics are set-membership based and do not require unverifiable provenance. Capture ordered initialIds = initial AuthoredLayer.id[] when the temporary project opens; do not construct the trusted initialIdSet yet. Loading is not yet trusted after setProject() acknowledges. Immediately after its acknowledgement revision, call getConsistentProject() and require the returned logical layer ID array to equal initialIds exactly in membership and outer order before #282 may enable editor interaction. If getConsistentProject() itself cannot obtain a stable snapshot inside its bounds, the pre-edit gate returns PROJECT_NOT_QUIESCENT and does not coerce it to load mismatch. Only after a stable snapshot exists, any missing, extra, duplicate, or reordered initial logical layer at this pre-edit verification point is PROJECT_LOAD_MISMATCH, tears down/fails closed, and cannot be interpreted as a user deletion. Real-bridge tests inject a GeoLibre load-time dropped/reordered layer and prove the editor never becomes usable. Once this initial integrity check passes, capture initialIdSet = Set(initialIds) for later edit/delete semantics. On Done: (a) returned IDs must be unique; duplicate IDs invalidate the whole candidate; (b) any returned logical layer whose ID is in initialIdSet is, by definition, the updated/replaced version of that existing AuthoredLayer and keeps that CoMapeo ID after full validation—even if the user deleted/recreated content inside GeoLibre; (c) any verified initial ID absent from the returned project is an intentional layer deletion; (d) any returned ID not in initialIdSet is a new GeoLibre layer, and its GeoLibre ID is not trusted as durable CoMapeo identity—the adapter assigns a fresh collision-checked CoMapeo UUID before commit. Thus a newly returned layer cannot 'steal' an existing CoMapeo ID: matching an initial ID means edit/replace semantics, while only unknown IDs enter the new-layer path. Unit and real-bridge tests cover update, deletion, new-layer UUID assignment, duplicate-returned-ID rejection, and delete/recreate-with-same-initial-ID semantics.
Logical style-bundle mapping
geolibre-adapter.ts owns a deterministic conversion between one #279 AuthoredLayer.render.layers[] bundle and one GeoLibre LayerStyle. Render-fragment IDs are never used as GeoLibre project identity.
For V1 vector layers the adapter recognizes these semantic fragment roles:
- polygon
fill; - polygon-outline
line; - LineString
line; - Point
circle; - label
symbol.
CoMapeo -> GeoLibre collapses those roles into the corresponding logical GeoLibre fill/stroke/point/label/zoom/expression style fields. GeoLibre exposes one logical stroke style for a vector layer, so when both polygon-outline and LineString stroke fragments exist their stroke semantics must be equivalent (or be representable by one GeoLibre-supported expression whose lossless mapping is explicitly covered by the capability table/tests). #279's canonical Add-layer default and AUTHORED_VECTOR_LAYER_FIXTURE intentionally satisfy this invariant. Multiple conflicting fragments for the same semantic role, differing polygon-vs-LineString strokes that cannot be represented by one supported expression, or another #279-valid render bundle that the current adapter cannot collapse losslessly are not silently normalized: the compatibility check rejects that layer for advanced editing and shows a layer-specific unsupported-style explanation while built-in CoMapeo authoring remains available.
GeoLibre -> CoMapeo reconstructs a canonical render.layers[] bundle from the returned logical style and the layer's actual geometry families, in this fixed order: polygon fill; polygon-outline line; LineString line; Point circle; label symbol. The exact V1 geometry-family filter emission is canonical and imported from #279 semantics: polygon fill => ['in', '$type', 'Polygon']; polygon-outline line => the same Polygon filter; LineString line => ['in', '$type', 'LineString']; Point circle => ['in', '$type', 'Point']; label symbol => no geometry-family $type filter in V1. A bridge-compatible pre-existing label symbol carrying a geometry-family filter is rejected as uncollapsible unless a future reviewed capability explicitly adds a lossless logical representation. These literal array forms are emitted for both existing and newly created GeoLibre logical layers; no equivalent alternate spelling is emitted. Golden adapter tests compare reconstructed structures against #279's canonical Add-layer output and AUTHORED_VECTOR_LAYER_FIXTURE. #279's canonical fixtures are dependency-contract fixtures for schema version 1: a semantic change to either fixture that breaks these adapter expectations must fail #281 CI and requires an explicit #281 compatibility review/update; downstream tests never silently regenerate expected output. In addition, tests/fixtures/geolibre/style-bundle-golden.ts contains five explicit golden role cases—polygon fill, polygon outline, LineString line, Point circle, label symbol—and asserts the exact emitted filter value (Polygon, Polygon, LineString, Point, and undefined respectively) independently of whether #279's mixed fixture happens to contain a label. If both Polygon and LineString geometries are present, the returned logical stroke style is copied into both canonical line fragments with the existing geometry-family filters. Raster uses one canonical raster fragment. Data-driven/categorized/expression styles are reconstructed into the relevant canonical fragment role(s) only when GEOLIBRE_TRANSLATION_CAPABILITIES and its tests prove an equivalent #279 expression; otherwise Done rejects the style rather than changing its meaning. This normalization means fragment-array structure is deterministic while logical visual/style semantics, layer identity, visibility, and outer layer order remain the round-trip contract.
#281 accepts only canonical #279 AuthoredLayer[] plus map context; it does not inspect #280 raw recovery draftEntries or render user-facing blocker copy. Export structured bridge/adapter blockers for #282:
type GeoLibreBridgeBlocker =
| { code: 'LAYER_UNCOLLAPSIBLE_STYLE'; layerIds: readonly string[] }
| { code: 'LAYER_UNSUPPORTED'; layerIds: readonly string[] }
| { code: 'PROJECT_LOAD_MISMATCH'; expectedIds: readonly string[]; actualIds: readonly string[] }
| { code: 'PROJECT_NOT_QUIESCENT' }
| { code: 'FOCUS_STYLE_UNAVAILABLE'; layerId: string }
| { code: 'BRIDGE_UNAVAILABLE' | 'BRIDGE_PROTOCOL_MISMATCH' | 'BRIDGE_TIMEOUT' };
The adapter/client returns these blocking typed codes and associated IDs; basemap/egress omissions use the separate non-blocking GeoLibreContextNotice union below and never appear in GeoLibreBridgeBlocker. #282 owns all rendering, localized explanations, recovery guidance, and retry/exit UX. #282 must maintain two exhaustive typed message maps: one Record<GeoLibreBridgeBlocker['code'], ...> and one Record<GeoLibreContextNotice['code'], ...>; adding/removing a blocker or notice code therefore breaks #282 typecheck until its reviewed UI mapping is updated. The no-raw-code/no-sensitive-detail rule applies equally to blockers and notices. The literal enum/code identifiers and raw technical messages are diagnostics/API values and must never be interpolated directly into user-facing strings; #282 maps each code to reviewed react-intl message IDs through an exhaustive const GEOLIBRE_BLOCKER_MESSAGE_IDS satisfies Record<GeoLibreBridgeBlocker['code'], MessageId> (or equivalent exhaustive switch). A #282 typecheck must fail when #281 adds/removes a blocker code without updating the mapping; and may log only non-sensitive code/IDs according to existing telemetry policy. #280 separately owns INVALID_RECOVERY_ENTRIES and its eligibility predicate. Unit tests exhaustively enumerate the #281 blocker union so unstructured strings do not become an accidental UI API.
Do not send project observations, alerts, attachments, invite/auth tokens, server bearer tokens, or unrelated project data.
GeoLibre -> CoMapeo on Done
Read the complete live project through the supported integration contract and translate it only through this exported adapter boundary:
type TranslateGeoLibreProjectResult =
| { ok: true; candidates: AuthoredLayer[] }
| { ok: false; blockers: readonly GeoLibreBridgeBlocker[] };
function translateGeoLibreProjectToAuthoredLayerCandidates(
project: GeoLibreProject,
context: {
initialIds: readonly string[];
createId?: () => string; // defaults to crypto.randomUUID; injectable only for deterministic tests
},
): TranslateGeoLibreProjectResult;
The function runtime-validates the returned GeoLibre project, requires returned logical IDs unique, applies the verified initialIds membership semantics above, preserves existing CoMapeo UUIDs for initial IDs, treats missing initial IDs as deletion, and assigns each truly new returned logical layer exactly one fresh collision-checked CoMapeo UUID (retrying createId until it is unique against initial and already-minted candidate IDs, with a bounded 16-attempt hard failure to LAYER_UNSUPPORTED if a pathological test/id source cannot produce uniqueness). It preserves returned outer order, reconstructs only supported #279 source/render candidates, strips/ignores all GeoLibre-only state listed below, and returns no partial candidates when any layer/project translation is unsupported or malformed. It does not call #279 prepareAuthoredLayerBatch; #282 owns that single final commit-capability call with current map context. Production callers omit createId; tests may inject a deterministic generator.
Automatically carry back:
- all supported authored map layers;
- layer data/geometry;
- stable IDs for pre-existing CoMapeo layers;
- new IDs for newly created GeoLibre layers;
- names;
- visibility and opacity;
- layer order;
- supported vector/raster styles;
- newly generated/processing-result layers only when their returned source translates without coercion to a #279 supported source type (
geojsonor qualifyingraster-tiles), their style is supported, and the complete candidate passes #279 preparation. Unsupported processing outputs areLAYER_UNSUPPORTEDand are never silently coerced to GeoJSON/raster tiles.
Ignore instead of persisting:
- GeoLibre basemap choice/change;
- plugins/plugin settings;
- all GeoLibre legend objects/legend layout metadata. V1 persists the visual layer style through #279 render fragments, not a separate legend model; no legend object is implied by 'supported styles';
- story maps;
- dashboards/widgets;
- notebooks/AI state;
- collaboration/share state;
- GeoLibre workspace/layout preferences;
- provider credentials/environment variables.
Basemap changes inside GeoLibre must not overwrite the CoMapeo-selected basemap.
Pre-open working-project preparation API
#281 exports one pure pre-open adapter boundary so #282 never reverse-engineers the capability table itself:
type GeoLibreContextNotice =
| { code: 'CONTEXT_OMITTED_EGRESS'; origins: readonly string[] }
| { code: 'CONTEXT_BASEMAP_UNSUPPORTED' };
type PrepareGeoLibreWorkingProjectResult =
| {
ok: true;
project: GeoLibreProject;
initialIds: readonly string[];
notices: readonly GeoLibreContextNotice[];
}
| {
ok: false;
blockers: readonly GeoLibreBridgeBlocker[];
};
function prepareGeoLibreWorkingProject(input: {
layers: readonly AuthoredLayer[];
mapView: { bbox: [number, number, number, number] };
basemap?: { type: string; styleUrl?: string };
approvedEgressOrigins: readonly string[];
}): PrepareGeoLibreWorkingProjectResult;
This helper is pure and network-free. It validates/collapses every supplied #279 layer through GEOLIBRE_TRANSLATION_CAPABILITIES, requires input layer IDs unique, emits the ordered initialIds, constructs only the allowed GeoLibre logical project/layer/style state, applies the exact basemap-context omission rules, and returns structured blockers for any bridge-incompatible layer. It does not inspect #280 draftEntries, perform runtime RPCs, or render UI. #282 must call it before creating/loading the iframe and treats ok:false as a pre-open disabled/blocker state. Unit tests cover an all-compatible collection, one/multiple incompatible layers, stable order/IDs, basemap notices, and prove no partial project is returned on failure.
Adapter and capability contract
Keep all GeoLibre-specific translation in src/lib/map/geolibre-adapter.ts. That module imports #279's canonical authored-layer types/constants and exports GEOLIBRE_TRANSLATION_CAPABILITIES with this stable conceptual shape:
import type {
AuthoredLayer,
SupportedAuthoredLayerRenderType,
SupportedAuthoredLayerSourceType,
} from './authored-layers';
type AuthoredLayerField = keyof Pick<AuthoredLayer, 'name' | 'visible'>;
type GeoLibreCapabilityTarget =
| { kind: 'field'; field: AuthoredLayerField }
| { kind: 'source-type'; value: SupportedAuthoredLayerSourceType }
| { kind: 'render-type'; value: SupportedAuthoredLayerRenderType }
| { kind: 'layer-order' };
type GeoLibreCapabilityBase = {
area: 'source' | 'render' | 'project';
feature: string; // stable GeoLibre capability key
};
type GeoLibreCapability =
| (GeoLibreCapabilityBase & {
status: 'supported';
target: GeoLibreCapabilityTarget;
})
| (GeoLibreCapabilityBase & {
status: 'ignored' | 'rejected';
reason: string;
});
export const GEOLIBRE_TRANSLATION_CAPABILITIES: readonly GeoLibreCapability[];
Classification examples: source/geojson -> supported -> { kind: 'source-type', value: 'geojson' }; render/fill -> supported -> { kind: 'render-type', value: 'fill' }; render/fill-opacity -> supported -> { kind: 'render-type', value: 'fill' }; render/raster-opacity -> supported -> { kind: 'render-type', value: 'raster' }; project/layer-order -> supported -> { kind: 'layer-order' }; project/name -> supported -> { kind: 'field', field: 'name' }; project/visibility -> supported -> { kind: 'field', field: 'visible' }; project/basemap -> ignored -> CoMapeo retains its basemap; project/story-map -> ignored; unsupported WMS/credentialed source -> rejected. The field target is intentionally limited to top-level name/visible; source and render capabilities always use their typed source-type / render-type targets rather than a second field representation. Do not duplicate #279 field/source/render names as free-text target strings.
Supported entries must name their #279 target; ignored/rejected entries must explain why. This table classifies capability/type support, not individual source-instance safety: after a type is classified supported, each concrete returned layer still passes #279's runtime schema/offline-packageability validation (for example raster URL auth/query rules and effective zoom range). Do not encode per-instance validity into the static capability table. Translation branches in the adapter must correspond to capability entries, and tests/unit/lib/map/geolibre-adapter.test.ts must enumerate every entry and assert the matching translation/rejection behavior. The adapter also exports GEOLIBRE_SUPPORTED_AUTHORED_LAYER_SCHEMA_VERSION = 1; a unit test must assert it equals #279's imported AUTHORED_LAYER_SCHEMA_VERSION. This makes any future #279 schema-version bump fail CI until the GeoLibre adapter compatibility is deliberately reviewed. Adding future GeoLibre/#279 support requires updating the table and tests rather than adding ad hoc branches elsewhere. Any #279 schemaVersion bump must explicitly review/update GEOLIBRE_TRANSLATION_CAPABILITIES, the supported-version constant, adapter tests, and docs/geolibre-integration.md before #223 can claim support for the new schema version.
Online-only editing sources
GeoLibre may use remote WMS/XYZ/COG/service layers or other online sources while the user is working.
On Done:
- a source that #279 can snapshot/package offline is translated normally;
- a source that cannot be made self-contained is reported as online-only/unsupported and blocks the atomic commit;
- the user must remove/replace/convert it before committing;
- no layer is silently omitted from the CoMapeo map.
This keeps GeoLibre useful as a GIS workspace without weakening CoMapeo’s offline-map contract.
Styling scope
GeoLibre is responsible for the advanced styling UI. CoMapeo should not duplicate its styling controls.
Opacity is not a top-level AuthoredLayer field in #279. Vector/raster opacity lives only in supported MapLibre render.layers[].paint properties, so GEOLIBRE_TRANSLATION_CAPABILITIES maps fill/raster/etc. opacity through { kind: 'render-type', ... }; AuthoredLayerField correctly remains limited to top-level name and visible.
The commit boundary is exactly #279 schemaVersion 1, not a broader interpretation of what GeoLibre or MapLibre can render:
- committable source types are #279
geojsonand public unauthenticated/query-freeraster-tilesonly; - committable render fragment types are #279
fill,line,circle,symbol, andrasteronly; - exact allowed paint/layout/filter/minzoom/maxzoom/expression properties come from
src/lib/schemas/authored-layer.tsand are not restated independently here; - polygon fill/stroke, line styling, point/circle/symbol styling, labels, zoom constraints, raster styling, and data-driven expressions round-trip only when accepted by that canonical schema.
Any GeoLibre source/style capability outside the #279 allowlists is marked rejected in GEOLIBRE_TRANSLATION_CAPABILITIES, remains editing-only, and blocks Done until removed or converted. Do not silently downgrade visual meaning or widen #279 from inside this adapter.
Security and privacy boundary
- Self-host and pin GeoLibre; do not send territorial data to the public GeoLibre share service as part of this workflow.
- Configure the GeoLibre embed allowlist to exact CoMapeo production/staging origins; never use
*in production. - Add an appropriate
Content-Security-Policy: frame-ancestors ...policy on the GeoLibre deployment so only intended CoMapeo origins can frame it. - Host-to-iframe messages must target the exact configured GeoLibre origin; inbound traffic must be filtered by exact origin and the expected iframe window/source.
- Keep GeoLibre on a distinct origin from CoMapeo and render it with a least-privilege iframe policy. V1 sandbox is exactly
sandbox="allow-scripts allow-same-origin"unless #281 proves an additional token is strictly required for the accepted Add-layer/style workflow. Do not grantallow-top-navigation,allow-top-navigation-by-user-activation,allow-popups,allow-popups-to-escape-sandbox,allow-downloads, orallow-formsby default. SetreferrerPolicy="no-referrer"and an explicit Permissions Policy/iframeallowthat denies geolocation, camera, microphone, clipboard-read, and clipboard-write. File selection through browser file inputs may remain available; it does not justify broader device permissions. Any required relaxation discovered in #281 must be documented indocs/geolibre-integration.md, justified capability-by-capability, and covered by bridge/security tests before merge. - Add integration assertions for iframe sandbox/referrer/Permissions-Policy attributes and verify top navigation/popups and disabled Share/collaboration/managed-AI surfaces are unavailable from the CoMapeo editor profile.
- Treat ephemeral territorial-data storage as a hard #281 invariant. The production/test GeoLibre artifact is built from the pinned upstream embedded full-app path (
npm run build:embed/ its pinned equivalent withGEOLIBRE_EMBED=1semantics), not the standalone PWA build; the official embedded build disables service-worker registration. #281 verifies the built dist contains no generated service-worker registration/runtime path for this profile and thatnavigator.serviceWorker.getRegistrations()remains empty for the GeoLibre origin at boot, after project load/edit, after the approved remote/tile-network exercise, after clean teardown, and after abrupt removal/reopen during the real bridge test. The generic downstream patch, when needed, supplies the ephemeral profile defined above and must disable project/recent/autosave/history persistence, plugin installation/registry loading and other project-bearing durable stores before any CoMapeo project is loaded. If the pinned embedded build + this already-bounded patch cannot satisfy these invariants, decision-tree branch 3 applies; do not invent another fallback. - The profile may keep immutable application/runtime assets or project-independent UI preferences, but project/layer content (geometry, feature properties, layer names/IDs, styles, source URLs, project JSON, derived processing results) must never be written to durable browser state such as
localStorage, IndexedDB, Cache Storage/runtime caches, cookies, persistent recent-project/autosave/history records, or an installed-plugin store.sessionStoragealso must not contain project/layer payloads because it survives a same-tab reload. Load CoMapeo data only through the in-memory project bridge, never through a public/project URL that would enter GeoLibre's recent-project flow. At editor start, initialize/acknowledge an empty in-memory project before loading the CoMapeo working copy. On Done, Cancel, confirmed discard, and recoverable failure while the bridge is responsive, set/load an empty project through the supported bridge and wait for acknowledgement before unmounting/navigating the iframe toabout:blank; then drop client/frame references. Abrupt tab/process crashes cannot guarantee a teardown callback, so correctness relies on the stronger no-durable-project-data invariant rather than best-effort exit cleanup. - #281's real Playwright privacy test uses baseline-versus-project differential storage auditing, not an unrealistic requirement that the app origin never mutate any browser storage at all. In a fresh browser context, boot the exact ephemeral embedded profile with no project and serialize a canonical baseline inventory of every durable/reload-surviving browser surface available to that origin: localStorage/sessionStorage keys+values, IndexedDB database/store names and record bytes/structured values, Cache Storage request/response metadata+body hashes, cookies, service-worker registrations/caches, Origin Private File System (OPFS) via
navigator.storage.getDirectory()recursively including directory/file names, byte lengths and content hashes when available), Storage Buckets and their contained storage surfaces whennavigator.storageBucketsexists,window.name, iframehistory.state, and any recent/autosave/plugin state. APIs unavailable in a target browser are recorded explicitly as unavailable rather than silently skipped. Any future durable/reload-surviving browser storage surface not enumerated by the audit is prohibited by default until added to this inventory and differential tests. Cross-context APIs that are not durable stores but can outlive the iframe or retain project-bearing process state are also deny-by-default: the Playwright context installs instrumentation before GeoLibre boot forBroadcastChannel,SharedWorker,navigator.locks.request, and Background Fetch registrations when those APIs exist. It records constructor/request names plus message/body hashes without exposing project data in logs; any project-dependent use, any SharedWorker surviving teardown, or any Background Fetch registration is a privacy failure. Because service workers are disabled, Background Fetch should be unavailable/unusable; tests assert that rather than assuming it. Web Locks may be used only for fixed project-independent app coordination names proven by the no-project baseline and must be released at teardown. A future cross-context/process API is prohibited until explicitly added to the audit/instrumentation. Establish a code-reviewed allowlist only for entries proven to be project-independent app/runtime/UI state by repeating the no-project boot at least three times. Deterministic entries require exact key/schema/value semantics. A genuinely volatile runtime field (for example a timestamp or random shell session ID) may be allowlisted only by an explicit per-surface canonical projection that names the volatile field(s) being ignored, preserves key/type/length/schema structure, and is reviewed in source; whole payloads/records may never be ignored. The raw values from all no-project/A/B runs are additionally scanned for each project's unique sentinel names/properties/coordinates/hashes before projection. The allowlist/projection itself is version-controlled test data, not generated dynamically during the test; no allowlisted entry may contain project-derived bytes/hashes/lengths/identifiers. Then run two materially different test projects (AandB, each containing unique random names/properties/coordinates and different sizes), including style edits, and snapshot those same surfaces—including OPFS/Storage Buckets/window.name/history.state—after load/edit, after clean teardown, and after abrupt iframe/page removal without cleanup. Relative to the project-independent baseline/allowlist there must be zero project-dependent durable mutations: no project-derived new key/record/cache/cookie, no project-derived changed serialized bytes/hash/size, and no state whose value correlates with A versus B. This catches encoded, compressed, encrypted, chunked, hashed, or derived persistence while permitting fixed application-shell/UI bookkeeping that is demonstrably independent of territorial data. Reopen the same origin after both clean and abrupt cases and prove GeoLibre cannot recover either project. Any project-dependent durable mutation blocks #281 and must be eliminated within the defined ephemeral-profile patch; if that requires broader divergence, decision-tree branch 3 applies. No privacy exception containing territorial/project-derived information is allowed in #281/#282. - #281 owns the checked-in GeoLibre profile and generated static-host CSP/CORS/header config plus their validation; #282 owns the staging/production promotion and emergency-disable workflows that deploy/verify those artifacts. #281 owns a canonical
ops/geolibre/profile.jsonso security policy exists before #282 release promotion. Its versioned schema includes at minimum{ profileVersion, trustedHostOrigins, corsAllowedOrigins, approvedEgressOrigins, cspDirectives, iframeSandboxTokens, permissionsPolicy, disableServiceWorker, disabledSurfaces }.corsAllowedOriginsdefaults to and must be a subset/equal-set oftrustedHostOriginsin V1; wildcard*is forbidden.src/lib/schemas/geolibre-profile.tsowns a strict Valibot schema used by browser client, Node build scripts, local profile server, and tests. Unknown fields, missing required directives/surfaces, duplicate/non-origin URLs, wildcard trusted/CORS/egress origins,disableServiceWorker !== true, or inconsistent trusted/CORS/frame-ancestor sets fail CI/build.ops/geolibre/profile.jsonand the local test profile must both parse through this same schema before any build/server starts.trustedHostOriginscontains the exact trusted CoMapeo staging/production origins;approvedEgressOriginsis the fixed V1 remote-data allowlist;cspDirectivescontains the exactdefault-src,connect-src,img-src,worker-src,object-src,base-uri,form-action:'none',child-src/frame-srcas narrowly required (default none),frame-ancestors, and other required directives;iframeSandboxTokens/permissionsPolicyare the host-side expectations that the production gate verifies on CoMapeo.scripts/geolibre-dist-manifest.tsvalidates this file, copies it byte-for-byte into the GeoLibre dist ascomapeo-geolibre-profile.json, generates the static-host header configuration (for example Pages_headers) from the same profile—including exact-originAccess-Control-Allow-Originbehavior forcomapeo-geolibre-manifest.json/profile assets plusVary: Origin, with no wildcard—and ensures the copied profile is akind:'served'manifest file while every deployment-consumed control file—including_headers,_redirects,_routes.json, and any static-host/functions routing config present in the GeoLibre dist—iskind:'host-config'. Both kinds are content-hashed into the release identity; host-config files are not assumed to be fetchable after deployment. The release manifest metadata includesprofileSha256as well asprofileVersion, so the release ID binds the complete profile and generated deployment-header bytes, not merely a version label. - That canonical profile defines one implementable fixed, deny-by-default network egress contract for V1. Its CSP
connect-src/img-src/ other relevant fetch directives enumerate only'self'plusapprovedEgressOrigins(and narrowly requiredblob:/data:schemes explicitly present in the profile). There is no per-session egress expansion and no broadhttps:wildcard. The GeoLibre app/profile uses the same fixed allowlist to validate URL/service additions before initiating fetches; if the pinned upstream profile cannot enforce that pre-request check, direct Add remote URL/service is disabled entirely and CSP remains the final browser enforcement. #281 can keepapprovedEgressOriginsempty for the base bridge and use a dedicated test-profile fixture to prove one allowed local test origin; any real origin later added by #282 changes the canonical profile, which forces the full bridge/privacy/network suite before staging. GeoLibre analytics/telemetry/error-report uploads are disabled entirely. Existing basemap/raster context is passed only when all required origins are in the fixed allowlist; otherwise that context is omitted with an explanatory advanced-editor warning while built-in authoring remains available. Local file/data creation remains available regardless. - #281's real bridge test records every iframe-origin network request from boot through project load/edit/teardown. With local canonical fixtures and no approved third-party source, project A/B must cause zero third-party requests. With an explicitly approved test tile/data origin, only the expected user-visible data requests to that origin are allowed; request URLs/headers/bodies must contain no unrelated project features/properties beyond what the selected remote protocol inherently reveals (for example tile coordinates). Any request to analytics, crash reporting, Share/gallery, plugin registries, AI services, or another unapproved origin fails the test. The final CSP/egress allowlist and why each non-self origin is needed are documented in
docs/geolibre-integration.mdand revalidated on every GeoLibre upgrade. - Disable or hide GeoLibre Share/Project Gallery/collaboration/managed AI or other upload-capable surfaces that are not part of this feature, using supported configuration/UI-profile mechanisms where available.
- Do not install or expose arbitrary third-party GeoLibre plugins in the CoMapeo editor profile. Any enabled plugin set must be explicitly trusted/pinned.
- Do not pass CoMapeo auth credentials into GeoLibre project state or provider environment variables.
- User-selected remote sources necessarily make browser requests to those sources; this should be treated as an explicit editing action, not an implicit upload performed by CoMapeo.
Versioning and deployment
- Use two public Vite values read/validated only in
src/lib/map/geolibre-config.ts:VITE_GEOLIBRE_ORIGIN(origin only: scheme + host + optional port, no path/query/credentials) andVITE_GEOLIBRE_RELEASE_ID(lowercase SHA-256 hex for the exact tested GeoLibre dist manifest). Advanced editor actions remain disabled unless both values are present and valid. Components/adapters import the validated config from that module rather than readingimport.meta.envor scattering URLs directly. - Pin a tested GeoLibre app commit/build and matching
@geolibre/embedversion.scripts/geolibre-dist-manifest.tsfirst validates/copiesops/geolibre/profile.jsonand generates the static-host header file from it, then walks the resulting dist using sorted relative paths and hashes every file with SHA-256 before the release manifest is added. It emitscomapeo-geolibre-manifest.jsonwith a versioned schema containing{ manifestVersion, upstreamRepo, upstreamCommit, downstreamPatchSha256, embedVersion, protocolVersion, profileVersion, profileSha256, files: [{ path, sha256, bytes, kind: 'served' | 'host-config' }] }.downstreamPatchSha256is lowercase SHA-256 hex of the exact patch artifact applied to the pinned source, ornullonly when no patch is applied;profileSha256is the SHA-256 of the exact profile bytes copied into the dist. CI verifies a non-null patch hash matches the checked-in single V1 patch fileops/geolibre/patches/<upstreamCommit>-comapeo-embed.patchbytes and that the upstream checkout plus that patch is the source used for the dist. - Release canonicalization has one shared implementation and one standard:
src/lib/map/geolibre-release-manifest.tsdefines the runtime schema and computes the release descriptor using RFC 8785 JSON Canonicalization Scheme (JCS) over I-JSON-compatible input, UTF-8 encoded. That module exports one sharedassertUnicodeScalarString(value, path)utility used recursively by both the Node build/staging scripts and browser client before JCS; it scans JavaScript UTF-16 code units and rejects any unpaired/lone high or low surrogate, returns the original string unchanged when valid, never applies Unicode normalization, and never relies on environment-specific replacement behavior. Numeric fields are finite non-negative safe integers only. File paths must already be normalized relative POSIX paths with/, no empty/./..segments, backslashes, NULs, or leading/; hashes are lowercase hex. Filesystem enumeration order is never trusted: collect validated relative file records first, then sort the complete records array by path with the shared comparator before any per-file manifest serialization/hash preimage is constructed. Tests feed the same synthetic file set in multiple shuffled/case-varied enumeration orders and require identical output on Linux/macOS-style ordering assumptions. Before JCS, sort thefilesarray using the sharedcompareManifestPath(a,b)that lexicographically compares raw JavaScript UTF-16 code units left-to-right as unsigned 16-bit integers and places the shorter string first when one is an exact prefix; this array-order rule is independent of JCS's own recursive object-property ordering.releaseId = SHA-256(JCS({ manifestVersion, upstreamRepo, upstreamCommit, downstreamPatchSha256, embedVersion, protocolVersion, profileVersion, profileSha256, files }));manifestVersionis exported asGEOLIBRE_RELEASE_MANIFEST_VERSION = 1from the same module and both build/browser validators require exact equality; thereleaseIdfield and manifest file itself are outside that preimage. The build script, browser client, bridge CI, staging verifier, and production gate import/use these same exported helpers rather than reimplementing surrogate checks, ordering, escaping, or number rules. Golden vectors undertests/fixtures/geolibre/include ASCII, accented/non-ASCII text, astral characters represented by valid surrogate pairs, JCS escaping/control characters, file paths that distinguish UTF-16 ordering from Unicode code-point intuition, exact-prefix paths, and explicit lone-high/lone-low-surrogate cases in every manifest string field class (metadata and file path); Node and browser tests import the same vectors and must produce identical canonical UTF-8 bytes/hex and SHA-256 release IDs or the sameINVALID_UNICODE_SCALARpath/code. The build fails if two paths normalize to the same name, if a symlink/out-of-root path appears, if the copied profile/hash/patch hash does not match the canonical source, or if any executable/runtime/header/profile file in the dist is omitted fromfiles. - CI packages the exact manifested dist as the promotion artifact. Before staging deployment and again in the protected production gate, require strict path-set equality:
all dist paths == all manifest-listed paths ∪ {'comapeo-geolibre-manifest.json'}with no unlisted-file exceptions—and recompute both served and host-config file SHA-256/byte lengths plus the canonical metadata-boundreleaseId. After deployment, fetch everykind:'served'manifest URL with cache bypass and verify status/body SHA-256/length; do not fetchkind:'host-config'paths such as Cloudflare Pages_headersbecause the host consumes them at deploy time. Instead verify their effect by asserting the required CSP/CORS/frame/other generated response headers on the served HTML, manifest/profile and applicable assets. HTML entry points may reference onlykind:'served'manifest scripts/styles/workers/assets. A missing/changed served file, unlisted runtime reference, or response-header effect inconsistent with the hashed host-config bytes fails promotion. - Before mounting the iframe,
geolibre-client.tsfetches${VITE_GEOLIBRE_ORIGIN}/comapeo-geolibre-manifest.jsonwithcache: 'no-store', validates its schema, recomputes the canonical metadata-boundreleaseId, requires it to equalVITE_GEOLIBRE_RELEASE_ID, and cross-checks the expected app/embed/protocol/profile versions recorded by #281. Runtime asset byte verification is performed by the staging/production artifact gates above on every GeoLibre deployment/promotion, not only first enablement;docs/geolibre-integration.mdrecords that the browser client itself verifies manifest identity rather than rehashing e
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with scripts/check-geolibre-prerequisites.ts --phase=bridge and read src/lib/map/geolibre-client.ts plus geolibre-adapter.ts. Then use tests/unit/lib/map/geolibre-adapter.test.ts, tests/unit/scripts/geolibre-bridge-changes.test.ts, the release-manifest fixtures, and .github/workflows/geolibre-bridge.yml to trace the required checks. Done means the pinned bridge, adapter, profile, release manifest, and real bridge/security tests pass without expanding into #282.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- github-actions, typescript
- Domain
- api, build-system, devops, security, testing
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 45/100