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');