import { createHash, createHmac, timingSafeEqual } from "node:crypto"; import { createReadStream, createWriteStream } from "node:fs"; import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; import { createServer } from "node:http"; import { dirname, join, resolve } from "node:path"; import { Readable, Transform } from "node:stream"; import { pipeline } from "node:stream/promises"; import { fileURLToPath } from "node:url"; import { loadZoneSourceProfile } from "./zone-source-snapshot.mjs"; const canonicalCesiumAssetIds = ["1", "2", "96188"]; const cesiumVerificationTransportVersion = 4; // The neighbouring VPN egress only serves official Cesium/Bing traffic for // Map Gateway. Other upstreams retain their existing direct policy. const canonicalCesiumEgressHosts = new Set([ "api.cesium.com", "assets.ion.cesium.com", "dev.virtualearth.net", "ecn.t0.tiles.virtualearth.net", "ecn.t1.tiles.virtualearth.net", "ecn.t2.tiles.virtualearth.net", "ecn.t3.tiles.virtualearth.net", ]); const config = await readConfig(); const zoneSourceProfileId = String(process.env.ZONE_SOURCE_PROFILE_ID || "moscow-pmd-slow-zones").trim(); const zoneSourceRoot = resolve(String( process.env.ZONE_SOURCE_SNAPSHOT_ROOT || resolve(dirname(fileURLToPath(import.meta.url)), "../zone-sources"), ).trim()); // The route contract is stable while the provider behind this profile is // replaceable: today it is the audited MMap snapshot, later an API adapter can // atomically materialise the same manifest + generation envelope. const zoneSource = await loadZoneSourceProfile(zoneSourceRoot, zoneSourceProfileId); const liveCache = createCacheStore("live", config.cacheDir, true); const offlineSnapshot = config.offlineSnapshotDir ? createCacheStore("offline-snapshot", config.offlineSnapshotDir, false) : null; const inflightWrites = new Map(); const gatewayDiagnostics = createGatewayDiagnostics(config.slowUpstreamMs); // Provider endpoint credentials are kept only here or in the private volume. // They are never part of an endpoint response or a browser resource URL. const activeIonEndpoints = new Map(); const inflightIonEndpointRefreshes = new Map(); const credentialQueryKeys = new Set(["access_token", "accesstoken", "iontoken", "token", "key", "apikey", "api_key", "signature", "sig"]); await initialiseCacheStore(liveCache); if (offlineSnapshot) await initialiseCacheStore(offlineSnapshot); // A Platform administrator may rotate the master token after deployment. The // private NAS file wins over the legacy environment bootstrap on restart. let activeCesiumIonToken = (await readStoredCesiumIonToken()) || config.bootstrapCesiumIonToken; let ionTokenWriteQueue = Promise.resolve(); let ionTokenGeneration = 0; const server = createServer(async (request, response) => { applyCors(request, response); if (request.method === "OPTIONS") return response.writeHead(204).end(); let requestPath = "unknown"; try { const requestUrl = new URL(request.url || "/", `http://${request.headers.host || "127.0.0.1"}`); requestPath = requestUrl.pathname; if (requestUrl.pathname === "/api/map/admin/cesium-ion" && ["GET", "PUT"].includes(request.method || "")) { return await serveCesiumIonAdmin(request, response, requestUrl.pathname); } if (requestUrl.pathname === `/internal/zone-sources/v1/profiles/${encodeURIComponent(zoneSourceProfileId)}/current`) { if (!["GET", "HEAD"].includes(request.method || "")) { response.setHeader("Allow", "GET, HEAD"); return writeJson(response, 405, { ok: false, error: "zone_source_method_not_allowed" }); } return serveZoneSourceGeneration(request, response); } if (!isAllowedRequest(request)) return writeJson(response, 401, { ok: false, error: "map_gateway_auth_required" }); if (requestUrl.pathname === "/healthz" && request.method === "GET") { return writeJson(response, 200, { ok: true, service: "nodedc-map-gateway", cache: await cacheStats(liveCache), offlineSnapshot: offlineSnapshot ? await cacheStats(offlineSnapshot) : null, diagnostics: gatewayDiagnostics.status(), ionConfigured: Boolean(activeCesiumIonToken), assetAllowlist: [...config.assetAllowlist].map(Number).sort((left, right) => left - right), anonymousAccess: config.allowAnonymous, zoneSource: { profileId: zoneSourceProfileId, authorityId: zoneSource.generation.authorityId, datasetId: zoneSource.generation.datasetId, sourceRevision: zoneSource.generation.sourceRevision, contentDigest: zoneSource.generation.contentDigest, sourceZoneCount: zoneSource.generation.metadata.sourceZoneCount, publishedZoneCount: zoneSource.generation.metadata.publishedZoneCount, }, }); } const ionAssetMatch = requestUrl.pathname.match(/^\/api\/map\/ion\/assets\/(\d+)\/endpoint$/); if (ionAssetMatch && request.method === "GET") { return await serveIonEndpoint(request, response, ionAssetMatch[1]); } if (requestUrl.pathname === "/api/map/cache" && ["GET", "HEAD"].includes(request.method || "")) { return await serveCachedUpstream(request, response, requestUrl); } return writeJson(response, 404, { ok: false, error: "map_gateway_route_not_found" }); } catch (error) { // A client can cancel a streamed cache response after its headers have // already been sent. Do not try to turn that into JSON: Node would throw // ERR_HTTP_HEADERS_SENT and take the entire gateway process down. if (response.headersSent || response.writableEnded) { if (!response.writableEnded) response.destroy(); return; } const status = Number(error?.statusCode || 500); const errorCode = safeDiagnosticCode(error?.message || "map_gateway_error"); gatewayDiagnostics.recordGatewayFailure(errorCode); console.warn(JSON.stringify({ event: "map_gateway_request_failed", route: requestPath, status, error: errorCode })); return writeJson(response, Number.isInteger(status) && status >= 400 && status < 600 ? status : 500, { ok: false, error: errorCode, }); } }); server.listen(config.port, "0.0.0.0", () => { console.log(`NODE.DC Map Gateway listening on http://0.0.0.0:${config.port}`); console.log(`Live map cache: ${config.cacheDir} (${config.mode}, max ${Math.round(config.maxCacheBytes / 1024 / 1024)} MB)`); if (offlineSnapshot) console.log(`Offline map snapshot: ${config.offlineSnapshotDir} (read-only)`); console.log(`Zone source: ${zoneSourceProfileId}@${zoneSource.generation.sourceRevision} (${zoneSource.generation.metadata.publishedZoneCount} zones)`); }); process.on("SIGTERM", () => server.close()); process.on("SIGINT", () => server.close()); function serveZoneSourceGeneration(request, response) { if (request.headers["if-none-match"] === zoneSource.etag) { response.writeHead(304, { ETag: zoneSource.etag, "Cache-Control": "private, max-age=60" }); return response.end(); } response.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Content-Length": zoneSource.body.byteLength, "Cache-Control": "private, max-age=60", ETag: zoneSource.etag, "X-Content-Type-Options": "nosniff", }); return response.end(request.method === "HEAD" ? undefined : zoneSource.body); } async function readConfig() { const mode = String(process.env.MAP_CACHE_MODE || "readwrite").trim().toLowerCase(); if (!["readwrite", "readonly", "offline"].includes(mode)) throw new Error("invalid_map_cache_mode"); const allowlist = parseList(process.env.MAP_GATEWAY_UPSTREAM_ALLOWLIST || "api.cesium.com,assets.ion.cesium.com,tile.openstreetmap.org,dev.virtualearth.net,ecn.t0.tiles.virtualearth.net,ecn.t1.tiles.virtualearth.net,ecn.t2.tiles.virtualearth.net,ecn.t3.tiles.virtualearth.net"); const mapEgress = await readMapEgressConfig(); const ionEndpointTtlMs = parsePositiveInt(process.env.CESIUM_ION_ENDPOINT_TTL_SECONDS, 300) * 1000; const configuredRefreshAheadMs = parsePositiveInt(process.env.CESIUM_ION_ENDPOINT_REFRESH_AHEAD_SECONDS, 60) * 1000; return { port: parsePositiveInt(process.env.PORT, 18103), cacheDir: String(process.env.MAP_CACHE_DIR || "/var/lib/nodedc-map-cache").trim(), offlineSnapshotDir: String(process.env.MAP_OFFLINE_SNAPSHOT_DIR || "").trim(), mode, maxCacheBytes: parsePositiveInt(process.env.MAP_CACHE_MAX_MB, 20480) * 1024 * 1024, maxObjectBytes: parsePositiveInt(process.env.MAP_CACHE_MAX_OBJECT_MB, 128) * 1024 * 1024, defaultTtlMs: parsePositiveInt(process.env.MAP_CACHE_DEFAULT_TTL_SECONDS, 604800) * 1000, upstreamTimeoutMs: parsePositiveInt(process.env.MAP_GATEWAY_UPSTREAM_TIMEOUT_SECONDS, 30) * 1000, slowUpstreamMs: parsePositiveInt(process.env.MAP_GATEWAY_SLOW_UPSTREAM_SECONDS, 2) * 1000, ionEndpointTtlMs, ionEndpointRefreshAheadMs: Math.min(configuredRefreshAheadMs, Math.max(0, ionEndpointTtlMs - 1000)), // Test-only loopback escape hatch for the cache policy smoke test. It is // impossible to enable in production and never broadens the real HTTPS // allowlist. testAllowHttpLoopback: process.env.NODE_ENV === "test" && parseBoolean(process.env.MAP_GATEWAY_TEST_ALLOW_HTTP_LOOPBACK, false), // Transitional bootstrap only. A token written through the Foundry admin // flow is held in the private cache volume and takes precedence. bootstrapCesiumIonToken: String(process.env.CESIUM_ION_TOKEN || "").trim(), cesiumIonApiBase: normalizeCesiumIonApiBase(process.env.CESIUM_ION_API_BASE_URL), cesiumIonTokenFile: String(process.env.MAP_GATEWAY_CESIUM_ION_TOKEN_FILE || join(String(process.env.MAP_CACHE_DIR || "/var/lib/nodedc-map-cache").trim(), "secrets", "cesium-ion-token")).trim(), // The production value is read from a runner-owned, read-only file. Local // test/dev may still inject a process-only value without creating a file. mapGatewayAdminSecret: await readMapGatewayAdminSecret(), mapEgress, cesiumEgressHosts: canonicalCesiumEgressHosts, // The Foundry Map Page contract always needs terrain (1), imagery (2), // and the canonical buildings asset. A malformed legacy environment value // must never turn all three into 403 before the Gateway can reach Cesium. // Root may add further approved assets, but cannot remove these core IDs. assetAllowlist: new Set([...canonicalCesiumAssetIds, ...parseList(process.env.CESIUM_ION_ASSET_ALLOWLIST)]), upstreamAllowlist: new Set(allowlist), legacyCacheHosts: new Set(parseList(process.env.MAP_GATEWAY_LEGACY_CACHE_HOSTS || "")), offlineProviderAllowlist: new Set(parseList(process.env.MAP_GATEWAY_OFFLINE_PROVIDER_ALLOWLIST || "")), corsOrigins: new Set(parseList(process.env.MAP_GATEWAY_CORS_ORIGIN || "http://127.0.0.1:3333,http://localhost:3333")), allowAnonymous: parseBoolean(process.env.MAP_GATEWAY_ALLOW_ANONYMOUS, process.env.NODE_ENV !== "production"), trustedSubjectHeader: String(process.env.MAP_GATEWAY_TRUSTED_SUBJECT_HEADER || "x-nodedc-user-id").toLowerCase(), }; } async function readMapEgressConfig() { const rawUrl = String(process.env.MAP_GATEWAY_EGRESS_URL || "").trim(); if (!rawUrl) return { url: "", proxyToken: "" }; let url; try { url = new URL(rawUrl); } catch { throw new Error("map_gateway_egress_url_invalid"); } if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password || url.pathname !== "/" || url.search || url.hash) { throw new Error("map_gateway_egress_url_invalid"); } const tokenFile = String(process.env.MAP_GATEWAY_EGRESS_PROXY_TOKEN_FILE || "").trim(); if (!tokenFile) throw new Error("map_gateway_egress_proxy_token_file_required"); let proxyToken; try { proxyToken = (await readFile(tokenFile, "utf8")).trim(); } catch { throw new Error("map_gateway_egress_proxy_token_file_unreadable"); } if (!proxyToken || proxyToken.length > 4096 || /[\u0000-\u001f\u007f\s]/.test(proxyToken)) { throw new Error("map_gateway_egress_proxy_token_file_invalid"); } return { url: url.toString(), proxyToken }; } async function readMapGatewayAdminSecret() { const secretFile = String(process.env.NODEDC_MAP_GATEWAY_ADMIN_SECRET_FILE || "").trim(); if (secretFile) { let secret; try { secret = (await readFile(secretFile, "utf8")).trim(); } catch { throw new Error("map_gateway_admin_secret_file_unreadable"); } if (!/^[A-Za-z0-9_-]{48,256}$/.test(secret)) throw new Error("map_gateway_admin_secret_file_invalid"); return secret; } if (process.env.NODE_ENV === "production") throw new Error("map_gateway_admin_secret_file_required"); return String(process.env.NODEDC_MAP_GATEWAY_ADMIN_SECRET || "").trim(); } function parseList(value) { return String(value || "").split(",").map((part) => part.trim().toLowerCase()).filter(Boolean); } function parsePositiveInt(value, fallback) { const parsed = Number.parseInt(String(value || ""), 10); return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; } function parseBoolean(value, fallback) { if (value === undefined || value === "") return fallback; return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase()); } function normalizeCesiumIonApiBase(value) { const configured = String(value || "https://api.cesium.com/").trim(); let base; try { base = new URL(configured); } catch { throw new Error("invalid_cesium_ion_api_base"); } const testHttp = process.env.NODE_ENV === "test" && base.protocol === "http:"; if ((!testHttp && base.protocol !== "https:") || base.username || base.password || base.search || base.hash) { throw new Error("invalid_cesium_ion_api_base"); } base.pathname = base.pathname.replace(/\/+$/, "") || "/"; return base.toString(); } function normalizeIonReferer(value) { const raw = String(value || "").trim(); if (!raw || raw.length > 2048) return ""; let referer; try { referer = new URL(raw); } catch { return ""; } if (!['http:', 'https:'].includes(referer.protocol) || referer.username || referer.password) return ""; referer.search = ""; referer.hash = ""; return referer.toString(); } function requestIonReferer(request) { // This is an internal Foundry-to-Gateway transport header. It is normalized // before reaching Cesium and, for admin calls, included in the HMAC payload. return normalizeIonReferer(request.headers['x-nodedc-ion-referer']); } function ionRefererHeaders(ionReferer) { return ionReferer ? { referer: ionReferer } : {}; } function cesiumIonAssetEndpointUrl(assetId) { return new URL(`v1/assets/${encodeURIComponent(assetId)}/endpoint`, config.cesiumIonApiBase).toString(); } function applyCors(request, response) { const origin = String(request.headers.origin || ""); if (!origin || (!config.corsOrigins.has("*") && !config.corsOrigins.has(origin))) return; response.setHeader("access-control-allow-origin", config.corsOrigins.has("*") ? "*" : origin); response.setHeader("access-control-allow-methods", "GET, HEAD, OPTIONS"); response.setHeader("access-control-allow-headers", "Range, Content-Type"); response.setHeader("vary", "Origin"); } async function serveCesiumIonAdmin(request, response, pathname) { const rawBody = request.method === "PUT" ? await readSmallBody(request) : ""; const { actorId, ionReferer } = verifyAdminRequest(request, pathname, rawBody); if (request.method === "GET") { const metadata = await readCesiumIonMetadata(); // A Bing asset endpoint request starts a billable provider session. The // normal GET path must therefore report the persisted verification state, // rather than re-validating every time an administrator opens the modal. // Artifacts created before this check existed have no state: validate that // one existing private value exactly once and then remember the safe result. let verification = metadata?.verification || (activeCesiumIonToken ? null : "not-configured"); // Transport v4 adds production-shaped file endpoint scopes and safe // cache-first refresh. Recheck a persisted failure once so a false v3 // result cannot survive the artifact upgrade forever. const mustUpgradeLegacyFailure = metadata?.verification === "failed" && metadata.verificationTransportVersion !== cesiumVerificationTransportVersion; if ((!verification || mustUpgradeLegacyFailure) && activeCesiumIonToken) { verification = (await verifyCesiumIonToken(activeCesiumIonToken, ionReferer)).verification; if (metadata) await persistCesiumIonVerification(metadata, verification); } return writeJson(response, 200, { ok: true, configured: Boolean(activeCesiumIonToken), updatedAt: metadata?.updatedAt || null, updatedBy: metadata?.updatedBy || null, verification: verification || "failed", }); } let input; try { input = JSON.parse(rawBody); } catch { throw gatewayError("invalid_cesium_ion_token", 400); } const token = typeof input?.token === "string" ? input.token.trim() : ""; if (token.length < 16 || token.length > 4096 || /[\u0000-\u001f\u007f\s]/.test(token)) { throw gatewayError("invalid_cesium_ion_token", 400); } const verification = await verifyCesiumIonToken(token, ionReferer); if (verification.verification !== "verified") throw gatewayError("cesium_ion_token_verification_failed", 422); const metadata = await persistCesiumIonToken(token, actorId, verification); return writeJson(response, 200, { ok: true, configured: true, updatedAt: metadata.updatedAt, updatedBy: metadata.updatedBy, verification: verification.verification, }); } async function readSmallBody(request) { const chunks = []; let size = 0; for await (const chunk of request) { size += chunk.length; if (size > 8 * 1024) throw gatewayError("map_gateway_admin_payload_too_large", 413); chunks.push(chunk); } return Buffer.concat(chunks).toString("utf8"); } function verifyAdminRequest(request, pathname, body) { if (!config.mapGatewayAdminSecret) throw gatewayError("map_gateway_admin_not_configured", 503); const timestamp = String(request.headers["x-nodedc-admin-timestamp"] || ""); const actorId = String(request.headers["x-nodedc-admin-actor"] || "").trim(); const suppliedSignature = String(request.headers["x-nodedc-admin-signature"] || "").trim().toLowerCase(); const ionReferer = normalizeIonReferer(request.headers["x-nodedc-ion-referer"]); const timestampMs = Number(timestamp); if (!Number.isInteger(timestampMs) || Math.abs(Date.now() - timestampMs) > 60_000 || !/^[A-Za-z0-9._:@-]{1,256}$/.test(actorId) || !/^[a-f0-9]{64}$/.test(suppliedSignature)) { throw gatewayError("map_gateway_admin_unauthorized", 401); } const bodyHash = createHash("sha256").update(body).digest("hex"); const message = `nodedc.map-gateway.admin.v2\n${request.method}\n${pathname}\n${timestamp}\n${actorId}\n${bodyHash}\n${ionReferer}`; const expectedSignature = createHmac("sha256", config.mapGatewayAdminSecret).update(message).digest("hex"); const expected = Buffer.from(expectedSignature, "hex"); const supplied = Buffer.from(suppliedSignature, "hex"); if (expected.length !== supplied.length || !timingSafeEqual(expected, supplied)) throw gatewayError("map_gateway_admin_unauthorized", 401); return { actorId, ionReferer }; } function cesiumIonMetadataFile() { return `${config.cesiumIonTokenFile}.metadata.json`; } async function readStoredCesiumIonToken() { try { return String(await readFile(config.cesiumIonTokenFile, "utf8")).trim(); } catch (error) { if (error?.code !== "ENOENT") console.warn("Cesium Ion token file ignored: unreadable"); return ""; } } async function readCesiumIonMetadata() { try { const metadata = JSON.parse(await readFile(cesiumIonMetadataFile(), "utf8")); const updatedAt = typeof metadata?.updatedAt === "string" ? metadata.updatedAt : null; const updatedBy = typeof metadata?.updatedBy === "string" ? metadata.updatedBy : null; const verification = metadata?.verification === "verified" || metadata?.verification === "failed" ? metadata.verification : null; const verificationTransportVersion = Number.isInteger(metadata?.verificationTransportVersion) ? metadata.verificationTransportVersion : 0; return updatedAt && updatedBy ? { updatedAt, updatedBy, verification, verificationTransportVersion } : null; } catch (error) { if (error?.code !== "ENOENT") console.warn("Cesium Ion token metadata ignored: invalid file"); return null; } } async function verifyCesiumIonToken(token, ionReferer = "") { // Settings must validate the complete Foundry Map Page contract. Accepting // a token that reads terrain and imagery but not canonical 3D Buildings // would defer a predictable provider 403 to the browser. const results = await Promise.all(canonicalCesiumAssetIds.map(async (assetId) => { try { await fetchCesiumIonEndpoint(assetId, token, ionReferer); return true; } catch { return false; } })); return { verification: results.every(Boolean) ? "verified" : "failed" }; } async function fetchCesiumIonEndpoint(assetId, token, ionReferer = "") { const endpoint = await fetchWithTimeout(cesiumIonAssetEndpointUrl(assetId), { headers: { authorization: `Bearer ${token}`, ...ionRefererHeaders(ionReferer) }, }, { operation: "ion-endpoint", assetId }); if (!endpoint.ok) throw gatewayError("cesium_ion_endpoint_unavailable", endpoint.status || 502); const body = await endpoint.json(); const next = body.type === "IMAGERY" && body.externalType === "BING" ? { assetId, type: body.type, externalType: "BING", options: { url: validateUpstream(String(body.options?.url || "")).toString(), key: String(body.options?.key || ""), mapStyle: String(body.options?.mapStyle || "Aerial"), }, attributions: Array.isArray(body.attributions) ? body.attributions : [], savedAt: Date.now(), } : { assetId, type: body.type, url: validateUpstream(String(body.url || "")).toString(), accessToken: String(body.accessToken || ""), attributions: Array.isArray(body.attributions) ? body.attributions : [], savedAt: Date.now(), }; if (assetId === "1" && next.type !== "TERRAIN") throw gatewayError("cesium_ion_endpoint_invalid", 502); if (assetId === "2" && (next.externalType !== "BING" || next.type !== "IMAGERY")) throw gatewayError("cesium_ion_endpoint_invalid", 502); if (!(next.externalType === "BING" ? next.options?.key : next.accessToken)) throw gatewayError("cesium_ion_endpoint_invalid", 502); const credentialExpiresAt = ionEndpointCredentialExpiry(next); if (credentialExpiresAt !== null && credentialExpiresAt <= Date.now()) throw gatewayError("cesium_ion_endpoint_expired", 502); return next; } function persistCesiumIonToken(token, actorId, verification) { const task = ionTokenWriteQueue.then(async () => { const secretDir = dirname(config.cesiumIonTokenFile); await mkdir(secretDir, { recursive: true, mode: 0o700 }); await chmod(secretDir, 0o700); const tokenTemp = `${config.cesiumIonTokenFile}.${process.pid}.${Date.now()}.tmp`; const metadata = { updatedAt: new Date().toISOString(), updatedBy: actorId, verification: verification?.verification === "verified" ? "verified" : "failed", verificationTransportVersion: cesiumVerificationTransportVersion, }; await writeFile(tokenTemp, `${token}\n`, { encoding: "utf8", mode: 0o600 }); await rename(tokenTemp, config.cesiumIonTokenFile); await chmod(config.cesiumIonTokenFile, 0o600); await writeCesiumIonMetadata(metadata); // Asset-scoped endpoint credentials are derived from the master token. // Discard the prior private endpoint cache on rotation so a fresh map load // cannot keep using credentials minted under the old provider token. ionTokenGeneration += 1; activeCesiumIonToken = token; activeIonEndpoints.clear(); inflightIonEndpointRefreshes.clear(); await Promise.all([...config.assetAllowlist].map((assetId) => rm(ionEndpointPath(assetId), { force: true }))); return metadata; }); ionTokenWriteQueue = task.then(() => undefined, () => undefined); return task; } function persistCesiumIonVerification(expectedMetadata, verification) { const task = ionTokenWriteQueue.then(async () => { const current = await readCesiumIonMetadata(); // Do not let an old GET validation race a newer token rotation. if (!current || current.updatedAt !== expectedMetadata.updatedAt || current.updatedBy !== expectedMetadata.updatedBy) return; await writeCesiumIonMetadata({ ...current, verification, verificationTransportVersion: cesiumVerificationTransportVersion }); }); ionTokenWriteQueue = task.then(() => undefined, () => undefined); return task; } async function writeCesiumIonMetadata(metadata) { const metadataPath = cesiumIonMetadataFile(); const metadataTemp = `${metadataPath}.${process.pid}.${Date.now()}.tmp`; await writeFile(metadataTemp, `${JSON.stringify(metadata)}\n`, { encoding: "utf8", mode: 0o600 }); await rename(metadataTemp, metadataPath); await chmod(metadataPath, 0o600); } function isAllowedRequest(request) { return config.allowAnonymous || Boolean(request.headers[config.trustedSubjectHeader]); } async function serveIonEndpoint(request, response, assetId) { if (!config.assetAllowlist.has(assetId)) return writeJson(response, 403, { ok: false, error: "cesium_asset_not_allowed" }); await ionTokenWriteQueue; const cached = await readIonEndpointCache(assetId); const cachedState = cached ? ionEndpointCacheState(cached) : null; if (config.mode === "offline") { if (!cached) return writeJson(response, 504, { ok: false, error: "cesium_ion_offline_endpoint_miss" }); if (!cachedState.usable) return writeJson(response, 504, { ok: false, error: "cesium_ion_offline_endpoint_expired" }); if (!isOfflineProviderAllowed(ionEndpointUrl(cached))) return writeJson(response, 409, { ok: false, error: "map_provider_offline_not_permitted" }); rememberIonEndpoint(cached); return writeJson(response, 200, { ok: true, ...publicIonEndpoint(cached), cache: "offline-endpoint-hit" }); } if (!activeCesiumIonToken) { if (cached && cachedState.usable) { rememberIonEndpoint(cached); return writeJson(response, 200, { ok: true, ...publicIonEndpoint(cached), cache: "cached-endpoint-no-master-token" }); } return writeJson(response, 503, { ok: false, error: "cesium_ion_not_configured" }); } const ionReferer = requestIonReferer(request); if (cached && cachedState.usable) { rememberIonEndpoint(cached); if (cachedState.needsRefresh) scheduleIonEndpointRefresh(assetId, ionReferer); return writeJson(response, 200, { ok: true, ...publicIonEndpoint(cached), cache: cachedState.needsRefresh ? "ion-endpoint-refresh-ahead" : "ion-endpoint-hit", }); } const next = await refreshIonEndpoint(assetId, ionReferer); return writeJson(response, 200, { ok: true, ...publicIonEndpoint(next), cache: "ion-endpoint-online" }); } function scheduleIonEndpointRefresh(assetId, ionReferer = "") { const active = activeIonEndpoints.get(String(assetId)); if (active) { const state = ionEndpointCacheState(active); if (state.usable && !state.needsRefresh) return; } void refreshIonEndpoint(assetId, ionReferer).catch((error) => { const errorCode = safeDiagnosticCode(error?.message || "cesium_ion_endpoint_refresh_failed"); if (errorCode === "cesium_ion_token_rotated") return; console.warn(JSON.stringify({ event: "map_ion_endpoint_refresh_failed", assetId, error: errorCode })); }); } function refreshIonEndpoint(assetId, ionReferer = "") { const token = activeCesiumIonToken; if (!token) return Promise.reject(gatewayError("cesium_ion_not_configured", 503)); const generation = ionTokenGeneration; const existing = inflightIonEndpointRefreshes.get(assetId); if (existing?.generation === generation) return existing.promise; const promise = (async () => { const next = await fetchCesiumIonEndpoint(assetId, token, ionReferer); // An administrator may rotate the master token while this provider request // is in flight. Never re-introduce an endpoint credential minted under the // previous token generation. if (generation !== ionTokenGeneration || token !== activeCesiumIonToken) { throw gatewayError("cesium_ion_token_rotated", 409); } rememberIonEndpoint(next); if (config.mode === "readwrite") await writeIonEndpointCache(next); return next; })(); const record = { generation, promise }; inflightIonEndpointRefreshes.set(assetId, record); promise.finally(() => { if (inflightIonEndpointRefreshes.get(assetId) === record) inflightIonEndpointRefreshes.delete(assetId); }).catch(() => undefined); return promise; } async function serveCachedUpstream(request, response, requestUrl) { const rawTarget = requestUrl.searchParams.get("url"); if (!rawTarget) return writeJson(response, 400, { ok: false, error: "map_cache_url_required" }); let target = validateUpstream(rawTarget); // Browser-facing route revision only. It must not affect the upstream URL // or create a duplicate TileCache object. target.searchParams.delete("nodedc_client_revision"); const cacheProfile = String(target.searchParams.get("nodedc_cache_profile") || "live").toLowerCase(); target.searchParams.delete("nodedc_cache_profile"); if (!new Set(["live", "offline"]).has(cacheProfile)) return writeJson(response, 400, { ok: false, error: "map_cache_profile_invalid" }); const store = cacheProfile === "offline" ? offlineSnapshot : liveCache; if (!store) return writeJson(response, 409, { ok: false, error: "map_offline_snapshot_not_configured" }); const cacheMode = String(target.searchParams.get("nodedc_cache_mode") || "hybrid").toLowerCase(); target.searchParams.delete("nodedc_cache_mode"); if (!new Set(["hybrid", "passthrough"]).has(cacheMode)) return writeJson(response, 400, { ok: false, error: "map_cache_mode_invalid" }); const isLegacyCacheHost = config.legacyCacheHosts.has(target.hostname.toLowerCase()); const forceRefresh = target.searchParams.get("nodedc_cache_refresh") === "1"; target.searchParams.delete("nodedc_cache_refresh"); const ionReferer = requestIonReferer(request); const cacheKey = createHash("sha256").update(canonicalCacheUrl(target)).digest("hex"); // "Live" without cache is still routed through Gateway — it is never a // browser-side direct request — but it neither reads from nor writes to the // persistent store. The immutable offline profile intentionally has no such // bypass mode. if (cacheMode === "passthrough" && cacheProfile === "live") { gatewayDiagnostics.recordCache("passthrough"); target = await injectGatewayCredentials(target, ionReferer); return proxyUncachedRequest(request, response, target, "live-pass-through", ionReferer); } const cached = await getCachedEntry(store, cacheKey); const fresh = cached && cached.expiresAt > Date.now(); // A refresh can have a previously published object at this key while its // replacement is still streaming into the private temp file. Treat every // request that arrives during that window as a follower: otherwise a normal // cache-first request could race the atomic rename and receive the old // object immediately after the refresh leader received the new one. const activeFill = inflightWrites.get(`${store.name}:${cacheKey}`); if (activeFill) { try { const entry = await activeFill.commit; const state = cached ? "live-cache-hit" : (forceRefresh ? "live-refresh-record" : "live-record"); return serveCachedFile(request, response, store, entry, state); } catch (error) { if (cached) { gatewayDiagnostics.recordCache("staleFallback"); return serveCachedFile(request, response, store, cached, "live-stale-upstream-error"); } throw error; } } // A migrated Engine cache is a local sandbox snapshot, not an instruction to // fetch arbitrary historic providers when an object is missing. The profile // is selected by the visual adapter; the browser can never turn it into a // new upstream fetch by changing a setting. if (cacheProfile === "offline" || config.mode === "offline") { if (cached) return serveCachedFile(request, response, store, cached, fresh ? `${store.name}-offline-hit` : `${store.name}-offline-stale`); return writeJson(response, 504, { ok: false, error: cacheProfile === "offline" ? "map_offline_snapshot_miss" : "map_cache_offline_miss" }); } if (isLegacyCacheHost) { if (cached) return serveCachedFile(request, response, store, cached, fresh ? "legacy-cache-hit" : "legacy-cache-stale"); return writeJson(response, 504, { ok: false, error: "map_legacy_cache_miss" }); } // The product setting "Не перезаписывать cache" is cache-first by // definition: a collected tile is served from the shared persistent volume // immediately, while only a miss travels through the official live // provider. `nodedc_cache_refresh=1` is the explicit opt-in to replace a // previously collected object. if (cached && !forceRefresh) { gatewayDiagnostics.recordCache(fresh ? "hit" : "staleHit"); return serveCachedFile(request, response, store, cached, fresh ? "live-cache-hit" : "live-cache-stale"); } // Credentials are needed only for a real upstream request. Cache lookup uses // the credential-free canonical URL, so a warm TileCache remains independent // from Ion token refresh and from the AMD/VPN route. target = await injectGatewayCredentials(target, ionReferer); if (config.mode === "readonly") { gatewayDiagnostics.recordCache("readonlyPassThrough"); return proxyUncachedRequest(request, response, target, "pass-through-readonly", ionReferer); } if (String(request.headers.range || "").trim()) { gatewayDiagnostics.recordCache(cached ? "rangeRefresh" : "rangeMiss"); return proxyRangeRequest(request, response, target, cached, store, ionReferer); } try { gatewayDiagnostics.recordCache(forceRefresh && cached ? "refresh" : "miss"); // Keep the await inside this try block: setup failures (notably an explicit // refresh whose provider is temporarily unavailable) must reach the stale // fallback below instead of bypassing it as an unobserved returned promise. return await streamAndCacheMiss( request, response, target, cacheKey, store, forceRefresh ? "live-refresh-record" : "live-record", ionReferer, ); } catch (error) { if (isCacheCapacityError(error)) return proxyUncachedRequest(request, response, target, "live-pass-through-cache-full", ionReferer); if (cached) { gatewayDiagnostics.recordCache("staleFallback"); return serveCachedFile(request, response, store, cached, "live-stale-upstream-error"); } throw error; } } function isOfflineProviderAllowed(rawTarget) { if (!config.offlineProviderAllowlist.size) return false; try { const target = new URL(rawTarget); return config.offlineProviderAllowlist.has(target.hostname.toLowerCase()); } catch { return false; } } function validateUpstream(rawTarget) { if (rawTarget.length > 8192) throw gatewayError("map_cache_url_too_long", 400); let target; try { target = new URL(rawTarget); } catch { throw gatewayError("invalid_map_cache_url", 400); } const hostname = target.hostname.toLowerCase(); // Bing's metadata endpoint still returns HTTP tile templates. They are not // allowed through as HTTP: upgrade only the known Bing tile hosts before // the standard HTTPS and allowlist checks. This keeps the browser and the // Gateway on encrypted transport while making the provider contract work. if (target.protocol === "http:" && /^(dev\.virtualearth\.net|ecn\.t[0-3]\.tiles\.virtualearth\.net|ecn\.tiles\.virtualearth\.net)$/.test(hostname)) { target.protocol = "https:"; } const testLoopbackHttp = config.testAllowHttpLoopback && target.protocol === "http:" && ["127.0.0.1", "localhost", "::1"].includes(hostname); if (target.protocol !== "https:" && !testLoopbackHttp) throw gatewayError("map_cache_https_required", 400); if (target.username || target.password || (!config.upstreamAllowlist.has(hostname) && !config.legacyCacheHosts.has(hostname))) { throw gatewayError("map_cache_upstream_not_allowed", 403); } return target; } function canonicalCacheUrl(target) { const url = stripCredentialQueryParameters(target); // The old Engine snapshot distributes identical Bing tile semantics across // t0…t3. A viewer can choose a different subdomain for the same quadkey, so // use one logical host for the cache key while preserving the original URL // only as provenance in the imported index entry. if (/^ecn\.t[0-3]\.tiles\.virtualearth\.net$/i.test(url.hostname)) { url.hostname = "ecn.tiles.virtualearth.net"; } url.searchParams.sort(); return url.toString(); } async function streamAndCacheMiss(request, response, target, cacheKey, store, cacheState, ionReferer = "") { const inflightKey = `${store.name}:${cacheKey}`; const existing = inflightWrites.get(inflightKey); if (existing) { const entry = await existing.commit; return serveCachedFile(request, response, store, entry, cacheState); } const setup = prepareStreamingCacheFill(target, cacheKey, store, ionReferer); const record = { setup, commit: null }; record.commit = setup.then(({ cacheCommit }) => cacheCommit); inflightWrites.set(inflightKey, record); // Cache fill is intentionally server-owned once started. A browser can stop // consuming its tee branch without cancelling the upstream/disk branch used // by concurrent viewers and future offline hits. record.commit.catch((error) => { const errorCode = safeDiagnosticCode(error?.message || "map_cache_fill_failed"); gatewayDiagnostics.recordGatewayFailure(errorCode); console.warn(JSON.stringify({ event: "map_cache_fill_failed", error: errorCode })); }); record.commit.finally(() => { if (inflightWrites.get(inflightKey) === record) inflightWrites.delete(inflightKey); }).catch(() => undefined); const prepared = await setup; if (request.method === "HEAD") { await prepared.clientBody.cancel().catch(() => undefined); const entry = await record.commit; return serveCachedFile(request, response, store, entry, cacheState); } if (request.aborted || response.destroyed) { await prepared.clientBody.cancel().catch(() => undefined); return; } copyUpstreamHeaders(response, prepared.headers); response.setHeader("x-nodedc-map-cache", cacheState); response.writeHead(prepared.status); try { await pipeline(Readable.fromWeb(prepared.clientBody), response); } catch (error) { if (request.aborted || response.destroyed || response.writableEnded) return; throw error; } } async function prepareStreamingCacheFill(target, cacheKey, store, ionReferer = "") { const upstream = await fetchWithTimeout(target, { headers: { accept: "application/json, application/octet-stream, image/*, */*;q=0.5", ...ionRefererHeaders(ionReferer) }, }, { operation: "cache-fill" }); if (!upstream.ok || !upstream.body) { await upstream.body?.cancel().catch(() => undefined); throw gatewayError("map_upstream_unavailable", upstream.status || 502); } const expectedBytes = Number.parseInt(upstream.headers.get("content-length") || "", 10); if (Number.isFinite(expectedBytes) && expectedBytes > config.maxObjectBytes) { await upstream.body.cancel().catch(() => undefined); throw gatewayError("map_cache_object_too_large", 413); } const [clientBody, cacheBody] = upstream.body.tee(); const cacheCommit = commitCacheBody(cacheBody, upstream.headers, cacheKey, store); return { status: upstream.status, headers: upstream.headers, clientBody, cacheCommit }; } async function commitCacheBody(cacheBody, upstreamHeaders, cacheKey, store) { const relativePath = join("objects", cacheKey.slice(0, 2), `${cacheKey}.bin`); const filePath = join(store.dir, relativePath); const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; await mkdir(dirname(filePath), { recursive: true }); let bytes = 0; const limit = new Transform({ transform(chunk, _encoding, callback) { bytes += chunk.length; if (bytes > config.maxObjectBytes) return callback(gatewayError("map_cache_object_too_large", 413)); callback(null, chunk); }, }); let reservationHeld = false; try { await pipeline(Readable.fromWeb(cacheBody), limit, createWriteStream(tempPath, { flags: "wx" })); const replacedBytes = Number(store.index.entries[cacheKey]?.bytes || 0); if (!(await reserveCacheCapacity(store, bytes, replacedBytes))) throw cacheCapacityError(); reservationHeld = true; await rename(tempPath, filePath); const now = Date.now(); const entry = { key: cacheKey, file: relativePath, bytes, contentType: upstreamHeaders.get("content-type") || "application/octet-stream", etag: upstreamHeaders.get("etag") || null, savedAt: now, lastAccessAt: now, expiresAt: now + responseTtl(upstreamHeaders.get("cache-control")), }; store.index.entries[cacheKey] = entry; await releaseCacheCapacity(store, bytes); reservationHeld = false; await writeCacheIndex(store); return entry; } catch (error) { if (reservationHeld) await releaseCacheCapacity(store, bytes); await rm(tempPath, { force: true }); throw error; } } function responseTtl(cacheControl) { const match = String(cacheControl || "").match(/max-age=(\d+)/i); if (!match) return config.defaultTtlMs; return Math.min(Number.parseInt(match[1], 10) * 1000, config.defaultTtlMs); } async function proxyRangeRequest(request, response, target, cached, store, ionReferer = "") { const upstream = await fetchWithTimeout(target, { headers: { range: String(request.headers.range), accept: "*/*", ...ionRefererHeaders(ionReferer) }, }, { operation: "range" }); if (!upstream.ok || !upstream.body) { if (cached) return serveCachedFile(request, response, store, cached, "stale-range-error"); throw gatewayError("map_upstream_range_unavailable", upstream.status || 502); } copyUpstreamHeaders(response, upstream.headers); response.setHeader("x-nodedc-map-cache", "range-pass-through"); response.writeHead(upstream.status); await pipeline(Readable.fromWeb(upstream.body), response); } async function proxyUncachedRequest(request, response, target, cacheState, ionReferer = "") { const upstream = await fetchWithTimeout(target, { headers: { accept: String(request.headers.accept || "application/json, application/octet-stream, image/*, */*;q=0.5"), ...(request.headers.range ? { range: String(request.headers.range) } : {}), ...ionRefererHeaders(ionReferer), }, }, { operation: "passthrough" }); if (!upstream.ok || !upstream.body) throw gatewayError("map_upstream_unavailable", upstream.status || 502); copyUpstreamHeaders(response, upstream.headers); response.setHeader("x-nodedc-map-cache", cacheState); response.writeHead(upstream.status); if (request.method === "HEAD") return response.end(); await pipeline(Readable.fromWeb(upstream.body), response); } async function serveCachedFile(request, response, store, entry, state) { const filePath = join(store.dir, entry.file); const info = await stat(filePath); const range = parseRange(request.headers.range, info.size); const etag = cacheEntityTag(entry, info.size); response.setHeader("content-type", entry.contentType); response.setHeader("accept-ranges", "bytes"); response.setHeader("cache-control", "public, max-age=60"); response.setHeader("etag", etag); response.setHeader("x-nodedc-map-cache", state); response.setHeader("x-nodedc-map-cache-age", String(Math.max(0, Math.round((Date.now() - entry.savedAt) / 1000)))); // This store has append-only/no-eviction policy. A hit therefore has no // persistent metadata to update: rewriting the complete JSON index for every // 20 KB tile made warm views slower than the provider itself. if (ifNoneMatchMatches(request.headers["if-none-match"], etag)) { response.writeHead(304); return response.end(); } if (!range) { response.setHeader("content-length", info.size); response.writeHead(200); if (request.method === "HEAD") return response.end(); return pipeline(createReadStream(filePath), response); } response.setHeader("content-length", range.end - range.start + 1); response.setHeader("content-range", `bytes ${range.start}-${range.end}/${info.size}`); response.writeHead(206); if (request.method === "HEAD") return response.end(); return pipeline(createReadStream(filePath, range), response); } function cacheEntityTag(entry, size) { const providerTag = String(entry.etag || "").trim(); if (providerTag.length <= 1024 && /^(?:W\/)?"[^"\r\n]*"$/.test(providerTag)) return providerTag; const key = /^[a-f0-9]{64}$/i.test(String(entry.key || "")) ? String(entry.key).slice(0, 32).toLowerCase() : "object"; const revision = Number.isFinite(Number(entry.savedAt)) ? Math.max(0, Number(entry.savedAt)).toString(36) : "0"; return `"nodedc-${key}-${Number(size).toString(16)}-${revision}"`; } function ifNoneMatchMatches(rawHeader, etag) { const raw = String(rawHeader || ""); if (!raw || raw.length > 8192) return false; const expected = etag.replace(/^W\//, ""); return raw.split(",").some((part) => { const candidate = part.trim(); return candidate === "*" || candidate.replace(/^W\//, "") === expected; }); } function parseRange(header, size) { if (!header) return null; const match = String(header).match(/^bytes=(\d*)-(\d*)$/); if (!match) return null; const start = match[1] ? Number.parseInt(match[1], 10) : 0; const end = match[2] ? Number.parseInt(match[2], 10) : size - 1; if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= size) return null; return { start, end }; } async function getCachedEntry(store, cacheKey) { const entry = store.index.entries[cacheKey]; if (!entry) return null; try { const info = await stat(join(store.dir, entry.file)); if (!info.isFile()) throw new Error("not_a_file"); return entry; } catch { if (store.mutable) { delete store.index.entries[cacheKey]; await writeCacheIndex(store); } return null; } } function createCacheStore(name, dir, mutable) { return { name, dir, mutable, objectsDir: join(dir, "objects"), ionEndpointsDir: join(dir, "ion-endpoints"), indexPath: join(dir, "index.json"), index: { version: 1, entries: {} }, indexRevision: 0, persistedIndexRevision: 0, indexWriteScheduled: false, indexWriteRunning: false, indexWriteWaiters: [], reservedBytes: 0, capacityQueue: Promise.resolve(), }; } async function initialiseCacheStore(store) { if (store.mutable) { await mkdir(store.objectsDir, { recursive: true }); await mkdir(store.ionEndpointsDir, { recursive: true }); } store.index = await readCacheIndex(store); } async function readCacheIndex(store) { try { const parsed = JSON.parse(await readFile(store.indexPath, "utf8")); if (parsed?.version === 1 && parsed.entries && typeof parsed.entries === "object") return parsed; } catch (error) { if (error?.code !== "ENOENT") console.warn("Map cache index ignored: invalid file"); } return { version: 1, entries: {} }; } function ionEndpointPath(assetId) { return join(liveCache.ionEndpointsDir, `${assetId}.json`); } async function readIonEndpointCache(assetId) { try { const value = JSON.parse(await readFile(ionEndpointPath(assetId), "utf8")); if (value?.assetId === assetId && ((value?.url && value?.accessToken) || (value?.externalType === "BING" && value?.options?.url && value?.options?.key))) return value; } catch (error) { if (error?.code !== "ENOENT") console.warn("Ion endpoint cache ignored: invalid file"); } return null; } async function writeIonEndpointCache(endpoint) { const target = ionEndpointPath(endpoint.assetId); const temp = `${target}.${process.pid}.tmp`; await writeFile(temp, `${JSON.stringify(endpoint)}\n`, { encoding: "utf8", mode: 0o600 }); await rename(temp, target); } function rememberIonEndpoint(endpoint) { if (endpoint?.assetId === undefined) return; const assetId = String(endpoint.assetId); const current = activeIonEndpoints.get(assetId); if (!current || Number(endpoint.savedAt || 0) >= Number(current.savedAt || 0)) activeIonEndpoints.set(assetId, endpoint); } function ionEndpointCacheState(endpoint, now = Date.now()) { const savedAt = Number(endpoint?.savedAt || 0); const credentialExpiresAt = ionEndpointCredentialExpiry(endpoint); const usable = credentialExpiresAt === null || now < credentialExpiresAt; const ttlRefreshAt = savedAt > 0 ? savedAt + Math.max(0, config.ionEndpointTtlMs - config.ionEndpointRefreshAheadMs) : 0; const credentialRefreshAt = credentialExpiresAt === null ? Number.POSITIVE_INFINITY : Math.max(0, credentialExpiresAt - config.ionEndpointRefreshAheadMs); return { usable, needsRefresh: !savedAt || now >= Math.min(ttlRefreshAt, credentialRefreshAt), credentialExpiresAt, }; } function ionEndpointCredentialExpiry(endpoint) { if (endpoint?.externalType === "BING") return null; const token = String(endpoint?.accessToken || ""); const parts = token.split("."); if (parts.length !== 3 || !parts[1]) return null; try { const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")); const seconds = Number(payload?.exp); return Number.isFinite(seconds) && seconds > 0 ? Math.floor(seconds * 1000) : null; } catch { return null; } } function ionEndpointUrl(endpoint) { return endpoint?.externalType === "BING" ? endpoint.options?.url : endpoint?.url; } function publicIonEndpoint(endpoint) { const shared = { assetId: endpoint.assetId, type: endpoint.type, attributions: Array.isArray(endpoint.attributions) ? endpoint.attributions : [], credentialMode: "gateway", }; if (endpoint.externalType === "BING") { return { ...shared, externalType: "BING", options: { url: publicProviderUrl(endpoint.options?.url), mapStyle: String(endpoint.options?.mapStyle || "Aerial"), }, }; } return { ...shared, url: publicProviderUrl(endpoint.url) }; } function publicProviderUrl(rawUrl) { return stripCredentialQueryParameters(validateUpstream(String(rawUrl || ""))).toString(); } function stripCredentialQueryParameters(target) { const sanitized = new URL(target.toString()); for (const key of [...sanitized.searchParams.keys()]) { if (credentialQueryKeys.has(key.toLowerCase())) sanitized.searchParams.delete(key); } return sanitized; } async function injectGatewayCredentials(target, ionReferer = "") { await ionTokenWriteQueue; const upstream = stripCredentialQueryParameters(target); const endpoints = await knownIonEndpoints(); let matchedIon = endpoints .filter((endpoint) => endpoint.externalType !== "BING" && endpoint.accessToken) .map((endpoint) => ({ endpoint, scopeLength: endpointResourceScopeLength(upstream, endpoint.url) })) .filter(({ scopeLength }) => scopeLength >= 0) .sort((left, right) => right.scopeLength - left.scopeLength)[0]?.endpoint; if (matchedIon) { const state = ionEndpointCacheState(matchedIon); if (!state.usable) { const refreshed = await refreshIonEndpoint(String(matchedIon.assetId), ionReferer); if (endpointResourceScopeLength(upstream, refreshed.url) < 0) throw gatewayError("cesium_ion_endpoint_scope_changed", 409); matchedIon = refreshed; } else if (state.needsRefresh) { scheduleIonEndpointRefresh(String(matchedIon.assetId), ionReferer); } } if (matchedIon) upstream.searchParams.set("access_token", matchedIon.accessToken); const bing = endpoints.find((endpoint) => endpoint.externalType === "BING" && endpoint.options?.key); if (bing && isBingResource(upstream)) upstream.searchParams.set("key", bing.options.key); return upstream; } async function knownIonEndpoints() { for (const assetId of config.assetAllowlist) { if (activeIonEndpoints.has(assetId)) continue; const cached = await readIonEndpointCache(assetId); if (cached) rememberIonEndpoint(cached); } return [...activeIonEndpoints.values()]; } function endpointResourceScopeLength(target, endpointUrl) { if (!endpointUrl) return -1; try { const endpoint = new URL(endpointUrl); if (target.origin !== endpoint.origin) return -1; if (target.pathname === endpoint.pathname) return 1_000_000 + endpoint.pathname.length; // Ion may return a root file such as `tileset.json`; its child URIs resolve // beside that file, not below `tileset.json/`. The directory is trusted only // because this endpoint and credential came from the private Ion response. // Never broaden a root-level file into an origin-wide credential scope. const directoryPath = new URL(".", endpoint).pathname; if (directoryPath === "/" || !target.pathname.startsWith(directoryPath)) return -1; return directoryPath.length; } catch { return -1; } } function isBingResource(target) { return /(^|\.)virtualearth\.net$/i.test(target.hostname); } function writeCacheIndex(store) { if (!store.mutable) return Promise.resolve(); const revision = ++store.indexRevision; const durable = new Promise((resolve, reject) => store.indexWriteWaiters.push({ revision, resolve, reject })); scheduleCacheIndexWrite(store); return durable; } function scheduleCacheIndexWrite(store) { if (store.indexWriteScheduled || store.indexWriteRunning) return; store.indexWriteScheduled = true; // Batch a short burst of neighbouring I/O completions. A Cesium viewport can // finish dozens of distinct tiles together; serialising the full index once // per tile is unnecessary because one later snapshot contains every entry. setTimeout(() => { store.indexWriteScheduled = false; void flushCacheIndex(store); }, 10); } async function flushCacheIndex(store) { if (store.indexWriteRunning) return; store.indexWriteRunning = true; try { while (store.persistedIndexRevision < store.indexRevision) { const snapshotRevision = store.indexRevision; const snapshot = `${JSON.stringify(store.index)}\n`; const tempPath = `${store.indexPath}.${process.pid}.${snapshotRevision}.tmp`; await writeFile(tempPath, snapshot, "utf8"); await rename(tempPath, store.indexPath); store.persistedIndexRevision = snapshotRevision; gatewayDiagnostics.recordIndexSnapshot(); settleIndexWaiters(store); } } catch (error) { console.warn("Map cache index write failed", error instanceof Error ? error.message : "unknown"); const waiters = store.indexWriteWaiters.splice(0); for (const waiter of waiters) waiter.reject(error); } finally { store.indexWriteRunning = false; if (store.indexWriteWaiters.length && store.persistedIndexRevision < store.indexRevision) scheduleCacheIndexWrite(store); } } function settleIndexWaiters(store) { const pending = []; for (const waiter of store.indexWriteWaiters) { if (waiter.revision <= store.persistedIndexRevision) waiter.resolve(); else pending.push(waiter); } store.indexWriteWaiters = pending; } function cacheBytes(store) { return Object.values(store.index.entries).reduce((sum, entry) => sum + Number(entry.bytes || 0), 0); } function reserveCacheCapacity(store, bytes, replacedBytes = 0) { const action = store.capacityQueue.then(() => { const used = Math.max(0, cacheBytes(store) - replacedBytes) + store.reservedBytes; if (used + bytes > config.maxCacheBytes) return false; store.reservedBytes += bytes; return true; }); store.capacityQueue = action.then(() => undefined, () => undefined); return action; } function releaseCacheCapacity(store, bytes) { const action = store.capacityQueue.then(() => { store.reservedBytes = Math.max(0, store.reservedBytes - bytes); }); store.capacityQueue = action.then(() => undefined, () => undefined); return action; } function cacheCapacityError() { return gatewayError("map_cache_capacity_reached", 507); } function isCacheCapacityError(error) { return error?.message === "map_cache_capacity_reached"; } async function cacheStats(store) { const entries = Object.values(store.index.entries); const bytes = cacheBytes(store); return { name: store.name, mode: store.mutable ? config.mode : "readonly", writePolicy: store.mutable ? "append-only-no-eviction" : "readonly", entries: entries.length, bytes, maxBytes: store.mutable ? config.maxCacheBytes : null, atCapacity: store.mutable ? bytes >= config.maxCacheBytes : false, persistent: true, }; } function shouldUseCesiumEgress(target) { if (!config.mapEgress.url) return false; try { return config.cesiumEgressHosts.has(new URL(String(target)).hostname.toLowerCase()); } catch { return false; } } function throughCesiumEgress(target, options = {}) { const upstreamUrl = new URL(String(target)); const proxyUrl = new URL("proxy/cesium/fetch", config.mapEgress.url); proxyUrl.searchParams.set("url", upstreamUrl.toString()); const headers = new Headers(options.headers || {}); const authorization = headers.get("authorization"); const referer = headers.get("referer"); headers.delete("authorization"); headers.delete("referer"); headers.set("x-proxy-token", config.mapEgress.proxyToken); if (authorization) headers.set("x-nodedc-cesium-authorization", authorization); if (referer) headers.set("x-nodedc-map-referer", referer); return { target: proxyUrl, options: { ...options, headers } }; } async function fetchWithTimeout(target, options = {}, diagnostic = {}) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), config.upstreamTimeoutMs); const viaEgress = shouldUseCesiumEgress(target); const provider = safeProviderName(target); const operation = safeProviderOperation(diagnostic.operation); const assetId = /^\d{1,20}$/.test(String(diagnostic.assetId || "")) ? String(diagnostic.assetId) : undefined; const request = viaEgress ? throughCesiumEgress(target, options) : { target, options }; const startedAt = Date.now(); try { const response = await fetch(request.target, { ...request.options, signal: controller.signal, redirect: "follow" }); const egressError = safeEgressErrorCode(response.headers.get("x-nodedc-egress-error")); // The restricted egress emits a short code instead of a target URL or a // secret. Preserve it through Map Gateway so the operator can distinguish // an AMD/VPN timeout from a real Cesium provider response. if (egressError) { await response.body?.cancel().catch(() => undefined); throw gatewayError(`map_egress_${egressError}`, response.status || 502); } const durationMs = Date.now() - startedAt; gatewayDiagnostics.recordUpstream({ viaEgress, status: response.status, durationMs, error: "", }); if (response.status >= 400) { console.warn(JSON.stringify({ event: "map_provider_http_failure", provider, operation, ...(assetId ? { assetId } : {}), status: response.status, viaEgress, durationMs, })); } return response; } catch (error) { const errorCode = error?.message === "map_egress_amd_connector_timeout" || error?.message === "map_egress_amd_upstream_tls_timeout" ? error.message : error?.name === "AbortError" ? "map_upstream_timeout" : error?.message?.startsWith?.("map_egress_") ? error.message : "map_upstream_fetch_failed"; const durationMs = Date.now() - startedAt; gatewayDiagnostics.recordUpstream({ viaEgress, status: Number(error?.statusCode || 502), durationMs, error: errorCode, }); console.warn(JSON.stringify({ event: "map_provider_transport_failure", provider, operation, ...(assetId ? { assetId } : {}), error: safeDiagnosticCode(errorCode), viaEgress, durationMs, })); throw gatewayError(errorCode, Number(error?.statusCode || 502)); } finally { clearTimeout(timeout); } } function copyUpstreamHeaders(response, headers) { // Node's fetch transparently decodes a compressed upstream body, but retains // the upstream Content-Length header. Forwarding that encoded byte length // alongside the decoded stream truncates JSON (notably Cesium layer.json) // in the browser. Cached responses calculate their own exact length; live // pass-through responses intentionally use HTTP chunking instead. for (const name of ["content-type", "content-range", "accept-ranges", "etag", "last-modified"]) { const value = headers.get(name); if (value) response.setHeader(name, value); } } function writeJson(response, status, value) { response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); response.end(JSON.stringify(value)); } function gatewayError(message, statusCode) { const error = new Error(message); error.statusCode = statusCode; return error; } function createGatewayDiagnostics(slowUpstreamMs) { const metrics = { cacheHits: 0, cacheStaleHits: 0, cacheMisses: 0, cacheRefreshes: 0, cachePassthroughs: 0, cacheStaleFallbacks: 0, upstreamRequests: 0, egressRequests: 0, upstreamFailures: 0, slowUpstreamRequests: 0, indexSnapshots: 0, lastFailure: null, lastFailureAt: null, }; const recordFailure = (error) => { if (!error) return; metrics.lastFailure = safeDiagnosticCode(error); metrics.lastFailureAt = new Date().toISOString(); }; return { recordCache(state) { if (state === "hit") metrics.cacheHits += 1; else if (state === "staleHit") metrics.cacheStaleHits += 1; else if (state === "refresh") metrics.cacheRefreshes += 1; else if (state === "passthrough" || state === "readonlyPassThrough" || state === "rangeMiss" || state === "rangeRefresh") metrics.cachePassthroughs += 1; else if (state === "staleFallback") metrics.cacheStaleFallbacks += 1; else metrics.cacheMisses += 1; }, recordUpstream({ viaEgress, status, durationMs, error }) { metrics.upstreamRequests += 1; if (viaEgress) metrics.egressRequests += 1; if (durationMs >= slowUpstreamMs) metrics.slowUpstreamRequests += 1; if (Number(status) >= 400 || error) { metrics.upstreamFailures += 1; recordFailure(error || `upstream_http_${status}`); } }, recordGatewayFailure(error) { recordFailure(error); }, recordIndexSnapshot() { metrics.indexSnapshots += 1; }, status() { return { ...metrics }; }, }; } function safeEgressErrorCode(value) { const code = String(value || "").trim(); return /^amd_[a-z0-9_.:-]{1,100}$/.test(code) ? code : ""; } function safeDiagnosticCode(value) { return String(value || "map_gateway_error").replace(/[^A-Za-z0-9_.:-]/g, "_").slice(0, 120) || "map_gateway_error"; } function safeProviderName(target) { try { const host = new URL(String(target)).hostname.toLowerCase(); if (host === "api.cesium.com") return "cesium-ion-api"; if (host === "assets.ion.cesium.com") return "cesium-ion-assets"; if (/(^|\.)virtualearth\.net$/.test(host)) return "bing-imagery"; return "allowlisted-provider"; } catch { return "unknown-provider"; } } function safeProviderOperation(value) { const operation = String(value || "resource").trim().toLowerCase(); return /^(?:resource|ion-endpoint|cache-fill|range|passthrough)$/.test(operation) ? operation : "resource"; }