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

1524 lines
67 KiB
JavaScript

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 {
MAP_REFERENCE_SEARCH_SCHEMA,
TRANSPORT_STATION_PROFILE_ID,
createReferenceStationSource,
} from "./reference-station-source.mjs";
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 referenceStationSource = await createReferenceStationSource({
seedFile: config.referenceStationSeedFile,
cacheDir: config.cacheDir,
fetchEnabled: config.referenceStationFetchEnabled,
overpassApiBase: config.referenceStationOverpassUrl,
cellDegrees: config.referenceStationCellDegrees,
timeoutMs: config.upstreamTimeoutMs,
});
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 === `/api/map/reference-sources/v1/profiles/${TRANSPORT_STATION_PROFILE_ID}/current`
&& ["GET", "HEAD"].includes(request.method || "")) {
return await serveReferenceStationSnapshot(request, response, requestUrl);
}
if (requestUrl.pathname === `/api/map/reference-sources/v1/profiles/${TRANSPORT_STATION_PROFILE_ID}/search`
&& ["GET", "HEAD"].includes(request.method || "")) {
return await serveReferenceStationSearch(request, response, requestUrl);
}
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,
},
referenceSources: {
transportStations: await referenceStationSource.status(),
},
});
}
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)`);
console.log(`Map reference source: ${TRANSPORT_STATION_PROFILE_ID}`);
});
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 serveReferenceStationSnapshot(request, response, requestUrl) {
const rawBbox = requestUrl.searchParams.get("bbox");
const bbox = rawBbox ? rawBbox.split(",").map(Number) : undefined;
const snapshot = await referenceStationSource.snapshot({ bbox });
const etag = `"sha256:${snapshot.contentDigest}"`;
if (request.headers["if-none-match"] === etag) {
response.writeHead(304, { ETag: etag, "Cache-Control": "private, max-age=300" });
return response.end();
}
const body = Buffer.from(`${JSON.stringify(snapshot)}\n`);
response.writeHead(200, {
"Content-Type": "application/json; charset=utf-8",
"Content-Length": body.byteLength,
"Cache-Control": "private, max-age=300",
ETag: etag,
"X-Content-Type-Options": "nosniff",
});
return response.end(request.method === "HEAD" ? undefined : body);
}
async function serveReferenceStationSearch(request, response, requestUrl) {
const search = await referenceStationSource.search({
query: requestUrl.searchParams.get("q"),
limit: requestUrl.searchParams.get("limit"),
});
if (search.schemaVersion !== MAP_REFERENCE_SEARCH_SCHEMA) {
throw new Error("reference_station_search_contract_invalid");
}
const etag = `"sha256:${search.contentDigest}"`;
if (request.headers["if-none-match"] === etag) {
response.writeHead(304, { ETag: etag, "Cache-Control": "private, max-age=300" });
return response.end();
}
const body = Buffer.from(`${JSON.stringify(search)}\n`);
response.writeHead(200, {
"Content-Type": "application/json; charset=utf-8",
"Content-Length": body.byteLength,
"Cache-Control": "private, max-age=300",
ETag: etag,
"X-Content-Type-Options": "nosniff",
});
return response.end(request.method === "HEAD" ? undefined : 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 || "")),
referenceStationSeedFile: String(
process.env.MAP_REFERENCE_STATION_SEED_FILE
|| resolve(dirname(fileURLToPath(import.meta.url)), "../reference-sources/transport-stations/moscow-v1.json"),
).trim(),
referenceStationFetchEnabled: parseBoolean(process.env.MAP_REFERENCE_STATION_FETCH_ENABLED, true),
referenceStationCellDegrees: Number(process.env.MAP_REFERENCE_STATION_CELL_DEGREES || 0.5),
referenceStationOverpassUrl: String(process.env.MAP_REFERENCE_STATION_OVERPASS_URL || "https://overpass-api.de/api/interpreter").trim(),
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";
}