From 546c2ac7e645ea30a80c4fe4a4661dddb7412b50 Mon Sep 17 00:00:00 2001 From: robin-dunn <58361313+robin-dunn@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:34:18 +0000 Subject: [PATCH 01/11] Added OS base map --- .env.example | 5 ++ src/client/javascripts/boundary-map.js | 2 +- src/client/javascripts/boundary-map.test.js | 6 +- src/config/config.js | 8 +- src/server/base-map/index.js | 10 +++ src/server/base-map/routes.js | 88 +++++++++++++++++++ .../common/helpers/content-security-policy.js | 9 +- .../content-security-policy.unit.test.js | 23 ----- src/server/quote/map/get-view-model.js | 7 +- src/server/router.js | 3 +- 10 files changed, 119 insertions(+), 42 deletions(-) create mode 100644 src/server/base-map/index.js create mode 100644 src/server/base-map/routes.js diff --git a/.env.example b/.env.example index 2fbd44c..d1235db 100644 --- a/.env.example +++ b/.env.example @@ -41,3 +41,8 @@ CDP_UPLOADER_URL=http://localhost:7337 # When true this will generate and display the Swagger Open API documentation page at the route /docs # We only tend to use this when developing locally. USE_SWAGGER=false + +# Ordnance Survey Vector Tile API key +# Get a key from https://osdatahub.os.uk/ +# When set, the map page uses OS Vector Tile API base tiles instead of demo tiles +OS_API_KEY= diff --git a/src/client/javascripts/boundary-map.js b/src/client/javascripts/boundary-map.js index e18ae0b..00e4406 100644 --- a/src/client/javascripts/boundary-map.js +++ b/src/client/javascripts/boundary-map.js @@ -57,7 +57,7 @@ function fitMapToBounds(mapInstance, geojson) { [west, south], [east, north] ], - { padding: 40 } + { padding: 40, maxZoom: 15 } ) } } diff --git a/src/client/javascripts/boundary-map.test.js b/src/client/javascripts/boundary-map.test.js index 708d7b6..d1d1278 100644 --- a/src/client/javascripts/boundary-map.test.js +++ b/src/client/javascripts/boundary-map.test.js @@ -204,7 +204,7 @@ describe('boundary-map', () => { [-1.5, 52.0], [-1.4, 52.1] ], - { padding: 40 } + { padding: 40, maxZoom: 15 } ) }) @@ -266,7 +266,7 @@ describe('boundary-map', () => { [-1.5, 52.0], [-1.5, 52.0] ], - { padding: 40 } + { padding: 40, maxZoom: 15 } ) }) @@ -307,7 +307,7 @@ describe('boundary-map', () => { [-2.0, 51.0], [-1.0, 53.0] ], - { padding: 40 } + { padding: 40, maxZoom: 15 } ) }) diff --git a/src/config/config.js b/src/config/config.js index 66d1d30..749b36b 100644 --- a/src/config/config.js +++ b/src/config/config.js @@ -315,11 +315,11 @@ export const config = convict({ env: 'USE_SWAGGER' }, map: { - styleUrl: { - doc: 'URL to the map style.json (Mapbox Style Specification)', + osApiKey: { + doc: 'Ordnance Survey Vector Tile API key for the OS Vector Tile API base map.', format: String, - default: 'https://demotiles.maplibre.org/style.json', - env: 'MAP_STYLE_URL' + default: '', + env: 'OS_API_KEY' } }, cdpUploader: { diff --git a/src/server/base-map/index.js b/src/server/base-map/index.js new file mode 100644 index 0000000..faf69f5 --- /dev/null +++ b/src/server/base-map/index.js @@ -0,0 +1,10 @@ +import routes from './routes.js' + +export const baseMap = { + plugin: { + name: 'base-map', + register(server) { + server.route(routes) + } + } +} diff --git a/src/server/base-map/routes.js b/src/server/base-map/routes.js new file mode 100644 index 0000000..ddeb7d3 --- /dev/null +++ b/src/server/base-map/routes.js @@ -0,0 +1,88 @@ +import Wreck from '@hapi/wreck' +import { config } from '../../config/config.js' +import { createLogger } from '../common/helpers/logging/logger.js' + +const logger = createLogger() + +const osBaseUrl = 'https://api.os.uk/maps/vector/v1/vts' + +export const routePath = '/base-map' + +function getOsUrl(path, query) { + const osApiKey = config.get('map.osApiKey') + const params = new URLSearchParams(query) + params.set('key', osApiKey) + params.set('srs', '3857') + const base = path ? `${osBaseUrl}/${path}` : osBaseUrl + return `${base}?${params.toString()}` +} + +function rewriteOsUrls(body, host) { + const escapedBase = osBaseUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const pattern = `${escapedBase}(?:/(.*?))?\\?[^"\\s]*` + return body.replace( + new RegExp(pattern, 'g'), + (_, path) => `${host}${routePath}${path ? `/${path}` : ''}` + ) +} + +function isBinaryPath(path) { + return path.endsWith('.pbf') || path.endsWith('.png') || path.endsWith('.jpg') +} + +const proxyHandler = { + method: 'GET', + path: `${routePath}/{path*}`, + options: { + auth: false + }, + async handler(request, h) { + const path = request.params.path || '' + const osUrl = getOsUrl(path, request.query) + + try { + // For binary resources (tiles, sprites), don't decompress — pass through raw + const binary = isBinaryPath(path) + const { res, payload } = await Wreck.get(osUrl, { + redirects: 3, + maxBytes: 10 * 1024 * 1024, + gunzip: !binary + }) + + const contentType = res.headers['content-type'] || '' + const cacheControl = res.headers['cache-control'] || 'no-cache' + + if (binary) { + const response = h + .response(payload) + .type(contentType) + .header('cache-control', cacheControl) + + if (res.headers['content-encoding']) { + response.header('content-encoding', res.headers['content-encoding']) + } + + return response + } + + // JSON responses — rewrite OS URLs to point to our proxy + const host = `${request.headers['x-forwarded-proto'] || request.server.info.protocol}://${request.info.host}` + const rewritten = rewriteOsUrls(payload.toString(), host) + return h + .response(rewritten) + .type(contentType) + .header('cache-control', cacheControl) + } catch (err) { + // Wreck throws a Boom error on non-2xx responses (e.g. 403 for tiles outside UK coverage) + if (err.data?.isResponseError) { + const statusCode = err.data.res.statusCode + return h.response(err.data.payload).code(statusCode) + } + + logger.error(`Map proxy error for ${path}: ${err.message}`) + return h.response('Map tile request failed').code(502) + } + } +} + +export default [proxyHandler] diff --git a/src/server/common/helpers/content-security-policy.js b/src/server/common/helpers/content-security-policy.js index 0daa665..6d4e7cc 100644 --- a/src/server/common/helpers/content-security-policy.js +++ b/src/server/common/helpers/content-security-policy.js @@ -6,8 +6,6 @@ import { config } from '../../../config/config.js' * @satisfies {import('@hapi/hapi').Plugin} */ const cdpUploaderUrl = config.get('cdpUploader.url') -const mapStyleUrl = config.get('map.styleUrl') -const mapStyleOrigin = mapStyleUrl ? new URL(mapStyleUrl).origin : null const contentSecurityPolicy = { plugin: Blankie, @@ -16,12 +14,7 @@ const contentSecurityPolicy = { // https://frontend.design-system.service.gov.uk/import-javascript/#if-our-inline-javascript-snippet-is-blocked-by-a-content-security-policy defaultSrc: ['self'], fontSrc: ['self', 'data:'], - connectSrc: [ - 'self', - 'wss', - 'data:', - ...(mapStyleOrigin ? [mapStyleOrigin] : []) - ], + connectSrc: ['self', 'wss', 'data:'], mediaSrc: ['self'], styleSrc: ['self'], scriptSrc: [ diff --git a/src/server/common/helpers/content-security-policy.unit.test.js b/src/server/common/helpers/content-security-policy.unit.test.js index 52db931..1976f0b 100644 --- a/src/server/common/helpers/content-security-policy.unit.test.js +++ b/src/server/common/helpers/content-security-policy.unit.test.js @@ -11,31 +11,8 @@ describe('#contentSecurityPolicy config variations', () => { vi.resetModules() }) - test('should include map style origin in connectSrc when configured', async () => { - mockConfigGet.mockImplementation((key) => { - if (key === 'map.styleUrl') { - return 'https://tiles.example.com/style.json' - } - if (key === 'cdpUploader.url') { - return null - } - return null - }) - - const { contentSecurityPolicy } = - await import('./content-security-policy.js') - - expect(contentSecurityPolicy.options.connectSrc).toContain( - 'https://tiles.example.com' - ) - expect(contentSecurityPolicy.options.formAction).toEqual(['self']) - }) - test('should include cdpUploader url in formAction when configured', async () => { mockConfigGet.mockImplementation((key) => { - if (key === 'map.styleUrl') { - return null - } if (key === 'cdpUploader.url') { return 'https://uploader.example.com' } diff --git a/src/server/quote/map/get-view-model.js b/src/server/quote/map/get-view-model.js index b8ad328..5d9086f 100644 --- a/src/server/quote/map/get-view-model.js +++ b/src/server/quote/map/get-view-model.js @@ -1,10 +1,13 @@ import { getPageTitle } from '../../common/helpers/page-title.js' import { routePath as uploadBoundaryPath } from '../upload-boundary/routes.js' import { routePath as boundaryTypePath } from '../boundary-type/routes.js' -import { config } from '../../../config/config.js' export const title = 'Boundary Map' +function getMapStyleUrl() { + return '/base-map/resources/styles' +} + export default function getViewModel(boundaryGeojson) { const geometry = boundaryGeojson?.geometry const intersectingEdps = boundaryGeojson?.intersecting_edps ?? [] @@ -22,6 +25,6 @@ export default function getViewModel(boundaryGeojson) { backLinkPath: uploadBoundaryPath, uploadBoundaryPath, cancelPath: boundaryTypePath, - mapStyleUrl: config.get('map.styleUrl') + mapStyleUrl: getMapStyleUrl() } } diff --git a/src/server/router.js b/src/server/router.js index 75daed8..3f219c2 100644 --- a/src/server/router.js +++ b/src/server/router.js @@ -6,6 +6,7 @@ import { auth } from './auth/index.js' import { profile } from './profile/index.js' import { serveStaticFiles } from './common/helpers/serve-static-files.js' import { quote } from './quote/index.js' +import { baseMap } from './base-map/index.js' export const router = { plugin: { @@ -17,7 +18,7 @@ export const router = { await server.register([health]) // Application specific routes, add your own routes here - await server.register([about, auth, profile, quote]) + await server.register([about, auth, profile, quote, baseMap]) // Static assets await server.register([serveStaticFiles]) From bab57efda482febdb0f3cf9d40fc3105185953d3 Mon Sep 17 00:00:00 2001 From: robin-dunn <58361313+robin-dunn@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:50:26 +0000 Subject: [PATCH 02/11] Coverage --- src/server/base-map/routes.js | 20 +- src/server/base-map/routes.test.js | 315 ++++++++++++++++++++ src/server/common/constants/status-codes.js | 3 +- 3 files changed, 330 insertions(+), 8 deletions(-) create mode 100644 src/server/base-map/routes.test.js diff --git a/src/server/base-map/routes.js b/src/server/base-map/routes.js index ddeb7d3..06df441 100644 --- a/src/server/base-map/routes.js +++ b/src/server/base-map/routes.js @@ -1,6 +1,7 @@ import Wreck from '@hapi/wreck' import { config } from '../../config/config.js' import { createLogger } from '../common/helpers/logging/logger.js' +import { statusCodes } from '../common/constants/status-codes.js' const logger = createLogger() @@ -18,11 +19,14 @@ function getOsUrl(path, query) { } function rewriteOsUrls(body, host) { - const escapedBase = osBaseUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const pattern = `${escapedBase}(?:/(.*?))?\\?[^"\\s]*` - return body.replace( - new RegExp(pattern, 'g'), - (_, path) => `${host}${routePath}${path ? `/${path}` : ''}` + const escapedBase = osBaseUrl.replaceAll( + /[.*+?^${}()|[\]\\]/g, + String.raw`\$&` + ) + const pattern = String.raw`${escapedBase}(?:/(.*?))?\?[^"\s]*` + const proxyBase = `${host}${routePath}` + return body.replaceAll(new RegExp(pattern, 'g'), (_, path) => + path ? `${proxyBase}/${path}` : proxyBase ) } @@ -66,7 +70,9 @@ const proxyHandler = { } // JSON responses — rewrite OS URLs to point to our proxy - const host = `${request.headers['x-forwarded-proto'] || request.server.info.protocol}://${request.info.host}` + const protocol = + request.headers['x-forwarded-proto'] || request.server.info.protocol + const host = `${protocol}://${request.info.host}` const rewritten = rewriteOsUrls(payload.toString(), host) return h .response(rewritten) @@ -80,7 +86,7 @@ const proxyHandler = { } logger.error(`Map proxy error for ${path}: ${err.message}`) - return h.response('Map tile request failed').code(502) + return h.response('Map tile request failed').code(statusCodes.badGateway) } } } diff --git a/src/server/base-map/routes.test.js b/src/server/base-map/routes.test.js new file mode 100644 index 0000000..14a8adb --- /dev/null +++ b/src/server/base-map/routes.test.js @@ -0,0 +1,315 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import Wreck from '@hapi/wreck' + +vi.mock('@hapi/wreck') + +vi.mock('../../config/config.js', () => ({ + config: { + get: vi.fn((key) => { + if (key === 'map.osApiKey') return 'test-api-key' + return null + }) + } +})) + +const mockLogger = vi.hoisted(() => ({ + error: vi.fn(), + info: vi.fn(), + warn: vi.fn() +})) + +vi.mock('../common/helpers/logging/logger.js', () => ({ + createLogger: () => mockLogger +})) + +const { default: routes } = await import('./routes.js') + +const handler = routes[0].handler + +function createMockRequest({ path = '', query = {} } = {}) { + return { + params: { path }, + query, + headers: {}, + server: { info: { protocol: 'http' } }, + info: { host: 'localhost:3000' } + } +} + +function createMockH() { + const response = { + type: vi.fn().mockReturnThis(), + header: vi.fn().mockReturnThis(), + code: vi.fn().mockReturnThis() + } + return { + response: vi.fn().mockReturnValue(response), + _response: response + } +} + +describe('base-map proxy routes', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('route config', () => { + it('should define a GET route at /base-map/{path*}', () => { + expect(routes[0].method).toBe('GET') + expect(routes[0].path).toBe('/base-map/{path*}') + }) + + it('should not require auth', () => { + expect(routes[0].options.auth).toBe(false) + }) + }) + + describe('JSON responses (style, TileJSON)', () => { + it('should proxy style requests and rewrite OS URLs', async () => { + const osStyleBody = JSON.stringify({ + sources: { + esri: { + url: 'https://api.os.uk/maps/vector/v1/vts?key=test-api-key&srs=3857' + } + }, + sprite: + 'https://api.os.uk/maps/vector/v1/vts/resources/sprites/sprite?key=test-api-key&srs=3857', + glyphs: + 'https://api.os.uk/maps/vector/v1/vts/resources/fonts/{fontstack}/{range}.pbf?key=test-api-key&srs=3857' + }) + + vi.mocked(Wreck.get).mockResolvedValue({ + res: { + statusCode: 200, + headers: { + 'content-type': 'application/json', + 'cache-control': 'max-age=3600' + } + }, + payload: Buffer.from(osStyleBody) + }) + + const request = createMockRequest({ path: 'resources/styles' }) + const h = createMockH() + + await handler(request, h) + + expect(Wreck.get).toHaveBeenCalledWith( + 'https://api.os.uk/maps/vector/v1/vts/resources/styles?key=test-api-key&srs=3857', + expect.objectContaining({ gunzip: true }) + ) + + const responseBody = h.response.mock.calls[0][0] + expect(responseBody).toContain('http://localhost:3000/base-map') + expect(responseBody).not.toContain('api.os.uk') + expect(responseBody).not.toContain('test-api-key') + expect(h._response.type).toHaveBeenCalledWith('application/json') + expect(h._response.header).toHaveBeenCalledWith( + 'cache-control', + 'max-age=3600' + ) + }) + + it('should proxy TileJSON at the root path', async () => { + const tileJson = JSON.stringify({ + tiles: [ + 'https://api.os.uk/maps/vector/v1/vts/tile/{z}/{y}/{x}.pbf?key=test-api-key&srs=3857' + ] + }) + + vi.mocked(Wreck.get).mockResolvedValue({ + res: { + statusCode: 200, + headers: { 'content-type': 'application/json', 'cache-control': '' } + }, + payload: Buffer.from(tileJson) + }) + + const request = createMockRequest() + const h = createMockH() + + await handler(request, h) + + expect(Wreck.get).toHaveBeenCalledWith( + 'https://api.os.uk/maps/vector/v1/vts?key=test-api-key&srs=3857', + expect.anything() + ) + + const responseBody = h.response.mock.calls[0][0] + expect(responseBody).toContain( + 'http://localhost:3000/base-map/tile/{z}/{y}/{x}.pbf' + ) + }) + + it('should use x-forwarded-proto when present', async () => { + vi.mocked(Wreck.get).mockResolvedValue({ + res: { + statusCode: 200, + headers: { 'content-type': 'application/json' } + }, + payload: Buffer.from( + '{"url":"https://api.os.uk/maps/vector/v1/vts?key=test-api-key&srs=3857"}' + ) + }) + + const request = createMockRequest({ path: 'resources/styles' }) + request.headers['x-forwarded-proto'] = 'https' + const h = createMockH() + + await handler(request, h) + + const responseBody = h.response.mock.calls[0][0] + expect(responseBody).toContain('https://localhost:3000/base-map') + }) + }) + + describe('binary responses (tiles, sprites)', () => { + it('should proxy .pbf tiles without decompression', async () => { + const tileData = Buffer.from([0x1a, 0x02, 0x03]) + + vi.mocked(Wreck.get).mockResolvedValue({ + res: { + statusCode: 200, + headers: { + 'content-type': 'application/octet-stream', + 'cache-control': 'max-age=86400', + 'content-encoding': 'gzip' + } + }, + payload: tileData + }) + + const request = createMockRequest({ + path: 'tile/7/63/42.pbf' + }) + const h = createMockH() + + await handler(request, h) + + expect(Wreck.get).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ gunzip: false }) + ) + expect(h.response).toHaveBeenCalledWith(tileData) + expect(h._response.type).toHaveBeenCalledWith('application/octet-stream') + expect(h._response.header).toHaveBeenCalledWith( + 'content-encoding', + 'gzip' + ) + expect(h._response.header).toHaveBeenCalledWith( + 'cache-control', + 'max-age=86400' + ) + }) + + it('should not set content-encoding if upstream does not send it', async () => { + vi.mocked(Wreck.get).mockResolvedValue({ + res: { + statusCode: 200, + headers: { + 'content-type': 'image/png', + 'cache-control': 'no-cache' + } + }, + payload: Buffer.from([0x89, 0x50, 0x4e, 0x47]) + }) + + const request = createMockRequest({ + path: 'resources/sprites/sprite.png' + }) + const h = createMockH() + + await handler(request, h) + + const headerCalls = h._response.header.mock.calls.map((c) => c[0]) + expect(headerCalls).not.toContain('content-encoding') + }) + }) + + describe('error handling', () => { + it('should pass through upstream error status codes', async () => { + const boomError = new Error('Response Error: 403 Forbidden') + boomError.data = { + isResponseError: true, + res: { statusCode: 403 }, + payload: Buffer.from('Forbidden') + } + + vi.mocked(Wreck.get).mockRejectedValue(boomError) + + const request = createMockRequest({ + path: 'tile/15/10706/16499.pbf' + }) + const h = createMockH() + + await handler(request, h) + + expect(h.response).toHaveBeenCalledWith(Buffer.from('Forbidden')) + expect(h._response.code).toHaveBeenCalledWith(403) + }) + + it('should return 502 for network errors', async () => { + vi.mocked(Wreck.get).mockRejectedValue(new Error('ECONNREFUSED')) + + const request = createMockRequest({ path: 'resources/styles' }) + const h = createMockH() + + await handler(request, h) + + expect(h.response).toHaveBeenCalledWith('Map tile request failed') + expect(h._response.code).toHaveBeenCalledWith(502) + expect(mockLogger.error).toHaveBeenCalledWith( + 'Map proxy error for resources/styles: ECONNREFUSED' + ) + }) + }) + + describe('URL construction', () => { + it('should append API key and srs=3857 to all requests', async () => { + vi.mocked(Wreck.get).mockResolvedValue({ + res: { + statusCode: 200, + headers: { 'content-type': 'application/json' } + }, + payload: Buffer.from('{}') + }) + + const request = createMockRequest({ path: 'resources/styles' }) + const h = createMockH() + + await handler(request, h) + + expect(Wreck.get).toHaveBeenCalledWith( + expect.stringContaining('key=test-api-key'), + expect.anything() + ) + expect(Wreck.get).toHaveBeenCalledWith( + expect.stringContaining('srs=3857'), + expect.anything() + ) + }) + + it('should forward query parameters from the client', async () => { + vi.mocked(Wreck.get).mockResolvedValue({ + res: { + statusCode: 200, + headers: { 'content-type': 'application/json' } + }, + payload: Buffer.from('{}') + }) + + const request = createMockRequest({ + path: 'resources/styles', + query: { f: 'json' } + }) + const h = createMockH() + + await handler(request, h) + + expect(Wreck.get).toHaveBeenCalledWith( + expect.stringContaining('f=json'), + expect.anything() + ) + }) + }) +}) diff --git a/src/server/common/constants/status-codes.js b/src/server/common/constants/status-codes.js index 93e6b85..8592fca 100644 --- a/src/server/common/constants/status-codes.js +++ b/src/server/common/constants/status-codes.js @@ -7,5 +7,6 @@ export const statusCodes = { forbidden: 403, notFound: 404, imATeapot: 418, - internalServerError: 500 + internalServerError: 500, + badGateway: 502 } From f5c1988e23f329232c0b9bfe78682d4fa76743e1 Mon Sep 17 00:00:00 2001 From: robin-dunn <58361313+robin-dunn@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:28:36 +0000 Subject: [PATCH 03/11] Use MSW for tests --- src/server/base-map/routes.js | 35 +++-- src/server/base-map/routes.test.js | 206 +++++++++++++---------------- 2 files changed, 122 insertions(+), 119 deletions(-) diff --git a/src/server/base-map/routes.js b/src/server/base-map/routes.js index 06df441..fbb312f 100644 --- a/src/server/base-map/routes.js +++ b/src/server/base-map/routes.js @@ -18,16 +18,35 @@ function getOsUrl(path, query) { return `${base}?${params.toString()}` } +// OS API JSON responses (e.g. style docs, tile metadata) contain absolute URLs +// back to api.os.uk. We rewrite these to route through our proxy, which injects +// the API key server-side and strips the original query strings (including the +// API key) so they aren't leaked to the client. function rewriteOsUrls(body, host) { - const escapedBase = osBaseUrl.replaceAll( - /[.*+?^${}()|[\]\\]/g, - String.raw`\$&` - ) - const pattern = String.raw`${escapedBase}(?:/(.*?))?\?[^"\s]*` const proxyBase = `${host}${routePath}` - return body.replaceAll(new RegExp(pattern, 'g'), (_, path) => - path ? `${proxyBase}/${path}` : proxyBase - ) + + const json = JSON.parse(body) + + const rewriteValue = (value) => { + if (typeof value === 'string' && value.startsWith(osBaseUrl)) { + const rest = value.slice(osBaseUrl.length) + // Strip query string (contains API key) but keep the sub-path. + // Can't use new URL() here as it would encode MapLibre template + // tokens like {z}/{y}/{x}. + const subPath = rest.split('?')[0] + return `${proxyBase}${subPath}` + } + if (Array.isArray(value)) return value.map(rewriteValue) + if (value && typeof value === 'object') return rewriteKeys(value) + return value + } + + const rewriteKeys = (obj) => + Object.fromEntries( + Object.entries(obj).map(([k, v]) => [k, rewriteValue(v)]) + ) + + return JSON.stringify(rewriteKeys(json)) } function isBinaryPath(path) { diff --git a/src/server/base-map/routes.test.js b/src/server/base-map/routes.test.js index 14a8adb..b1d7194 100644 --- a/src/server/base-map/routes.test.js +++ b/src/server/base-map/routes.test.js @@ -1,7 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import Wreck from '@hapi/wreck' - -vi.mock('@hapi/wreck') +import { http, HttpResponse } from 'msw' +import { setupMswServer } from '../../test-utils/setup-msw-server.js' vi.mock('../../config/config.js', () => ({ config: { @@ -22,6 +21,8 @@ vi.mock('../common/helpers/logging/logger.js', () => ({ createLogger: () => mockLogger })) +const server = setupMswServer() + const { default: routes } = await import('./routes.js') const handler = routes[0].handler @@ -66,7 +67,7 @@ describe('base-map proxy routes', () => { describe('JSON responses (style, TileJSON)', () => { it('should proxy style requests and rewrite OS URLs', async () => { - const osStyleBody = JSON.stringify({ + const osStyleBody = { sources: { esri: { url: 'https://api.os.uk/maps/vector/v1/vts?key=test-api-key&srs=3857' @@ -76,29 +77,24 @@ describe('base-map proxy routes', () => { 'https://api.os.uk/maps/vector/v1/vts/resources/sprites/sprite?key=test-api-key&srs=3857', glyphs: 'https://api.os.uk/maps/vector/v1/vts/resources/fonts/{fontstack}/{range}.pbf?key=test-api-key&srs=3857' - }) + } - vi.mocked(Wreck.get).mockResolvedValue({ - res: { - statusCode: 200, - headers: { - 'content-type': 'application/json', - 'cache-control': 'max-age=3600' - } - }, - payload: Buffer.from(osStyleBody) - }) + server.use( + http.get('https://api.os.uk/maps/vector/v1/vts/resources/styles', () => + HttpResponse.json(osStyleBody, { + headers: { + 'content-type': 'application/json', + 'cache-control': 'max-age=3600' + } + }) + ) + ) const request = createMockRequest({ path: 'resources/styles' }) const h = createMockH() await handler(request, h) - expect(Wreck.get).toHaveBeenCalledWith( - 'https://api.os.uk/maps/vector/v1/vts/resources/styles?key=test-api-key&srs=3857', - expect.objectContaining({ gunzip: true }) - ) - const responseBody = h.response.mock.calls[0][0] expect(responseBody).toContain('http://localhost:3000/base-map') expect(responseBody).not.toContain('api.os.uk') @@ -111,30 +107,25 @@ describe('base-map proxy routes', () => { }) it('should proxy TileJSON at the root path', async () => { - const tileJson = JSON.stringify({ + const tileJson = { tiles: [ 'https://api.os.uk/maps/vector/v1/vts/tile/{z}/{y}/{x}.pbf?key=test-api-key&srs=3857' ] - }) + } - vi.mocked(Wreck.get).mockResolvedValue({ - res: { - statusCode: 200, - headers: { 'content-type': 'application/json', 'cache-control': '' } - }, - payload: Buffer.from(tileJson) - }) + server.use( + http.get('https://api.os.uk/maps/vector/v1/vts', () => + HttpResponse.json(tileJson, { + headers: { 'content-type': 'application/json' } + }) + ) + ) const request = createMockRequest() const h = createMockH() await handler(request, h) - expect(Wreck.get).toHaveBeenCalledWith( - 'https://api.os.uk/maps/vector/v1/vts?key=test-api-key&srs=3857', - expect.anything() - ) - const responseBody = h.response.mock.calls[0][0] expect(responseBody).toContain( 'http://localhost:3000/base-map/tile/{z}/{y}/{x}.pbf' @@ -142,15 +133,13 @@ describe('base-map proxy routes', () => { }) it('should use x-forwarded-proto when present', async () => { - vi.mocked(Wreck.get).mockResolvedValue({ - res: { - statusCode: 200, - headers: { 'content-type': 'application/json' } - }, - payload: Buffer.from( - '{"url":"https://api.os.uk/maps/vector/v1/vts?key=test-api-key&srs=3857"}' + server.use( + http.get('https://api.os.uk/maps/vector/v1/vts/resources/styles', () => + HttpResponse.json({ + url: 'https://api.os.uk/maps/vector/v1/vts?key=test-api-key&srs=3857' + }) ) - }) + ) const request = createMockRequest({ path: 'resources/styles' }) request.headers['x-forwarded-proto'] = 'https' @@ -165,32 +154,28 @@ describe('base-map proxy routes', () => { describe('binary responses (tiles, sprites)', () => { it('should proxy .pbf tiles without decompression', async () => { - const tileData = Buffer.from([0x1a, 0x02, 0x03]) - - vi.mocked(Wreck.get).mockResolvedValue({ - res: { - statusCode: 200, - headers: { - 'content-type': 'application/octet-stream', - 'cache-control': 'max-age=86400', - 'content-encoding': 'gzip' - } - }, - payload: tileData - }) + const tileData = new Uint8Array([0x1a, 0x02, 0x03]) + + server.use( + http.get( + 'https://api.os.uk/maps/vector/v1/vts/tile/7/63/42.pbf', + () => + new HttpResponse(tileData, { + headers: { + 'content-type': 'application/octet-stream', + 'cache-control': 'max-age=86400', + 'content-encoding': 'gzip' + } + }) + ) + ) - const request = createMockRequest({ - path: 'tile/7/63/42.pbf' - }) + const request = createMockRequest({ path: 'tile/7/63/42.pbf' }) const h = createMockH() await handler(request, h) - expect(Wreck.get).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ gunzip: false }) - ) - expect(h.response).toHaveBeenCalledWith(tileData) + expect(h.response).toHaveBeenCalled() expect(h._response.type).toHaveBeenCalledWith('application/octet-stream') expect(h._response.header).toHaveBeenCalledWith( 'content-encoding', @@ -203,16 +188,18 @@ describe('base-map proxy routes', () => { }) it('should not set content-encoding if upstream does not send it', async () => { - vi.mocked(Wreck.get).mockResolvedValue({ - res: { - statusCode: 200, - headers: { - 'content-type': 'image/png', - 'cache-control': 'no-cache' - } - }, - payload: Buffer.from([0x89, 0x50, 0x4e, 0x47]) - }) + server.use( + http.get( + 'https://api.os.uk/maps/vector/v1/vts/resources/sprites/sprite.png', + () => + new HttpResponse(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), { + headers: { + 'content-type': 'image/png', + 'cache-control': 'no-cache' + } + }) + ) + ) const request = createMockRequest({ path: 'resources/sprites/sprite.png' @@ -228,14 +215,12 @@ describe('base-map proxy routes', () => { describe('error handling', () => { it('should pass through upstream error status codes', async () => { - const boomError = new Error('Response Error: 403 Forbidden') - boomError.data = { - isResponseError: true, - res: { statusCode: 403 }, - payload: Buffer.from('Forbidden') - } - - vi.mocked(Wreck.get).mockRejectedValue(boomError) + server.use( + http.get( + 'https://api.os.uk/maps/vector/v1/vts/tile/15/10706/16499.pbf', + () => new HttpResponse('Forbidden', { status: 403 }) + ) + ) const request = createMockRequest({ path: 'tile/15/10706/16499.pbf' @@ -249,7 +234,11 @@ describe('base-map proxy routes', () => { }) it('should return 502 for network errors', async () => { - vi.mocked(Wreck.get).mockRejectedValue(new Error('ECONNREFUSED')) + server.use( + http.get('https://api.os.uk/maps/vector/v1/vts/resources/styles', () => + HttpResponse.error() + ) + ) const request = createMockRequest({ path: 'resources/styles' }) const h = createMockH() @@ -258,45 +247,43 @@ describe('base-map proxy routes', () => { expect(h.response).toHaveBeenCalledWith('Map tile request failed') expect(h._response.code).toHaveBeenCalledWith(502) - expect(mockLogger.error).toHaveBeenCalledWith( - 'Map proxy error for resources/styles: ECONNREFUSED' - ) + expect(mockLogger.error).toHaveBeenCalled() }) }) describe('URL construction', () => { it('should append API key and srs=3857 to all requests', async () => { - vi.mocked(Wreck.get).mockResolvedValue({ - res: { - statusCode: 200, - headers: { 'content-type': 'application/json' } - }, - payload: Buffer.from('{}') - }) + let capturedUrl + server.use( + http.get( + 'https://api.os.uk/maps/vector/v1/vts/resources/styles', + ({ request: req }) => { + capturedUrl = new URL(req.url) + return HttpResponse.json({}) + } + ) + ) const request = createMockRequest({ path: 'resources/styles' }) const h = createMockH() await handler(request, h) - expect(Wreck.get).toHaveBeenCalledWith( - expect.stringContaining('key=test-api-key'), - expect.anything() - ) - expect(Wreck.get).toHaveBeenCalledWith( - expect.stringContaining('srs=3857'), - expect.anything() - ) + expect(capturedUrl.searchParams.get('key')).toBe('test-api-key') + expect(capturedUrl.searchParams.get('srs')).toBe('3857') }) it('should forward query parameters from the client', async () => { - vi.mocked(Wreck.get).mockResolvedValue({ - res: { - statusCode: 200, - headers: { 'content-type': 'application/json' } - }, - payload: Buffer.from('{}') - }) + let capturedUrl + server.use( + http.get( + 'https://api.os.uk/maps/vector/v1/vts/resources/styles', + ({ request: req }) => { + capturedUrl = new URL(req.url) + return HttpResponse.json({}) + } + ) + ) const request = createMockRequest({ path: 'resources/styles', @@ -306,10 +293,7 @@ describe('base-map proxy routes', () => { await handler(request, h) - expect(Wreck.get).toHaveBeenCalledWith( - expect.stringContaining('f=json'), - expect.anything() - ) + expect(capturedUrl.searchParams.get('f')).toBe('json') }) }) }) From 45094357776ea5230a51a82a990de8c3cda4b2b1 Mon Sep 17 00:00:00 2001 From: robin-dunn <58361313+robin-dunn@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:31:51 +0000 Subject: [PATCH 04/11] Renamed esri to osVectorTiles --- src/server/base-map/routes.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/base-map/routes.test.js b/src/server/base-map/routes.test.js index b1d7194..56f9a76 100644 --- a/src/server/base-map/routes.test.js +++ b/src/server/base-map/routes.test.js @@ -69,7 +69,7 @@ describe('base-map proxy routes', () => { it('should proxy style requests and rewrite OS URLs', async () => { const osStyleBody = { sources: { - esri: { + osVectorTiles: { url: 'https://api.os.uk/maps/vector/v1/vts?key=test-api-key&srs=3857' } }, From 70127899d762a80cea1da683f331e4dc105c76d3 Mon Sep 17 00:00:00 2001 From: robin-dunn <58361313+robin-dunn@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:35:18 +0000 Subject: [PATCH 05/11] Rename base-map to os-base-map --- src/server/{base-map => os-base-map}/index.js | 4 ++-- src/server/{base-map => os-base-map}/routes.js | 2 +- src/server/{base-map => os-base-map}/routes.test.js | 12 ++++++------ src/server/quote/map/get-view-model.js | 2 +- src/server/router.js | 4 ++-- 5 files changed, 12 insertions(+), 12 deletions(-) rename src/server/{base-map => os-base-map}/index.js (67%) rename src/server/{base-map => os-base-map}/routes.js (98%) rename src/server/{base-map => os-base-map}/routes.test.js (95%) diff --git a/src/server/base-map/index.js b/src/server/os-base-map/index.js similarity index 67% rename from src/server/base-map/index.js rename to src/server/os-base-map/index.js index faf69f5..d9ffbf4 100644 --- a/src/server/base-map/index.js +++ b/src/server/os-base-map/index.js @@ -1,8 +1,8 @@ import routes from './routes.js' -export const baseMap = { +export const osBaseMap = { plugin: { - name: 'base-map', + name: 'os-base-map', register(server) { server.route(routes) } diff --git a/src/server/base-map/routes.js b/src/server/os-base-map/routes.js similarity index 98% rename from src/server/base-map/routes.js rename to src/server/os-base-map/routes.js index fbb312f..1cc69c6 100644 --- a/src/server/base-map/routes.js +++ b/src/server/os-base-map/routes.js @@ -7,7 +7,7 @@ const logger = createLogger() const osBaseUrl = 'https://api.os.uk/maps/vector/v1/vts' -export const routePath = '/base-map' +export const routePath = '/os-base-map' function getOsUrl(path, query) { const osApiKey = config.get('map.osApiKey') diff --git a/src/server/base-map/routes.test.js b/src/server/os-base-map/routes.test.js similarity index 95% rename from src/server/base-map/routes.test.js rename to src/server/os-base-map/routes.test.js index 56f9a76..a221b02 100644 --- a/src/server/base-map/routes.test.js +++ b/src/server/os-base-map/routes.test.js @@ -49,15 +49,15 @@ function createMockH() { } } -describe('base-map proxy routes', () => { +describe('os-base-map proxy routes', () => { beforeEach(() => { vi.clearAllMocks() }) describe('route config', () => { - it('should define a GET route at /base-map/{path*}', () => { + it('should define a GET route at /os-base-map/{path*}', () => { expect(routes[0].method).toBe('GET') - expect(routes[0].path).toBe('/base-map/{path*}') + expect(routes[0].path).toBe('/os-base-map/{path*}') }) it('should not require auth', () => { @@ -96,7 +96,7 @@ describe('base-map proxy routes', () => { await handler(request, h) const responseBody = h.response.mock.calls[0][0] - expect(responseBody).toContain('http://localhost:3000/base-map') + expect(responseBody).toContain('http://localhost:3000/os-base-map') expect(responseBody).not.toContain('api.os.uk') expect(responseBody).not.toContain('test-api-key') expect(h._response.type).toHaveBeenCalledWith('application/json') @@ -128,7 +128,7 @@ describe('base-map proxy routes', () => { const responseBody = h.response.mock.calls[0][0] expect(responseBody).toContain( - 'http://localhost:3000/base-map/tile/{z}/{y}/{x}.pbf' + 'http://localhost:3000/os-base-map/tile/{z}/{y}/{x}.pbf' ) }) @@ -148,7 +148,7 @@ describe('base-map proxy routes', () => { await handler(request, h) const responseBody = h.response.mock.calls[0][0] - expect(responseBody).toContain('https://localhost:3000/base-map') + expect(responseBody).toContain('https://localhost:3000/os-base-map') }) }) diff --git a/src/server/quote/map/get-view-model.js b/src/server/quote/map/get-view-model.js index 5d9086f..a760056 100644 --- a/src/server/quote/map/get-view-model.js +++ b/src/server/quote/map/get-view-model.js @@ -5,7 +5,7 @@ import { routePath as boundaryTypePath } from '../boundary-type/routes.js' export const title = 'Boundary Map' function getMapStyleUrl() { - return '/base-map/resources/styles' + return '/os-base-map/resources/styles' } export default function getViewModel(boundaryGeojson) { diff --git a/src/server/router.js b/src/server/router.js index 3f219c2..24adceb 100644 --- a/src/server/router.js +++ b/src/server/router.js @@ -6,7 +6,7 @@ import { auth } from './auth/index.js' import { profile } from './profile/index.js' import { serveStaticFiles } from './common/helpers/serve-static-files.js' import { quote } from './quote/index.js' -import { baseMap } from './base-map/index.js' +import { osBaseMap } from './os-base-map/index.js' export const router = { plugin: { @@ -18,7 +18,7 @@ export const router = { await server.register([health]) // Application specific routes, add your own routes here - await server.register([about, auth, profile, quote, baseMap]) + await server.register([about, auth, profile, quote, osBaseMap]) // Static assets await server.register([serveStaticFiles]) From e94ae768fef4075070a92e56fd128e0f039eec5f Mon Sep 17 00:00:00 2001 From: robin-dunn <58361313+robin-dunn@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:46:49 +0000 Subject: [PATCH 06/11] Removed data: from fontSrc, connectSrc, imgSrc, and frameSrc --- src/server/common/helpers/content-security-policy.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/server/common/helpers/content-security-policy.js b/src/server/common/helpers/content-security-policy.js index 6d4e7cc..19c9be3 100644 --- a/src/server/common/helpers/content-security-policy.js +++ b/src/server/common/helpers/content-security-policy.js @@ -13,16 +13,16 @@ const contentSecurityPolicy = { // Hash 'sha256-GUQ5ad8JK5KmEWmROf3LZd9ge94daqNvd8xy9YS1iDw=' is to support a GOV.UK frontend script bundled within Nunjucks macros // https://frontend.design-system.service.gov.uk/import-javascript/#if-our-inline-javascript-snippet-is-blocked-by-a-content-security-policy defaultSrc: ['self'], - fontSrc: ['self', 'data:'], - connectSrc: ['self', 'wss', 'data:'], + fontSrc: ['self'], + connectSrc: ['self', 'wss'], mediaSrc: ['self'], styleSrc: ['self'], scriptSrc: [ 'self', "'sha256-GUQ5ad8JK5KmEWmROf3LZd9ge94daqNvd8xy9YS1iDw='" ], - imgSrc: ['self', 'data:'], - frameSrc: ['self', 'data:'], + imgSrc: ['self'], + frameSrc: ['self'], objectSrc: ['none'], frameAncestors: ['none'], formAction: ['self', ...(cdpUploaderUrl ? [cdpUploaderUrl] : [])], From 07a1035668066faa13a438eaa907ee780254d4b6 Mon Sep 17 00:00:00 2001 From: robin-dunn <58361313+robin-dunn@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:44:43 +0000 Subject: [PATCH 07/11] rewriteOrdnanceSurveyMapUrls --- .../content-security-policy.unit.test.js | 12 +--- src/server/os-base-map/routes.js | 68 ++++++++++--------- 2 files changed, 38 insertions(+), 42 deletions(-) diff --git a/src/server/common/helpers/content-security-policy.unit.test.js b/src/server/common/helpers/content-security-policy.unit.test.js index 1976f0b..043f27c 100644 --- a/src/server/common/helpers/content-security-policy.unit.test.js +++ b/src/server/common/helpers/content-security-policy.unit.test.js @@ -22,11 +22,7 @@ describe('#contentSecurityPolicy config variations', () => { const { contentSecurityPolicy } = await import('./content-security-policy.js') - expect(contentSecurityPolicy.options.connectSrc).toEqual([ - 'self', - 'wss', - 'data:' - ]) + expect(contentSecurityPolicy.options.connectSrc).toEqual(['self', 'wss']) expect(contentSecurityPolicy.options.formAction).toContain( 'https://uploader.example.com' ) @@ -38,11 +34,7 @@ describe('#contentSecurityPolicy config variations', () => { const { contentSecurityPolicy } = await import('./content-security-policy.js') - expect(contentSecurityPolicy.options.connectSrc).toEqual([ - 'self', - 'wss', - 'data:' - ]) + expect(contentSecurityPolicy.options.connectSrc).toEqual(['self', 'wss']) expect(contentSecurityPolicy.options.formAction).toEqual(['self']) }) }) diff --git a/src/server/os-base-map/routes.js b/src/server/os-base-map/routes.js index 1cc69c6..ed2f21c 100644 --- a/src/server/os-base-map/routes.js +++ b/src/server/os-base-map/routes.js @@ -5,48 +5,52 @@ import { statusCodes } from '../common/constants/status-codes.js' const logger = createLogger() -const osBaseUrl = 'https://api.os.uk/maps/vector/v1/vts' +const ordnanceSurveyMapUrl = 'https://api.os.uk/maps/vector/v1/vts' export const routePath = '/os-base-map' -function getOsUrl(path, query) { - const osApiKey = config.get('map.osApiKey') +function getOrdnanceSurveyMapUrl(path, query) { + const ordnanceSurveyApiKey = config.get('map.osApiKey') const params = new URLSearchParams(query) - params.set('key', osApiKey) + params.set('key', ordnanceSurveyApiKey) params.set('srs', '3857') - const base = path ? `${osBaseUrl}/${path}` : osBaseUrl + const base = path ? `${ordnanceSurveyMapUrl}/${path}` : ordnanceSurveyMapUrl return `${base}?${params.toString()}` } -// OS API JSON responses (e.g. style docs, tile metadata) contain absolute URLs -// back to api.os.uk. We rewrite these to route through our proxy, which injects -// the API key server-side and strips the original query strings (including the -// API key) so they aren't leaked to the client. -function rewriteOsUrls(body, host) { +// Rewrites api.os.uk URLs in JSON responses to route through our proxy, stripping +// query strings so the API key isn't leaked to the client. +function rewriteOrdnanceSurveyMapUrls(body, host) { const proxyBase = `${host}${routePath}` - const json = JSON.parse(body) + let json + try { + json = JSON.parse(body) + } catch { + return body + } + + const rewrite = (v) => { + if (typeof v === 'string') { + if (!v.startsWith(ordnanceSurveyMapUrl)) return v - const rewriteValue = (value) => { - if (typeof value === 'string' && value.startsWith(osBaseUrl)) { - const rest = value.slice(osBaseUrl.length) - // Strip query string (contains API key) but keep the sub-path. - // Can't use new URL() here as it would encode MapLibre template - // tokens like {z}/{y}/{x}. - const subPath = rest.split('?')[0] - return `${proxyBase}${subPath}` + const rest = v.slice(ordnanceSurveyMapUrl.length) + const i = rest.indexOf('?') + return proxyBase + (i === -1 ? rest : rest.slice(0, i)) } - if (Array.isArray(value)) return value.map(rewriteValue) - if (value && typeof value === 'object') return rewriteKeys(value) - return value - } - const rewriteKeys = (obj) => - Object.fromEntries( - Object.entries(obj).map(([k, v]) => [k, rewriteValue(v)]) - ) + if (Array.isArray(v)) return v.map(rewrite) + + if (v && typeof v === 'object') { + return Object.fromEntries( + Object.entries(v).map(([k, val]) => [k, rewrite(val)]) + ) + } + + return v + } - return JSON.stringify(rewriteKeys(json)) + return JSON.stringify(rewrite(json)) } function isBinaryPath(path) { @@ -61,12 +65,12 @@ const proxyHandler = { }, async handler(request, h) { const path = request.params.path || '' - const osUrl = getOsUrl(path, request.query) + const ordnanceSurveyUrl = getOrdnanceSurveyMapUrl(path, request.query) try { // For binary resources (tiles, sprites), don't decompress — pass through raw const binary = isBinaryPath(path) - const { res, payload } = await Wreck.get(osUrl, { + const { res, payload } = await Wreck.get(ordnanceSurveyUrl, { redirects: 3, maxBytes: 10 * 1024 * 1024, gunzip: !binary @@ -88,11 +92,11 @@ const proxyHandler = { return response } - // JSON responses — rewrite OS URLs to point to our proxy + // JSON responses — rewrite Ordnance Survey URLs to point to our proxy const protocol = request.headers['x-forwarded-proto'] || request.server.info.protocol const host = `${protocol}://${request.info.host}` - const rewritten = rewriteOsUrls(payload.toString(), host) + const rewritten = rewriteOrdnanceSurveyMapUrls(payload.toString(), host) return h .response(rewritten) .type(contentType) From 44b84820f6451f4df57c1d3167ef6f56eb51e06e Mon Sep 17 00:00:00 2001 From: robin-dunn <58361313+robin-dunn@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:53:45 +0000 Subject: [PATCH 08/11] Clean up rewriteOrdnanceSurveyMapUrls --- src/server/os-base-map/routes.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/server/os-base-map/routes.js b/src/server/os-base-map/routes.js index ed2f21c..a9b6cab 100644 --- a/src/server/os-base-map/routes.js +++ b/src/server/os-base-map/routes.js @@ -22,6 +22,7 @@ function getOrdnanceSurveyMapUrl(path, query) { // query strings so the API key isn't leaked to the client. function rewriteOrdnanceSurveyMapUrls(body, host) { const proxyBase = `${host}${routePath}` + const basePath = new URL(ordnanceSurveyMapUrl).pathname let json try { @@ -33,10 +34,10 @@ function rewriteOrdnanceSurveyMapUrls(body, host) { const rewrite = (v) => { if (typeof v === 'string') { if (!v.startsWith(ordnanceSurveyMapUrl)) return v - - const rest = v.slice(ordnanceSurveyMapUrl.length) - const i = rest.indexOf('?') - return proxyBase + (i === -1 ? rest : rest.slice(0, i)) + const subPath = decodeURIComponent( + new URL(v).pathname.slice(basePath.length) + ) + return proxyBase + subPath } if (Array.isArray(v)) return v.map(rewrite) From 4a1d570b6b67a266a55a86848f84742fdb448083 Mon Sep 17 00:00:00 2001 From: robin-dunn <58361313+robin-dunn@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:59:15 +0000 Subject: [PATCH 09/11] rewriteOrdnanceSurveyMapUrl revivor --- src/server/os-base-map/routes.js | 39 ++++++++++++-------------------- 1 file changed, 15 insertions(+), 24 deletions(-) diff --git a/src/server/os-base-map/routes.js b/src/server/os-base-map/routes.js index a9b6cab..1ae1599 100644 --- a/src/server/os-base-map/routes.js +++ b/src/server/os-base-map/routes.js @@ -24,34 +24,25 @@ function rewriteOrdnanceSurveyMapUrls(body, host) { const proxyBase = `${host}${routePath}` const basePath = new URL(ordnanceSurveyMapUrl).pathname - let json try { - json = JSON.parse(body) + // Walk every value in the JSON using the parse reviver callback + const json = JSON.parse(body, (_key, value) => { + if (typeof value === 'string' && value.startsWith(ordnanceSurveyMapUrl)) { + // Extract the sub-path (e.g. /resources/styles) and discard the query string. + // decodeURIComponent restores MapLibre template tokens like {z}/{y}/{x} + // that new URL() percent-encodes. + const subPath = decodeURIComponent( + new URL(value).pathname.slice(basePath.length) + ) + return proxyBase + subPath + } + return value + }) + + return JSON.stringify(json) } catch { return body } - - const rewrite = (v) => { - if (typeof v === 'string') { - if (!v.startsWith(ordnanceSurveyMapUrl)) return v - const subPath = decodeURIComponent( - new URL(v).pathname.slice(basePath.length) - ) - return proxyBase + subPath - } - - if (Array.isArray(v)) return v.map(rewrite) - - if (v && typeof v === 'object') { - return Object.fromEntries( - Object.entries(v).map(([k, val]) => [k, rewrite(val)]) - ) - } - - return v - } - - return JSON.stringify(rewrite(json)) } function isBinaryPath(path) { From d5eaf9ba9dc95efb5726ddf3d44e18f0ef711e62 Mon Sep 17 00:00:00 2001 From: robin-dunn <58361313+robin-dunn@users.noreply.github.com> Date: Thu, 19 Mar 2026 13:06:11 +0000 Subject: [PATCH 10/11] Updated the test names and variable to 'map style' --- src/server/os-base-map/routes.test.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/server/os-base-map/routes.test.js b/src/server/os-base-map/routes.test.js index a221b02..ac61e4c 100644 --- a/src/server/os-base-map/routes.test.js +++ b/src/server/os-base-map/routes.test.js @@ -65,9 +65,9 @@ describe('os-base-map proxy routes', () => { }) }) - describe('JSON responses (style, TileJSON)', () => { - it('should proxy style requests and rewrite OS URLs', async () => { - const osStyleBody = { + describe('JSON responses (map style, TileJSON)', () => { + it('should proxy map style requests and rewrite Ordnance Survey URLs', async () => { + const osMapStyleBody = { sources: { osVectorTiles: { url: 'https://api.os.uk/maps/vector/v1/vts?key=test-api-key&srs=3857' @@ -81,7 +81,7 @@ describe('os-base-map proxy routes', () => { server.use( http.get('https://api.os.uk/maps/vector/v1/vts/resources/styles', () => - HttpResponse.json(osStyleBody, { + HttpResponse.json(osMapStyleBody, { headers: { 'content-type': 'application/json', 'cache-control': 'max-age=3600' From ffb3f4dedfe50ed0b573316a0a7a36147f630b21 Mon Sep 17 00:00:00 2001 From: robin-dunn <58361313+robin-dunn@users.noreply.github.com> Date: Thu, 19 Mar 2026 13:28:55 +0000 Subject: [PATCH 11/11] Fix tests --- src/client/javascripts/boundary-map.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/client/javascripts/boundary-map.test.js b/src/client/javascripts/boundary-map.test.js index 45c8d72..c2e439d 100644 --- a/src/client/javascripts/boundary-map.test.js +++ b/src/client/javascripts/boundary-map.test.js @@ -484,7 +484,7 @@ describe('boundary-map', () => { [-3.0, 50.0], [0.0, 53.0] ], - { padding: 40 } + { maxZoom: 15, padding: 40 } ) }) @@ -514,7 +514,7 @@ describe('boundary-map', () => { [-1.0, 51.0], [-0.5, 51.5] ], - { padding: 40 } + { maxZoom: 15, padding: 40 } ) }) })