digidem / digidem/comapeo-cloud-app
feat(map): integrate full-screen GeoLibre layer editor into Map authoring
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 0
- Forks
- 0
- Avg merge
- 1d 7h
- Merged PRs (30d)
- 29
Description
feat(map): integrate full-screen GeoLibre layer editor into Map authoring
Status: implementation-ready
Parent tracker: #223
Depends on: #279, #280, and #281 all merged/green. #279 is a direct dependency because #282 invokes prepareAuthoredLayerBatch on Done.
Goal
Integrate the already-proven #281 GeoLibre bridge into CoMapeo Map authoring as the optional Edit style / Advanced editor product flow. This issue owns the full-screen desktop/tablet/mobile UX, process-local session/history semantics, atomic Done/Cancel behavior, compatibility/recovery feedback, and the protected staging→production promotion/rollback lifecycle.
This issue is one independently mergeable production-integration unit. It does not change #279's authored-layer/package schema or #281's bridge/profile/patch contract. Any necessary change to those dependencies must land and be reviewed there first.
Product contract
- CoMapeo owns the map. GeoLibre is the advanced layer editor.
- Existing Add layer remains the simple path. Every bridge-compatible valid authored layer gains Edit style; the layer section gains Advanced editor.
- Edit style opens the same full-screen editor with the whole current map context loaded and the requested layer focused with its Style surface opened through #281.
- Advanced editor opens without a required selected layer and allows adding/reordering/styling multiple temporary logical layers.
- Done atomically applies only a complete #279-compatible result to #280's in-memory authoring draft. Cancel, failure, or rejected Done leaves the CoMapeo draft unchanged.
- Basemap and offline download bbox remain controlled by CoMapeo. GeoLibre basemap/workspace changes never alter them.
- Full-screen behavior is required at desktop, tablet, and mobile sizes. Built-in authoring remains usable when GeoLibre is unconfigured, unavailable, rejected, or offline.
Dependencies and immutable interfaces
#280 — SavedMap draft/recovery owner
draftEntriesis the authoritative ordered authoring list.- Before any editor open, call
getAdvancedEditorRecoveryEligibility(draftEntries).allowed:falsedisables/blocks both entry points, renders localized recovery guidance, and must not create a #281 session, iframe, or project. The user explicitly removes invalid recovery rows through #280 first. - Current draft
minZoom/maxZoomand contextual layer issues come from #280'ssrc/lib/map/saved-map-authoring.ts. These zoom values remain host-side validation context; they are not injected into #281's runtime profile/project. Before open, #282 uses #280 context validation; on Done it passes current min/max to #279prepareAuthoredLayerBatch. An otherwise-valid contextual error cannot be bypassed through GeoLibre. - #280
draftEntriescontains authored-layer entries only (kind:'valid'|'invalid'); unrelated MapScreen fields are separate state, not interleaved entries. Because #282 may open only when #280 recovery eligibility isallowed:true, every entry at editor open is valid. After successful Done, replace the entire authored-layerdraftEntriessequence atomically withreturnedLayers.map(layer => ({ kind:'valid', key:'layer:'+layer.id, layer }))in exact returned order. No SavedMap/Dexie write occurs until the normal Save flow. Preserve all other unsaved MapScreen fields byte/structurally unchanged.
#281 — bridge/security/release owner
- Consume exactly the merged
EXPECTED_GEOLIBRE_CONTRACT,geolibre-client.ts,geolibre-adapter.ts,prepareGeoLibreWorkingProject,translateGeoLibreProjectToAuthoredLayerCandidates,GeoLibreBridgeBlocker, profile schema/header artifacts, manifest/JCS helpers, andgeolibre-bridgeworkflow. #282 never derives compatibility by inspecting table entries itself. - #282 may not repin upstream GeoLibre, alter the downstream patch/profile/protocol, or recreate raw
postMessagemessages. If a dependency upgrade is required, land a separate #281 bridge-upgrade review first. - Use #281
getConsistentProject(), load-integrity check,focusLayer(..., { openStyle:true }),clearProject(),dispose(), and failure-onlydisposeAfterFrameTermination()lifecycle exactly as specified there.
#279 — canonical layer/package owner
- Every candidate returned from #281 is untrusted until
prepareAuthoredLayerBatchsucceeds under the current #280 map context. - #279's schema/version, source/render allowlists, model/JSON limits, UUID rules, and offline-packageability are final. #282 never widens them because GeoLibre supports more.
Consumer contract snapshot (normative imports, not local redefinitions)
The following is a consumer snapshot of the exact prerequisite exports #282 must import. The source modules/issues remain authoritative; #282 must not redeclare parallel runtime types. If a merged prerequisite differs from this snapshot, #282 is blocked until the prerequisite/spec is corrected and re-reviewed.
// #279 — src/lib/map/authored-layers.ts
type AuthoredLayerCommitContext = {
minZoom: number;
maxZoom: number;
reservedIds?: ReadonlySet<string>;
};
type PrepareAuthoredLayerBatchResult =
| { ok: true; layers: AuthoredLayer[] }
| { ok: false; errors: readonly { index: number; candidateId?: string; error: AuthoredLayerValidationError }[] };
function prepareAuthoredLayerBatch(
inputs: readonly unknown[],
context: AuthoredLayerCommitContext,
): PrepareAuthoredLayerBatchResult;
// #280 — src/lib/map/saved-map-authoring.ts
type AdvancedEditorRecoveryEligibility =
| { allowed: true }
| { allowed: false; code: 'INVALID_RECOVERY_ENTRIES'; keys: readonly `invalid:${number}`[] };
function getAdvancedEditorRecoveryEligibility(
entries: readonly AuthoredLayerDraftEntry[],
): AdvancedEditorRecoveryEligibility;
// #281 — src/lib/map/geolibre-adapter.ts / geolibre-client.ts
type GeoLibreContextNotice =
| { code: 'CONTEXT_OMITTED_EGRESS'; origins: readonly string[] }
| { code: 'CONTEXT_BASEMAP_UNSUPPORTED' };
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' };
#281 additionally exports prepareGeoLibreWorkingProject, translateGeoLibreProjectToAuthoredLayerCandidates, getConsistentProject, focusLayer, clearProject, dispose, and disposeAfterFrameTermination with the exact lifecycle/revision semantics in #281. #282 calls those exports directly; it never reconstructs their protocol or error behavior.
Key implementation files
| Path | Owner in #282 |
|---|---|
src/lib/schemas/map-route-search.ts |
strict /map editor search contract |
src/lib/map/saved-map-authoring.ts (consumed from #280) |
recovery eligibility, current-context validation, authoritative authored-layer draft contract |
src/lib/map/geolibre-editor-session.ts |
process-local ephemeral session/history/dirty/closing state |
src/screens/MapScreen/GeoLibreEditorScreen.tsx |
full-viewport CoMapeo-owned shell and blocker/error UI |
src/screens/MapScreen/MapScreen.tsx |
Edit style / Advanced editor entry points + #280 recovery gate + atomic draft replacement |
src/app/router.tsx |
search validation/navigation blocker integration only; no new top-level editor route |
tests/unit/lib/map/geolibre-editor-session.test.ts |
process-local session state, dirty/closing lifecycle, remount/stale-token behavior |
tests/unit/screens/MapScreen/GeoLibreEditorScreen.test.tsx + MapScreen authoring tests |
pre-open gates, atomic Done replacement, Cancel/no-mutation, basemap/bbox invariance, blocker/i18n rendering |
tests/e2e/geolibre-editor*.ts |
history, Done/Cancel/rejection/failure/device coverage |
tests/e2e/geolibre-editor.screenshots.ts |
CI/staging-gated desktop/mobile plus suite-local 768x1024 tablet visual coverage; tablet is not added to the repo-wide viewport matrix |
src/i18n/messages/{en,pt,es}.json |
extracted/translations for all CoMapeo-owned editor, recovery, blocker, retry and promotion-facing UI copy |
ops/geolibre/release.json |
stable staging-approved production candidate; no run-specific evidence |
ops/geolibre/deployment.md |
candidate/config/runbook references; not runtime source of truth |
.github/workflows/geolibre-staging-gate.yml |
protected real-domain staging verification |
.github/workflows/geolibre-production-gate.yml |
protected verified enable + conservative rollback |
.github/workflows/geolibre-emergency-disable.yml |
disable-only break-glass recovery |
.github/workflows/ci.yml |
ordinary-main production deployment must serialize with GeoLibre gates and may preserve—but never initiate/change—an active verified release |
src/lib/schemas/comapeo-build-info.ts + scripts/write-comapeo-build-info.ts |
validated public live-build marker used for post-verify/emergency source binding |
scripts/resolve-geolibre-production-state.ts |
fail-closed resolver for normal-main carry-forward from live marker + GitHub Deployment state + committed release candidate |
scripts/read-cloudflare-production-state.ts + tests |
read-only current/queued/in-progress Cloudflare Pages production deployment IDs/status for stable-disable convergence |
scripts/verify-production-disabled.ts + tests |
assert no-cache live marker/actions disabled and provider has no newer/in-flight production deploy that can overwrite the disable |
scripts/check-geolibre-environment-protection.ts |
read-only machine audit of required staging/production environment reviewers/protection |
Full-screen UX
Entry points
Layer row -> Edit style
- opens the full-screen GeoLibre editing surface;
- loads the complete current CoMapeo map context;
- focuses/selects the chosen layer and makes its styling UI immediately available where the supported GeoLibre API permits.
Layers section -> Advanced editor
- opens the same editor with the complete current CoMapeo map context;
- no specific layer must be preselected;
- user can add/create/reorder/style layers.
Shell behavior
GeoLibreEditorScreenis a transient full-screen editor surface, not a route-level detail screen, so AGENTS.md’s mandatory detail-screen arrow-back + page-name convention does not apply. Its CoMapeo-owned header instead uses the explicit Cancel and Done / Use changes contract below. AGENTS.md’s async loading convention still applies: use the sharedSkeletoncomponent for the full-viewport loading placeholder rather than a bespoke spinner-only state.- Implement
src/screens/MapScreen/GeoLibreEditorScreen.tsxas a full-viewport surface mounted byMapScreen; do not introduce a separate top-level route or move the unsaved map draft into global state solely for this feature. Keep the user on/mapand addsrc/lib/schemas/map-route-search.tswith a Valibot-backedmapRouteSearchSchemafor exactly{ editor?: 'advanced'; layerId?: string };layerIdis read only wheneditor === 'advanced'and must match an existing stable authored-layer ID to focus Edit style, otherwise open the general editor. - Distinguish an intentional in-app editor open from a pasted/reloaded URL with an ephemeral process-local session token, not from search params alone.
src/lib/map/geolibre-editor-session.tsowns a module-scope in-memory registryMap<sessionId, GeoLibreEditorSession>; it is not persisted tosessionStorage, localStorage, IndexedDB, URL search, or a global application store. Module scope intentionally survives ordinary React component/StrictMode remounts in the same JavaScript process but is lost on a true document reload/new tab process, preserving the hard-reload sanitization rule. When Edit style/Advanced editor opens,MapScreengeneratessessionId = crypto.randomUUID()and registers{ sessionId, initialCanonicalLayerSnapshot, latestCanonicalLayerSnapshot, latestRevisionSeen, latestRevisionCanonicalized, dirtyState }. It then uses a non-replacing TanStack navigation to push exactly one/map?editor=advanced...entry (E) above the existing normal/mapauthoring entry (M). The pushed entry'shistory.state.comapeoGeoLibreSessionIdis exactly that generatedsessionId; no base-entry ID or separate token exists. The implementation uses the router's supported navigation/state API (equivalent semantics tonavigate({ to: '/map', search: editorSearch, replace: false, state: prev => ({ ...prev, comapeoGeoLibreSessionId: sessionId }) })) and tests the actual resultingwindow.history.state. The push intentionally truncates any pre-existing browser Forward branch, so whileEis active there is no valid Forward destination to preserve/resume. RenderGeoLibreEditorScreenonly while search sayseditor=advanced, current history state contains that exact token, and the process-local registry contains the same active session. A component remount reattaches to the registry entry; a document reload/duplicated/pasted entry has history state but no registry entry and is sanitized in place without opening an iframe. - Use one
closeGeoLibreEditor()history contract. Done (after atomically applying the candidate draft), explicit Cancel, recoverable editor failure exit, and a confirmed browser-Back close all: (1) call #281clearProject()and await the acknowledged empty project per the privacy teardown contract, thendispose(); if the bridge is unresponsive, terminate/navigate the iframe first and use #281disposeAfterFrameTermination(); (2) invalidate the in-memory session; (3) replace current entryEwith a sanitized normal/mapentryM′containing no editor search or GeoLibre session history state; then (4) callhistory.back()once to return to the originalM. The resulting stack is… -> M [current] -> M′; Back goes to whatever precededM, and Forward moves only to harmless normalM′. FromM′, Back returns toM; neither direction can reopen GeoLibre because both entries lack editor search/session state. If the user instead confirms navigation from the editor to another routeT, perform cleanup/invalidation and replaceEdirectly withT, yielding… -> M -> T [current]; Back fromTreturns toMand the editor entry no longer exists. - While a valid editor session is active, intercept every browser Back and in-app route/search transition with TanStack Router's supported navigation-blocker resolver before the transition commits; configure the blocker to return
truesynchronously for any attempted exit from activeEand use the resolver's blocked state/reset()semantics while CoMapeo decides what to do. Do not install a special active-editor Forward path because pushingEhas already truncated Forward history. Once blocked, immediately move focus to CoMapeo-owned exit UI and set the iframe non-interactive (pointer-events: noneplus a CoMapeo overlay) for the duration of the exit check, then call #281getConsistentProject(). It returns either a complete revision-consistent project inside #281's 100ms quiet/2s absolute/3-attempt bounds or a typed bridge blocker such asPROJECT_NOT_QUIESCENT; canonicalize the successful result through #281 and compare it toinitialCanonicalLayerSnapshot. Any bridge blocker/disconnect/error is conservatively dirty/unknown and keeps the navigation held. Only after a fresh stable snapshot proves clean may CoMapeo close automatically. If dirty/unknown, show the discard confirmation while the router is still blocked. If the user confirms discard, execute the custom close/replace sequence above; if the user cancels, restore iframe interactivity/focus as appropriate and call blockerreset(), leaving URL, history index, registry session, iframe, and working copy unchanged. Do not call the blocker'sproceed()for editor exit, because the original pop/transition would bypass the custom history-sanitization contract;closeGeoLibreEditor()first marks the sessionclosing, disables/ignores its own blocker for the internal sanitized replace/back navigation, then performs the exact close sequence. Explicit Cancel uses the sameclosingbypass but never runs dirty detection. This always-block-then-refresh design is the correctness source of truth and cannot miss a queuedprojectChangedmessage that has not reached the host before Back; the fresh #281getConsistentProject()reads a bounded stable child snapshot while navigation is already held.projectChangedremains required for responsive dirty UI andbeforeunload, but in-app exit correctness does not depend on its delivery timing. If an editor search entry is encountered with no matching process-local registry session (hard reload, pasted URL, duplicated tab, stale history from an earlier process), do not use the close/back sequence because its opener is unknown: replace that current entry only with sanitized normal/mapand open no iframe. Tests assert the exact stack/index/URL behavior for open (including truncation of any pre-existing Forward branch), StrictMode/component remount, Done, Cancel, failure, clean Back with async fresh-snapshot auto-close, queued-change Back, dirty Back, canceled/confirmed discard, navigation to another route, blocker bypass only during internal cleanup navigation, prior-page Back destination, Forward-to-M′after close and Back-to-M, repeated Back/Forward after close, hard reload, pasted editor URL, and duplicate-tab-equivalent missing-memory state. - Render the editor shell as a fixed full-viewport layer (
position: fixed; inset: 0; min-height: 100dvh) above the still-mounted MapScreen. While open, mark the underlying authoring contentinertandaria-hiddenso focus/pointer events cannot escape behind the editor. The shell ownsrole="dialog",aria-modal="true", an accessible heading/label, and a flex column where the CoMapeo header never scrolls away and the iframe flexes to the remaining viewport. Useenv(safe-area-inset-top/right/bottom/left)padding on the host shell/header and resize naturally on orientation/visual-viewport changes; never hard-code100vhor a desktop-only height. - Provide CoMapeo-owned Cancel and Done / Use changes controls outside the untrusted iframe content so the exit contract remains under CoMapeo control. Host controls have at least 44x44 CSS-pixel touch targets at all three required viewports. On open, focus Cancel after the shell mounts; Tab order reaches Done and the iframe without trapping the user inside host chrome. On close, restore focus to the exact invoking control (the layer's Edit style button or the Advanced editor button) if it still exists, otherwise to the layers-section heading. No hidden MapScreen element remains tabbable while
inert. - Give the iframe an accessible title. Loading/status changes use a CoMapeo
aria-live="polite"region. Dirty-discard confirmation uses the existing accessible AlertDialog primitive with a title+description; initial focus is Keep editing (the non-destructive action), Escape closes only that confirmation and leaves the editor/session/history unchanged, and confirming discard runs the normal close contract. The editor itself does not bind Escape to destructive Cancel. - Desktop 1440x900, tablet 768x1024, and mobile 375x812 all use the same full-screen shell, not a sidebar/bottom-sheet fallback. Visual/E2E tests additionally rotate the tablet/mobile context once (portrait -> landscape -> portrait) and assert the host controls remain visible/reachable, iframe resizes without overflow trapping, and safe-area padding is retained.
- Explicit Cancel discards immediately without an extra confirmation; Done validates/commits. Browser/app Back, removal of the editor search state by other in-app navigation, or navigation to another route triggers the dirty-state check and prompts only when CoMapeo-relevant state changed.
- Dirty detection is based on CoMapeo-relevant state, not arbitrary GeoLibre UI/view changes.
geolibre-adapter.tsexports the deterministic comparison/canonicalization helper for authored layers. Initialization is revision-safe: mark the registry sessioninitializing, callsetProject(prepared.project), record its acknowledgement revisionackRevision, coalesce/ignoreprojectChangedevents<= ackRevisionas part of that initialization, then immediately call #281getConsistentProject()and require a stable result whose revision is>= ackRevision. The same result must pass #281's ordered initial-ID integrity check. Canonicalize it asinitialCanonicalLayerSnapshot, seedlatestRevisionSeen/latestRevisionCanonicalizedto that stable revision, and setdirtyState='clean'before enabling editing. A non-quiescent/protocol/load-mismatch result never enables interaction and follows the mapped retry/exit teardown path. After initialization, every requiredprojectChanged(revision)event updateslatestRevisionSeenmonotonically and makes the cached session synchronously non-clean until the debounced serialized #281getConsistentProjectrefresh completes; the adapter compares authored-layer data/style/order/visibility and may returncleanagain when a GeoLibre mutation affected only ignored state. Map panning, GeoLibre workspace layout, basemap changes, and other ignored state therefore do not remain dirty once canonicalization catches up. Revision-event coverage remains a required bridge invariant for responsive dirty UI and diagnostics: #281 tests mutate every supported CoMapeo-relevant operation (style, name, visibility, order, add/remove/data) and require strictly increasing project revisions; missing/duplicate/out-of-order mutation revisions fail the bridge contract. In-app exit correctness, however, uses the always-block-then-fresh-#281-getConsistentProject()procedure above rather than trusting cross-frame event delivery timing. Done disables iframe interaction and calls #281getConsistentProject()while the editor is held; only a stable successful project may enter atomic validation/commit.PROJECT_NOT_QUIESCENTor another blocker keeps the working copy intact and surfaces the #282-mapped recoverable UI. Full document refresh/tab close cannot await an iframe export and cross-frame event delivery may itself be queued, so installbeforeunloadfor every active initialized editor session, even when the cached snapshot currently appears clean; remove it only after the session entersclosing/is invalidated. This may conservatively warn on a clean hard unload, but it cannot silently lose a last-millisecond edit; the hard-reload sanitization/no-durable-data rule remains authoritative after reload. The handler is synchronous and performs no RPC/canonicalization/debounce: while an initialized non-closing session exists it callsevent.preventDefault()and setsevent.returnValue = ''as required by supported browsers, allowing only the browser-native confirmation. TanStack's in-app blocker is a separate mechanism and never runs insidebeforeunload; after the user accepts a true document unload, the page is allowed to terminate and the next load follows stale-session sanitization. - Closing/canceling never mutates the CoMapeo authoring draft.
Loading/error states
- Show the shared
Skeleton-based full-viewport loading state while the GeoLibre bundle and handshake initialize. - If either
VITE_GEOLIBRE_ORIGINorVITE_GEOLIBRE_RELEASE_IDis absent/invalid, or if the served release manifest does not match the expected release ID/version contract, treat the advanced editor as unconfigured: do not mount the iframe or expose Edit style / Advanced editor actions, while built-in authoring remains fully usable. Staging/production acceptance requires the verified pair to be configured, so this state is the intentional production-disable mechanism before promotion as well as a local/misconfiguration safety path. - If a configured iframe/network load fails, #281 handshake/configuration times out, protocol/release/profile mismatches, the session becomes poisoned, or project load/integrity fails, map the typed #281 blocker to localized recovery UI. There is no automatic retry/backoff loop. A user-initiated Retry first performs the strongest available #281 teardown (responsive clear+dispose; otherwise frame termination+
disposeAfterFrameTermination()), invalidates the old registry session, generates/registers a fresh session UUID, andreplaces the current editor entryEhistory state with that new token without pushing another history entry; then it reruns the full pre-open/profile/handshake/setProject/integrity sequence. Retry never reuses the failed client/project/session. Exit editor performs the same teardown and normalcloseGeoLibreEditor()sanitization as a recoverable failure/Cancel path and never mutates the CoMapeo draft. Retry remains user-invokable after each completed failed attempt; each attempt is independently bounded by #281 timeouts, so no separate retry count/backoff is required. - Never lose or mutate the current CoMapeo map draft because GeoLibre failed.
- If GeoLibre is unavailable/offline, built-in Add layer/show/hide/remove/map-save behavior remains usable.
Entry eligibility and user-facing blocker mapping
Before either entry point is enabled/opened, evaluate in this order:
- #281 runtime configuration/release manifest is valid and the bridge is available;
- #280
getAdvancedEditorRecoveryEligibility(draftEntries)isallowed:true. This is the raw-data boundary: if false, stop here. If true, assert every authoreddraftEntriesitem iskind:'valid'and formcurrentAuthoredLayers = draftEntries.map(entry => entry.layer); no raw invalid placeholder or raw storage value is ever passed to #281; - build one #280
preOpenContext = buildAuthoredLayerCommitContext(currentDraftFields, draftEntries, { kind:'extract' }). Here and throughout this spec, “current zoom context” means the current unsaved map fieldsminZoomandmaxZoom, not the user's current viewport/camera zoom. Recompute #280 contextual validation forcurrentAuthoredLayersusing that context. If anydraftIssuesremain (for example a raster whose effective supported zoom range has no intersection with the map's unsaved min/max range), block both entry points before iframe creation, show the same localized layer-specific context error used by #280 Save/Download, and direct the user to change map/layer zoom or remove the layer in built-in authoring. If context is clean, call #281prepareGeoLibreWorkingProject({ layers: currentAuthoredLayers, ... });ok:falsedisables opening with mapped layer blockers, whileok:truesupplies the exact project/orderedinitialIdsplus non-blocking context notices; - only after that preflight create the #281 client/iframe, configure profile,
setProject(prepared.project), then require #281's post-loadgetConsistentProjectordered-ID integrity gate before iframe interaction is enabled.
No failing preflight mutates the CoMapeo draft. Recovery/context/compatibility failures do not mount an iframe at all. On an ok:true pre-open result, render each #281 context notice as a non-blocking CoMapeo info banner above the iframe after the shell opens: CONTEXT_BASEMAP_UNSUPPORTED explains that the CoMapeo basemap could not be shown in the advanced editor; CONTEXT_OMITTED_EGRESS explains that basemap context was omitted because its origin is not approved, without rendering the raw origin/URL. Notices never mark the layer draft dirty and never block Done. Bridge/load failures tear down according to #281 and leave built-in authoring available.
#282 owns localized UI but not diagnostic enums. Define an exhaustive mapping:
const GEOLIBRE_BLOCKER_MESSAGE_IDS = {
LAYER_UNCOLLAPSIBLE_STYLE: 'map.geolibre.blocker.uncollapsibleStyle',
LAYER_UNSUPPORTED: 'map.geolibre.blocker.unsupportedLayer',
PROJECT_LOAD_MISMATCH: 'map.geolibre.blocker.loadMismatch',
PROJECT_NOT_QUIESCENT: 'map.geolibre.blocker.notQuiescent',
FOCUS_STYLE_UNAVAILABLE: 'map.geolibre.blocker.focusStyleUnavailable',
BRIDGE_UNAVAILABLE: 'map.geolibre.blocker.unavailable',
BRIDGE_PROTOCOL_MISMATCH: 'map.geolibre.blocker.protocolMismatch',
BRIDGE_TIMEOUT: 'map.geolibre.blocker.timeout',
} as const satisfies Record<GeoLibreBridgeBlocker['code'], string>;
const GEOLIBRE_NOTICE_MESSAGE_IDS = {
CONTEXT_OMITTED_EGRESS: 'map.geolibre.notice.basemapOriginOmitted',
CONTEXT_BASEMAP_UNSUPPORTED: 'map.geolibre.notice.basemapUnsupported',
} as const satisfies Record<GeoLibreContextNotice['code'], string>;
Typecheck fails when #281 adds/removes a blocker/notice without #282 updating the map, and i18n extraction/catalog tests require every mapped ID in en/pt/es. #280 INVALID_RECOVERY_ENTRIES and #279 indexed preparation errors use separate exhaustive UI helpers.
Safe layer display names: for UI error labels, use a canonical layer's name only when it is a string whose UTF-8 length is <=256 bytes and contains no C0/C1 control characters; render it only as escaped React text. Otherwise render the localized generic “Layer” label plus a non-sensitive ordinal. Never render raw foreign GeoLibre names before adapter validation, raw enum codes, technical exception messages, source URLs/origins, query data, or stack text.
Action matrix:
- Unconfigured/invalid
VITE_GEOLIBRE_ORIGINor release ID, or a pre-sessionBRIDGE_UNAVAILABLE/ manifest/profile/protocol incompatibility discovered before an editor history entry/client is created: keep all advanced-editor actions disabled with mapped configuration/unavailable text; create no history entry, registry session, iframe or teardown obligation; built-in authoring remains available. A later explicit button-state refresh/navigation/remount may re-evaluate configuration, but there is no polling loop. INVALID_RECOVERY_ENTRIES: no iframe; show recovery explanation adjacent to disabled editor controls and rely on #280's existing removable invalid rows. The user must remove those rows or Cancel/leave; there is no GeoLibre action.- #280 contextual
draftIssues: no iframe; affected layer row keeps #280's existing error; editor controls are disabled with text directing the user to adjust map/layer zoom or remove the layer. LAYER_UNCOLLAPSIBLE_STYLE/ pre-openLAYER_UNSUPPORTED: no iframe; show affected safe layer labels and keep built-in authoring controls available. Per-layer Edit style is disabled for an incompatible layer; Advanced editor is disabled if any current layer prevents a lossless complete project.- Done-time
LAYER_UNCOLLAPSIBLE_STYLE/LAYER_UNSUPPORTEDor #279 indexed preparation errors: keep the working copy alive, show a CoMapeo-owned rejection panel/AlertDialog, and Return to editor closes that panel, restores iframe interactivity, and callsfocusLayer(offendingId, { openStyle: true })when a current returned layer ID is available. If focusing itself returnsFOCUS_STYLE_UNAVAILABLE, keep the editor open and show its mapped non-destructive message; the user can still find/remove the layer manually. - Initial post-
setProjectintegrity gate failure before interaction is enabled:PROJECT_NOT_QUIESCENT,PROJECT_LOAD_MISMATCH, protocol mismatch, timeout or poisoned session all leave the draft untouched and never enable iframe interaction. Show Retry (tear down and create a fresh session/client in the same editor history entry) and Exit editor. This is distinct from Done-time non-quiescence. - Done/exit-time
PROJECT_NOT_QUIESCENTafter an already-usable editor session exists: keep editor/session alive, re-enable interaction and show Try Done again / Keep editing; no automatic retry loop. - Runtime
BRIDGE_PROTOCOL_MISMATCH, poisoned bridge, timeout, or unavailable bridge after interaction had already been enabled: no draft mutation; show Retry (fresh session/client in the same history entry per Full-screen UX) and Exit editor (normal privacy teardown/history sanitization). - Non-blocking context notices render only as informational banners; they expose neither raw origins nor URLs and do not affect dirty state/Done eligibility.
Adapter consumption and identity
Use #281's adapter contract exactly. Existing CoMapeo authored-layer UUIDs are bare GeoLibre logical-layer IDs after the #281 post-load integrity check. #281's return translator solely owns identity semantics and ID minting: verified initial IDs preserve their CoMapeo UUIDs, missing initial IDs mean deletion, duplicate returned logical IDs reject the complete translation, and unknown returned IDs receive fresh collision-checked CoMapeo UUIDv4 values inside translateGeoLibreProjectToAuthoredLayerCandidates before #279 preparation. Its collision set is the verified initial IDs plus candidate IDs already preserved/minted during that translation. #282 never mints or rewrites IDs itself, and #279's replace-all batch therefore correctly receives empty external reservedIds while still enforcing candidate-batch internal uniqueness. No MapLibre runtime fragment ID is persisted or used as GeoLibre identity.
#282 never interprets raw .geolibre.json itself; it consumes the adapter's canonical candidate/structured blockers. #281 adapter output contains authored-layer candidates/notices only, so GeoLibre basemap/workspace state has no merge channel into MapScreen. Unit/E2E tests deliberately change GeoLibre basemap/workspace state and assert CoMapeo basemap type/styleUrl and bbox are byte/structurally identical before and after successful Done.
Atomic validation on Done
When the user presses Done / Use changes:
- set session state to
committing, hold router navigation, disable iframe interaction; - call #281
getConsistentProject(); a blocker/non-quiescent result keeps the working copy intact and returns to recoverable editor UI; - call #281
translateGeoLibreProjectToAuthoredLayerCandidates(stableProject, { initialIds }); onok:false, keep the working copy intact and render the structured blockers; onok:true, take its complete orderedcandidatesarray. #282 does not separately inspect raw logical IDs, mint IDs, or strip GeoLibre-only state; - build exactly one #280
commitContext = buildAuthoredLayerCommitContext(currentDraftFields, draftEntries, { kind:'extract' }), wherecurrentDraftFields.minZoom/maxZoomare the unsaved map min/max fields, never viewport/camera zoom, and extract mode supplies empty externalreservedIds. Run the complete returned candidate set once through #279prepareAuthoredLayerBatch(candidates, commitContext); - reject credentials, temporary blob URLs, signed/expiring sources, unsupported source/render/style, oversized/invalid vector data, contextual invalidity, and any #279 preparation error;
- on
ok:false, mutate nothing, keep the GeoLibre working copy/session alive, render indexed layer-specific errors and Return to editor; focusing an error uses #281focusLayer(layerId, { openStyle:true })when the offending returned layer still exists; - on #279
ok:true, build a proposed authored-onlynextDraftEntries = layers.map(layer => ({ kind:'valid', key:'layer:'+layer.id, layer }))without mutating React/MapScreen state. Reuse that exact samecommitContextobject for #280 defense-in-depth validation and callvalidateAuthoredLayerDraftContext(nextDraftEntries, commitContext). Extract-mode context depends only on the current unsaved map min/max fields and has empty external reserved IDs, so changing the collection from pre-commit draft entries to the canonical replacement cannot change the context. Any non-empty issue map rejects Done with no mutation and is surfaced as indexed/contextual validation feedback rather than closing the editor. Only when both #279 and this #280 check succeed does one state update atomically replace all authored-layerdraftEntrieswithnextDraftEntriesin exact #279/#281-preserved order and setdraftIssuesto the proven-empty map. Assert unrelated draft fields—including basemap type/styleUrl, bbox, min/max zoom, name and other unsaved map configuration—remain unchanged; then execute the normal privacy teardown/close history contract. Saving/package generation remains a later normal #280 action.
There is no partial commit. A layer that GeoLibre can display but #279 cannot persist/package must be removed/converted before Done succeeds.
Online-only and styling behavior
- GeoLibre may temporarily show remote/service formats only when #281's fixed deployment profile/egress policy permits them. They are never durable merely because they render.
- Done can commit only #279
geojsonor qualifyingraster-tilessources and #279fill|line|circle|symbol|rasterrender fragments accepted by #281's capability table and #279 validator. - Processing-result layers are committable only if #281 translates them without coercion into the supported #279 union and the full batch passes.
- All GeoLibre legend/workspace/plugin/story/dashboard/notebook/collaboration state is ignored according to #281; visual layer style survives through canonical #279 render fragments only.
- A bridge-incompatible but otherwise valid #279 layer (for example an uncollapsible future style) is detected by #281
prepareGeoLibreWorkingProject. Because both Edit style and Advanced editor always load the complete current authored-layer project, one incompatible current layer blocks all advanced-editor entry points—no layer is silently excluded. The incompatible layer row shows its own disabled Edit style with localized reason; otherwise-compatible layer Edit style buttons and the section-level Advanced editor button are also disabled while a section-level explanation lists the safe affected layer names. All layers remain fully usable through built-in CoMapeo authoring. No migration/downgrade is performed.
Security/privacy consumption
#282 must preserve #281's exact trusted origin/source/protocol, iframe sandbox="allow-scripts allow-same-origin", referrerPolicy="no-referrer", deny-by-default Permissions Policy, fixed egress/CSP/profile, service-worker/persistence disablement, manifest/release identity, differential storage/cross-context audit, and clear/dispose teardown behavior.
The GeoLibre project receives only authoring context: bbox/view, eligible basemap visual context, canonical authored layers/styles, and the requested layer focus. Never send project observations, alerts, cases, attachments, project members, invite/auth tokens, API bearer tokens, provider credentials, signed URLs, or unrelated territorial/project records.
On responsive close paths, #282 awaits #281 clearProject() acknowledgement before frame teardown and dispose(). On an unresponsive/poisoned bridge, terminate/navigate/detach the frame first, then use disposeAfterFrameTermination(). Hard document unload cannot await RPC; #281's no-durable-state profile remains the authoritative privacy protection and beforeunload remains conservatively installed while a session is active.
Production enablement model
There is no mutable client-side runtime feature flag. GeoLibre availability in a CoMapeo deployment is a build/deployment configuration: enabled builds receive the verified pair VITE_GEOLIBRE_ORIGIN + VITE_GEOLIBRE_RELEASE_ID; disabled builds receive both unset, and geolibre-config.ts therefore exposes no advanced-editor actions.
One serialized production deployment surface
All workflows that can deploy the production CoMapeo app—the ordinary main deploy in .github/workflows/ci.yml, protected GeoLibre production gate, rollback, and emergency disable—use the same job-level concurrency group comapeo-production. Ordinary main and normal promotion use cancel-in-progress:false; the emergency-disable workflow alone uses cancel-in-progress:true so invoking the protected break-glass path is the sanctioned way to interrupt a hung/in-flight production deployment and converge to disabled. Operators do not manually force-cancel a normal promotion; they invoke emergency-disable instead.
Because the current CI has workflow-level ci-${{ github.ref }} cancellation, #282 must change that top-level rule so main-push workflows are never auto-canceled once they may contain a production deployment (cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} or equivalent). PR/staging supersession may remain cancellable. Job-level comapeo-production serialization prevents two production app deployments from interleaving even when multiple main CI runs are queued.
Factor the existing production build/deploy commands into one repository-owned, reviewable primitive (a reusable workflow/composite action is preferred) consumed by .github/workflows/ci.yml, geolibre-production-gate.yml, rollback, and emergency disable. It preserves the current pinned Bun/Node install, bun install --frozen-lockfile --ignore-scripts, Sentry upload when configured, source-map cleanup/assertion, Functions copy, and pinned cloudflare/wrangler-action Pages deploy. Callers choose only one of three validated modes:
- disabled — both GeoLibre Vite values unset;
- preserve-active — ordinary main CI may reuse only the exact currently-active verified origin/release resolved below;
- verified-enable — accepted only from the protected GeoLibre production gate after its candidate verification and must exactly equal committed
ops/geolibre/release.json.
No caller can pass an arbitrary GeoLibre URL/release pair without the mode-specific validator succeeding. Workflow/classifier tests cover every call site and fail if another production deploy path appears outside this shared surface.
Live build marker
Every production CoMapeo build, enabled or disabled, emits dist/comapeo-build.json after the application build and before Pages deploy. src/lib/schemas/comapeo-build-info.ts owns the strict schema:
type ComapeoBuildInfo = {
schemaVersion: 1;
sourceSha: string; // exact lowercase 40-hex checked-out commit
geolibreEnabled: boolean;
geolibreOrigin: string | null;
geolibreReleaseId: string | null;
geolibreProfileSha256: string | null;
geolibreDownstreamPatchSha256: string | null;
};
write-comapeo-build-info.ts refuses inconsistent states: enabled requires the exact validated origin/release/profile/patch identity (patch hash may itself be null only for stock upstream); disabled requires all four GeoLibre identity fields null. The values are public release identity only, never credentials. The production static headers set /comapeo-build.json to Cache-Control: no-store; verification always fetches it with a cache-busting query and cache-control: no-cache. The marker contains no secrets, tokens, territorial data, or provider credentials. Post-deploy checks require its sourceSha and GeoLibre state to equal the build just deployed.
Ordinary main deploy continuity — preserve, never enable
An unrelated later main push must preserve an already-promoted GeoLibre release, while ordinary CI may never enable a disabled release or adopt a different candidate. A main commit that intentionally changes ops/geolibre/release.json, #281 EXPECTED_GEOLIBRE_CONTRACT, the GeoLibre profile/patch/protocol, or another classified GeoLibre contract path is a deliberate compatibility boundary: preserve-only resolution returns disabled and CI reports that GeoLibre is safely disabled pending a new protected promotion. Before its production build, .github/workflows/ci.yml runs scripts/resolve-geolibre-production-state.ts in preserve-only mode. The resolver:
- fetches/parses the currently live
/comapeo-build.json; - queries GitHub
productionDeployments and each candidate's latest Deployment status, filters to payloadcomapeoGeoLibreStateVersion: 1with latest statussuccess, and deterministically selects the greatest numeric Deployment ID (GitHub's monotonically assigned record ID; ties are impossible). Records later markedinactive,failure,error, pending, or queued are not active state; - parses the current commit's JCS-valid
ops/geolibre/release.jsonand the checked-in #281 expected contract/profile identity; - returns
enabledonly when the live marker, selected successful state record, and current checked-in contract/candidate all agree on enabled=true, live/statesourceShacorrespondence for the currently deployed build, and exact origin/release/profile/patch identity, and no classified GeoLibre contract path changed since the selected state's source commit. Any missing state, disabled marker/record, source or identity mismatch, malformed/API failure, or intentional contract/candidate change returns disabled, never guessed-enabled.
The ordinary main production deployment is one job that holds comapeo-production from before state resolution through its final verified safe state; build, first Pages deploy, post-verify, any required disabled redeploy, and state-record finalization all occur before that job releases concurrency. A newer main run cannot cancel it because main workflow auto-cancel is disabled. The only allowed interrupter is the protected emergency workflow; if emergency cancels this job, emergency itself inherits the obligation to read the live marker and disable the actually-live source before completing.
If resolved disabled, ordinary CI builds/deploys the new main SHA with all GeoLibre identity fields null. If resolved enabled, it builds that new SHA with the exact already-active origin/release/profile/patch pair; it cannot select another release. After deploy, it verifies the complete live build marker, app reachability and a synthetic GeoLibre manifest/handshake when enabled. If enabled carry-forward verification or enabled state-record finalization fails, the still-locked same job immediately rebuilds/redeploys the same new main SHA disabled, verifies the disabled marker/actions, and then attempts a disabled state record; it never intentionally exits while its own unverified enabled build is live.
Every successful production app deployment writes one versioned state payload:
type GeoLibreProductionOperation =
| 'verified-enable'
| 'carry-forward'
| 'disabled-main'
| 'rollback-disable'
| 'emergency-disable';
type GeoLibreProductionState = {
comapeoGeoLibreStateVersion: 1;
geolibreEnabled: boolean;
geolibreReleaseId: string | null;
geolibreProfileSha256: string | null;
geolibreDownstreamPatchSha256: string | null;
operation: GeoLibreProductionOperation;
};
The GitHub Deployment ref is the deployed source SHA; enabled records also carry the verified origin as non-secret payload metadata. After the new record receives success, the workflow marks the previously selected successful enabled state record inactive when one exists. Disabled records themselves remain success so they deterministically represent the current disabled state. If an enabled deployment cannot durably write its success record after bounded retries, it is not considered safely active and ordinary CI performs the same-source disabled recovery above. If a disabled deployment's audit write fails, the live app remains disabled and the workflow fails for audit repair; future resolution also stays disabled because marker/state cannot agree.
Only .github/workflows/geolibre-production-gate.yml may transition the state from disabled→enabled or from one GeoLibre release ID→another. A workflow/path-classifier test asserts ordinary CI's resolver is invoked in preserve-only mode and that no hard-coded/free-form nonempty GeoLibre pair exists in its production deploy path.
Protected environment requirements
The repository must have protected GitHub Environments named exactly staging and production. geolibre-staging-gate uses staging; production enable/rollback and emergency-disable use production. Environment protection is machine-audited, not a checklist. ops/geolibre/environment-policy.json is the checked-in expected policy (allowed maintainer user/team IDs plus branch policy). scripts/check-geolibre-environment-protection.ts and unit tests query the GitHub Environments API using repository secret GEOLIBRE_ENV_AUDIT_TOKEN, a read-only fine-grained token/App credential with only the minimum administration/environment-settings read permission required by that API. For production, the audit requires at least one allowlisted reviewer, prevent_self_review when supported, and deployment branch policy that permits main only (no arbitrary tags/feature branches). For staging, it requires the explicitly checked-in branch/PR policy needed by the staging gate and no broader wildcard than that file declares. Missing token, unreadable settings, wrong/missing reviewer, or branch-policy mismatch fails closed.
Normal staging and production promotion workflows run a non-deploying verify-environment-protection job and the deploying job needs: that audit while also declaring environment: staging|production, so GitHub independently enforces reviewer protection before environment secrets are exposed. The emergency-disable workflow is the deliberate exception to the extra API-audit prerequisite: it must itself be dispatched from refs/heads/main and declares the protected production environment, so human environment approval still applies, but it does not depend on GEOLIBRE_ENV_AUDIT_TOKEN or an additional environment-settings API read that could be unavailable during an incident. Break-glass still requires production-environment reviewer approval by design. ops/geolibre/environment-policy.json names an emergencyApproverUserOrTeamIds allowlist with at least two distinct designated maintainers/teams (they may overlap the normal reviewer allowlist but must not be a single-person-only dependency). A verified-enable promotion may start only after the operator records in the workflow/run notes that at least one listed emergency approver and one maintainer with access to the documented Cloudflare break-glass procedure are reachable for the next 60 minutes. This is rollout-window coverage, not an assertion of permanent 24/7 staffing.
The incident SLO is live GeoLibre disabled within 10 minutes of emergency-disable dispatch/incident declaration. If the emergency workflow has not entered the protected production job because required environment approval is still absent after 5 minutes, or if GitHub Actions/environment approval is unavailable, the operator stops waiting and executes the out-of-band Cloudflare disable procedure in docs/geolibre-integration.md immediately. That procedure derives the live source from comapeo-build.json, requires it is a main ancestor, rebuilds/redeploys that same source with GeoLibre env values unset using the documented production primitive, and verifies the disabled build marker/actions; it never chooses a new release. The incident is subsequently reconciled into GitHub Deployment/audit state when GitHub is available. Secrets/Cloudflare credentials exist only in protected automation or the maintainers' separately managed break-glass credential store; the environment-audit token cannot deploy or mutate settings.
Staging and production promotion
#281 already owns the exact GeoLibre artifact contract: pinned upstream/patch/profile, release manifest/JCS algorithm, served-vs-host-config file classification, generated CSP/CORS headers, and expected contract. #282 consumes those helpers/constants; it does not duplicate canonicalization or repin anything.
Stable release candidate
After protected staging succeeds, commit ops/geolibre/release.json with versioned stable fields exactly:
{
releaseId,
profileVersion,
profileSha256,
upstreamCommit,
downstreamPatchSha256,
embedVersion,
protocolVersion,
stagingOrigin,
productionOrigin,
}
No sourceSha, workflow run ID/attempt, test evidence, artifact digest, or timestamp belongs in this stable file. Run-specific staging evidence remains workflow artifacts and is referenced human-readably from ops/geolibre/deployment.md. ops/geolibre/release.json is written by a repository script using the same #281 RFC 8785 JCS implementation: UTF-8 JCS bytes of the validated stable release object plus one trailing newline. The final staging rerun parses it, recomputes the stable object, requires structural equality, and requires its JCS(object) + '\n' bytes to equal the committed file exactly; whitespace/key-order differences therefore cannot create ambiguous comparisons.
Protected staging gate
.github/workflows/geolibre-staging-gate.yml is bound to the protected staging environment. Given the #282 PR commit SHA it:
verify-environment-protectionpasses forstaging, then the protected deploy job checks out that exact SHA and verifiesprereqMode='editor'/ immutable base prerequisites #279+#280+#281;- uses the exact merged #281
EXPECTED_GEOLIBRE_CONTRACT, canonical profile, and manifested GeoLibre artifact—no upstream repin/rebuild from moving main; before modifying staging, query/record the current staging GeoLibre deployment identifier/release ID and current staging CoMapeo deployment/config aspreviousStagingrollback evidence (nullable only on first-ever staging deployment); - deploys GeoLibre to the real CoMapeo-controlled staging GeoLibre domain and verifies strict artifact set/hash/release identity: fetch every #281
kind:'served'file; verify everykind:'host-config'file before deployment; then derive expected response headers from the same #281 profile->host-header helper and assert exact profile-defined values on the appropriate served responses. At minimum the manifest/profile responses must returnAccess-Control-Allow-Originequal to the exact staging CoMapeo origin (never*) plusVary: Origin; GeoLibre HTML must return the complete generated CSP including exactframe-ancestorscontaining only the profile-approved CoMapeo host origins, deny/default directives includingobject-src/base-uri/form-action, and the profile's fixedconnect-src/img-srcegress list. An untrusted probe origin must not receive a permissive ACAO and must be unable to frame the app. Tests compare generated expected header values, not hand-written duplicates; - deploys/serves the matching CoMapeo PR build with that exact staging origin/release pair;
- reruns every required #281 real-domain bridge/security/privacy gate with no subset exceptions: manifest/profile/protocol, exact origin/source, project round-trip + revision events, load-integrity, differential durable storage and cross-context audit, service-worker absence after network use, egress/network recording, CSP/CORS/frame/sandbox/Permissions/cookie assertions, patch/profile identity, plus #282 Chromium/Firefox/WebKit editor E2E and required visual/device/history/Done-rejection tests. Any skipped required test is a gate failure unless the #281 spec itself explicitly marks that browser API unavailable and defines the recorded-unavailable behavior;
- emits immutable
geolibre-staging-evidence.jsonandrelease-candidate.jsonwith{ release: <stable object above>, evidence: { sourceSha, workflowRunId, workflowRunAttempt, artifactDigest, previousStaging, ... } }.
The PR author commits only release-candidate.json.release as ops/geolibre/release.json. A final staging-gate rerun on that new commit recomputes the stable release and requires byte/canonical equality to the committed record; new run-specific evidence naturally differs and is compared/audited separately. Missing staging credentials/protected-environment approval is a merge blocker, never a bypass reason. If any step after the staging deployment begins fails/cancels/times out, a protected rollback-staging job runs with always() after the recorded deployment attempt: when previousStaging exists it redeploys/restores that exact prior GeoLibre artifact and prior CoMapeo staging config, then verifies the previous release/header identity; on a first-ever deployment with no previous state it redeploys the staging CoMapeo build with GeoLibre env values unset and removes/disables the failed GeoLibre staging deployment when the provider supports it. Failure to verify the restored/disabled staging state marks the gate failed and staging unhealthy; it never permits merge/promotion. Staging rollback evidence is included in the run artifact but never changes the stable release candidate.
Protected production gate
.github/workflows/geolibre-production-gate.yml is the only disabled→enabled / release-change path, manually dispatched from merged main, protected by the production environment and shared concurrency: comapeo-production with cancel-in-progress:false.
The machine-enforced DAG is:
verify-environment-protection -> verify-candidate -> enable-deploy -> post-verify -> finalize-success
with rollback-disable running conservatively after any attempted enable that does not reach both successful post-verification and durable finalization. Finite timeouts are explicit by job: verify-environment-protection: 5m, verify-candidate: 45m, enable-deploy: 30m, post-verify: 20m, finalize-success: 5m, and rollback-disable: 30m. A timeout is failure, never an indefinite lock.
verify-candidate:
- requires
GITHUB_REF == 'refs/heads/main', captures immutablesourceSha = GITHUB_SHA, fetchesorigin/main, and requires that SHA is an ancestor oforigin/main; - reads profile/release/workflows/scripts at exactly
sourceSha; accepts no free-form ref/SHA/origin/release/profile/egress inputs; - verifies the production GeoLibre candidate serves the exact staging-approved #281 release/profile/patch identity, every served asset and host-config header effect, protocol/privacy/egress invariant and synthetic bridge;
- fetches the current live
comapeo-build.jsonfor audit/rollback context but does not infer the requested release from it; - performs no production app deployment and creates no GitHub Deployment record, so a verification failure cannot leave a dangling pending runtime record.
enable-deploy:
- checks out exactly verified
sourceSha, rehashes committed profile/release values, and invokes the shared production primitive in verified-enable mode with only that verified pair; - writes
comapeo-build.jsonwithsourceSha,geolibreEnabled:true, and the verified release ID; - makes the Cloudflare Pages deploy the last irreversible step. No audit/status API call or other fallible bookkeeping follows the deploy inside this job.
post-verify is purely functional verification, not audit bookkeeping. It fetches the live no-cache build marker and requires the exact source SHA/release state, confirms advanced actions are enabled only with the verified pair, refetches/revalidates GeoLibre manifest/profile/served assets/header effects, and runs a synthetic editor handshake. It performs no GitHub Deployment status mutation.
finalize-success runs only after successful post-verification. With bounded retry/backoff it creates a GitHub production Deployment for sourceSha (auto_merge:false, required_contexts:[]) whose payload is { comapeoGeoLibreStateVersion: 1, geolibreEnabled: true, geolibreReleaseId, geolibreProfileSha256, geolibreDownstreamPatchSha256, operation: 'verified-enable', workflowRunId, workflowRunAttempt }, then writes a success status with the live environment URL. If creation succeeds but status writing fails after retries, an always() cleanup attempts to mark that exact record failure; the resolver never treats pending/failure records as active. Persistent finalization failure is still a gate failure and triggers conservative disable rollback because production activation must be both verified and durably auditable.
rollback-disable has needs covering verify-candidate, enable-deploy, post-verify, and finalize-success, with the semantic condition:
always() &&
needs.verify-candidate.result == 'success' &&
needs.enable-deploy.result != 'skipped' &&
(needs.post-verify.result != 'success' || needs.finalize-success.result != 'success')
Because all production app deploys share comapeo-production, no ordinary main deployment can intentionally interleave between enable and rollback. Rollback uses the same disabled-convergence contract as emergency: it rebuilds/redeploys the verified sourceSha in disabled mode, then verify-production-disabled.ts polls both the no-cache live marker and the Cloudflare Pages production-deployment API. Success requires (a) sourceSha still equals the intended live source, (b) all GeoLibre identity fields are disabled/null and advanced actions are absent, (c) the current Cloudflare production deployment is the disable deployment just issued (or a later disabled deployment accepted by the same convergence loop), (d) no newer production deployment is queued/in-progress that could overwrite it, and (e) those facts hold for three consecutive polls 10 seconds apart. If an earlier/canceled enable deploy finishes late and makes the marker enabled again, the convergence routine detects it and reissues disabled deployment for the now-live trusted main-ancestor source rather than assuming cancellation stopped provider work.
Rollback gets at most three disabled deploy attempts or 8 minutes of convergence time, whichever comes first. After stable disabled convergence it writes a successful disabled-state Deployment record (operation:'rollback-disable') and marks any just-created enable record inactive/failure as applicable. If audit APIs are unavailable after the live disable succeeds, retry them and leave the workflow failed for audit repair, but do not re-enable; the live disabled marker is the safety state and future preserve-only CI also fails closed to disabled when state/marker disagree.
If the disabled deploy itself repeatedly fails or stable-disabled verification is not reached inside that bound, rollback must not merely end with a red job. The terminal escalation order is intentionally cancellation-safe:
- before dispatching anything that can cancel this run, write a prominent GitHub job-summary/operator alert and upload a durable
geolibre-disable-escalation.jsonartifact containing workflow/run/source/release identity, last live build marker, Cloudflare current/queued/in-progress deployment IDs/statuses, all disabled-deploy attempts, andproductionDisabledVerified:false; - only after the summary/artifact steps succeed (or have made their final bounded attempt and recorded any artifact failure in the summary), the final step of the job uses the minimum
actions:writetoken to dispatch.github/workflows/geolibre-emergency-disable.ymlonmain; - because that emergency workflow uses
cancel-in-progress:true, cancellation of the current production-gate run after the dispatch attempt is expected and never interpreted as successful convergence. The already-persisted evidence proves escalation was required; the gate remains failed/unhealthy until emergency or out-of-band verification proves disabled.
If the emergency dispatch API call itself fails before concurrency cancellation can occur, the job summary/artifact already contain the evidence and the step fails visibly. The already-confirmed emergency approver/cloudflare operator then follows the 5/10-minute break-glass rule below; if GitHub cannot start the emergency path, the out-of-band Cloudflare disable is mandatory. This cancellation-safe escalation is the only sanctioned terminal path for a rollback that cannot prove production disabled.
Emergency disable and audit
.github/workflows/geolibre-emergency-disable.yml is the protected disable-only break-glass path. It has a 30-minute timeout and uses the same comapeo-production concurrency group with cancel-in-progress:true. Starting this protected workflow is the only sanctioned way to cancel/supersede a hung production deployment job. cancel-in-progress is treated only as a GitHub-job cancellation signal—never as proof that an already-issued Cloudflare deployment stopped. Emergency success therefore requires the stable-disabled provider/marker convergence test below, which detects and re-disables any late-winning canceled enable deploy. A second emergency dispatch may supersede the first; the newer run inherits the same convergence obligation and cannot succeed until production is stably disabled.
It accepts no ref/SHA/release/origin/profile/deployment inputs. After protected-environment approval it:
- fetches/parses the currently live no-cache
/comapeo-build.jsonand derives the actually livesourceSha, never the newest arbitrary GitHub Deployment; - requires the SHA is well-formed and an ancestor of `
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 the prerequisite exports from #279, #280, and #281, then read src/lib/map/geolibre-editor-session.ts and src/screens/MapScreen/GeoLibreEditorScreen.tsx. Trace the MapScreen entry points, route-search validation, draft replacement, and promotion workflows in the named files. Done means the full-screen flow preserves atomic Done/Cancel semantics and passes the session, screen, authoring, E2E, screenshot, and deployment-gate tests.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- github-actions, typescript
- Domain
- ci-cd, devops, frontend, testing
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 35/100