@@ -1317,3 +1324,11 @@ export function renderMemoryContent(vm) {
}
return html``;
}
+
+export function renderContextContent(_vm) {
+ return html`
+
+ `;
+}
diff --git a/blocks/skills/utils/egov-bridge.js b/blocks/skills/utils/egov-bridge.js
new file mode 100644
index 0000000..9f752c6
--- /dev/null
+++ b/blocks/skills/utils/egov-bridge.js
@@ -0,0 +1,220 @@
+/**
+ * Host-side bridge for embedding the Experience Governance MFE's `embed`
+ * entry via `@assets/microfrontend`'s MessageRpc protocol.
+ *
+ * Vanilla re-implementation: no Unified Shell, no React. The protocol, per
+ * @assets/microfrontend's own rpcBridge:
+ * - handshake = '__connect'. BOTH sides invoke it unprompted; each answers
+ * the other's request with an invokeResponse.
+ * - host->mfe props: fnName 'reactSetProps', params:[{ simple, callbacks }].
+ * Function props are sent by NAME only, in `callbacks`.
+ * - mfe->host events: fnName 'reactCallback', params:[{ callbackName, args }].
+ * The host MUST reply or the MFE's promise hangs.
+ */
+
+import { EGOV_MFE } from '../constants.js';
+
+const { CHANNEL, PROTOCOL, VERSION } = EGOV_MFE;
+const LOCAL_VERSION = { internal: VERSION, consumer: '1.1' };
+
+const HANDSHAKE_RETRY_MS = 300;
+const HANDSHAKE_WINDOW_MS = 15000;
+
+/**
+ * Wire the MessageRpc bridge to an already-mounted iframe pointed at the MFE's
+ * embed.html. The handshake retries until the MFE's own listener exists, since
+ * it mounts React asynchronously after load.
+ *
+ * @param {object} opts
+ * @param {HTMLIFrameElement} opts.iframe
+ * @param {() => { path?: string, env?: string, imsToken?: string, imsOrg?: string }} opts.getProps
+ * @param {(path: string) => void} [opts.onNavigate]
+ * @returns {{ destroy: () => void }}
+ */
+export function setupEgovBridge({ iframe, getProps, onNavigate }) {
+ let msgId = 0;
+ const pending = new Map();
+ let connected = false;
+ let disposed = false;
+ let retryTimer = null;
+ let retryDeadline = null;
+
+ /**
+ * The props payload carries a live IMS bearer token, so both directions pin
+ * to the embed's own origin: `'*'` would leak the token to whatever document
+ * ends up in the frame, and `event.source` alone doesn't establish who that
+ * document is. Null for an empty or unparseable src, which leaves the bridge
+ * inert rather than insecure.
+ */
+ const targetOrigin = (() => {
+ if (!iframe.src) return null;
+ try {
+ const { protocol, origin } = new URL(iframe.src, window.location.href);
+ // Opaque origins (about:, data:, blob:) serialize to "null", which is not
+ // a usable targetOrigin.
+ return protocol === 'https:' || protocol === 'http:' ? origin : null;
+ } catch {
+ return null;
+ }
+ })();
+
+ const post = (m) => {
+ if (!targetOrigin) return;
+ iframe.contentWindow?.postMessage(m, targetOrigin);
+ };
+
+ function invoke(fnName, params = []) {
+ msgId += 1;
+ const id = String(msgId);
+ const message = {
+ type: 'invokeRequest',
+ channelId: CHANNEL,
+ fnName,
+ params,
+ id,
+ protocol: PROTOCOL,
+ version: VERSION,
+ };
+ return new Promise((resolve, reject) => {
+ pending.set(id, { resolve, reject });
+ post(message);
+ });
+ }
+
+ function respond(request, value, isError = false) {
+ post({
+ type: isError ? 'invokeResponseError' : 'invokeResponse',
+ channelId: CHANNEL,
+ fnName: request.fnName,
+ params: [value],
+ id: request.id,
+ protocol: PROTOCOL,
+ version: VERSION,
+ });
+ }
+
+ const handlers = {
+ __connect() {
+ markConnected();
+ return LOCAL_VERSION;
+ },
+ reactCallback({ callbackName, args }) {
+ if (callbackName === 'onNavigate') onNavigate?.(...(args || []));
+ return undefined; // must still respond, or the MFE's promise hangs
+ },
+ };
+
+ function onWindowMessage(event) {
+ if (!targetOrigin || event.origin !== targetOrigin) return;
+ if (event.source !== iframe.contentWindow) return;
+ const d = event.data;
+ if (!d || typeof d !== 'object' || d.protocol !== PROTOCOL) return;
+ if (d.channelId !== CHANNEL) return;
+ if (typeof d.version !== 'string' || !d.version.startsWith('1.')) return;
+
+ if (d.type === 'invokeResponse' || d.type === 'invokeResponseError') {
+ const p = pending.get(d.id);
+ if (!p) return;
+ pending.delete(d.id);
+ const [value] = d.params || [];
+ if (d.type === 'invokeResponse') p.resolve(value); else p.reject(value);
+ return;
+ }
+
+ if (d.type === 'invokeRequest') {
+ const fn = handlers[d.fnName];
+ if (!fn) {
+ respond(d, `Received request to invoke non-existing function: '${d.fnName}'.`, true);
+ return;
+ }
+ Promise.resolve()
+ .then(() => fn(...(d.params || [])))
+ .then((v) => respond(d, v, false))
+ .catch((e) => respond(d, String(e), true));
+ }
+ }
+
+ function sendProps() {
+ const { path = '/', env = 'PROD', imsToken, imsOrg } = getProps() || {};
+ // `metrics` is deliberately absent: it isn't an MFE app prop but a
+ // bridge-level field rpcBridge derives from window.adobeMetrics on the
+ // sender side. Omitting it makes the guest skip MetricsWrapper.init;
+ // sending `{}` would only feed it a bogus id.
+ //
+ // `colorScheme: 'light'` is not a guess — it matches the host, which pins
+ // itself to light in skills.html (`:root { color-scheme: light }`) until the
+ // editor's dark mode is finished. The two must be un-pinned together: drop
+ // that rule without forwarding the real scheme here and the frame stays
+ // light inside a dark panel. Forwarding it needs a `prefers-color-scheme`
+ // listener plus a props resend when it flips.
+ //
+ // `en-US` likewise matches what the editor ships today (no i18n).
+ const simple = {
+ path, env, optIn: false, colorScheme: 'light', locale: 'en-US', featureFlags: [],
+ };
+ if (imsToken) simple.imsToken = imsToken;
+ if (imsOrg) simple.imsOrg = imsOrg;
+ // Only callbacks `reactCallback` actually handles are advertised. Listing
+ // one we ignore is a contract we don't keep: the MFE may suppress its own
+ // in-frame UI for an event it believes the host renders, so a dropped toast
+ // becomes silence rather than a fallback. Add the name here and a branch in
+ // `reactCallback` together, never one without the other.
+ return invoke('reactSetProps', [{ simple, callbacks: ['onNavigate'] }]);
+ }
+
+ function markConnected() {
+ if (connected) return;
+ connected = true;
+ clearInterval(retryTimer);
+ retryTimer = null;
+ sendProps();
+ }
+
+ window.addEventListener('message', onWindowMessage);
+
+ /**
+ * Unanswered attempts are left in `pending` on purpose: only the one that
+ * lands gets a response, and they're bounded by the retry window and cleared
+ * on destroy. Evicting the previous attempt could discard a response already
+ * in flight for it.
+ */
+ const tryConnect = () => {
+ if (connected || disposed) return;
+ invoke('__connect', [LOCAL_VERSION]).then(markConnected).catch(() => {});
+ };
+
+ /** Opens (or re-opens) a retry window and connects now. Idempotent. */
+ function startHandshake() {
+ if (connected || disposed) return;
+ retryDeadline = Date.now() + HANDSHAKE_WINDOW_MS;
+ tryConnect();
+ if (retryTimer) return;
+ retryTimer = setInterval(() => {
+ if (connected || disposed || Date.now() > retryDeadline) {
+ clearInterval(retryTimer);
+ retryTimer = null;
+ return;
+ }
+ tryConnect();
+ }, HANDSHAKE_RETRY_MS);
+ }
+
+ // Both entry points are needed. The frame may already have loaded by the time
+ // the bridge is built, in which case `load` never fires and only this call
+ // starts the handshake; posting early is harmless, since the pinned
+ // targetOrigin doesn't match about:blank and a retry picks it up.
+ startHandshake();
+
+ // And a frame that loads slowly may burn the whole first window before its
+ // document runs, so `load` re-opens one.
+ iframe.addEventListener('load', startHandshake, { once: true });
+
+ return {
+ destroy() {
+ disposed = true;
+ clearInterval(retryTimer);
+ window.removeEventListener('message', onWindowMessage);
+ pending.clear();
+ },
+ };
+}
diff --git a/blocks/skills/utils/egov-embed.js b/blocks/skills/utils/egov-embed.js
new file mode 100644
index 0000000..134495b
--- /dev/null
+++ b/blocks/skills/utils/egov-embed.js
@@ -0,0 +1,101 @@
+/**
+ * Resolves which Experience Governance MFE bundle to embed.
+ *
+ * Priority:
+ * 1. `?egov=`: explicit override (same pattern as
+ * `?nx=`/`?da-admin=`), for pointing at a local MFE dev server or a
+ * specific deployed env regardless of where da-live itself is hosted.
+ * 2. da-live's own hostname, using the same stage/prod split as the IMS tier
+ * (see da-nx/nx/scripts/nexter.js): localhost / *.aem.page → stage;
+ * da.live / *.aem.live → prod.
+ */
+
+import { EGOV_MFE } from '../constants.js';
+
+/** Host URL params this module owns: the env override and the deep-link path. */
+const EGOV_ENV_PARAM = 'egov';
+const EGOV_PATH_PARAM = 'egovPath';
+
+const SAFE_EGOV_PARAM = /^(local|qa|stage|prod)$/;
+
+/** Governance-relative deep-link path (see the MFE's useGovernancePath), e.g. `/brands/123/knowledge/connectors`. */
+const SAFE_EGOV_PATH = /^\/[a-zA-Z0-9\-_/%.]*$/;
+
+export function resolveEgovEnv(location = window.location) {
+ const override = new URLSearchParams(location.search).get(EGOV_ENV_PARAM);
+ if (override && SAFE_EGOV_PARAM.test(override)) return override;
+
+ const { hostname } = location;
+ if (hostname === 'localhost' || hostname.endsWith('.aem.page')) return 'stage';
+ return 'prod';
+}
+
+export function resolveEgovEmbedUrl(location = window.location) {
+ const env = resolveEgovEnv(location);
+ return EGOV_MFE.EMBED_URLS[env] || EGOV_MFE.EMBED_URLS.prod;
+}
+
+/**
+ * Host env → the MFE's own `Env` union (see its src/types/env.ts), which picks
+ * the backend API host. The MFE does no case normalization: anything outside
+ * this exact uppercase set silently falls back to its own STAGE API, which is
+ * why we always send an explicit value.
+ *
+ * `local` maps to STAGE, not DEV, because `?egov=local` means "serve the MFE
+ * *bundle* from a local dev server", not "use a local backend". DEV would
+ * point the API at https://localhost:8080/api (usually not running) and, per
+ * the MFE's EnvProvider, force every feature flag on.
+ */
+const EGOV_MFE_ENVS = {
+ local: 'STAGE',
+ qa: 'QA',
+ stage: 'STAGE',
+ prod: 'PROD',
+};
+
+/**
+ * Resolves the `env` value to hand the MFE, so it targets the backend matching
+ * the bundle we embedded. Falls back to PROD to match `resolveEgovEnv`: an
+ * unrecognized env should not quietly serve stage data to a production user.
+ */
+export function resolveEgovMfeEnv(location = window.location) {
+ return EGOV_MFE_ENVS[resolveEgovEnv(location)] || 'PROD';
+}
+
+/**
+ * Reads a governance-relative deep-link path from `?egovPath=`, e.g.
+ * `?egovPath=/brands/123/knowledge/connectors`. Falls back to `/` (brand
+ * list) if absent or malformed.
+ */
+export function resolveEgovPath(location = window.location) {
+ const raw = new URLSearchParams(location.search).get(EGOV_PATH_PARAM);
+ return raw && SAFE_EGOV_PATH.test(raw) ? raw : '/';
+}
+
+/**
+ * Reflects the MFE's current internal route into the host URL's `?egovPath=`,
+ * so the deep link is shareable and survives reload. Always replaces rather
+ * than pushes: the MFE owns its own router and can't be driven from a popstate
+ * yet, so added history entries would move the URL without moving the MFE.
+ *
+ * The query string is built by hand because `URLSearchParams` percent-encodes
+ * every `/`, turning a readable `/brands/123/knowledge/connectors` into
+ * `%2Fbrands%2F123%2F...`. RFC 3986 doesn't require escaping `/` in a query, so
+ * un-escaping just `%2F` keeps the param valid and legible.
+ *
+ * `location`/`history` are injectable for tests.
+ */
+export function setEgovPath(path, {
+ location = window.location,
+ history = window.history,
+} = {}) {
+ if (!path || !SAFE_EGOV_PATH.test(path)) return;
+ const url = new URL(location.href);
+ const params = new URLSearchParams(url.search);
+ params.delete(EGOV_PATH_PARAM);
+ const query = params.toString();
+ const encodedPath = encodeURIComponent(path).replace(/%2F/g, '/');
+ const egovPathParam = path === '/' ? '' : `${EGOV_PATH_PARAM}=${encodedPath}`;
+ url.search = [query, egovPathParam].filter(Boolean).join('&');
+ history.replaceState(history.state, '', url);
+}
diff --git a/test/unit/utils/egov-bridge.test.js b/test/unit/utils/egov-bridge.test.js
new file mode 100644
index 0000000..49da90b
--- /dev/null
+++ b/test/unit/utils/egov-bridge.test.js
@@ -0,0 +1,172 @@
+import { expect } from '@esm-bundle/chai';
+import { setupEgovBridge } from '../../../blocks/skills/utils/egov-bridge.js';
+import { EGOV_MFE } from '../../../blocks/skills/constants.js';
+
+const { CHANNEL, PROTOCOL } = EGOV_MFE;
+
+const realPostMessage = window.postMessage.bind(window);
+
+/**
+ * The bridge only touches `src`, `contentWindow` and `addEventListener`, so a
+ * stub is enough, and it lets each test control the `load` event precisely.
+ *
+ * `contentWindow` must be the test window itself, because the bridge rejects
+ * any message whose `event.source` isn't that exact window and `MessageEvent`
+ * won't carry a plain object as `source`. Outbound posts are therefore captured
+ * by patching `window.postMessage` (recorded, not dispatched, so the bridge
+ * never receives its own requests), while `respondTo` impersonates the MFE
+ * through the unpatched original.
+ */
+function stubIframe() {
+ const posted = [];
+ let loadListener = null;
+ window.postMessage = (m) => posted.push(m);
+ return {
+ posted,
+ fireLoad() { loadListener?.(); },
+ src: window.location.href,
+ contentWindow: window,
+ addEventListener(type, fn) { if (type === 'load') loadListener = fn; },
+ };
+}
+
+const connectRequests = (iframe) => iframe.posted.filter((m) => m.fnName === '__connect' && m.type === 'invokeRequest');
+const propsRequests = (iframe) => iframe.posted.filter((m) => m.fnName === 'reactSetProps');
+
+/** Impersonate the MFE answering a host invokeRequest. */
+function respondTo(request, value) {
+ realPostMessage({
+ type: 'invokeResponse',
+ channelId: CHANNEL,
+ fnName: request.fnName,
+ params: [value],
+ id: request.id,
+ protocol: PROTOCOL,
+ version: '1.0.0',
+ }, window.location.origin);
+}
+
+/** Let queued message events and their promise chains settle. */
+const settle = () => new Promise((r) => { setTimeout(r, 50); });
+
+describe('setupEgovBridge', () => {
+ let bridge;
+
+ afterEach(() => {
+ bridge?.destroy();
+ bridge = null;
+ window.postMessage = realPostMessage;
+ });
+
+ it('starts the handshake without waiting for the iframe load event', () => {
+ const iframe = stubIframe();
+ bridge = setupEgovBridge({ iframe, getProps: () => ({}) });
+
+ // The host mounts the bridge after awaiting the IMS profile, by which point
+ // the frame may already have loaded. If `load` were the only trigger, the
+ // handshake would never start and the tab would stay blank.
+ expect(connectRequests(iframe).length).to.equal(1);
+ });
+
+ it('still connects when load fires after mount', async () => {
+ const iframe = stubIframe();
+ bridge = setupEgovBridge({ iframe, getProps: () => ({}) });
+ iframe.fireLoad();
+ await settle();
+
+ expect(connectRequests(iframe).length).to.be.greaterThan(1);
+ });
+
+ it('sends props once the MFE answers the handshake', async () => {
+ const iframe = stubIframe();
+ bridge = setupEgovBridge({
+ iframe,
+ getProps: () => ({ path: '/brands/1', env: 'QA', imsToken: 't', imsOrg: 'org@AdobeOrg' }),
+ });
+ respondTo(connectRequests(iframe)[0], { internal: '1.0.0', consumer: '1.1' });
+ await settle();
+
+ const [props] = propsRequests(iframe);
+ expect(props).to.exist;
+ const [{ simple, callbacks }] = props.params;
+ expect(simple.path).to.equal('/brands/1');
+ expect(simple.env).to.equal('QA');
+ expect(simple.imsToken).to.equal('t');
+ expect(simple.imsOrg).to.equal('org@AdobeOrg');
+ // Only what `reactCallback` actually handles: advertising a callback the
+ // host ignores can make the MFE suppress its own in-frame UI for it.
+ expect(callbacks).to.deep.equal(['onNavigate']);
+ });
+
+ it('reports the MFE\'s navigation through onNavigate', async () => {
+ const iframe = stubIframe();
+ const navigated = [];
+ bridge = setupEgovBridge({
+ iframe,
+ getProps: () => ({}),
+ onNavigate: (path) => navigated.push(path),
+ });
+ realPostMessage({
+ type: 'invokeRequest',
+ channelId: CHANNEL,
+ fnName: 'reactCallback',
+ params: [{ callbackName: 'onNavigate', args: ['/brands/1/knowledge'] }],
+ id: 'mfe-1',
+ protocol: PROTOCOL,
+ version: '1.0.0',
+ }, window.location.origin);
+ await settle();
+
+ expect(navigated).to.deep.equal(['/brands/1/knowledge']);
+ });
+
+ it('stops retrying after destroy', async () => {
+ const iframe = stubIframe();
+ bridge = setupEgovBridge({ iframe, getProps: () => ({}) });
+ bridge.destroy();
+ const atDestroy = connectRequests(iframe).length;
+ await settle();
+
+ expect(connectRequests(iframe).length).to.equal(atDestroy);
+ });
+
+ it('drops messages from an origin other than the iframe\'s', async () => {
+ // The pinned-origin check is what keeps the IMS token in. A document that
+ // isn't the embed must not be able to answer the handshake and draw the
+ // props payload out of the host, nor steer it with a faked navigation.
+ // Here the frame is pinned to experience.adobe.com while the messages come
+ // from the test page's own origin, so every one of them must be ignored.
+ const iframe = { ...stubIframe(), src: 'https://experience.adobe.com/embed.html' };
+ const navigated = [];
+ bridge = setupEgovBridge({
+ iframe,
+ getProps: () => ({ imsToken: 'secret-token' }),
+ onNavigate: (path) => navigated.push(path),
+ });
+
+ respondTo(connectRequests(iframe)[0], { internal: '1.0.0', consumer: '1.1' });
+ realPostMessage({
+ type: 'invokeRequest',
+ channelId: CHANNEL,
+ fnName: 'reactCallback',
+ params: [{ callbackName: 'onNavigate', args: ['/brands/evil'] }],
+ id: 'spoof-1',
+ protocol: PROTOCOL,
+ version: '1.0.0',
+ }, window.location.origin);
+ await settle();
+
+ expect(navigated).to.be.empty;
+ expect(propsRequests(iframe)).to.be.empty;
+ expect(JSON.stringify(iframe.posted)).to.not.contain('secret-token');
+ });
+
+ it('is inert when the iframe src has no usable origin', () => {
+ // The props payload carries a live IMS token, so a bridge with no origin to
+ // pin postMessage to must send nothing rather than fall back to '*'.
+ const iframe = { ...stubIframe(), src: '' };
+ bridge = setupEgovBridge({ iframe, getProps: () => ({}) });
+
+ expect(iframe.posted.length).to.equal(0);
+ });
+});
diff --git a/test/unit/utils/egov-embed.test.js b/test/unit/utils/egov-embed.test.js
new file mode 100644
index 0000000..6e4ee1c
--- /dev/null
+++ b/test/unit/utils/egov-embed.test.js
@@ -0,0 +1,216 @@
+import { expect } from '@esm-bundle/chai';
+import {
+ resolveEgovEmbedUrl,
+ resolveEgovEnv,
+ resolveEgovMfeEnv,
+ resolveEgovPath,
+ setEgovPath,
+} from '../../../blocks/skills/utils/egov-embed.js';
+import { EGOV_MFE } from '../../../blocks/skills/constants.js';
+
+/**
+ * A stand-in for `location` + `history`, injected rather than patched onto
+ * `window`, whose history is shared with the test runner and can't be rewound
+ * between cases.
+ *
+ * A `URL` serves as `location`, since it exposes `href` and `search`, which is
+ * all egov-embed reads. Each recorded write advances it as a real history write
+ * would. Hrefs are recorded as plain strings so chai can serialize them into a
+ * failure message; a `URL` is expensive for it to deep-inspect.
+ */
+function recordHistory(startHref = 'https://da.live/skills') {
+ const location = new URL(startHref);
+ const calls = [];
+ const replaceState = (_state, _title, url) => {
+ const href = new URL(url, location.href).href;
+ calls.push(href);
+ location.href = href;
+ };
+ return { location, calls, history: { state: null, replaceState } };
+}
+
+/** Options for a call under test; `h` carries both injected globals. */
+const at = (h) => ({ location: h.location, history: h.history });
+
+const lastUrl = ({ calls }) => new URL(calls[calls.length - 1]);
+
+/**
+ * A `URL` stands in for `location` here too: env resolution reads only `search`
+ * and `hostname`, both of which it exposes.
+ */
+const urlAt = (href) => new URL(href);
+
+describe('resolveEgovEnv', () => {
+ describe('?egov= override', () => {
+ it('wins over the hostname', () => {
+ // The point of the override is pointing a prod-hosted page at another
+ // bundle, so it has to beat a hostname that would otherwise say prod.
+ expect(resolveEgovEnv(urlAt('https://da.live/skills?egov=local'))).to.equal('local');
+ expect(resolveEgovEnv(urlAt('https://da.live/skills?egov=qa'))).to.equal('qa');
+ expect(resolveEgovEnv(urlAt('https://da.live/skills?egov=stage'))).to.equal('stage');
+ expect(resolveEgovEnv(urlAt('http://localhost:3000/skills?egov=prod'))).to.equal('prod');
+ });
+
+ it('falls back to the hostname when the value is not allow-listed', () => {
+ // An unrecognized override must not become an env of its own: it would
+ // miss EMBED_URLS and EGOV_MFE_ENVS and take both fallbacks at once.
+ expect(resolveEgovEnv(urlAt('https://da.live/skills?egov=bogus'))).to.equal('prod');
+ expect(resolveEgovEnv(urlAt('https://da.live/skills?egov='))).to.equal('prod');
+ expect(resolveEgovEnv(urlAt('http://localhost:3000/skills?egov=bogus'))).to.equal('stage');
+ });
+
+ it('is case-sensitive, so PROD is not an override', () => {
+ // SAFE_EGOV_PARAM is lowercase-only; a near-miss must be rejected rather
+ // than passed through as an env the lookup tables don't have.
+ expect(resolveEgovEnv(urlAt('http://localhost:3000/skills?egov=PROD'))).to.equal('stage');
+ });
+
+ it('ignores an override smuggled in the hash rather than the query', () => {
+ expect(resolveEgovEnv(urlAt('https://da.live/skills#/org/site?egov=stage'))).to.equal('prod');
+ });
+ });
+
+ describe('hostname split', () => {
+ it('treats localhost and *.aem.page as stage', () => {
+ expect(resolveEgovEnv(urlAt('http://localhost:3000/skills'))).to.equal('stage');
+ expect(resolveEgovEnv(urlAt('https://main--da-live--adobe.aem.page/skills'))).to.equal('stage');
+ });
+
+ it('treats da.live and *.aem.live as prod', () => {
+ expect(resolveEgovEnv(urlAt('https://da.live/skills'))).to.equal('prod');
+ expect(resolveEgovEnv(urlAt('https://main--da-live--adobe.aem.live/skills'))).to.equal('prod');
+ });
+
+ it('falls back to prod for an unrecognized hostname', () => {
+ // The security-relevant default: an unknown host must not quietly get
+ // stage data behind a production-looking UI.
+ expect(resolveEgovEnv(urlAt('https://example.com/skills'))).to.equal('prod');
+ });
+
+ it('does not treat aem.page as a suffix match on an attacker domain', () => {
+ // `endsWith('.aem.page')` is the guard; a lookalike host must miss it.
+ expect(resolveEgovEnv(urlAt('https://aem.page.evil.example/skills'))).to.equal('prod');
+ expect(resolveEgovEnv(urlAt('https://notaem.page/skills'))).to.equal('prod');
+ });
+ });
+});
+
+describe('resolveEgovEmbedUrl', () => {
+ it('picks the bundle for the resolved env', () => {
+ expect(resolveEgovEmbedUrl(urlAt('http://localhost:3000/skills?egov=qa')))
+ .to.equal(EGOV_MFE.EMBED_URLS.qa);
+ expect(resolveEgovEmbedUrl(urlAt('http://localhost:3000/skills')))
+ .to.equal(EGOV_MFE.EMBED_URLS.stage);
+ expect(resolveEgovEmbedUrl(urlAt('https://da.live/skills')))
+ .to.equal(EGOV_MFE.EMBED_URLS.prod);
+ });
+
+ it('serves the prod bundle for an unrecognized host', () => {
+ // `resolveEgovEnv` only ever returns an allow-listed env, so this is the
+ // reachable route to the fallback rather than the `|| EMBED_URLS.prod`
+ // guard, which is unreachable through the public API by construction.
+ expect(resolveEgovEmbedUrl(urlAt('https://example.com/skills')))
+ .to.equal(EGOV_MFE.EMBED_URLS.prod);
+ });
+});
+
+describe('resolveEgovMfeEnv', () => {
+ it('maps the host env onto the MFE\'s own uppercase Env union', () => {
+ // The MFE does no case normalization: anything off this exact set silently
+ // selects its STAGE API, so each mapping is load-bearing.
+ expect(resolveEgovMfeEnv(urlAt('http://localhost:3000/skills?egov=qa'))).to.equal('QA');
+ expect(resolveEgovMfeEnv(urlAt('http://localhost:3000/skills?egov=stage'))).to.equal('STAGE');
+ expect(resolveEgovMfeEnv(urlAt('https://da.live/skills'))).to.equal('PROD');
+ });
+
+ it('maps local to STAGE, not DEV', () => {
+ // `?egov=local` means "bundle from a local dev server", not "local
+ // backend": DEV would point the API at localhost:8080 and force every
+ // feature flag on.
+ expect(resolveEgovMfeEnv(urlAt('http://localhost:3000/skills?egov=local'))).to.equal('STAGE');
+ });
+
+ it('sends PROD for an unrecognized host', () => {
+ expect(resolveEgovMfeEnv(urlAt('https://example.com/skills'))).to.equal('PROD');
+ });
+});
+
+describe('setEgovPath', () => {
+ it('reflects the path into ?egovPath=', () => {
+ const h = recordHistory();
+ setEgovPath('/brands/1', at(h));
+
+ expect(h.calls.length).to.equal(1);
+ expect(lastUrl(h).search).to.equal('?egovPath=/brands/1');
+ });
+
+ it('leaves / unencoded rather than %2F', () => {
+ const h = recordHistory();
+ setEgovPath('/brands/123/knowledge/connectors', at(h));
+
+ expect(lastUrl(h).href).to.contain('egovPath=/brands/123/knowledge/connectors');
+ });
+
+ it('still escapes characters that do need it', () => {
+ const h = recordHistory();
+ // `%` is the one character SAFE_EGOV_PATH allows that isn't literal-safe in
+ // a query string, so un-escaping only `%2F` must leave it encoded.
+ setEgovPath('/brands/100%/knowledge', at(h));
+
+ expect(lastUrl(h).search).to.equal('?egovPath=/brands/100%25/knowledge');
+ });
+
+ it('keeps other params and drops any stale egovPath', () => {
+ const h = recordHistory('https://da.live/skills?egov=qa&egovPath=/brands/old&tab=context');
+ setEgovPath('/brands/new', at(h));
+
+ const url = lastUrl(h);
+ expect(url.searchParams.get('egov')).to.equal('qa');
+ expect(url.searchParams.get('tab')).to.equal('context');
+ expect(url.search.match(/egovPath=/g).length).to.equal(1);
+ expect(resolveEgovPath(url)).to.equal('/brands/new');
+ });
+
+ it('omits the param entirely for the root path', () => {
+ // Mirrors the tab-exit reset in nx-skills-editor.js.
+ const h = recordHistory('https://da.live/skills?egovPath=/brands/1');
+ setEgovPath('/', at(h));
+
+ expect(lastUrl(h).search).to.equal('');
+ });
+
+ it('ignores paths outside SAFE_EGOV_PATH', () => {
+ const h = recordHistory();
+ setEgovPath('', at(h));
+ setEgovPath('brands/1', at(h));
+ setEgovPath('https://evil.example/', at(h));
+ setEgovPath('/brands/