From f60d8d7bc691c436b839e132be85c32478f7668b Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Fri, 21 Aug 2026 10:39:38 -0400 Subject: [PATCH 1/3] feat: consolidate GeoJSON preview and style preview into one MCP App MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GeojsonPreviewUIResource and PreviewStyleUIResource were two nearly identical hand-written MCP Apps templates — the same postMessage handshake, fullscreen/open-link controls, and resize handling copy-pasted across ~650 lines, differing only in what they drew on the map once a tool result arrived. Merged into MapPreviewUIResource, which dispatches on the shape of the incoming tool-result URL (a geojson.io data URL vs. a Styles API .html preview URL) instead of each resource assuming a fixed mode. geojson_preview_tool and preview_style_tool now both point at 'ui://mapbox/map-preview/index.html'. Neither tool's own input/output contract changed — this only touches which resource renders their result. The GeoJSON path keeps its existing eager map bootstrap (drawing Standard immediately so an overlay has something to render onto right away); the style-preview path reuses that map via setStyle() when it exists, falling back to constructing its own when it doesn't (e.g. no server-mintable token yet). style_comparison_tool's StyleComparisonUIResource stays separate — it renders two synced map instances under a swipe/compare slider, a genuinely different UI shape from "one map, different content." Breaking change to the public resources export: previewStyleUI/ geojsonPreviewUI are replaced by mapPreviewUI. Migrated the existing AGI-905 cross-account token-leak regression suite onto the merged resource unchanged, and added new coverage for the merged dispatch logic (geojson vs. style-preview routing, the setStyle()-reuse vs. fresh-map paths, and the unrecognized-URL error case) via a node:vm sandbox running the actual embedded script, mirroring the pattern already used for this in the sibling mcp-server repo. Verified live against the real Mapbox API in a real browser: both the GeoJSON overlay path and the style-swap-in-place path (loading a real custom style by name) work end-to-end on the merged resource. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 7 + src/resources/index.ts | 16 +- src/resources/resourceRegistry.ts | 8 +- ...wUIResource.ts => MapPreviewUIResource.ts} | 142 ++++++- .../ui-apps/PreviewStyleUIResource.ts | 268 ------------- .../GeojsonPreviewTool.ts | 2 +- .../preview-style-tool/PreviewStyleTool.ts | 2 +- .../ui-apps/GeojsonPreviewUIResource.test.ts | 149 ------- .../ui-apps/MapPreviewUIResource.test.ts | 372 ++++++++++++++++++ 9 files changed, 514 insertions(+), 452 deletions(-) rename src/resources/ui-apps/{GeojsonPreviewUIResource.ts => MapPreviewUIResource.ts} (71%) delete mode 100644 src/resources/ui-apps/PreviewStyleUIResource.ts delete mode 100644 test/resources/ui-apps/GeojsonPreviewUIResource.test.ts create mode 100644 test/resources/ui-apps/MapPreviewUIResource.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 044072b..d63166c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ ## Unreleased +### Breaking Changes + +- **Consolidated `GeojsonPreviewUIResource` and `PreviewStyleUIResource` into a single `MapPreviewUIResource`.** Both were near-identical hand-written MCP Apps templates — the same postMessage handshake, fullscreen/open-link controls, and resize handling copy-pasted across ~650 lines, differing only in what they drew on the map once a tool result arrived (a GeoJSON overlay on the default Standard style vs. swapping to an arbitrary preview style). `geojson_preview_tool` and `preview_style_tool` now both declare `_meta.ui.resourceUri: 'ui://mapbox/map-preview/index.html'`, served by the merged resource, which dispatches on the shape of the tool-result URL it receives (a `geojson.io` URL vs. a Styles API `.html?access_token=...` preview URL) rather than assuming a fixed mode. Neither tool's own input/output contract changed. + - The public resource exports change: `previewStyleUI`/`geojsonPreviewUI` (from `@mapbox/mcp-devkit-server/resources`) are replaced by a single `mapPreviewUI`. `GeojsonPreviewUIResource`/`PreviewStyleUIResource` class exports are removed in favor of `MapPreviewUIResource`. + - `style_comparison_tool`'s `StyleComparisonUIResource` is untouched and stays separate — it renders two synced `mapboxgl.Map` instances under a swipe/compare slider, a genuinely different UI shape from "one map, different content," not just another mode to fold in. + - The GeoJSON-preview path keeps its existing eager map bootstrap (drawing the default Standard style immediately, before any tool result arrives, so an overlay has something to render onto right away); the style-preview path now reuses that same map instance via `setStyle()` when it exists, falling back to constructing its own (as before) when it doesn't. Verified live against the real Mapbox API in a real browser: the GeoJSON overlay path and the style-swap-in-place path (loading a real custom style by name) both work end-to-end on the merged resource. + ### Dependencies - Bumped `@modelcontextprotocol/sdk` to `1.30.0`. Not adopting the `2026-07-28` spec revision this release covers (stateless request/response model, elicitation replaced by Multi Round-Trip Requests, Sampling deprecated) — that's a separate migration, tracked in #130, given this repo's own elicitation-based features depend on the mechanism being replaced. Regenerated `patches/@modelcontextprotocol+sdk+1.30.0.patch` (previously pinned to `1.29.0`) — same patch content, applies cleanly to the new version, verified live against the built server. diff --git a/src/resources/index.ts b/src/resources/index.ts index bc8a1fc..162a413 100644 --- a/src/resources/index.ts +++ b/src/resources/index.ts @@ -23,18 +23,16 @@ export { MapboxStyleLayersResource } from './mapbox-style-layers-resource/Mapbox export { MapboxStreetsV8FieldsResource } from './mapbox-streets-v8-fields-resource/MapboxStreetsV8FieldsResource.js'; export { MapboxTokenScopesResource } from './mapbox-token-scopes-resource/MapboxTokenScopesResource.js'; export { MapboxLayerTypeMappingResource } from './mapbox-layer-type-mapping-resource/MapboxLayerTypeMappingResource.js'; -export { PreviewStyleUIResource } from './ui-apps/PreviewStyleUIResource.js'; +export { MapPreviewUIResource } from './ui-apps/MapPreviewUIResource.js'; export { StyleComparisonUIResource } from './ui-apps/StyleComparisonUIResource.js'; -export { GeojsonPreviewUIResource } from './ui-apps/GeojsonPreviewUIResource.js'; // Import resource classes for instantiation import { MapboxStyleLayersResource } from './mapbox-style-layers-resource/MapboxStyleLayersResource.js'; import { MapboxStreetsV8FieldsResource } from './mapbox-streets-v8-fields-resource/MapboxStreetsV8FieldsResource.js'; import { MapboxTokenScopesResource } from './mapbox-token-scopes-resource/MapboxTokenScopesResource.js'; import { MapboxLayerTypeMappingResource } from './mapbox-layer-type-mapping-resource/MapboxLayerTypeMappingResource.js'; -import { PreviewStyleUIResource } from './ui-apps/PreviewStyleUIResource.js'; +import { MapPreviewUIResource } from './ui-apps/MapPreviewUIResource.js'; import { StyleComparisonUIResource } from './ui-apps/StyleComparisonUIResource.js'; -import { GeojsonPreviewUIResource } from './ui-apps/GeojsonPreviewUIResource.js'; // Export pre-configured resource instances with short, clean names @@ -50,15 +48,15 @@ export const mapboxTokenScopes = new MapboxTokenScopesResource(); /** Mapbox layer type mapping reference */ export const mapboxLayerTypeMapping = new MapboxLayerTypeMappingResource(); -/** Preview style UI resource */ -export const previewStyleUI = new PreviewStyleUIResource(); +/** + * Shared map preview UI resource — serves both geojson_preview_tool and + * preview_style_tool's inline MCP Apps preview. + */ +export const mapPreviewUI = new MapPreviewUIResource(); /** Style comparison UI resource */ export const styleComparisonUI = new StyleComparisonUIResource(); -/** GeoJSON preview UI resource */ -export const geojsonPreviewUI = new GeojsonPreviewUIResource(); - // Export registry functions for batch access export { getAllResources, diff --git a/src/resources/resourceRegistry.ts b/src/resources/resourceRegistry.ts index a9758bc..a3c871a 100644 --- a/src/resources/resourceRegistry.ts +++ b/src/resources/resourceRegistry.ts @@ -5,9 +5,8 @@ import { MapboxStyleLayersResource } from './mapbox-style-layers-resource/Mapbox import { MapboxStreetsV8FieldsResource } from './mapbox-streets-v8-fields-resource/MapboxStreetsV8FieldsResource.js'; import { MapboxTokenScopesResource } from './mapbox-token-scopes-resource/MapboxTokenScopesResource.js'; import { MapboxLayerTypeMappingResource } from './mapbox-layer-type-mapping-resource/MapboxLayerTypeMappingResource.js'; -import { PreviewStyleUIResource } from './ui-apps/PreviewStyleUIResource.js'; +import { MapPreviewUIResource } from './ui-apps/MapPreviewUIResource.js'; import { StyleComparisonUIResource } from './ui-apps/StyleComparisonUIResource.js'; -import { GeojsonPreviewUIResource } from './ui-apps/GeojsonPreviewUIResource.js'; // Central registry of all resources export const ALL_RESOURCES = [ @@ -16,9 +15,8 @@ export const ALL_RESOURCES = [ new MapboxTokenScopesResource(), new MapboxLayerTypeMappingResource(), // MCP Apps UI resources (ui:// scheme) - new PreviewStyleUIResource(), - new StyleComparisonUIResource(), - new GeojsonPreviewUIResource() + new MapPreviewUIResource(), + new StyleComparisonUIResource() ] as const; export type ResourceInstance = (typeof ALL_RESOURCES)[number]; diff --git a/src/resources/ui-apps/GeojsonPreviewUIResource.ts b/src/resources/ui-apps/MapPreviewUIResource.ts similarity index 71% rename from src/resources/ui-apps/GeojsonPreviewUIResource.ts rename to src/resources/ui-apps/MapPreviewUIResource.ts index 48052fd..4eefe0d 100644 --- a/src/resources/ui-apps/GeojsonPreviewUIResource.ts +++ b/src/resources/ui-apps/MapPreviewUIResource.ts @@ -19,6 +19,12 @@ const MAPBOX_GL_VERSION = '3.12.0'; // GL JS needs a public token; mint a short-lived one per request from the // caller's sk.*. Do NOT cache it in module scope — on a multi-tenant server a // process-global cache can return one caller's token to a different caller. +// +// Only used to bootstrap the map before any tool result has arrived (the +// GeoJSON-preview path draws on top of the default Standard style using this +// token). The style-preview path never needs it: its tool result URL already +// carries a token scoped to the style being previewed, which takes over via +// mapboxgl.accessToken once that result arrives — see handleToolResult below. async function createPreviewToken(skToken: string): Promise { const username = getUserNameFromToken(skToken); const expires = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 1 hour @@ -28,7 +34,7 @@ async function createPreviewToken(skToken: string): Promise { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - note: 'GeoJSON Preview (auto-generated, expires in 1h)', + note: 'Map Preview (auto-generated, expires in 1h)', scopes: ['styles:tiles', 'styles:read', 'fonts:read'], expires }) @@ -44,15 +50,27 @@ async function createPreviewToken(skToken: string): Promise { } /** - * Serves UI App HTML for GeoJSON Preview using Mapbox GL JS directly. - * Renders GeoJSON inline — no inner iframe needed, so frame-src CSP is not an issue. - * Implements MCP Apps pattern with ui:// scheme. + * Serves the UI App HTML shared by `geojson_preview_tool` and + * `preview_style_tool` — one Mapbox GL JS map that either draws a GeoJSON + * overlay on the default Standard style, or swaps to an arbitrary preview + * style, depending on which tool's result arrives. Renders inline — no + * inner iframe needed, so frame-src CSP is not an issue. + * + * Previously two nearly-identical resources (GeojsonPreviewUIResource, + * PreviewStyleUIResource) each hand-wrote the same MCP-Apps postMessage + * handshake, fullscreen/open-link controls, and resize handling. Merged + * here since the only real difference between them was what they drew on + * the map once data arrived, not how the map/iframe worked. + * + * `style_comparison_tool`'s dual-map swipe UI (StyleComparisonUIResource) + * is a genuinely different UI shape — two synced map instances under a + * compare slider, not "one map, different content" — and stays separate. */ -export class GeojsonPreviewUIResource extends BaseResource { - readonly name = 'GeoJSON Preview UI'; - readonly uri = 'ui://mapbox/geojson-preview/index.html'; +export class MapPreviewUIResource extends BaseResource { + readonly name = 'Mapbox Map Preview UI'; + readonly uri = 'ui://mapbox/map-preview/index.html'; readonly description = - 'Interactive UI for previewing GeoJSON data rendered inline with Mapbox GL JS (MCP Apps)'; + 'Interactive UI for previewing GeoJSON data or Mapbox styles rendered inline with Mapbox GL JS (MCP Apps)'; readonly mimeType = RESOURCE_MIME_TYPE; public async readCallback( @@ -76,7 +94,8 @@ export class GeojsonPreviewUIResource extends BaseResource { accessToken = minted; } } catch { - // Non-fatal — map won't render but the link button still works + // Non-fatal — map won't render until a style-preview result + // supplies its own token, but the link button still works. } } else if (skToken.startsWith('pk.')) { accessToken = skToken; // Already a public token @@ -87,7 +106,7 @@ export class GeojsonPreviewUIResource extends BaseResource { - GeoJSON Preview + Map Preview - - -
-
Loading style preview...
- -
- - - - - -`; - - return { - contents: [ - { - uri: this.uri, - mimeType: RESOURCE_MIME_TYPE, - text: html, - _meta: { - ui: { - csp: { - connectDomains: ['https://*.mapbox.com'], - resourceDomains: ['https://api.mapbox.com'], - workerDomains: ['blob:'] - }, - preferredSize: { - width: 1000, - height: 600 - } - } - } - } - ] - }; - } -} diff --git a/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts b/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts index ce60f0c..4a76641 100644 --- a/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts +++ b/src/tools/geojson-preview-tool/GeojsonPreviewTool.ts @@ -25,7 +25,7 @@ export class GeojsonPreviewTool extends BaseTool { readonly meta = { ui: { - resourceUri: 'ui://mapbox/geojson-preview/index.html', + resourceUri: 'ui://mapbox/map-preview/index.html', csp: { frameDomains: ['https://geojson.io'] } diff --git a/src/tools/preview-style-tool/PreviewStyleTool.ts b/src/tools/preview-style-tool/PreviewStyleTool.ts index f445161..60d0b84 100644 --- a/src/tools/preview-style-tool/PreviewStyleTool.ts +++ b/src/tools/preview-style-tool/PreviewStyleTool.ts @@ -23,7 +23,7 @@ export class PreviewStyleTool extends BaseTool { readonly meta = { ui: { - resourceUri: 'ui://mapbox/preview-style/index.html', + resourceUri: 'ui://mapbox/map-preview/index.html', csp: { connectDomains: ['https://*.mapbox.com'], resourceDomains: ['https://*.mapbox.com'], diff --git a/test/resources/ui-apps/GeojsonPreviewUIResource.test.ts b/test/resources/ui-apps/GeojsonPreviewUIResource.test.ts deleted file mode 100644 index 95ed635..0000000 --- a/test/resources/ui-apps/GeojsonPreviewUIResource.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -// Copyright (c) Mapbox, Inc. -// Licensed under the MIT License. - -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { GeojsonPreviewUIResource } from '../../../src/resources/ui-apps/GeojsonPreviewUIResource.js'; - -const uri = new URL('ui://mapbox/geojson-preview/index.html'); - -// Build a Mapbox-style 3-part JWT whose payload carries the username (`u`). -function makeToken(prefix: 'sk' | 'pk' | 'tk', username: string): string { - const payload = Buffer.from(JSON.stringify({ u: username })).toString( - 'base64' - ); - return `${prefix}.${payload}.sig`; -} - -function embeddedToken(html: string): string | null { - const m = html.match(/var TOKEN = '([^']*)'/); - return m ? m[1] : null; -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function extra(token?: string): any { - return token ? { authInfo: { token } } : {}; -} - -async function readHtml( - resource: GeojsonPreviewUIResource, - token?: string -): Promise { - const result = await resource['readCallback'](uri, extra(token)); - return result.contents[0].text as string; -} - -// Stub global fetch to mirror real Mapbox behaviour: POST tokens/v2/{username} -// mints a `tk` token for THAT account. Returns the mock for call assertions. -function stubMintingFetch() { - const fn = vi.fn(async (input: string | URL | Request) => { - const url = String(input); - const username = decodeURIComponent( - url.match(/tokens\/v2\/([^?]+)/)?.[1] ?? '' - ); - return new Response(JSON.stringify({ token: makeToken('tk', username) }), { - status: 200 - }); - }); - vi.stubGlobal('fetch', fn); - return fn; -} - -describe('GeojsonPreviewUIResource — AGI-905 cross-account token leak', () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('embeds only the caller’s own minted token, never another account’s (regression)', async () => { - const fetchMock = stubMintingFetch(); - const resource = new GeojsonPreviewUIResource(); - - const htmlA = await readHtml(resource, makeToken('sk', 'accountA')); - const htmlB = await readHtml(resource, makeToken('sk', 'accountB')); - - const tokA = embeddedToken(htmlA); - const tokB = embeddedToken(htmlB); - - // Each caller receives a token minted for their own account. - expect(tokA).toBe(makeToken('tk', 'accountA')); - expect(tokB).toBe(makeToken('tk', 'accountB')); - - // B must never receive A's token. - expect(tokB).not.toBe(tokA); - expect(htmlB).not.toContain(tokA as string); - - // No process-global cache: each read mints fresh. - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - it('mints a fresh token on every read (no shared cache, even for the same caller)', async () => { - const fetchMock = stubMintingFetch(); - const resource = new GeojsonPreviewUIResource(); - const sk = makeToken('sk', 'acct'); - - await readHtml(resource, sk); - await readHtml(resource, sk); - - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - it('does not embed a token minted for a different account (identity assertion)', async () => { - // Simulate a (hypothetical) backend returning a token for someone else. - vi.stubGlobal( - 'fetch', - vi.fn( - async () => - new Response(JSON.stringify({ token: makeToken('tk', 'attacker') }), { - status: 200 - }) - ) - ); - const resource = new GeojsonPreviewUIResource(); - - const html = await readHtml(resource, makeToken('sk', 'victim')); - - expect(embeddedToken(html)).toBe(''); - }); - - it('renders without a token when minting fails (graceful degradation)', async () => { - vi.stubGlobal( - 'fetch', - vi.fn(async () => new Response('forbidden', { status: 403 })) - ); - const resource = new GeojsonPreviewUIResource(); - - const result = await resource['readCallback']( - uri, - extra(makeToken('sk', 'acct')) - ); - - expect(result.contents).toHaveLength(1); - expect(embeddedToken(result.contents[0].text as string)).toBe(''); - }); - - it('passes a pk token through unchanged without minting', async () => { - const fetchMock = stubMintingFetch(); - const resource = new GeojsonPreviewUIResource(); - const pk = makeToken('pk', 'acct'); - - const html = await readHtml(resource, pk); - - expect(embeddedToken(html)).toBe(pk); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('renders without a token when no token is provided', async () => { - const saved = process.env.MAPBOX_ACCESS_TOKEN; - delete process.env.MAPBOX_ACCESS_TOKEN; - try { - const fetchMock = stubMintingFetch(); - const resource = new GeojsonPreviewUIResource(); - - const html = await readHtml(resource); - - expect(embeddedToken(html)).toBe(''); - expect(fetchMock).not.toHaveBeenCalled(); - } finally { - if (saved !== undefined) process.env.MAPBOX_ACCESS_TOKEN = saved; - } - }); -}); diff --git a/test/resources/ui-apps/MapPreviewUIResource.test.ts b/test/resources/ui-apps/MapPreviewUIResource.test.ts new file mode 100644 index 0000000..7322f66 --- /dev/null +++ b/test/resources/ui-apps/MapPreviewUIResource.test.ts @@ -0,0 +1,372 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import * as vm from 'node:vm'; +import { MapPreviewUIResource } from '../../../src/resources/ui-apps/MapPreviewUIResource.js'; + +const uri = new URL('ui://mapbox/map-preview/index.html'); + +// Build a Mapbox-style 3-part JWT whose payload carries the username (`u`). +function makeToken(prefix: 'sk' | 'pk' | 'tk', username: string): string { + const payload = Buffer.from(JSON.stringify({ u: username })).toString( + 'base64' + ); + return `${prefix}.${payload}.sig`; +} + +function embeddedToken(html: string): string | null { + const m = html.match(/var TOKEN = '([^']*)'/); + return m ? m[1] : null; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function extra(token?: string): any { + return token ? { authInfo: { token } } : {}; +} + +async function readHtml( + resource: MapPreviewUIResource, + token?: string +): Promise { + const result = await resource['readCallback'](uri, extra(token)); + return result.contents[0].text as string; +} + +// Stub global fetch to mirror real Mapbox behaviour: POST tokens/v2/{username} +// mints a `tk` token for THAT account. Returns the mock for call assertions. +function stubMintingFetch() { + const fn = vi.fn(async (input: string | URL | Request) => { + const url = String(input); + const username = decodeURIComponent( + url.match(/tokens\/v2\/([^?]+)/)?.[1] ?? '' + ); + return new Response(JSON.stringify({ token: makeToken('tk', username) }), { + status: 200 + }); + }); + vi.stubGlobal('fetch', fn); + return fn; +} + +describe('MapPreviewUIResource — AGI-905 cross-account token leak', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('embeds only the caller’s own minted token, never another account’s (regression)', async () => { + const fetchMock = stubMintingFetch(); + const resource = new MapPreviewUIResource(); + + const htmlA = await readHtml(resource, makeToken('sk', 'accountA')); + const htmlB = await readHtml(resource, makeToken('sk', 'accountB')); + + const tokA = embeddedToken(htmlA); + const tokB = embeddedToken(htmlB); + + // Each caller receives a token minted for their own account. + expect(tokA).toBe(makeToken('tk', 'accountA')); + expect(tokB).toBe(makeToken('tk', 'accountB')); + + // B must never receive A's token. + expect(tokB).not.toBe(tokA); + expect(htmlB).not.toContain(tokA as string); + + // No process-global cache: each read mints fresh. + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('mints a fresh token on every read (no shared cache, even for the same caller)', async () => { + const fetchMock = stubMintingFetch(); + const resource = new MapPreviewUIResource(); + const sk = makeToken('sk', 'acct'); + + await readHtml(resource, sk); + await readHtml(resource, sk); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('does not embed a token minted for a different account (identity assertion)', async () => { + // Simulate a (hypothetical) backend returning a token for someone else. + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response(JSON.stringify({ token: makeToken('tk', 'attacker') }), { + status: 200 + }) + ) + ); + const resource = new MapPreviewUIResource(); + + const html = await readHtml(resource, makeToken('sk', 'victim')); + + expect(embeddedToken(html)).toBe(''); + }); + + it('renders without a token when minting fails (graceful degradation)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('forbidden', { status: 403 })) + ); + const resource = new MapPreviewUIResource(); + + const result = await resource['readCallback']( + uri, + extra(makeToken('sk', 'acct')) + ); + + expect(result.contents).toHaveLength(1); + expect(embeddedToken(result.contents[0].text as string)).toBe(''); + }); + + it('passes a pk token through unchanged without minting', async () => { + const fetchMock = stubMintingFetch(); + const resource = new MapPreviewUIResource(); + const pk = makeToken('pk', 'acct'); + + const html = await readHtml(resource, pk); + + expect(embeddedToken(html)).toBe(pk); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('renders without a token when no token is provided', async () => { + const saved = process.env.MAPBOX_ACCESS_TOKEN; + delete process.env.MAPBOX_ACCESS_TOKEN; + try { + const fetchMock = stubMintingFetch(); + const resource = new MapPreviewUIResource(); + + const html = await readHtml(resource); + + expect(embeddedToken(html)).toBe(''); + expect(fetchMock).not.toHaveBeenCalled(); + } finally { + if (saved !== undefined) process.env.MAPBOX_ACCESS_TOKEN = saved; + } + }); +}); + +/** + * Extracts and runs the resource's inline