Skip to content

Commit a76bbbf

Browse files
authored
feat(embed): add a versioned postMessage API for host pages (#1468)
* feat(embed): add a versioned postMessage API for host pages URL parameters configure an embed once, at load. A host page that frames GeoLibre had no way to keep talking to the map: every interaction meant reloading the iframe with a new `?url=`, throwing away the analyst's session, and nothing the user did inside the map was visible outside it. Add an opt-in runtime protocol on top of the existing embed plumbing. Host to app: `loadProject`, `setView`, `highlightFeature`, `openTool` (the runtime twin of `?tool=`). App to host: `ready`, `ack`, `projectLoaded`, `selectionChanged`, `viewChanged` (throttled), `toolCompleted`, `serverFileWritten`. Every message is versioned `{v, type, payload}`, and app messages carry `source: "geolibre"` so a host can filter its own postMessage traffic. The API is off by default. It activates only when the deployment names the origins it trusts (`GEOLIBRE_EMBED_ORIGINS` on the Docker image, `VITE_GEOLIBRE_EMBED_ORIGINS` at build time), so a public deployment can never be driven by whoever frames it. The allowlist is enforced in both directions, and setting it also narrows the existing Jupyter/`?embed=1` project and scripting bridges to those origins. Fixes #1462 * fix(embed): resolve highlight ids against the layer instead of trusting them Explicit `featureId`/`featureIds` were passed through unchecked, so a `highlightFeature` naming an id no feature carries — a typo, or any layer whose features live in its MapLibre source rather than `layer.geojson` — selected a phantom id, drew no highlight, and still answered `ack {ok: true}`. Resolve ids against the layer's features under the same `String(feature.id ?? index)` convention the map controller uses, and reject a request that names features but resolves to none, leaving the user's existing selection untouched. A request naming nothing is still the documented "clear the highlight" form. Also fix the docs claim that outbound messages are never addressed to `*` (they are, before the handshake, when the `*` wildcard is configured), type the null-geometry test fixtures as `Feature<null>[]` (`Feature` defaults its geometry parameter to `Geometry`), and match the surrounding assertion style in one test.
1 parent 33488ca commit a76bbbf

11 files changed

Lines changed: 1264 additions & 13 deletions

File tree

Dockerfile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,15 @@ ARG VITE_MAPILLARY_ACCESS_TOKEN=
2727
# Set to 1 (or true) to disable the first-launch welcome wizard for the whole
2828
# deployment; visitors land straight on the map.
2929
ARG VITE_WELCOME_DISABLED=
30+
# Comma-separated origins allowed to drive a framed app over the embed
31+
# postMessage API. Usually set at RUN time instead (-e GEOLIBRE_EMBED_ORIGINS=…),
32+
# which the entrypoint writes into the runtime config without a rebuild.
33+
ARG VITE_GEOLIBRE_EMBED_ORIGINS=
3034
ENV GEOLIBRE_APP_BASE=${GEOLIBRE_APP_BASE}
3135
ENV VITE_GEE_OAUTH_CLIENT_ID=${VITE_GEE_OAUTH_CLIENT_ID}
3236
ENV VITE_MAPILLARY_ACCESS_TOKEN=${VITE_MAPILLARY_ACCESS_TOKEN}
3337
ENV VITE_WELCOME_DISABLED=${VITE_WELCOME_DISABLED}
38+
ENV VITE_GEOLIBRE_EMBED_ORIGINS=${VITE_GEOLIBRE_EMBED_ORIGINS}
3439

3540
RUN npm run build
3641

apps/geolibre-desktop/src/components/layout/DesktopShell.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ import { KnowledgeCardConsentDialog } from "./KnowledgeCardConsentDialog";
114114
import { MapGrid } from "./MapGrid";
115115
import { RemoteCursorsOverlay } from "./RemoteCursorsOverlay";
116116
import { useCommandBridge } from "../../hooks/useCommandBridge";
117+
import { useEmbedApi } from "../../hooks/useEmbedApi";
117118
import { useJupyterRelay } from "../../hooks/useJupyterRelay";
118119
import { appendDiagnostic, useDiagnosticsSnapshot } from "../../lib/diagnostics";
119120
import { SectionErrorBoundary, SilentErrorBoundary } from "../common/error-boundaries";
@@ -748,6 +749,10 @@ export function DesktopShell({
748749
// Request/reply + event channel backing the Python scripting API (live
749750
// queries, processing, map events). Also inert when not embedded.
750751
useCommandBridge(mapControllerRef);
752+
// Runtime postMessage API for a third-party host page that frames the app
753+
// (fly to a record, highlight it, open a tool; selection/view/tool events back
754+
// out). Off unless the deployment configured GEOLIBRE_EMBED_ORIGINS.
755+
useEmbedApi(mapControllerRef);
751756
// Same scripting surface, reached over the desktop Jupyter server's relay, so
752757
// a kernel driven from an EXTERNAL client (VS Code's Jupyter extension) can
753758
// control the map too. Inert until that server is running.

apps/geolibre-desktop/src/hooks/embedHost.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
// (useCommandBridge) talk to the SAME host window and must apply the SAME trust
55
// rules, so the detection and origin handshake live here once.
66

7+
import { EMBED_ORIGIN_WILDCARD, isEmbedOriginAllowed, readEmbedOrigins } from "../lib/embed-api";
8+
79
/**
810
* Detects whether the app is running inside the GeoLibre Jupyter/embed host.
911
*
@@ -21,7 +23,10 @@
2123
* broadcasts full project state to it. Because the legitimate hosts (the Jupyter
2224
* widget, Colab's proxy) have arbitrary, unknowable origins, an origin allowlist
2325
* is not viable here; instead the deployment constraint is: an `?embed=1`
24-
* export must only be served from a trusted context, never a public URL.
26+
* export must only be served from a trusted context, never a public URL. A
27+
* deployment that *can* name its hosts (a web build framed by a portal) should
28+
* set `GEOLIBRE_EMBED_ORIGINS`, which both enables the embed API
29+
* (`hooks/useEmbedApi.ts`) and narrows these bridges to those origins.
2530
*
2631
* @returns True when the postMessage bridges should be active.
2732
*/
@@ -64,6 +69,11 @@ export interface EmbedHost {
6469
readonly handshakeComplete: boolean;
6570
/** Origin to scope outbound posts to (`"*"` until the host is identified). */
6671
targetOrigin(): string;
72+
/**
73+
* Origins a pre-handshake broadcast (the `ready` ping) may go to: the known
74+
* host origin, else every allowlisted origin, else `["*"]`.
75+
*/
76+
broadcastTargets(): string[];
6777
/**
6878
* Record an inbound message from the host: marks the handshake complete and
6979
* learns the host's origin. Returns true when the message actually came from
@@ -77,9 +87,17 @@ export interface EmbedHost {
7787
* always defined; when the app is the top-level document (the `?embed=1`
7888
* self-test) it is `window` itself, so the bridge naturally posts to and
7989
* receives from itself.
90+
*
91+
* When the deployment configured an embed-API origin allowlist
92+
* (`GEOLIBRE_EMBED_ORIGINS`), it applies here too: a host whose origin is not
93+
* listed never completes the handshake, so an operator who names their trusted
94+
* hosts also narrows the `?embed=1` project/scripting bridges to them. With no
95+
* allowlist configured nothing changes (the Jupyter widget's host origin is
96+
* arbitrary and unknowable, so it cannot be listed in advance).
8097
*/
8198
export function createEmbedHost(): EmbedHost {
8299
const host = window.parent;
100+
const allowedOrigins = readEmbedOrigins();
83101
let hostOrigin: string | null = null;
84102
let handshakeComplete = false;
85103
return {
@@ -88,8 +106,18 @@ export function createEmbedHost(): EmbedHost {
88106
return handshakeComplete;
89107
},
90108
targetOrigin: () => hostOrigin ?? "*",
109+
broadcastTargets: () => {
110+
if (hostOrigin) return [hostOrigin];
111+
if (allowedOrigins.length === 0 || allowedOrigins.includes(EMBED_ORIGIN_WILDCARD)) {
112+
return [EMBED_ORIGIN_WILDCARD];
113+
}
114+
return allowedOrigins;
115+
},
91116
note(event: MessageEvent): boolean {
92117
if (event.source !== host) return false;
118+
if (allowedOrigins.length > 0 && !isEmbedOriginAllowed(event.origin, allowedOrigins)) {
119+
return false;
120+
}
93121
handshakeComplete = true;
94122
// "null" (opaque/file origins) stays "*".
95123
if (event.origin && event.origin !== "null") hostOrigin = event.origin;

0 commit comments

Comments
 (0)