diff --git a/build/vite.js b/build/vite.js
index 977e25eb2..12915f72f 100644
--- a/build/vite.js
+++ b/build/vite.js
@@ -8,6 +8,39 @@ export const PANEL_CORS_ORIGINS = Object.freeze([
/^https:\/\/[a-z0-9]+\.claudemcpcontent\.com$/,
]);
+/**
+ * Vite plugin answering cross-origin reads from PANEL_CORS_ORIGINS on every
+ * path. It runs ahead of the API middlewares, which Vite's own `cors` option
+ * would not cover.
+ */
+function panelCorsPlugin() {
+ const install = (server) => {
+ server.middlewares.use((req, res, next) => {
+ const origin = req.headers.origin;
+ if (
+ !origin ||
+ !PANEL_CORS_ORIGINS.some((allowed) => allowed.test(origin))
+ )
+ return next();
+ res.setHeader('Access-Control-Allow-Origin', origin);
+ res.setHeader('Vary', 'Origin');
+ if (req.method !== 'OPTIONS') return next();
+ res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, POST, OPTIONS');
+ res.setHeader(
+ 'Access-Control-Allow-Headers',
+ req.headers['access-control-request-headers'] || '',
+ );
+ res.statusCode = 204;
+ res.end();
+ });
+ };
+ return {
+ name: 'panel-cors',
+ configureServer: install,
+ configurePreviewServer: install,
+ };
+}
+
/** Build browser assets with explicit inputs; never load environment or providers. */
export function createBrowserViteConfig({
plugins = [],
@@ -20,6 +53,7 @@ export function createBrowserViteConfig({
} = {}) {
return {
plugins: [
+ panelCorsPlugin(),
cesium(),
applicationHtmlPlugin(),
...plugins,
@@ -52,7 +86,7 @@ export function createBrowserViteConfig({
},
// Cross-origin reads: local pages, as Vite allows by default, plus the
// MCP Apps panels that load the app into a conversation (Claude's
- // sandbox origins). Development and preview only.
+ // sandbox origins); see panelCorsPlugin. Development and preview only.
cors: { origin: PANEL_CORS_ORIGINS },
// These headers protect the document containing Provider Settings.
// Embed-mode documents are framable instead; see embed-framing.js.
diff --git a/src/data/aisLiveVessels.js b/src/data/aisLiveVessels.js
index 348ca26c3..320318742 100644
--- a/src/data/aisLiveVessels.js
+++ b/src/data/aisLiveVessels.js
@@ -5,6 +5,9 @@ import { createAisStreamSource } from '../sources/live/standalone.js';
const aisLiveVesselsLayer = createApplicationVessels({
source: createAisStreamSource({
apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live',
+ // The app's server, also when a panel page loads the app under .
+ origin: () =>
+ new URL(globalThis.document?.baseURI ?? 'http://localhost').origin,
}),
options: {
maxRows: import.meta.env?.VITE_AIS_LIVE_MAX_ROWS,
diff --git a/src/keySetup.js b/src/keySetup.js
index ee1d3b2dd..d66d23867 100644
--- a/src/keySetup.js
+++ b/src/keySetup.js
@@ -318,7 +318,11 @@ export async function initKeySetup({
globalThis.location?.hash?.slice(1) || '',
);
if (next !== null)
- globalThis.history?.replaceState?.(null, '', `#${next}`);
+ globalThis.history?.replaceState?.(
+ null,
+ '',
+ new URL(`#${next}`, globalThis.location.href).href,
+ );
} catch {
// Continuity is a nicety, never a blocker.
}
diff --git a/src/reasonableDefaults.test.mjs b/src/reasonableDefaults.test.mjs
index 4772630aa..dfd951377 100644
--- a/src/reasonableDefaults.test.mjs
+++ b/src/reasonableDefaults.test.mjs
@@ -61,7 +61,7 @@ function uiBlock(start, end) {
/** A ShareLinkManager over a synthetic hash — enough surface for parseInitialHash. */
function managerForHash(hash) {
globalThis.window = { location: { hash, href: `http://localhost/${hash}` } };
- globalThis.history = { replaceState(_s, _t, next) { window.location.hash = next; } };
+ globalThis.history = { replaceState(_s, _t, next) { window.location.hash = new URL(next, window.location.href).hash; } };
const viewer = {
camera: {
changed: { addEventListener() {} },
diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs
index cc2672785..567bb09dd 100644
--- a/src/sharelink.celestial.test.mjs
+++ b/src/sharelink.celestial.test.mjs
@@ -34,8 +34,8 @@ function assertClaimsBefore(block, mutation, label) {
function makeManager(hash = '') {
globalThis.window = { location: { hash, href: `http://localhost/${hash}` } };
globalThis.history = {
- replaceState(_state, _title, nextHash) {
- window.location.hash = nextHash;
+ replaceState(_state, _title, next) {
+ window.location.hash = new URL(next, window.location.href).hash;
},
};
const viewer = {
diff --git a/src/sharelink.js b/src/sharelink.js
index 9c7c3728e..36862dcbf 100644
--- a/src/sharelink.js
+++ b/src/sharelink.js
@@ -529,7 +529,13 @@ export class ShareLinkManager {
if (this._destroyed || this._initialRestorePending) return;
const params = this._buildHashParams();
if (!params) return;
- history.replaceState(null, '', `#${params.toString()}`);
+ // The hash belongs to this page's address, not the document base the
+ // app may load under.
+ history.replaceState(
+ null,
+ '',
+ new URL(`#${params.toString()}`, window.location.href).href,
+ );
}
/** Build a deterministic snapshot without mutating history. */
diff --git a/src/standalone/layerSources.js b/src/standalone/layerSources.js
index 1856dfb69..e51e64530 100644
--- a/src/standalone/layerSources.js
+++ b/src/standalone/layerSources.js
@@ -29,6 +29,9 @@ export function createStandaloneLayerSources() {
military: createAdsbLolSource(),
vessels: createAisStreamSource({
apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live',
+ // The app's server, also when a panel page loads the app under .
+ origin: () =>
+ new URL(globalThis.document?.baseURI ?? 'http://localhost').origin,
}),
cctv: createCctvSource(),
radio: createRadioSource(),
diff --git a/src/standalone/toolCatalog.js b/src/standalone/toolCatalog.js
index 53a1f6a7b..4d697d9ed 100644
--- a/src/standalone/toolCatalog.js
+++ b/src/standalone/toolCatalog.js
@@ -16,7 +16,7 @@ export function loadToolCatalog() {
tools: coreTools,
services: createToolServices({
fetchImpl: (...args) => globalThis.fetch(...args),
- appUrl: globalThis.location.origin,
+ appUrl: new URL(globalThis.document.baseURI).origin,
}),
}),
'voice',
diff --git a/src/standalone/tools.js b/src/standalone/tools.js
index 047434924..d2e6043af 100644
--- a/src/standalone/tools.js
+++ b/src/standalone/tools.js
@@ -8,7 +8,7 @@ export function createStandaloneTools(options) {
sceneDataPacks: {
sources: {
assets: createAssetDirectorySource({
- baseUrl: new URL('/scene-assets/', window.location.href).href,
+ baseUrl: new URL('/scene-assets/', document.baseURI).href,
}),
},
},
diff --git a/src/tooling/viteBuild.test.mjs b/src/tooling/viteBuild.test.mjs
index 169e9d515..f2c0c586a 100644
--- a/src/tooling/viteBuild.test.mjs
+++ b/src/tooling/viteBuild.test.mjs
@@ -12,7 +12,8 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop
googleApiKey: 'browser-fixture',
cesiumToken: 'ion-fixture',
});
- assert.equal(config.plugins[2], plugin);
+ assert.equal(config.plugins[0].name, 'panel-cors');
+ assert.equal(config.plugins[3], plugin);
assert.equal(config.server.host, 'localhost');
assert.equal(config.server.port, 4173);
assert.deepEqual(config.server.allowedHosts, [
@@ -42,6 +43,40 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop
);
});
+test('panel origins may read every dev server path, other sites may not', () => {
+ let middleware;
+ createBrowserViteConfig().plugins[0].configureServer({
+ middlewares: { use: (fn) => (middleware = fn) },
+ });
+ const request = (origin, method = 'GET') => {
+ const headers = {};
+ let ended = false;
+ let passed = false;
+ middleware(
+ { method, headers: origin ? { origin } : {} },
+ {
+ setHeader: (name, value) => (headers[name] = value),
+ end: () => (ended = true),
+ },
+ () => (passed = true),
+ );
+ return { headers, ended, passed };
+ };
+ const panel = request('https://abc123.claudemcpcontent.com');
+ assert.equal(
+ panel.headers['Access-Control-Allow-Origin'],
+ 'https://abc123.claudemcpcontent.com',
+ );
+ assert.equal(panel.passed, true);
+ assert.equal(
+ request('https://abc123.claudemcpcontent.com', 'OPTIONS').ended,
+ true,
+ );
+ const other = request('https://example.com');
+ assert.deepEqual(other.headers, {});
+ assert.equal(other.passed, true);
+});
+
test('build helper does not discover environment values or construct local providers', () => {
const before = process.env.GOOGLE_MAPS_API_KEY;
process.env.GOOGLE_MAPS_API_KEY = 'environment-fixture';
@@ -52,7 +87,7 @@ test('build helper does not discover environment values or construct local provi
undefined,
);
assert.deepEqual(
- config.plugins.slice(2).map((plugin) => plugin.name),
+ config.plugins.slice(3).map((plugin) => plugin.name),
['embed-framing'],
);
} finally {
@@ -66,7 +101,7 @@ test('root config retains existing named exports and standalone provider order',
assert.equal(compatibility[name], value, name);
const config = standaloneConfig({ mode: 'test' });
assert.deepEqual(
- config.plugins.slice(2, -3).map((plugin) => plugin.name),
+ config.plugins.slice(3, -3).map((plugin) => plugin.name),
providers.localProviderPlugins().map((plugin) => plugin.name),
);
assert.equal(config.plugins.at(-4).name, 'gev-key-setup');