From e527812826acd4d0c227a09dabfd9d7140a0af92 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 17:14:34 +0300 Subject: [PATCH] feat(map): add persistent gateway and ontology package --- README.md | 2 + infra/.env.example | 22 + infra/README.md | 17 + infra/docker-compose.dev.yml | 56 ++ .../ai-workspace-assistant/src/server.mjs | 60 +- services/map-gateway/.dockerignore | 6 + services/map-gateway/Dockerfile | 20 + services/map-gateway/README.md | 87 +++ services/map-gateway/docker-entrypoint.sh | 11 + services/map-gateway/package.json | 15 + .../scripts/audit-engine-cache.mjs | 91 +++ .../scripts/import-engine-cache.mjs | 173 ++++++ services/map-gateway/src/server.mjs | 533 ++++++++++++++++++ services/ontology-core/README.md | 1 + .../catalog/domain-packages/map/aliases.json | 35 ++ .../catalog/domain-packages/map/entities.json | 26 + .../catalog/domain-packages/map/evidence.json | 43 ++ .../domain-packages/map/guardrails.json | 50 ++ .../catalog/domain-packages/map/package.json | 7 + .../domain-packages/map/relations.json | 21 + .../ontology-core/docs/MAP_DOMAIN_ONTOLOGY.md | 56 ++ 21 files changed, 1323 insertions(+), 9 deletions(-) create mode 100644 services/map-gateway/.dockerignore create mode 100644 services/map-gateway/Dockerfile create mode 100644 services/map-gateway/README.md create mode 100644 services/map-gateway/docker-entrypoint.sh create mode 100644 services/map-gateway/package.json create mode 100644 services/map-gateway/scripts/audit-engine-cache.mjs create mode 100644 services/map-gateway/scripts/import-engine-cache.mjs create mode 100644 services/map-gateway/src/server.mjs create mode 100644 services/ontology-core/catalog/domain-packages/map/aliases.json create mode 100644 services/ontology-core/catalog/domain-packages/map/entities.json create mode 100644 services/ontology-core/catalog/domain-packages/map/evidence.json create mode 100644 services/ontology-core/catalog/domain-packages/map/guardrails.json create mode 100644 services/ontology-core/catalog/domain-packages/map/package.json create mode 100644 services/ontology-core/catalog/domain-packages/map/relations.json create mode 100644 services/ontology-core/docs/MAP_DOMAIN_ONTOLOGY.md diff --git a/README.md b/README.md index e285bad..d1af247 100644 --- a/README.md +++ b/README.md @@ -46,3 +46,5 @@ Notification Core живёт в `services/notification-core` как отдель AI Workspace Assistant живёт в `services/ai-workspace-assistant` как общий платформенный слой для пользовательских Codex executors, selected executor, shared conversations, surfaces и tool packs. AI Workspace Hub остаётся отдельным тонким транспортом для remote Codex workers и не хранит смысловое состояние ассистента. Ontology Core живёт в `services/ontology-core` как docs-first семантический слой для canonical entities, relations, aliases, guardrails, evidence, первого resolver MVP между OPS и ENGINE и policy MVP для NDC Core Assistant access. Он не владеет доменными БД HUB/OPS/ENGINE и не заменяет Launcher/HUB roles или OPS Gateway enforcement. + +Map Gateway живёт в `services/map-gateway` как общий platform boundary для provider credentials, безопасного asset endpoint exchange, tile/3D Tiles proxy и persistent offline TileCache. Runtime cache не является source artifact и хранится в named volume/object storage, а не в Git. diff --git a/infra/.env.example b/infra/.env.example index 7f9fd60..e57614f 100644 --- a/infra/.env.example +++ b/infra/.env.example @@ -78,3 +78,25 @@ AI_WORKSPACE_HUB_HOST_BIND=127.0.0.1:18081 AI_WORKSPACE_HUB_PUBLIC_URL=wss://ai-hub.nodedc.ru/api/ai-workspace/hub AI_WORKSPACE_HUB_INTERNAL_URL=https://ai-hub.nodedc.ru AI_WORKSPACE_HUB_FALLBACK_URLS= + +# map gateway — keep the actual ion token only in local/staging environment files or Docker secrets. +# Never copy it into workflow metadata, Git, frontend code, or a runtime cache key. +MAP_GATEWAY_HOST_BIND=127.0.0.1:18103 +MAP_GATEWAY_ALLOW_ANONYMOUS=true +MAP_GATEWAY_CORS_ORIGIN=http://127.0.0.1:3333,http://localhost:3333 +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 +# Imported Engine imagery is selected through the explicit offline cache +# profile, not through a global host-side block. +MAP_GATEWAY_LEGACY_CACHE_HOSTS= +# Leave empty unless the provider/data licence explicitly permits disconnected/offline use. +MAP_GATEWAY_OFFLINE_PROVIDER_ALLOWLIST= +MAP_GATEWAY_UPSTREAM_TIMEOUT_SECONDS=30 +CESIUM_ION_TOKEN= +CESIUM_ION_ASSET_ALLOWLIST=1,2,96188 +MAP_CACHE_MODE=readwrite +# Mutable cache is mounted at /var/lib/nodedc-map-live-cache by compose. +# MAP_OFFLINE_SNAPSHOT_DIR is intentionally wired by compose as a read-only +# imported Engine snapshot; never place secrets or the live cache there. +MAP_CACHE_MAX_MB=20480 +MAP_CACHE_MAX_OBJECT_MB=128 +MAP_CACHE_DEFAULT_TTL_SECONDS=604800 diff --git a/infra/README.md b/infra/README.md index 3f212f1..fe16f66 100644 --- a/infra/README.md +++ b/infra/README.md @@ -6,6 +6,7 @@ - reverse proxy; - локальные домены; - shared env examples; +- Map Gateway с persistent TileCache; - будущие docker compose файлы. Первый local dev слой проксирует текущие локальные приложения без физического переноса репозиториев: @@ -70,10 +71,26 @@ docker compose --env-file infra/.env -f infra/docker-compose.dev.yml ps curl -I -H 'Host: auth.local.nodedc' http://127.0.0.1/ curl -I -H 'Host: launcher.local.nodedc' http://127.0.0.1/ curl -I -H 'Host: task.local.nodedc' http://127.0.0.1/ +curl http://127.0.0.1:18103/healthz ``` Generated Authentik bootstrap credentials are stored only in `infra/.env`. +## Map Gateway и offline TileCache + +`map-gateway` добавлен как общий платформенный сервис на `127.0.0.1:18103`. Его mutable live-cache монтируется как внешний named Docker volume `nodedc-platform_map-live-tile-cache`; offline snapshot — как `nodedc-platform_map-offline-snapshot`. Они не пишутся в `platform/`, не попадают в Git и не включаются в Docker image. + +Оба тома намеренно объявлены `external`: `docker compose down -v` не должен уничтожать уже записанные тайлы. На чистой машине они создаются один раз до первого запуска: + +```bash +docker volume create nodedc-platform_map-live-tile-cache +docker volume create nodedc-platform_map-offline-snapshot +``` + +Очистка кэша — только осознанной командой `docker volume rm nodedc-platform_map-live-tile-cache` при остановленном Gateway. + +Для реального ion terrain/buildings положите `CESIUM_ION_TOKEN` только в неотслеживаемый `infra/.env` или deployment secret. Studio получает asset-scoped endpoint token, а master token остаётся внутри Gateway. Детали API, cache modes и production access boundary описаны в `services/map-gateway/README.md`. + 5. Bootstrap local Authentik groups and OIDC applications: ```bash diff --git a/infra/docker-compose.dev.yml b/infra/docker-compose.dev.yml index aaade14..8be69a9 100644 --- a/infra/docker-compose.dev.yml +++ b/infra/docker-compose.dev.yml @@ -19,6 +19,8 @@ services: condition: service_started ai-workspace-assistant: condition: service_started + map-gateway: + condition: service_healthy extra_hosts: - "host.docker.internal:host-gateway" @@ -183,6 +185,51 @@ services: ports: - "${AI_WORKSPACE_HUB_HOST_BIND:-127.0.0.1:18081}:18081" + map-gateway: + image: nodedc/map-gateway:local + build: + context: ../services/map-gateway + restart: unless-stopped + environment: + NODE_ENV: production + PORT: 18103 + CESIUM_ION_TOKEN: ${CESIUM_ION_TOKEN:-} + CESIUM_ION_ASSET_ALLOWLIST: ${CESIUM_ION_ASSET_ALLOWLIST:-1,2,96188} + MAP_GATEWAY_UPSTREAM_ALLOWLIST: ${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} + # Offline access is selected explicitly with the cache profile, rather + # than treating live Bing tile hosts as legacy providers globally. + MAP_GATEWAY_LEGACY_CACHE_HOSTS: ${MAP_GATEWAY_LEGACY_CACHE_HOSTS:-} + MAP_GATEWAY_OFFLINE_PROVIDER_ALLOWLIST: ${MAP_GATEWAY_OFFLINE_PROVIDER_ALLOWLIST:-} + MAP_GATEWAY_CORS_ORIGIN: ${MAP_GATEWAY_CORS_ORIGIN:-http://127.0.0.1:3333,http://localhost:3333} + MAP_GATEWAY_ALLOW_ANONYMOUS: ${MAP_GATEWAY_ALLOW_ANONYMOUS:-true} + # The mutable live cache and the immutable imported Engine snapshot are + # separate stores. Selecting the offline adapter can never write to or + # fetch through the live store. + MAP_CACHE_DIR: /var/lib/nodedc-map-live-cache + MAP_OFFLINE_SNAPSHOT_DIR: /var/lib/nodedc-map-offline-snapshot + MAP_CACHE_MODE: ${MAP_CACHE_MODE:-readwrite} + MAP_CACHE_MAX_MB: ${MAP_CACHE_MAX_MB:-20480} + MAP_CACHE_MAX_OBJECT_MB: ${MAP_CACHE_MAX_OBJECT_MB:-128} + MAP_CACHE_DEFAULT_TTL_SECONDS: ${MAP_CACHE_DEFAULT_TTL_SECONDS:-604800} + MAP_GATEWAY_UPSTREAM_TIMEOUT_SECONDS: ${MAP_GATEWAY_UPSTREAM_TIMEOUT_SECONDS:-30} + expose: + - "18103" + ports: + - "${MAP_GATEWAY_HOST_BIND:-127.0.0.1:18103}:18103" + volumes: + - map-live-tile-cache:/var/lib/nodedc-map-live-cache + - map-offline-snapshot:/var/lib/nodedc-map-offline-snapshot:ro + healthcheck: + test: + - CMD + - node + - -e + - "fetch('http://127.0.0.1:18103/healthz').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))" + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + volumes: authentik-database: authentik-data: @@ -191,3 +238,12 @@ volumes: ai-workspace-database: caddy-data: caddy-config: + # Cache data is runtime state, not Compose lifecycle state. These volumes are + # created once by Platform bootstrap and deliberately survive `down -v`. + # Removing them requires an explicit `docker volume rm` operation. + map-live-tile-cache: + external: true + name: ${NODEDC_MAP_LIVE_CACHE_VOLUME:-nodedc-platform_map-live-tile-cache} + map-offline-snapshot: + external: true + name: ${NODEDC_MAP_OFFLINE_SNAPSHOT_VOLUME:-nodedc-platform_map-offline-snapshot} diff --git a/services/ai-workspace-assistant/src/server.mjs b/services/ai-workspace-assistant/src/server.mjs index a605d76..fc1801e 100644 --- a/services/ai-workspace-assistant/src/server.mjs +++ b/services/ai-workspace-assistant/src/server.mjs @@ -271,8 +271,9 @@ app.post("/api/ai-workspace/assistant/v1/actions", requireInternalApi, asyncRout app.get("/api/ai-workspace/assistant/v1/executors", requireInternalApi, asyncRoute(async (req, res) => { const owner = getRequestOwner(req); + const visibility = sanitizeExecutorVisibility(req.query.visibility || req.query.executorVisibility || req.query.executor_visibility || "interactive"); const [executors, settings] = await Promise.all([ - listExecutors(owner), + listExecutors(owner, { visibility }), getOwnerSettings(owner), ]); res.json({ ok: true, owner: publicOwner(owner), selectedExecutorId: settings.selectedExecutorId, settings: publicOwnerSettings(settings), executors }); @@ -692,7 +693,17 @@ httpServer.listen(config.port, "0.0.0.0", () => { process.on("SIGTERM", shutdown); process.on("SIGINT", shutdown); -async function listExecutors(owner) { +function executorVisibility(executor) { + const metadata = isPlainObject(executor?.metadata) ? executor.metadata : {}; + const explicitVisibility = normalizeKey(metadata.visibility || metadata.executorVisibility); + if (explicitVisibility === "service" || explicitVisibility === "interactive") return explicitVisibility; + const appId = normalizeKey(metadata.appId || metadata.app_id); + const modeId = normalizeKey(metadata.modeId || metadata.mode_id); + if (appId === "seo-mode" || modeId === "seo-model-task") return "service"; + return "interactive"; +} + +async function listExecutors(owner, { visibility = "all" } = {}) { const result = await pool.query( `select * from ai_workspace_executors @@ -700,7 +711,10 @@ async function listExecutors(owner) { order by updated_at desc, created_at desc`, [owner.key] ); - return Promise.all(result.rows.map((row) => decorateExecutorLiveStatus(toExecutor(row)))); + const executors = result.rows.map(toExecutor).filter((executor) => ( + visibility === "all" || executorVisibility(executor) === visibility + )); + return Promise.all(executors.map(decorateExecutorLiveStatus)); } async function decorateExecutorLiveStatus(executor) { @@ -1557,7 +1571,11 @@ async function readExecutorEvents(executor, options = {}) { 10000 ); return { - events: Array.isArray(payload.events) ? payload.events.map(cleanBridgeEventForUi) : [], + events: Array.isArray(payload.events) + ? payload.events.map((event) => cleanBridgeEventForUi(event, { + preserveLegacyCodexOutput: options.preserveLegacyCodexOutput === true, + })) + : [], latestEventId: Number(payload.latestEventId || 0), online: payload.online === true, }; @@ -1898,6 +1916,7 @@ async function mirrorBridgeRunOnce(state) { const payload = await readExecutorEvents(state.executor, { since: state.latestEventId, limit: 500, + preserveLegacyCodexOutput: true, }); state.latestEventId = Math.max(state.latestEventId, Number(payload.latestEventId || 0)); const nextEvents = (Array.isArray(payload.events) ? payload.events : []) @@ -3081,7 +3100,7 @@ function bridgeCompatibilityStatus(health, agent) { }; } -function cleanBridgeEventForUi(event) { +function cleanBridgeEventForUi(event, options = {}) { if (!isPlainObject(event)) return event; const kind = optionalString(event.kind) || ""; const text = optionalString(event.text || event.message) || ""; @@ -3093,7 +3112,9 @@ function cleanBridgeEventForUi(event) { }; } if (kind !== "stdout" && kind !== "stderr") return event; - const cleaned = summarizeLegacyCodexEventText(text); + const cleaned = summarizeLegacyCodexEventText(text, { + preserveCodexOutput: options.preserveLegacyCodexOutput === true, + }); if (!cleaned || cleaned === text) return event; return { ...event, @@ -3103,12 +3124,14 @@ function cleanBridgeEventForUi(event) { }; } -function summarizeLegacyCodexEventText(value) { +function summarizeLegacyCodexEventText(value, options = {}) { const text = optionalString(value) || ""; if (!text) return ""; if (/^codex\s*\n/i.test(text)) { const answer = text.replace(/^codex\s*\n/i, "").trim(); - return answer ? `Codex emitted assistant output: ${answer.slice(0, 1200)}` : "Codex emitted assistant output."; + if (!answer) return "Codex emitted assistant output."; + const maxLength = options.preserveCodexOutput ? 250000 : 1200; + return `Codex emitted assistant output: ${answer.slice(0, maxLength)}`; } if (/^tokens used/im.test(text)) return "Tokens reported."; return text.length > 1200 ? `${text.slice(0, 1200).trim()}...` : text; @@ -3139,8 +3162,21 @@ async function listThreads(owner, { surface, kind = "all", limit, includeArchive } if (kind === "shared") { clauses.push("coalesce(active_context->>'modeId', '') <> 'codex-remote'"); + clauses.push(`not ( + coalesce(metadata->>'visibility', '') = 'service' + or origin_surface = 'seo-mode' + or coalesce(active_context->>'modeId', '') = 'seo-model-task' + or coalesce(metadata->>'threadKind', '') = 'model-task' + )`); } else if (kind === "remote") { clauses.push("active_context->>'modeId' = 'codex-remote'"); + } else if (kind === "service") { + clauses.push(`( + coalesce(metadata->>'visibility', '') = 'service' + or origin_surface = 'seo-mode' + or coalesce(active_context->>'modeId', '') = 'seo-model-task' + or coalesce(metadata->>'threadKind', '') = 'model-task' + )`); } const result = await pool.query( `select * @@ -3653,10 +3689,16 @@ function sanitizeBridgeDispatchCommand(payload) { function sanitizeThreadKind(value) { const text = normalizeKey(value || "all"); - if (text === "shared" || text === "remote" || text === "all") return text; + if (text === "shared" || text === "remote" || text === "service" || text === "all") return text; return "all"; } +function sanitizeExecutorVisibility(value) { + const text = normalizeKey(value || "interactive"); + if (text === "interactive" || text === "service" || text === "all") return text; + return "interactive"; +} + function sanitizeBridgeStopCommand(payload) { const source = isPlainObject(payload) ? payload : {}; return { diff --git a/services/map-gateway/.dockerignore b/services/map-gateway/.dockerignore new file mode 100644 index 0000000..87a8b6c --- /dev/null +++ b/services/map-gateway/.dockerignore @@ -0,0 +1,6 @@ +node_modules/ +npm-debug.log +.env +.env.* +data/ +volumes/ diff --git a/services/map-gateway/Dockerfile b/services/map-gateway/Dockerfile new file mode 100644 index 0000000..3ee5406 --- /dev/null +++ b/services/map-gateway/Dockerfile @@ -0,0 +1,20 @@ +FROM node:20-alpine AS runner + +ENV NODE_ENV=production +ENV PORT=18103 +ENV MAP_CACHE_DIR=/var/lib/nodedc-map-cache + +WORKDIR /app + +RUN apk add --no-cache su-exec + +COPY package.json ./ +COPY src ./src +COPY docker-entrypoint.sh /usr/local/bin/nodedc-map-gateway + +RUN chmod +x /usr/local/bin/nodedc-map-gateway + +EXPOSE 18103 + +ENTRYPOINT ["/usr/local/bin/nodedc-map-gateway"] +CMD ["node", "src/server.mjs"] diff --git a/services/map-gateway/README.md b/services/map-gateway/README.md new file mode 100644 index 0000000..e51c364 --- /dev/null +++ b/services/map-gateway/README.md @@ -0,0 +1,87 @@ +# NODE.DC Map Gateway + +`map-gateway` — единый платформенный boundary для map providers. Он не является частью Engine и не принадлежит отдельному Module Studio application. + +## Что делает + +- хранит master `CESIUM_ION_TOKEN` только в process environment; +- выдаёт браузеру только asset-scoped endpoint token для allowlisted ion assets; +- проксирует и кэширует tile/3D Tiles/terrain resources через `GET /api/map/cache?url=`; +- может отдать перенесённый локальный Engine snapshot через `MAP_GATEWAY_LEGACY_CACHE_HOSTS`, но никогда не догружает для него cache miss из сети; +- работает с persistent volume, а не с Git или Docker image layer; +- при upstream outage отдаёт ранее сохранённый cache object; offline режим включается только для providers, явно разрешённых их лицензией; +- защищает proxy allowlist-ом upstream hosts, HTTPS-only правилом, лимитами размера и token-free cache key; +- ведёт лёгкий persistent cache index для LRU eviction и health/stats. + +## API + +```text +GET /healthz +GET /api/map/ion/assets/:assetId/endpoint +GET|HEAD /api/map/cache?url= +``` + +`/api/map/ion/assets/:assetId/endpoint` разрешает только `CESIUM_ION_ASSET_ALLOWLIST`. Ответ содержит runtime asset token, а не master token. Browser использует endpoint вместе с Cesium `DefaultProxy`, направляющим resource requests в `/api/map/cache`. + +Ion endpoint metadata также сохраняется в persistent volume с файловыми правами `0600`. Это нужно для cold start в offline: Cesium получает сохранённый asset URL и asset-scoped token, а proxy отвечает только из ранее записанного cache. В metadata никогда не записывается master token. + +## Cache modes + +- `readwrite` — свежий cache hit отдаётся сразу; stale object обновляется online; при upstream failure возвращается stale copy; +- `readonly` — использует cache, но не записывает новый; +- `offline` — никогда не идёт наружу; cache miss возвращает `504`. + +`offline` дополнительно требует, чтобы hostname был явно указан в `MAP_GATEWAY_OFFLINE_PROVIDER_ALLOWLIST`. По умолчанию список пуст: Gateway не превращает Cesium ion или public OSM tile service в offline distribution. Это осознанная provider policy, а не техническая ошибка cache. + +Range requests пока transparently проксируются и не записываются как partial cache object. Полные GET responses кэшируются. Это безопасная начальная граница для terrain/3D Tiles; отдельным следующим шагом добавляется range-aware chunk cache после замеров реальных GOS/3D Tiles assets. + +## Persistent storage + +Platform Compose использует два внешних named volume: mutable `nodedc-platform_map-live-tile-cache` монтируется в `/var/lib/nodedc-map-live-cache`, read-only snapshot `nodedc-platform_map-offline-snapshot` — в `/var/lib/nodedc-map-offline-snapshot`. Их содержимое намеренно исключено из Git. Внешний volume не удаляется через `docker compose down -v`; очистка требует явного `docker volume rm` при остановленном Gateway. При необходимости offline seed доставляется отдельным export/import artifact, а не коммитом runtime cache. + +Кэш не способен создать участок карты, который никогда не был скачан. Для целевых офлайн-регионов нужен отдельный licensed dataset/provider и его `seed/prefetch` job: он заранее проходит разрешённые assets, zoom-диапазоны и географические области, записывает их в тот же persistent volume и формирует проверяемый export/import artifact. Эта задача не смешивается с runtime gateway и не помещает binary tiles в историю репозитория. + +### Legacy Engine cache: локальный перенос без изменения Engine + +В локальной Studio-песочнице существующий Engine cache можно скопировать в persistent volume как неизменяемый входной snapshot. Исходная директория Engine при этом только читается: import job не удаляет, не переименовывает и не изменяет ни одного её файла. + +```bash +npm run audit:engine-cache -- /absolute/path/to/nodedc-data/api/cesium/manifest.json +``` + +Команда audit выдаёт агрегированный отчёт: hosts, число объектов, declared/on-disk размер и отсутствующие файлы. Сам перенос выполняется отдельно, после остановки gateway на время записи index: + +```bash +npm run import:engine-cache -- /absolute/path/to/nodedc-data/api/cesium/manifest.json /persistent/map-tile-cache +``` + +По умолчанию действует режим `no-overwrite`: если объект уже присутствует в новом cache, он остаётся без изменений. `--dry-run` выводит план без копирования, `--refresh` разрешает осознанно заменить уже импортированный объект. Legacy `http` URLs нормализуются до `https`, чтобы совпасть с HTTPS-only Gateway; сами файлы, content-type, etag, timestamp и provenance сохраняются в новом index/import report. + +Это sandbox-механизм переноса уже существующей локальной реализации. Для внешней коммерческой публикации потребуется отдельная provider/offline policy; она не блокирует текущую локальную миграцию, но и не должна смешиваться с ней. Runtime cache по-прежнему не попадает в Git или Docker image layer. + +## Security and access + +В local dev допускается `MAP_GATEWAY_ALLOW_ANONYMOUS=true`. В production он должен быть `false`; Caddy/Auth BFF проверяет Authentik session и передаёт очищенный trusted subject header в private platform network. Нельзя открывать gateway напрямую наружу и нельзя принимать user-provided upstream hosts вне allowlist. + +## Required environment + +```text +CESIUM_ION_TOKEN= +CESIUM_ION_ASSET_ALLOWLIST=1,96188 +MAP_GATEWAY_UPSTREAM_ALLOWLIST=api.cesium.com,assets.ion.cesium.com,tile.openstreetmap.org +MAP_GATEWAY_LEGACY_CACHE_HOSTS=ecn.t0.tiles.virtualearth.net,ecn.t1.tiles.virtualearth.net,ecn.t2.tiles.virtualearth.net,ecn.t3.tiles.virtualearth.net +MAP_GATEWAY_OFFLINE_PROVIDER_ALLOWLIST= +MAP_CACHE_DIR=/var/lib/nodedc-map-cache +MAP_CACHE_MODE=readwrite +``` +# Cache topology + +`MAP_CACHE_DIR` is the mutable **live** cache. In the Platform compose profile it +is mounted as `map-live-tile-cache` and is the only store that can receive new +upstream objects. + +`MAP_OFFLINE_SNAPSHOT_DIR` is optional and mounted read-only as +`map-offline-snapshot`. It is populated only by the controlled Engine cache +import. Requests marked with the `offline` cache profile are served exclusively +from this snapshot; a missing tile returns a cache miss and never reaches an +upstream provider. diff --git a/services/map-gateway/docker-entrypoint.sh b/services/map-gateway/docker-entrypoint.sh new file mode 100644 index 0000000..b3d913d --- /dev/null +++ b/services/map-gateway/docker-entrypoint.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -eu + +# Docker creates a fresh named volume as root. Prepare only the mutable live +# cache before dropping privileges; the imported offline snapshot stays read-only. +if [ -d "${MAP_CACHE_DIR:-}" ]; then + mkdir -p "$MAP_CACHE_DIR" + chown -R node:node "$MAP_CACHE_DIR" +fi + +exec su-exec node "$@" diff --git a/services/map-gateway/package.json b/services/map-gateway/package.json new file mode 100644 index 0000000..1ccbef8 --- /dev/null +++ b/services/map-gateway/package.json @@ -0,0 +1,15 @@ +{ + "name": "@nodedc/map-gateway", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "start": "node src/server.mjs", + "dev": "node --watch src/server.mjs", + "audit:engine-cache": "node scripts/audit-engine-cache.mjs", + "import:engine-cache": "node scripts/import-engine-cache.mjs" + }, + "engines": { + "node": ">=20" + } +} diff --git a/services/map-gateway/scripts/audit-engine-cache.mjs b/services/map-gateway/scripts/audit-engine-cache.mjs new file mode 100644 index 0000000..88f4add --- /dev/null +++ b/services/map-gateway/scripts/audit-engine-cache.mjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node + +import { access, readFile, stat } from "node:fs/promises"; +import { dirname, isAbsolute, join, resolve } from "node:path"; + +const manifestPath = resolve(process.argv[2] || ""); +if (!process.argv[2]) { + console.error("Usage: npm run audit:engine-cache -- /absolute/path/to/cesium/manifest.json"); + process.exitCode = 1; +} else { + await audit(manifestPath); +} + +async function audit(path) { + let manifest; + try { + manifest = JSON.parse(await readFile(path, "utf8")); + } catch (error) { + throw new Error(`Cannot read legacy cache manifest: ${error instanceof Error ? error.message : "unknown error"}`); + } + + const root = dirname(path); + const entries = Object.entries(manifest?.entries || {}); + const providers = new Map(); + let bytesFromManifest = 0; + let presentFiles = 0; + let missingFiles = 0; + let bytesOnDisk = 0; + + for (const [rawUrl, entry] of entries) { + let hostname = "invalid-url"; + try { hostname = new URL(rawUrl).hostname.toLowerCase() || "invalid-url"; } catch {} + const provider = providers.get(hostname) || { entries: 0, bytes: 0, presentFiles: 0, missingFiles: 0 }; + const entryBytes = Math.max(0, Number(entry?.size || 0)); + provider.entries += 1; + provider.bytes += entryBytes; + bytesFromManifest += entryBytes; + + const relativeFile = String(entry?.path || ""); + const candidate = resolve(root, relativeFile); + if (!relativeFile || !candidate.startsWith(`${root}/`) || isAbsolute(relativeFile) || !(await fileExists(candidate))) { + provider.missingFiles += 1; + missingFiles += 1; + providers.set(hostname, provider); + continue; + } + + const fileInfo = await stat(candidate); + presentFiles += 1; + provider.presentFiles += 1; + bytesOnDisk += fileInfo.size; + providers.set(hostname, provider); + } + + const report = { + kind: "nodedc-legacy-cesium-cache-audit", + generatedAt: new Date().toISOString(), + manifestPath: path, + manifestVersion: manifest?.version ?? null, + entries: entries.length, + declaredBytes: bytesFromManifest, + declaredMegabytes: toMegabytes(bytesFromManifest), + presentFiles, + missingFiles, + bytesOnDisk, + megabytesOnDisk: toMegabytes(bytesOnDisk), + providers: [...providers.entries()] + .map(([hostname, value]) => ({ hostname, ...value, megabytes: toMegabytes(value.bytes) })) + .sort((left, right) => right.bytes - left.bytes), + migrationPolicy: { + automaticImport: false, + reason: "Audit only. Import requires an explicit licence/provider decision for every source host and must preserve required attribution.", + target: "Map Gateway persistent volume or approved object storage; never Git history or a Docker image layer." + } + }; + + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +} + +async function fileExists(path) { + try { + await access(path); + return true; + } catch { + return false; + } +} + +function toMegabytes(bytes) { + return Number((bytes / 1024 / 1024).toFixed(2)); +} diff --git a/services/map-gateway/scripts/import-engine-cache.mjs b/services/map-gateway/scripts/import-engine-cache.mjs new file mode 100644 index 0000000..aa76c60 --- /dev/null +++ b/services/map-gateway/scripts/import-engine-cache.mjs @@ -0,0 +1,173 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { copyFile, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, join, resolve } from "node:path"; + +const args = new Set(process.argv.slice(2)); +const positional = process.argv.slice(2).filter((value) => !value.startsWith("--")); +const [manifestArgument, cacheArgument] = positional; +const dryRun = args.has("--dry-run"); +const refresh = args.has("--refresh"); + +if (!manifestArgument || !cacheArgument) { + console.error("Usage: npm run import:engine-cache -- /absolute/path/to/manifest.json /persistent/map-cache [--dry-run] [--refresh]"); + process.exitCode = 1; +} else { + await importEngineCache(resolve(manifestArgument), resolve(cacheArgument), { dryRun, refresh }); +} + +async function importEngineCache(manifestPath, cacheDir, options) { + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + const sourceRoot = dirname(manifestPath); + const sourceEntries = Object.entries(manifest?.entries || {}); + const indexPath = join(cacheDir, "index.json"); + const index = await readIndex(indexPath); + const reindexed = reindexLegacyEntries(index); + const report = { + kind: "nodedc-engine-cache-import", + importedAt: new Date().toISOString(), + sourceManifest: manifestPath, + sourceManifestVersion: manifest?.version ?? null, + mode: options.dryRun ? "dry-run" : options.refresh ? "refresh" : "no-overwrite", + totalEntries: sourceEntries.length, + imported: 0, + alreadyPresent: 0, + missingSourceFiles: 0, + invalidEntries: 0, + reindexed, + bytesImported: 0, + providers: {}, + }; + + if (!options.dryRun) await mkdir(join(cacheDir, "objects"), { recursive: true }); + + for (const [rawUrl, source] of sourceEntries) { + const normalized = normalizeLegacyUrl(rawUrl); + const relativeSource = String(source?.path || ""); + const sourcePath = resolve(sourceRoot, relativeSource); + if (!normalized || !relativeSource || isAbsolute(relativeSource) || !sourcePath.startsWith(`${sourceRoot}/`)) { + report.invalidEntries += 1; + continue; + } + + let sourceInfo; + try { + sourceInfo = await stat(sourcePath); + if (!sourceInfo.isFile()) throw new Error("not_file"); + } catch { + report.missingSourceFiles += 1; + continue; + } + + const key = createHash("sha256").update(canonicalCacheUrl(normalized)).digest("hex"); + const targetFile = join("objects", key.slice(0, 2), `${key}.bin`); + const targetPath = join(cacheDir, targetFile); + const existsInIndex = Boolean(index.entries[key]); + const existsOnDisk = await fileExists(targetPath); + if (!options.refresh && (existsInIndex || existsOnDisk)) { + report.alreadyPresent += 1; + continue; + } + + const hostname = new URL(normalized).hostname.toLowerCase(); + report.providers[hostname] = (report.providers[hostname] || 0) + 1; + const savedAt = Number(source?.savedAt || Date.now()); + const entry = { + key, + file: targetFile, + bytes: sourceInfo.size, + contentType: String(source?.contentType || "application/octet-stream"), + etag: String(source?.etag || ""), + savedAt, + lastAccessAt: Date.now(), + // Imported records are deliberately treated as fresh in the sandbox. + // A refresh is always explicit through nodedc_cache_refresh=1. + expiresAt: Date.now() + 3650 * 24 * 60 * 60 * 1000, + source: "engine-legacy-cache", + sourceUrl: normalized, + }; + + if (!options.dryRun) { + await mkdir(dirname(targetPath), { recursive: true }); + await copyFile(sourcePath, targetPath); + index.entries[key] = entry; + } + report.imported += 1; + report.bytesImported += sourceInfo.size; + } + + if (!options.dryRun) { + await writeJsonAtomically(indexPath, index); + const importsDir = join(cacheDir, "imports"); + await mkdir(importsDir, { recursive: true }); + await writeJsonAtomically(join(importsDir, "engine-legacy-cache-import.json"), report); + } + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); +} + +function normalizeLegacyUrl(rawUrl) { + try { + const target = new URL(rawUrl); + // The Engine cache predates the HTTPS-only Gateway. The same tile hosts + // support HTTPS, so normalize only the protocol; path and provider stay intact. + if (target.protocol === "http:") target.protocol = "https:"; + if (target.protocol !== "https:") return null; + return target; + } catch { + return null; + } +} + +function canonicalCacheUrl(target) { + const url = new URL(target.toString()); + if (/^ecn\.t[0-3]\.tiles\.virtualearth\.net$/i.test(url.hostname)) { + url.hostname = "ecn.tiles.virtualearth.net"; + } + for (const key of [...url.searchParams.keys()]) { + if (["access_token", "accesstoken", "iontoken", "token", "key", "apikey", "api_key", "signature", "sig"].includes(key.toLowerCase())) { + url.searchParams.delete(key); + } + } + url.searchParams.sort(); + return url.toString(); +} + +function reindexLegacyEntries(index) { + let reindexed = 0; + for (const [currentKey, entry] of Object.entries(index.entries)) { + if (entry?.source !== "engine-legacy-cache" || !entry?.sourceUrl) continue; + let source; + try { source = new URL(String(entry.sourceUrl)); } catch { continue; } + const nextKey = createHash("sha256").update(canonicalCacheUrl(source)).digest("hex"); + if (nextKey === currentKey || index.entries[nextKey]) continue; + index.entries[nextKey] = { ...entry, key: nextKey }; + delete index.entries[currentKey]; + reindexed += 1; + } + return reindexed; +} + +async function readIndex(indexPath) { + try { + const parsed = JSON.parse(await readFile(indexPath, "utf8")); + if (parsed?.version === 1 && parsed.entries && typeof parsed.entries === "object") return parsed; + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + return { version: 1, entries: {} }; +} + +async function fileExists(path) { + try { + return (await stat(path)).isFile(); + } catch { + return false; + } +} + +async function writeJsonAtomically(path, value) { + const temp = `${path}.${process.pid}.tmp`; + await writeFile(temp, `${JSON.stringify(value)}\n`, "utf8"); + await rename(temp, path); +} diff --git a/services/map-gateway/src/server.mjs b/services/map-gateway/src/server.mjs new file mode 100644 index 0000000..f96c507 --- /dev/null +++ b/services/map-gateway/src/server.mjs @@ -0,0 +1,533 @@ +import { createHash } from "node:crypto"; +import { createReadStream, createWriteStream } from "node:fs"; +import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { dirname, join } from "node:path"; +import { Readable, Transform } from "node:stream"; +import { pipeline } from "node:stream/promises"; + +const config = readConfig(); +const liveCache = createCacheStore("live", config.cacheDir, true); +const offlineSnapshot = config.offlineSnapshotDir ? createCacheStore("offline-snapshot", config.offlineSnapshotDir, false) : null; +const inflightWrites = new Map(); + +await initialiseCacheStore(liveCache); +if (offlineSnapshot) await initialiseCacheStore(offlineSnapshot); + +const server = createServer(async (request, response) => { + applyCors(request, response); + if (request.method === "OPTIONS") return response.writeHead(204).end(); + + try { + const requestUrl = new URL(request.url || "/", `http://${request.headers.host || "127.0.0.1"}`); + 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, + ionConfigured: Boolean(config.cesiumIonToken), + assetAllowlist: [...config.assetAllowlist].map(Number).sort((left, right) => left - right), + anonymousAccess: config.allowAnonymous, + }); + } + + const ionAssetMatch = requestUrl.pathname.match(/^\/api\/map\/ion\/assets\/(\d+)\/endpoint$/); + if (ionAssetMatch && request.method === "GET") { + return await serveIonEndpoint(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); + return writeJson(response, Number.isInteger(status) && status >= 400 && status < 600 ? status : 500, { + ok: false, + error: error instanceof Error ? error.message : "map_gateway_error", + }); + } +}); + +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)`); +}); + +process.on("SIGTERM", () => server.close()); +process.on("SIGINT", () => server.close()); + +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"); + 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, + cesiumIonToken: String(process.env.CESIUM_ION_TOKEN || "").trim(), + assetAllowlist: new Set(parseList(process.env.CESIUM_ION_ASSET_ALLOWLIST || "1,2,96188")), + 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(), + }; +} + +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 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"); +} + +function isAllowedRequest(request) { + return config.allowAnonymous || Boolean(request.headers[config.trustedSubjectHeader]); +} + +async function serveIonEndpoint(response, assetId) { + if (!config.assetAllowlist.has(assetId)) return writeJson(response, 403, { ok: false, error: "cesium_asset_not_allowed" }); + const cached = await readIonEndpointCache(assetId); + if (config.mode === "offline") { + if (!cached) return writeJson(response, 504, { ok: false, error: "cesium_ion_offline_endpoint_miss" }); + if (!isOfflineProviderAllowed(cached.url)) return writeJson(response, 409, { ok: false, error: "map_provider_offline_not_permitted" }); + return writeJson(response, 200, { ok: true, ...cached, cache: "offline-endpoint-hit" }); + } + if (!config.cesiumIonToken) { + if (cached) return writeJson(response, 200, { ok: true, ...cached, cache: "cached-endpoint-no-master-token" }); + return writeJson(response, 503, { ok: false, error: "cesium_ion_not_configured" }); + } + + try { + const endpoint = await fetchWithTimeout(`https://api.cesium.com/v1/assets/${assetId}/endpoint`, { + headers: { authorization: `Bearer ${config.cesiumIonToken}` }, + }); + 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 (!(next.externalType === "BING" ? next.options?.key : next.accessToken)) throw gatewayError("cesium_ion_endpoint_invalid", 502); + if (config.mode === "readwrite") await writeIonEndpointCache(next); + return writeJson(response, 200, { ok: true, ...next, cache: "ion-endpoint-online" }); + } catch (error) { + if (cached) return writeJson(response, 200, { ok: true, ...cached, cache: "stale-ion-endpoint" }); + throw error; + } +} + +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" }); + const target = validateUpstream(rawTarget); + 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 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") { + return proxyUncachedRequest(request, response, target, "live-pass-through"); + } + const cached = await getCachedEntry(store, cacheKey); + const fresh = cached && cached.expiresAt > Date.now(); + + if (cached && (!forceRefresh || !store.mutable) && (fresh || !store.mutable || config.mode !== "readwrite")) { + return serveCachedFile(request, response, store, cached, fresh ? `${store.name}-hit` : `${store.name}-stale`); + } + + // 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") return writeJson(response, 504, { ok: false, error: "map_offline_snapshot_miss" }); + if (isLegacyCacheHost) return writeJson(response, 504, { ok: false, error: "map_legacy_cache_miss" }); + + if (config.mode === "offline") { + if (!isOfflineProviderAllowed(target.toString())) return writeJson(response, 409, { ok: false, error: "map_provider_offline_not_permitted" }); + return writeJson(response, 504, { ok: false, error: "map_cache_offline_miss" }); + } + + if (config.mode === "readonly") { + return proxyUncachedRequest(request, response, target, "pass-through-readonly"); + } + + if (String(request.headers.range || "").trim()) { + return proxyRangeRequest(request, response, target, cached, store); + } + + try { + const entry = await fetchAndCache(target, cacheKey, store); + return serveCachedFile(request, response, store, entry, forceRefresh ? "live-refresh-record" : "live-record"); + } catch (error) { + if (cached) 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:" && /^(ecn\.t[0-3]\.tiles\.virtualearth\.net|ecn\.tiles\.virtualearth\.net)$/.test(hostname)) { + target.protocol = "https:"; + } + if (target.protocol !== "https:") 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 = new URL(target.toString()); + // 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"; + } + for (const key of [...url.searchParams.keys()]) { + if (["access_token", "accesstoken", "iontoken", "token", "key", "apikey", "api_key", "signature", "sig"].includes(key.toLowerCase())) { + url.searchParams.delete(key); + } + } + url.searchParams.sort(); + return url.toString(); +} + +async function fetchAndCache(target, cacheKey, store) { + const inflightKey = `${store.name}:${cacheKey}`; + const existing = inflightWrites.get(inflightKey); + if (existing) return existing; + const task = downloadAndCache(target, cacheKey, store); + inflightWrites.set(inflightKey, task); + // Several Cesium requests can share one in-flight download. Keep a rejection + // observer on the shared promise, then rethrow to each HTTP caller below. + // A 4xx/5xx tile response must never become an unhandled rejection that stops + // the gateway process. + task.catch(() => undefined); + try { + return await task; + } finally { + if (inflightWrites.get(inflightKey) === task) inflightWrites.delete(inflightKey); + } +} + +async function downloadAndCache(target, cacheKey, store) { + const upstream = await fetchWithTimeout(target, { headers: { accept: "application/json, application/octet-stream, image/*, */*;q=0.5" } }); + if (!upstream.ok || !upstream.body) 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) throw gatewayError("map_cache_object_too_large", 413); + + 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); + }, + }); + try { + await pipeline(Readable.fromWeb(upstream.body), limit, createWriteStream(tempPath, { flags: "wx" })); + await rename(tempPath, filePath); + } catch (error) { + await rm(tempPath, { force: true }); + throw error; + } + + const now = Date.now(); + const entry = { + key: cacheKey, + file: relativePath, + bytes, + contentType: upstream.headers.get("content-type") || "application/octet-stream", + etag: upstream.headers.get("etag") || null, + savedAt: now, + lastAccessAt: now, + expiresAt: now + responseTtl(upstream.headers.get("cache-control")), + }; + store.index.entries[cacheKey] = entry; + await evictCache(store); + await writeCacheIndex(store); + return entry; +} + +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) { + const upstream = await fetchWithTimeout(target, { headers: { range: String(request.headers.range), accept: "*/*" } }); + 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) { + 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) } : {}), + }, + }); + 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); + response.setHeader("content-type", entry.contentType); + response.setHeader("accept-ranges", "bytes"); + response.setHeader("cache-control", "public, max-age=60"); + 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)))); + if (store.mutable) { + entry.lastAccessAt = Date.now(); + store.index.entries[entry.key] = entry; + void writeCacheIndex(store); + } + + 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 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: {} }, + pendingIndexWrite: 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 writeCacheIndex(store) { + if (!store.mutable) return Promise.resolve(); + store.pendingIndexWrite = store.pendingIndexWrite.then(async () => { + const tempPath = `${store.indexPath}.${process.pid}.tmp`; + await writeFile(tempPath, `${JSON.stringify(store.index)}\n`, "utf8"); + await rename(tempPath, store.indexPath); + }).catch((error) => console.warn("Map cache index write failed", error instanceof Error ? error.message : "unknown")); + return store.pendingIndexWrite; +} + +async function evictCache(store) { + let entries = Object.values(store.index.entries); + let size = entries.reduce((sum, entry) => sum + Number(entry.bytes || 0), 0); + if (size <= config.maxCacheBytes) return; + entries = entries.sort((left, right) => Number(left.lastAccessAt || 0) - Number(right.lastAccessAt || 0)); + for (const entry of entries) { + if (size <= config.maxCacheBytes) break; + await rm(join(store.dir, entry.file), { force: true }); + delete store.index.entries[entry.key]; + size -= Number(entry.bytes || 0); + } +} + +async function cacheStats(store) { + const entries = Object.values(store.index.entries); + const bytes = entries.reduce((sum, entry) => sum + Number(entry.bytes || 0), 0); + return { name: store.name, mode: store.mutable ? config.mode : "readonly", entries: entries.length, bytes, maxBytes: store.mutable ? config.maxCacheBytes : null, persistent: true }; +} + +async function fetchWithTimeout(target, options = {}) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), config.upstreamTimeoutMs); + try { return await fetch(target, { ...options, signal: controller.signal, redirect: "follow" }); } + catch (error) { throw gatewayError(error?.name === "AbortError" ? "map_upstream_timeout" : "map_upstream_fetch_failed", 502); } + finally { clearTimeout(timeout); } +} + +function copyUpstreamHeaders(response, headers) { + for (const name of ["content-type", "content-length", "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; +} diff --git a/services/ontology-core/README.md b/services/ontology-core/README.md index f553e1b..9ee0ed2 100644 --- a/services/ontology-core/README.md +++ b/services/ontology-core/README.md @@ -160,6 +160,7 @@ Each package may contribute entities, relations, aliases, guardrails, evidence, Current package: - `seo` - NDC SEO mod domain ontology for site scans, project ontology instances, scope contracts, semantic analysis, market evidence, optimization planning, validation, changesets, and future app-owned SEO assistant actions. +- `map` - provider-neutral NDC Module Studio map ontology for spatial subjects, layers, routes, zones, shared labels, visibility rules, selection, and replaceable renderer adapters. The package loader merges domain packages into the base catalog before validation. Domain packages extend core meanings; they do not own app data or execute mutations. diff --git a/services/ontology-core/catalog/domain-packages/map/aliases.json b/services/ontology-core/catalog/domain-packages/map/aliases.json new file mode 100644 index 0000000..646f7b7 --- /dev/null +++ b/services/ontology-core/catalog/domain-packages/map/aliases.json @@ -0,0 +1,35 @@ +{ + "version": "0.1.0", + "updatedAt": "2026-07-12", + "aliases": [ + { "alias": "map view", "canonicalId": "map.view" }, + { "alias": "карта", "canonicalId": "map.view" }, + { "alias": "карточная страница", "canonicalId": "map.view" }, + { "alias": "камера карты", "canonicalId": "map.viewport" }, + { "alias": "геосетка", "canonicalId": "map.grid_layer" }, + { "alias": "планетарная сетка", "canonicalId": "map.grid_layer" }, + { "alias": "городской таргет", "canonicalId": "map.place_target" }, + { "alias": "маркер города", "canonicalId": "map.place_target" }, + { "alias": "движущийся объект", "canonicalId": "map.moving_object" }, + { "alias": "электротранспорт", "canonicalId": "map.moving_object" }, + { "alias": "шпилька", "canonicalId": "map.pin" }, + { "alias": "маркер-шпилька", "canonicalId": "map.pin" }, + { "alias": "плашка на карте", "canonicalId": "map.label" }, + { "alias": "метка объекта", "canonicalId": "map.label" }, + { "alias": "геозона", "canonicalId": "map.zone" }, + { "alias": "сектор карты", "canonicalId": "map.zone" }, + { "alias": "geofence", "canonicalId": "map.zone" }, + { "alias": "маршрут", "canonicalId": "map.route" }, + { "alias": "железнодорожный путь", "canonicalId": "map.track_segment" }, + { "alias": "станция метро", "canonicalId": "map.station" }, + { "alias": "железнодорожная станция", "canonicalId": "map.station" }, + { "alias": "остановка", "canonicalId": "map.stop" }, + { "alias": "вокзал", "canonicalId": "map.terminal" }, + { "alias": "выбранный объект карты", "canonicalId": "map.selection" }, + { "alias": "LOD карты", "canonicalId": "map.visibility_rule" }, + { "alias": "правила видимости карты", "canonicalId": "map.visibility_rule" }, + { "alias": "движок карты", "canonicalId": "map.renderer_adapter" }, + { "alias": "Cesium adapter", "canonicalId": "map.renderer_adapter" }, + { "alias": "провайдер карты", "canonicalId": "map.provider_capability" } + ] +} diff --git a/services/ontology-core/catalog/domain-packages/map/entities.json b/services/ontology-core/catalog/domain-packages/map/entities.json new file mode 100644 index 0000000..314be22 --- /dev/null +++ b/services/ontology-core/catalog/domain-packages/map/entities.json @@ -0,0 +1,26 @@ +{ + "version": "0.1.0", + "updatedAt": "2026-07-12", + "entities": [ + { "id": "map.view", "name": "Map View", "surface": "map", "status": ["product-required"], "authority": "NDC Module Studio + Ontology Core", "summary": "Provider-neutral map work view composed by an approved Map Page template." }, + { "id": "map.viewport", "name": "Map Viewport", "surface": "map", "status": ["source-evidenced"], "authority": "NDC Module Studio", "summary": "Camera/view state including position, orientation, zoom or height, focus and saved view presets." }, + { "id": "map.base_layer", "name": "Map Base Layer", "surface": "map", "status": ["source-evidenced"], "authority": "NDC Module Studio", "summary": "Base spatial imagery or cartographic layer independent of a concrete provider." }, + { "id": "map.building_layer", "name": "Map Building Layer", "surface": "map", "status": ["source-evidenced"], "authority": "NDC Module Studio", "summary": "Optional spatial building or 3D structure layer exposed as a renderer capability." }, + { "id": "map.grid_layer", "name": "Map Grid Layer", "surface": "map", "status": ["source-evidenced"], "authority": "NDC Module Studio", "summary": "Planetary or situational grid with configurable modes, levels of detail, step, radius and visual primitives." }, + { "id": "map.place_target", "name": "Map Place Target", "surface": "map", "status": ["source-evidenced"], "authority": "NDC Module Studio", "summary": "Geographic place target such as a city or territory with location, pulse, radius and distance-dependent presentation." }, + { "id": "map.moving_object", "name": "Map Moving Object", "surface": "map", "status": ["source-evidenced"], "authority": "NDC domain source", "summary": "Dynamic spatial object such as transport, robot or vehicle with identity, position, movement and status." }, + { "id": "map.pin", "name": "Map Pin", "surface": "map", "status": ["source-evidenced"], "authority": "NDC Module Studio", "summary": "Reusable spatial pin presentation with stem, point, height, color, status and visibility rules." }, + { "id": "map.label", "name": "Map Label", "surface": "map", "status": ["source-evidenced", "product-required"], "authority": "NDC Module Studio", "summary": "Reusable information plate attached to a spatial subject with style, size variant, anchor, offset and visibility rules." }, + { "id": "map.zone", "name": "Map Zone", "surface": "map", "status": ["source-evidenced"], "authority": "NDC domain source", "summary": "Polygon or multipolygon zone, sector or geofence with optional height, extrusion and level-dependent style." }, + { "id": "map.route", "name": "Map Route", "surface": "map", "status": ["source-evidenced"], "authority": "NDC domain source", "summary": "Logical ordered path or service route independent of renderer geometry." }, + { "id": "map.track_segment", "name": "Map Track Segment", "surface": "map", "status": ["source-evidenced"], "authority": "NDC domain source", "summary": "Renderable linear segment of rail, road, trace or other path geometry." }, + { "id": "map.station", "name": "Map Station", "surface": "map", "status": ["source-evidenced"], "authority": "NDC transport domain", "summary": "Transport station with semantic kind, location, identity and optional label presentation." }, + { "id": "map.stop", "name": "Map Stop", "surface": "map", "status": ["source-evidenced"], "authority": "NDC transport domain", "summary": "Transport stop or stop position represented through the common spatial target contract." }, + { "id": "map.terminal", "name": "Map Terminal", "surface": "map", "status": ["source-evidenced"], "authority": "NDC transport domain", "summary": "Major station, terminal or transport hub with a distinct semantic and presentation variant." }, + { "id": "map.selection", "name": "Map Selection", "surface": "map", "status": ["source-evidenced"], "authority": "NDC Module Studio", "summary": "Current selected spatial subject and normalized context used by Inspector, quick cards and commands." }, + { "id": "map.visibility_rule", "name": "Map Visibility Rule", "surface": "map", "status": ["product-required"], "authority": "NDC Module Studio", "summary": "Provider-neutral distance, zoom, height, state and transition rule controlling visibility or level of detail." }, + { "id": "map.style_profile", "name": "Map Style Profile", "surface": "map", "status": ["product-required"], "authority": "NDC Module Studio", "summary": "Versioned semantic presentation profile for map entities without renderer-specific graphics types." }, + { "id": "map.renderer_adapter", "name": "Map Renderer Adapter", "surface": "map", "status": ["product-required"], "authority": "NDC Module Studio", "summary": "Replaceable implementation translating the provider-neutral Map Page contract to a concrete rendering engine." }, + { "id": "map.provider_capability", "name": "Map Provider Capability", "surface": "map", "status": ["product-required"], "authority": "Platform Map Gateway + renderer adapter", "summary": "Declared provider feature such as terrain, imagery, buildings, offline cache, attribution or supported geometry." } + ] +} diff --git a/services/ontology-core/catalog/domain-packages/map/evidence.json b/services/ontology-core/catalog/domain-packages/map/evidence.json new file mode 100644 index 0000000..c720b6f --- /dev/null +++ b/services/ontology-core/catalog/domain-packages/map/evidence.json @@ -0,0 +1,43 @@ +{ + "version": "0.1.0", + "updatedAt": "2026-07-12", + "sourceRoots": [ + { + "surface": "engine", + "path": "/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_ENGINE_INFRA/nodedc-source", + "mode": "read-only MMAP workflow and Cesium layer donor inspection" + }, + { + "surface": "module-studio", + "path": "/Users/dcconstructions/Downloads/mnt/NODEDC/NODEDC_DESIGN_GUIDELINE", + "mode": "Map Page target surface and visual contract owner" + } + ], + "ledgers": [ + { + "id": "ledger.map_domain_v0", + "path": "docs/MAP_DOMAIN_ONTOLOGY.md", + "entityIds": [ + "map.view", + "map.grid_layer", + "map.place_target", + "map.moving_object", + "map.pin", + "map.label", + "map.zone", + "map.route", + "map.track_segment", + "map.station", + "map.selection", + "map.renderer_adapter" + ] + } + ], + "baselineDocs": ["docs/MAP_DOMAIN_ONTOLOGY.md"], + "restrictions": [ + "Do not copy the monolithic ENGINE Cesium node into Module Studio.", + "Do not make Cesium, Ion, OSM, GOS, Gelios, RZD or Yandex Rasp canonical map domain roots.", + "Do not store runtime snapshots, provider credentials or heavy spatial datasets in Ontology Core.", + "Do not design NDC capability bindings before Map Page slots, fixture schema and actions are accepted." + ] +} diff --git a/services/ontology-core/catalog/domain-packages/map/guardrails.json b/services/ontology-core/catalog/domain-packages/map/guardrails.json new file mode 100644 index 0000000..fa936bc --- /dev/null +++ b/services/ontology-core/catalog/domain-packages/map/guardrails.json @@ -0,0 +1,50 @@ +{ + "version": "0.1.0", + "updatedAt": "2026-07-12", + "rules": [ + { + "id": "guardrail.map.provider_neutral_domain", + "severity": "error", + "summary": "Map domain contracts must not use Cesium, Ion, OSM, GOS or renderer graphics types as canonical business entities.", + "entityIds": ["map.view", "map.renderer_adapter", "map.provider_capability"] + }, + { + "id": "guardrail.map.engine_does_not_own_visuals", + "severity": "error", + "summary": "ENGINE/NDC sources may publish spatial data and commands but must not own Map Page geometry, label design, pin design or renderer credentials.", + "entityIds": ["map.view", "map.pin", "map.label", "map.style_profile", "future.interface_binding", "engine.workflow_l1"] + }, + { + "id": "guardrail.map.selection_targets_domain_subject", + "severity": "error", + "summary": "Selection and application commands must target stable domain subject identifiers, not transient renderer entity references.", + "entityIds": ["map.selection", "map.renderer_adapter", "future.interface_binding"] + }, + { + "id": "guardrail.map.labels_use_shared_contract", + "severity": "warning", + "summary": "Transport, station, city and other labels must use the shared Map Label contract with semantic variants instead of source-specific label implementations.", + "entityIds": ["map.label", "map.moving_object", "map.station", "map.stop", "map.terminal", "map.place_target"] + }, + { + "id": "guardrail.map.mass_geometry_requires_strategy", + "severity": "warning", + "summary": "Large zone and track collections require explicit batching, tiling, visibility and update strategy before production renderer acceptance.", + "entityIds": ["map.zone", "map.track_segment", "map.visibility_rule", "map.renderer_adapter"] + }, + { + "id": "guardrail.map.ontology_does_not_store_runtime_snapshots", + "severity": "error", + "summary": "Ontology Core describes map meanings and contracts; it must not store live vehicle positions, provider tokens, full geozone snapshots or railway GeoJSON datasets.", + "entityIds": ["map.moving_object", "map.zone", "map.track_segment", "map.provider_capability"] + } + ], + "blockedConflations": [ + ["map.view", "map.renderer_adapter"], + ["map.moving_object", "map.pin"], + ["map.station", "map.label"], + ["map.route", "map.track_segment"], + ["map.visibility_rule", "map.provider_capability"], + ["map.selection", "map.renderer_adapter"] + ] +} diff --git a/services/ontology-core/catalog/domain-packages/map/package.json b/services/ontology-core/catalog/domain-packages/map/package.json new file mode 100644 index 0000000..2834dac --- /dev/null +++ b/services/ontology-core/catalog/domain-packages/map/package.json @@ -0,0 +1,7 @@ +{ + "id": "map", + "version": "0.1.0", + "updatedAt": "2026-07-12", + "status": "product-required/source-evidenced", + "summary": "Provider-neutral map domain ontology for NDC Module Studio views, spatial entities, transport layers, visibility rules, selection, and replaceable renderer adapters." +} diff --git a/services/ontology-core/catalog/domain-packages/map/relations.json b/services/ontology-core/catalog/domain-packages/map/relations.json new file mode 100644 index 0000000..a527e28 --- /dev/null +++ b/services/ontology-core/catalog/domain-packages/map/relations.json @@ -0,0 +1,21 @@ +{ + "version": "0.1.0", + "updatedAt": "2026-07-12", + "relations": [ + { "id": "map.view.uses_viewport", "from": ["map.view"], "to": ["map.viewport"], "status": "product-required", "summary": "A Map View owns provider-neutral viewport state." }, + { "id": "map.view.contains_layer", "from": ["map.view"], "to": ["map.base_layer", "map.building_layer", "map.grid_layer"], "status": "product-required", "summary": "A Map View composes enabled spatial layers." }, + { "id": "map.view.displays_subject", "from": ["map.view"], "to": ["map.place_target", "map.moving_object", "map.zone", "map.route", "map.track_segment", "map.station", "map.stop", "map.terminal"], "status": "product-required", "summary": "A Map View displays domain subjects through approved page slots." }, + { "id": "map.moving_object.uses_pin", "from": ["map.moving_object"], "to": ["map.pin"], "status": "product-required", "summary": "A moving object can use the shared pin presentation." }, + { "id": "map.subject.has_label", "from": ["map.place_target", "map.moving_object", "map.station", "map.stop", "map.terminal"], "to": ["map.label"], "status": "product-required", "summary": "Spatial subjects can use the common label contract and semantic size variants." }, + { "id": "map.zone.uses_style_profile", "from": ["map.zone"], "to": ["map.style_profile"], "status": "product-required", "summary": "Zones use semantic fill, outline, height and level-dependent styling." }, + { "id": "map.route.contains_track_segment", "from": ["map.route"], "to": ["map.track_segment"], "status": "product-required", "summary": "A logical route is rendered from one or more track segments." }, + { "id": "map.route.serves_station", "from": ["map.route"], "to": ["map.station", "map.stop", "map.terminal"], "status": "product-required", "summary": "A route can reference the transport points it serves." }, + { "id": "map.subject.uses_visibility_rule", "from": ["map.grid_layer", "map.place_target", "map.moving_object", "map.pin", "map.label", "map.zone", "map.route", "map.track_segment", "map.station", "map.stop", "map.terminal"], "to": ["map.visibility_rule"], "status": "product-required", "summary": "Map subjects use a provider-neutral visibility and LOD policy." }, + { "id": "map.selection.targets_subject", "from": ["map.selection"], "to": ["map.place_target", "map.moving_object", "map.zone", "map.route", "map.track_segment", "map.station", "map.stop", "map.terminal"], "status": "product-required", "summary": "Selection points to a domain subject rather than a renderer object." }, + { "id": "map.view.rendered_by_adapter", "from": ["map.view"], "to": ["map.renderer_adapter"], "status": "product-required", "summary": "A Map View is rendered through a replaceable adapter." }, + { "id": "map.renderer_adapter.declares_capability", "from": ["map.renderer_adapter"], "to": ["map.provider_capability"], "status": "product-required", "summary": "A renderer adapter declares the provider features it can satisfy." }, + { "id": "map.view.is_interface_view", "from": ["map.view"], "to": ["future.interface_view"], "status": "product-required", "summary": "Map View specializes the Future Interface Layer view concept." }, + { "id": "map.subject.is_interface_widget", "from": ["map.grid_layer", "map.place_target", "map.pin", "map.label", "map.zone", "map.route"], "to": ["future.interface_widget"], "status": "product-required", "summary": "Map visual subjects are exposed through approved interface widgets and slots." }, + { "id": "map.view.receives_interface_binding", "from": ["map.view"], "to": ["future.interface_binding"], "status": "product-required", "summary": "NDC data reaches Map View through the generic Future Interface binding boundary." } + ] +} diff --git a/services/ontology-core/docs/MAP_DOMAIN_ONTOLOGY.md b/services/ontology-core/docs/MAP_DOMAIN_ONTOLOGY.md new file mode 100644 index 0000000..7224c91 --- /dev/null +++ b/services/ontology-core/docs/MAP_DOMAIN_ONTOLOGY.md @@ -0,0 +1,56 @@ +# Map Domain Ontology + +Package: `catalog/domain-packages/map` + +Status: first provider-neutral ontology slice for NDC Module Studio Map Page. + +## Purpose + +The package names stable spatial meanings already evidenced by the ENGINE MMAP workflow without making the current Cesium implementation canonical. + +The Map Page, its visual components and renderer adapters belong to NDC Module Studio. ENGINE and NDC sources produce normalized domain data and commands through the Future Interface binding boundary. Platform services own access, provider credentials, proxy/cache and deployment concerns. + +## Evidence boundary + +The donor inspection covered the working `MMAP` workflow and its node of type `cesium`, plus Gelios, RZD/Yandex Rasp, Rail Inspector, city target, selection and grid layers. + +The old `MoscowMapNode` is not a canonical donor. Runtime snapshots, provider tokens and full geometry datasets are evidence only and are not copied into Ontology Core. + +## Canonical layers + +```text +NDC domain source + -> moving objects, places, zones, routes, tracks, stations and events + -> Future Interface binding + -> Map View / Map Page slots + -> shared pin, label, selection, style and visibility contracts + -> replaceable renderer adapter + -> provider capabilities +``` + +Cesium is one possible renderer adapter. A future Cesium version or another provider can replace it without changing Map View, application manifests or domain subject identifiers. + +## First acceptance slice + +The first fixture-backed Map Page must cover: + +- viewport and saved view; +- base/building layer capability states; +- multi-level grid using both local 3D and distant graticule modes; +- place targets with pulse and scale-by-distance; +- moving transport objects using shared pins and labels; +- metro, railway station, stop and terminal variants; +- railway route and track geometry; +- polygon/multipolygon zones with level-dependent style; +- selection driving Inspector or a quick card; +- loading, empty, stale, error and offline states. + +## Known donor debt + +- Viewer lifecycle, map provider setup, cache, grid and layer orchestration are concentrated in one large node. +- Labels are independently implemented by multiple source-specific layers. +- Large geozone sets are rendered as many individual polygon/polyline entities. +- Railway source handling, geometry LOD and renderer logic are only partially separated. +- Existing Grid/AIS rendering relies on a proxy canvas because moving a live WebGL canvas is unstable. + +These constraints guide acceptance tests but do not become public Map Page contracts.