NODEDC_PLATFORM/services/map-gateway/src/server.mjs

1447 lines
63 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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";
}