feat(foundry): productionize Cesium Map Page and platform runtime

This commit is contained in:
Codex 2026-07-16 02:25:58 +03:00
parent 1a2ca8c82c
commit 5e8c1cc3fc
41 changed files with 6977 additions and 179 deletions

12
.dockerignore Normal file
View File

@ -0,0 +1,12 @@
.git
node_modules
**/node_modules
**/dist
runtime-data
!runtime-seed/
.env
.env.*
!.env.example
coverage
.vite
*.log

View File

@ -1,5 +1,55 @@
# Runtime secrets are injected by the deployment environment and never committed.
CESIUM_ION_TOKEN=
NODEDC_MAP_GATEWAY_URL=
CESIUM_ION_ASSET_ALLOWLIST=1,96188
# Server-side operational configuration. Nothing from this file is shipped to the browser.
# Private Platform Map Gateway URL. It is never returned to the browser.
NODEDC_MAP_GATEWAY_INTERNAL_URL=
# Gateway BFF waits this long for response headers; an active body then uses a
# separate progress-based idle limit rather than an absolute request deadline.
NODEDC_MAP_GATEWAY_HEADERS_TIMEOUT_MS=15000
NODEDC_MAP_GATEWAY_BODY_IDLE_TIMEOUT_MS=30000
# Private External Data Plane address. It is used only by the Foundry server;
# map pages use same-origin /api/applications/.../data-bindings/... routes.
NODEDC_EXTERNAL_DATA_PLANE_INTERNAL_URL=
# Absolute path to a runner-managed directory of opaque reader grants. Each
# filename is sha256("<applicationId>/<pageId>/<bindingId>"); file content is
# exactly one ndc_edprb_ reader token. Files must be root-owned and read-only.
# Neither this directory nor any token is shipped to the browser or persisted
# in an application/page dataProductBinding.
NODEDC_EXTERNAL_DATA_PLANE_READER_GRANTS_DIR=
# Cesium Ion credentials belong only to the private Platform Map Gateway
# secret store (its legacy bootstrap may be environment-based), never here.
CESIUM_GAUSSIAN_SPLAT_ASSET_ID=
# NDC Module Foundry — server-side configuration. Do not put these values in a browser build.
# Foundry is published through the Synology reverse proxy:
# https://foundry.nodedc.ru -> 172.22.0.222:9920 -> container:3333.
# Keep the public URL without a trailing slash.
FOUNDRY_PUBLIC_URL=https://foundry.nodedc.ru
FOUNDRY_MCP_URL=
# MCP receives a short-lived capability minted server-side from the existing
# NODEDC_INTERNAL_ACCESS_TOKEN. Do not create or pass a separate Foundry secret.
FOUNDRY_MCP_CAPABILITY_TTL_MS=600000
FOUNDRY_MCP_ALLOWED_ORIGINS=
FOUNDRY_ALLOW_ALL_AUTHENTICATED=false
# user_root (dcctouch@gmail.com) and the Authentik group
# nodedc:module-foundry:access are included by default. Add only exceptional
# additional platform identities here.
FOUNDRY_ALLOWED_OWNER_IDS=
FOUNDRY_ALLOWED_OWNER_GROUPS=
FOUNDRY_HOST_BIND=172.22.0.222:9920
# Access is owned by Launcher + Authentik. Foundry has no standalone login form.
# Reuse the existing server-to-server value shared by NODE.DC services; do not create a browser token.
NODEDC_FOUNDRY_AUTH_REQUIRED=false
NODEDC_FOUNDRY_SERVICE_SLUG=module-foundry
NODEDC_LAUNCHER_BASE_URL=http://127.0.0.1:5173
NODEDC_LAUNCHER_INTERNAL_URL=http://127.0.0.1:5173
NODEDC_INTERNAL_ACCESS_TOKEN=
NODEDC_FOUNDRY_SESSION_COOKIE=nodedc_foundry_session
NODEDC_FOUNDRY_SESSION_TTL_MS=43200000
# Production validation TTL is deliberately clamped to 1530 seconds. A
# transient Launcher outage may use the last good identity only for read-only
# requests and only inside the bounded grace window.
NODEDC_FOUNDRY_SESSION_VALIDATION_TTL_MS=20000
NODEDC_FOUNDRY_SESSION_VALIDATION_GRACE_MS=30000
NODEDC_FOUNDRY_SESSION_VALIDATION_RETRY_MS=2000
NODEDC_FOUNDRY_COOKIE_SECURE=false
NODEDC_FOUNDRY_COOKIE_SAMESITE=Lax

31
Dockerfile Normal file
View File

@ -0,0 +1,31 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json tsconfig.base.json ./
COPY apps ./apps
COPY packages ./packages
COPY registry ./registry
COPY scripts ./scripts
COPY server ./server
RUN npm ci
RUN npm run build
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3333
COPY --from=build /app/server ./server
COPY --from=build /app/apps/catalog/dist ./apps/catalog/dist
COPY --from=build /app/registry ./registry
COPY runtime-seed ./runtime-seed
EXPOSE 3333
ENTRYPOINT ["node", "/app/server/runtime-entrypoint.mjs"]
CMD ["node", "server/catalog-server.mjs"]

View File

@ -4,6 +4,8 @@
Это не архив скриншотов и не описание того, как разные приложения когда-то стилизовали одинаковый контрол. Репозиторий содержит реальные переиспользуемые пакеты, машинный реестр и живой каталог. Новые приложения должны подключать эти пакеты, выбирать тему/акцент и собирать предметный интерфейс из готовых элементов.
На том же каталоге развивается **NDC Module Foundry**: контур создания экземпляров готовых приложений из `Page Library`. Foundry не заменяет Design Guideline: он использует его как каноническую визуальную библиотеку и runtime-правило для будущих модулей.
## Что здесь является каноном
- общая геометрия приложения и верхней панели;
@ -68,3 +70,7 @@ npm run build
- [План подключения приложений](docs/ADOPTION.md)
- [Как подключать пакеты в новый проект](docs/CONSUMPTION.md)
- [Инвентарь следующих компонентов](docs/CANDIDATES.md)
- [NDC Module Foundry](docs/MODULE_STUDIO.md)
- [Production-канон Foundry Map Page, Cesium, TileCache и AMD egress](docs/FOUNDRY_MAP_CESIUM_CANON.md)
- [Базовый MCP-контур Foundry](docs/MODULE_FOUNDRY_MCP.md)
- [Привязки Data Product к Foundry](docs/FOUNDRY_DATA_PRODUCT_BINDINGS.md)

View File

@ -1,6 +1,6 @@
{
"name": "@nodedc/ui-catalog",
"version": "0.6.0",
"version": "0.7.0",
"private": true,
"type": "module",
"scripts": {

View File

@ -124,6 +124,27 @@ interface StoredLayout {
};
}
interface FoundrySessionProfile {
user: {
id: string;
email: string;
displayName: string;
avatarUrl: string | null;
initials: string;
} | null;
profileUrl: string | null;
access?: {
role: "admin" | "user";
};
}
interface CesiumIonSecretStatus {
configured: boolean;
updatedAt: string | null;
updatedBy: string | null;
verification?: "verified" | "failed" | "not-configured";
}
const materialDefaults: Record<NodedcTheme, MaterialDraft> = {
dark: {
panelHex: "#151517",
@ -307,6 +328,12 @@ export function CatalogApp() {
const [applicationDraft, setApplicationDraft] = useState<ApplicationManifestV01 | null>(null);
const [applicationSaveState, setApplicationSaveState] = useState<ApplicationDraftSaveState>("idle");
const [applicationError, setApplicationError] = useState("");
const [sessionProfile, setSessionProfile] = useState<FoundrySessionProfile | null>(null);
const [platformSettingsOpen, setPlatformSettingsOpen] = useState(false);
const [cesiumIonToken, setCesiumIonToken] = useState("");
const [cesiumIonStatus, setCesiumIonStatus] = useState<CesiumIonSecretStatus | null>(null);
const [cesiumIonSaveState, setCesiumIonSaveState] = useState<"idle" | "loading" | "saving" | "saved" | "error">("idle");
const [cesiumIonError, setCesiumIonError] = useState("");
const [mapTemplateLayout, setMapTemplateLayout] = useState<MapPageLayout | null>(null);
const [mapTemplateSaveState, setMapTemplateSaveState] = useState<"idle" | "loading" | "saving" | "saved" | "error">("loading");
const mapTemplatePreviewRef = useRef<MapFixturePreviewHandle>(null);
@ -355,8 +382,11 @@ export function CatalogApp() {
const [notes, setNotes] = useState("Общий контракт компонентов без доменной логики приложения.");
const [mediaSource, setMediaSource] = useState<"file" | "url">("file");
const [mediaUrl, setMediaUrl] = useState("");
const [mediaFileName, setMediaFileName] = useState("launcher-stage.mp4");
const [fileMediaSrc, setFileMediaSrc] = useState("/launcher-stage.mp4");
// Match the persisted production design-profile default from first paint.
// This prevents the packaged pink sample clip from flashing before
// /api/layout returns the same saved media configuration.
const [mediaFileName, setMediaFileName] = useState("possible shapes.mp4");
const [fileMediaSrc, setFileMediaSrc] = useState("/uploads/1783715822132-possible-shapes.mp4");
const [mediaError, setMediaError] = useState("");
const [mediaVisible, setMediaVisible] = useState(true);
const mediaObjectUrlRef = useRef<string | null>(null);
@ -431,6 +461,71 @@ export function CatalogApp() {
applyFaviconAssets(faviconAssets);
}, [faviconAssets]);
useEffect(() => {
let active = true;
fetch("/api/session/profile", { cache: "no-store" })
.then((response) => response.ok ? response.json() as Promise<FoundrySessionProfile> : null)
.then((profile) => { if (active) setSessionProfile(profile); })
.catch(() => { if (active) setSessionProfile(null); });
return () => { active = false; };
}, []);
const isFoundryAdmin = sessionProfile?.access?.role === "admin";
const openPlatformSettings = () => {
setPlatformSettingsOpen(true);
setCesiumIonToken("");
setCesiumIonError("");
setCesiumIonSaveState("loading");
void fetch("/api/platform-settings/cesium-ion", { cache: "no-store" })
.then(async (response) => {
if (!response.ok) throw new Error("platform_settings_load_failed");
return await response.json() as CesiumIonSecretStatus;
})
.then((status) => {
setCesiumIonStatus(status);
setCesiumIonSaveState("idle");
})
.catch(() => setCesiumIonSaveState("error"));
};
const closePlatformSettings = () => {
setPlatformSettingsOpen(false);
setCesiumIonToken("");
setCesiumIonError("");
};
const saveCesiumIonToken = async () => {
const token = cesiumIonToken.trim();
if (token.length < 16) return;
setCesiumIonSaveState("saving");
setCesiumIonError("");
try {
const response = await fetch("/api/platform-settings/cesium-ion", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token }),
});
if (!response.ok) {
const result = await response.json().catch(() => null) as { error?: string } | null;
throw new Error(result?.error || "platform_settings_save_failed");
}
setCesiumIonStatus(await response.json() as CesiumIonSecretStatus);
setCesiumIonToken("");
setCesiumIonSaveState("saved");
} catch (error) {
const code = error instanceof Error ? error.message : "platform_settings_save_failed";
setCesiumIonError(code === "cesium_ion_token_verification_failed"
? "Token не прошёл проверку Cesium Ion для terrain, imagery или 3D Buildings. Значение не сохранено."
: code === "map_gateway_admin_unauthorized"
? "Внутренний signing profile Gateway не совпадает с Foundry. Это исправляется только runner-managed deployment, не вводом значений в интерфейс."
: code === "map_gateway_admin_not_configured"
? "Runner-managed signing file Gateway недоступен."
: "Не удалось применить token. Проверьте права администратора и доступность Gateway.");
setCesiumIonSaveState("error");
}
};
useEffect(() => {
let active = true;
fetch("/api/layout", { cache: "no-store" })
@ -1291,7 +1386,7 @@ export function CatalogApp() {
return (
<div className="catalog-glass-lab">
<section className="catalog-glass-stage">
<video key={stageMediaSrc} autoPlay muted loop playsInline poster="/launcher-stage-poster.png" aria-hidden="true"><source src={stageMediaSrc} type="video/mp4" /></video>
<video key={stageMediaSrc} autoPlay muted loop playsInline aria-hidden="true"><source src={stageMediaSrc} type="video/mp4" /></video>
<div className="catalog-glass-stage__shade" />
<GlassMaterialSurface className="catalog-glass-stage__sample">
<span>CANONICAL GLASS</span>
@ -1616,7 +1711,15 @@ export function CatalogApp() {
}));
return (
<div className="catalog-application-page">
<MapFixturePreview key={page.id} ref={applicationMapPreviewRef} initialLayout={page.layout?.map ?? null} features={page.features} expanded />
<MapFixturePreview
key={page.id}
ref={applicationMapPreviewRef}
applicationId={applicationDraft.id}
pageId={page.id}
initialLayout={page.layout?.map ?? null}
features={page.features}
expanded
/>
{applicationMode === "edit" ? (
<SettingsCard eyebrow="PAGE SETTINGS" title={page.title} description={`${template.title} · ${template.version}`}>
<div className="catalog-application-draft__features">
@ -1652,7 +1755,7 @@ export function CatalogApp() {
<>
<HeaderWorkspace kind="mark" label="NODE.DC Design" imageUrl={headerMarkSrc} />
<HeaderNavigation
label="Рабочая область Module Studio"
label="Рабочая область Module Foundry"
value={guidelineOpen ? studioContext : undefined}
items={[
{ value: "visual", label: "Visual Library" },
@ -1673,15 +1776,23 @@ export function CatalogApp() {
right={
<HeaderProfile>
<IconButton label="Уведомления"><Icon name="inbox" size={20} strokeWidth={1.7} /></IconButton>
<HeaderProfileButton>Профиль</HeaderProfileButton>
<HeaderAvatar label="DC" />
<HeaderProfileButton
title={sessionProfile?.user?.displayName || "Профиль NODE.DC"}
onClick={() => { if (sessionProfile?.profileUrl) window.location.assign(sessionProfile.profileUrl); }}
>
{sessionProfile?.user?.displayName || "Профиль"}
</HeaderProfileButton>
<HeaderAvatar
label={sessionProfile?.user?.displayName || sessionProfile?.user?.initials || "DC"}
imageUrl={sessionProfile?.user?.avatarUrl || undefined}
/>
</HeaderProfile>
}
/>
}
stage={
<section className="catalog-launcher-stage">
<video key={stageMediaSrc} className="catalog-launcher-stage__media" hidden={!mediaVisible} autoPlay muted loop playsInline poster="/launcher-stage-poster.png" aria-hidden="true">
<video key={stageMediaSrc} className="catalog-launcher-stage__media" hidden={!mediaVisible} autoPlay muted loop playsInline aria-hidden="true">
<source src={stageMediaSrc} type="video/mp4" />
</video>
<div className="catalog-launcher-stage__shade" />
@ -1696,11 +1807,13 @@ export function CatalogApp() {
<AdminNavigationPanel
eyebrow="NODE.DC"
title={studioContext === "applications" ? "Applications" : studioContext === "pages" ? "Page Library" : "Visual Library"}
closeLabel="Закрыть Module Studio"
closeLabel="Закрыть Module Foundry"
navigationLabel={studioContext === "applications" ? "Application Drafts" : studioContext === "pages" ? "Page Templates" : "Разделы Visual Library"}
onClose={closeGuideline}
headerActions={studioContext === "applications" ? (
<IconButton label="Создать модуль" onClick={() => setCreateModuleOpen(true)}><Icon name="plus" /></IconButton>
) : studioContext === "pages" && isFoundryAdmin ? (
<IconButton label="Настройки Platform" onClick={openPlatformSettings}><Icon name="settings" /></IconButton>
) : undefined}
contextSlot={studioContext === "applications" ? (
<Select
@ -1746,7 +1859,7 @@ export function CatalogApp() {
footer={
<>
<span className="nodedc-admin-panel__nav-icon" aria-hidden="true"><Icon name={studioContext === "applications" ? "apps" : studioContext === "pages" ? "globe" : "shield"} /></span>
<span>{studioContext === "applications" ? activeApplication ? `${activeApplication.metadata.name} · ${activeApplication.version}` : `${applicationSummaries.length} drafts` : studioContext === "pages" ? `${pageTemplates.length} templates` : "Design System 0.6.0"}</span>
<span>{studioContext === "applications" ? activeApplication ? `${activeApplication.metadata.name} · ${activeApplication.version}` : `${applicationSummaries.length} drafts` : studioContext === "pages" ? `${pageTemplates.length} templates` : "Design Guideline 0.7.0"}</span>
</>
}
/>
@ -1923,7 +2036,7 @@ export function CatalogApp() {
<ConfirmationModal
open={deleteModuleOpen}
title="Удалить модуль?"
description={<><strong>{applicationDraft?.metadata.name}</strong><p>Draft будет убран из Module Studio. Опубликованные releases эта операция не затрагивает.</p></>}
description={<><strong>{applicationDraft?.metadata.name}</strong><p>Draft будет убран из Module Foundry. Опубликованные releases эта операция не затрагивает.</p></>}
confirmLabel="Удалить draft"
pendingLabel="Удаление…"
danger
@ -1942,6 +2055,56 @@ export function CatalogApp() {
onConfirm={confirmPageRemoval}
/>
<Window
open={platformSettingsOpen}
title="Настройки Platform"
subtitle="Map Gateway · provider credentials"
size="sm"
onClose={closePlatformSettings}
footer={
<>
<Button variant="ghost" onClick={closePlatformSettings}>Отмена</Button>
<WindowFooterActions>
<Button
variant="primary"
shape="pill"
icon={<Icon name="save" />}
disabled={cesiumIonSaveState === "loading" || cesiumIonSaveState === "saving" || cesiumIonToken.trim().length < 16}
onClick={() => { void saveCesiumIonToken(); }}
>{cesiumIonSaveState === "saving" ? "Применение…" : "Применить"}</Button>
</WindowFooterActions>
</>
}
>
<div className="catalog-form">
<SettingsCard
title="Cesium Ion"
description="Master token хранится только в закрытом хранилище Platform Map Gateway. После применения он не отображается и не попадает в browser/env или artifact."
>
<ControlRow label="Состояние">
<strong>{cesiumIonSaveState === "loading" ? "Проверяем…" : cesiumIonStatus?.configured ? "Настроен" : "Не настроен"}</strong>
</ControlRow>
{cesiumIonStatus?.configured ? <ControlRow label="Проверка provider"><strong>{cesiumIonStatus.verification === "verified" ? "Пройдена: terrain и imagery доступны" : cesiumIonStatus.verification === "failed" ? "Не пройдена: проверьте token" : "Будет выполнена при следующем применении"}</strong></ControlRow> : null}
{cesiumIonStatus?.updatedAt ? <small>Последнее изменение: {new Date(cesiumIonStatus.updatedAt).toLocaleString()}</small> : null}
</SettingsCard>
<TextField
type="password"
autoComplete="new-password"
label="Cesium Ion token"
hint="обязательно"
description="Вставьте новый token. Существующее значение намеренно нельзя прочитать обратно."
value={cesiumIonToken}
onChange={(event) => {
setCesiumIonToken(event.target.value);
if (cesiumIonSaveState === "saved" || cesiumIonSaveState === "error") setCesiumIonSaveState("idle");
setCesiumIonError("");
}}
/>
{cesiumIonSaveState === "saved" ? <small className="catalog-application-draft__status">Token применён в Platform Map Gateway.</small> : null}
{cesiumIonSaveState === "error" ? <small className="catalog-application-draft__status" data-state="error">{cesiumIonError}</small> : null}
</div>
</Window>
<Window
open={createModalOpen}
title="Создать проект"

View File

@ -9,27 +9,56 @@ import {
Cesium3DTileset,
Cesium3DTileStyle,
CesiumTerrainProvider,
CallbackProperty,
ConstantPositionProperty,
CustomDataSource,
DefaultProxy,
DistanceDisplayCondition,
EllipsoidTerrainProvider,
Entity,
HeightReference,
HeadingPitchRange,
HorizontalOrigin,
ImageryLayer,
JulianDate,
LabelGraphics,
Matrix4,
Math as CesiumMath,
PolygonHierarchy,
PointGraphics,
Resource,
ScreenSpaceEventHandler,
ScreenSpaceEventType,
SunLight,
VerticalOrigin,
Viewer,
} from "cesium";
import "cesium/Build/Cesium/Widgets/widgets.css";
import sceneFixture from "../../../registry/fixtures/map/map-operational-v0.1.json";
import { mapRuntimeEntityId, type MapRuntimeBinding, type MapRuntimeFact } from "./useMapDataProductRuntime.js";
type Position = [number, number, number?];
type PinPresentation = {
variant: "elevated-spike";
stemHeightMeters: number;
headSizePx: number;
stemWidthPx: number;
outlineColor: string;
outlineOpacity: number;
outlineWidthPx: number;
labelOffsetX: number;
labelOffsetY: number;
pinHideCameraHeightMeters?: number;
labelHideCameraHeightMeters?: number;
};
type MapStyleProfile = {
id: string;
kind: string;
color?: string;
opacity?: number;
size?: number;
pinPresentation?: PinPresentation;
};
type RuntimeConfig = {
cesiumVersion: string;
provider: string;
@ -44,17 +73,48 @@ type RuntimeConfig = {
};
export type MapGatewayHealth = {
cache?: { mode?: string; entries?: number; bytes?: number; maxBytes?: number; persistent?: boolean };
cache?: {
mode?: string;
writePolicy?: string;
entries?: number;
bytes?: number;
maxBytes?: number;
atCapacity?: boolean;
persistent?: boolean;
};
diagnostics?: {
cacheHits?: number;
cacheMisses?: number;
cacheRefreshes?: number;
upstreamRequests?: number;
egressRequests?: number;
upstreamFailures?: number;
slowUpstreamRequests?: number;
lastFailure?: string | null;
lastFailureAt?: string | null;
};
ionConfigured?: boolean;
};
type MapProviderState = "loading" | "ready" | "error" | "not-configured";
// Provider loading must be observable independently. In particular, imagery
// is optional for scene construction: a Bing metadata failure must never
// prevent Cesium World Terrain from being requested and rendered.
export type MapProviderStatus = {
imagery: MapProviderState;
terrain: MapProviderState;
buildings: MapProviderState;
errors: Partial<Record<"imagery" | "terrain" | "buildings", string>>;
};
type IonAssetEndpoint = {
assetId: string;
type: "TERRAIN" | "3DTILES" | "IMAGERY";
url?: string;
accessToken?: string;
credentialMode?: "gateway";
externalType?: "BING";
options?: { url?: string; key?: string; mapStyle?: string };
options?: { url?: string; mapStyle?: string };
attributions: Array<{ html?: string; collapsible?: boolean }>;
};
@ -62,6 +122,7 @@ export type MapPresentation = {
imagerySource: "cesium-live";
imageryVisible: boolean;
cacheEnabled: boolean;
cacheNoOverwrite: boolean;
terrainEnabled: boolean;
terrainExaggeration: number;
monochrome: boolean;
@ -121,6 +182,22 @@ const toCartesianArray = (positions: Position[]) => positions.map(toCartesian);
const accent = Color.fromCssColorString("#ff2f92");
const violet = Color.fromCssColorString("#8f72dc");
const clamp = (value: number, minimum: number, maximum: number) => Math.max(minimum, Math.min(maximum, value));
const defaultElevatedPin: PinPresentation = {
variant: "elevated-spike",
stemHeightMeters: 120,
headSizePx: 8,
stemWidthPx: 2,
outlineColor: "#0c0d12",
outlineOpacity: 0.6,
outlineWidthPx: 1,
labelOffsetX: 10,
labelOffsetY: 0,
};
function showBelowCameraHeight(viewer: Viewer, limit?: number) {
if (!limit) return true;
return new CallbackProperty(() => Number(viewer.camera.positionCartographic?.height || 0) <= limit, false);
}
function getCameraView(viewer: Viewer): MapCameraView {
const position = viewer.camera.positionCartographic;
@ -135,6 +212,9 @@ function getCameraView(viewer: Viewer): MapCameraView {
}
function addFixtureEntities(viewer: Viewer) {
const styleProfiles = new Map<string, MapStyleProfile>(
(sceneFixture.styleProfiles as unknown as MapStyleProfile[]).map((profile) => [profile.id, profile]),
);
for (const place of sceneFixture.scene.places) {
viewer.entities.add({
id: place.id,
@ -207,10 +287,29 @@ function addFixtureEntities(viewer: Viewer) {
},
});
}
const pinStyle = styleProfiles.get(object.pinStyleProfileId);
const pin = pinStyle?.pinPresentation ?? defaultElevatedPin;
const pinColor = Color.fromCssColorString(pinStyle?.color || "#ff2f92").withAlpha(pinStyle?.opacity ?? 1);
const outlineColor = Color.fromCssColorString(pin.outlineColor).withAlpha(pin.outlineOpacity);
const [longitude, latitude] = object.position as Position;
const base = Cartesian3.fromDegrees(longitude, latitude, 0);
const top = Cartesian3.fromDegrees(longitude, latitude, pin.stemHeightMeters);
viewer.entities.add({
id: object.id,
position: toCartesian(object.position as Position),
point: { pixelSize: 18, color: accent, outlineColor: Color.WHITE, outlineWidth: 3 },
position: top,
polyline: {
positions: [base, top],
width: pin.stemWidthPx,
material: pinColor,
show: showBelowCameraHeight(viewer, pin.pinHideCameraHeightMeters),
},
point: {
pixelSize: pin.headSizePx,
color: pinColor,
outlineColor,
outlineWidth: pin.outlineWidthPx,
show: showBelowCameraHeight(viewer, pin.pinHideCameraHeightMeters),
},
label: {
text: object.label.text,
font: "700 13px Arial",
@ -218,7 +317,12 @@ function addFixtureEntities(viewer: Viewer) {
showBackground: true,
backgroundColor: Color.BLACK.withAlpha(0.72),
backgroundPadding: new Cartesian2(10, 7),
pixelOffset: new Cartesian2(0, -31),
pixelOffset: new Cartesian2(pin.labelOffsetX, pin.labelOffsetY),
horizontalOrigin: HorizontalOrigin.LEFT,
verticalOrigin: VerticalOrigin.BOTTOM,
heightReference: HeightReference.NONE,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
show: showBelowCameraHeight(viewer, pin.labelHideCameraHeightMeters),
},
});
}
@ -241,6 +345,82 @@ function addFixtureEntities(viewer: Viewer) {
}
}
function runtimeDisplayLabel(fact: MapRuntimeFact) {
for (const key of ["label", "name", "title", "subject_id"]) {
const value = fact.attributes[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
return fact.sourceId;
}
function runtimePointColor(fact: MapRuntimeFact) {
// This is a semantic default for the generic Map entity-stream adapter,
// not a provider style. A renderer-neutral style profile can refine it
// later without changing a data product or its L2 workflow.
return fact.semanticType === "map.moving_object" ? accent : violet;
}
function syncRuntimeDataSources(
viewer: Viewer,
dataSources: Map<string, CustomDataSource>,
bindings: MapRuntimeBinding[],
) {
const activeBindings = new Map(bindings
.filter((binding) => binding.slotId === "points")
.map((binding) => [binding.bindingId, binding]));
for (const [bindingId, dataSource] of dataSources) {
if (activeBindings.has(bindingId)) continue;
viewer.dataSources.remove(dataSource, true);
dataSources.delete(bindingId);
}
for (const binding of activeBindings.values()) {
let dataSource = dataSources.get(binding.bindingId);
if (!dataSource) {
dataSource = new CustomDataSource(`nodedc-map-slot:${binding.bindingId}`);
viewer.dataSources.add(dataSource);
dataSources.set(binding.bindingId, dataSource);
}
const wanted = new Set<string>();
for (const fact of binding.facts) {
if (!fact.geometry) continue;
const entityId = mapRuntimeEntityId(binding.bindingId, fact);
wanted.add(entityId);
const [longitude, latitude] = fact.geometry.coordinates;
const color = runtimePointColor(fact);
const label = runtimeDisplayLabel(fact);
const entity = dataSource.entities.getById(entityId) ?? dataSource.entities.add({ id: entityId });
entity.name = label;
entity.position = new ConstantPositionProperty(Cartesian3.fromDegrees(longitude, latitude, 0));
entity.point = new PointGraphics({
pixelSize: 10,
color,
outlineColor: Color.fromCssColorString("#0c0d12").withAlpha(0.72),
outlineWidth: 2,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
entity.label = new LabelGraphics({
text: label,
font: "700 13px Arial",
fillColor: Color.WHITE,
showBackground: true,
backgroundColor: Color.BLACK.withAlpha(0.72),
backgroundPadding: new Cartesian2(10, 7),
pixelOffset: new Cartesian2(10, 0),
horizontalOrigin: HorizontalOrigin.LEFT,
verticalOrigin: VerticalOrigin.BOTTOM,
heightReference: HeightReference.NONE,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
}
for (const entity of [...dataSource.entities.values]) {
if (typeof entity.id === "string" && !wanted.has(entity.id)) dataSource.entities.remove(entity);
}
}
viewer.scene.requestRender();
}
function rebuildElevatedGrid(viewer: Viewer, dataSource: CustomDataSource, presentation: MapPresentation) {
const entities = dataSource.entities;
entities.removeAll();
@ -363,15 +543,21 @@ function applyPresentation(
export function CesiumMapRenderer({
onSelect,
onGatewayHealth,
onProviderStatus,
onCameraChange,
onCacheRefreshConsumed,
initialCamera,
presentation,
runtimeBindings = [],
}: {
onSelect?: (entityId: string) => void;
onGatewayHealth?: (health: MapGatewayHealth | null) => void;
onProviderStatus?: (status: MapProviderStatus) => void;
onCameraChange?: (camera: MapCameraView) => void;
onCacheRefreshConsumed?: () => void;
initialCamera?: MapCameraView;
presentation: MapPresentation;
runtimeBindings?: MapRuntimeBinding[];
}) {
const containerRef = useRef<HTMLDivElement>(null);
const creditContainerRef = useRef<HTMLDivElement>(null);
@ -380,24 +566,45 @@ export function CesiumMapRenderer({
const buildingsRef = useRef<Cesium3DTileset | null>(null);
const terrainRef = useRef<{ world: CesiumTerrainProvider | null; ellipsoid: EllipsoidTerrainProvider } | null>(null);
const rebuildGridRef = useRef<(() => void) | null>(null);
const runtimeDataSourcesRef = useRef(new Map<string, CustomDataSource>());
const presentationRef = useRef(presentation);
const runtimeBindingsRef = useRef(runtimeBindings);
const onSelectRef = useRef(onSelect);
const onCameraChangeRef = useRef(onCameraChange);
const onCacheRefreshConsumedRef = useRef(onCacheRefreshConsumed);
useEffect(() => {
onSelectRef.current = onSelect;
}, [onSelect]);
useEffect(() => {
onCameraChangeRef.current = onCameraChange;
}, [onCameraChange]);
useEffect(() => {
onCacheRefreshConsumedRef.current = onCacheRefreshConsumed;
}, [onCacheRefreshConsumed]);
useEffect(() => {
presentationRef.current = presentation;
if (viewerRef.current && terrainRef.current) applyPresentation(viewerRef.current, imageryLayerRef.current, buildingsRef.current, terrainRef.current, presentation);
rebuildGridRef.current?.();
}, [presentation]);
useEffect(() => {
runtimeBindingsRef.current = runtimeBindings;
if (viewerRef.current && !viewerRef.current.isDestroyed()) {
syncRuntimeDataSources(viewerRef.current, runtimeDataSourcesRef.current, runtimeBindings);
}
}, [runtimeBindings]);
useEffect(() => {
let viewer: Viewer | undefined;
let handler: ScreenSpaceEventHandler | undefined;
let resizeObserver: ResizeObserver | undefined;
let removeGridCameraListener: (() => void) | undefined;
let removeRefreshRenderListener: (() => void) | undefined;
let removeRenderErrorListener: (() => void) | undefined;
let cancelled = false;
const start = async () => {
@ -426,27 +633,33 @@ export function CesiumMapRenderer({
timeline: false,
requestRenderMode: true,
maximumRenderTimeChange: Number.POSITIVE_INFINITY,
// The default Cesium panel hides the useful UI state and displays
// `[object Object]` for several non-Error render faults. We report a
// safe diagnostic through the Map panel and attempt one bounded
// recovery instead of leaving a modal over a stopped renderer.
showRenderLoopErrors: false,
// Sandbox-only: Cesium writes credits into a dedicated, visually
// suppressed container. Runtime attribution metadata is preserved;
// external and commercial surfaces must provide visible credits.
creditContainer: creditContainerRef.current ?? undefined,
});
const resourceProxy = config?.resourceProxyBase ? new DefaultProxy(config.resourceProxyBase) : undefined;
const buildResource = (url: string, accessToken?: string) => {
const buildResource = (url: string) => {
// Put cache intent into the upstream URL itself. Cesium providers
// derive child resources (metadata, imagery tiles, terrain and 3D
// tiles) from this URL; DefaultProxy then forwards the exact intent
// to Gateway for every derived request.
const routedUrl = new URL(url);
// Provider bytes are cached once by Platform Map Gateway. Version
// the browser-facing route so an old malformed HTTP response cannot
// shadow the shared TileCache after a transport fix.
routedUrl.searchParams.set("nodedc_client_revision", "2");
if (!presentationRef.current.cacheEnabled) routedUrl.searchParams.set("nodedc_cache_mode", "passthrough");
if (presentationRef.current.cacheRefresh) routedUrl.searchParams.set("nodedc_cache_refresh", "1");
if (presentationRef.current.cacheRefresh || !presentationRef.current.cacheNoOverwrite) routedUrl.searchParams.set("nodedc_cache_refresh", "1");
return new Resource({
url: routedUrl.toString(),
queryParameters: {
...(accessToken ? { access_token: accessToken } : {}),
},
proxy: resourceProxy,
});
url: routedUrl.toString(),
proxy: resourceProxy,
});
};
const loadEndpoint = async (assetId: string) => {
if (!config?.gatewayReady) throw new Error("map_gateway_not_ready");
@ -454,23 +667,14 @@ export function CesiumMapRenderer({
if (!endpointResponse.ok) throw new Error(`Map Gateway asset ${assetId}: ${endpointResponse.status}`);
return endpointResponse.json() as Promise<IonAssetEndpoint>;
};
const endpoint = await loadEndpoint("2");
if (endpoint.externalType !== "BING" || !endpoint.options?.url || !endpoint.options?.key) throw new Error("cesium_live_imagery_endpoint_invalid");
const imageryProvider = await BingMapsImageryProvider.fromUrl(buildResource(endpoint.options.url), {
key: endpoint.options.key,
mapStyle: (endpoint.options.mapStyle || "Aerial") as BingMapsStyle,
tileProtocol: "https",
});
for (const attribution of endpoint.attributions) if (attribution.html) viewer.creditDisplay.addStaticCredit(new Credit(attribution.html, attribution.collapsible));
const imageryLayer = viewer.imageryLayers.addImageryProvider(imageryProvider);
const gridDataSource = new CustomDataSource("nodedc-map-grid");
viewer.dataSources.add(gridDataSource);
const terrain = { world: null as CesiumTerrainProvider | null, ellipsoid: new EllipsoidTerrainProvider() };
viewer.terrainProvider = terrain.ellipsoid;
viewer.scene.globe.depthTestAgainstTerrain = true;
addFixtureEntities(viewer);
syncRuntimeDataSources(viewer, runtimeDataSourcesRef.current, runtimeBindingsRef.current);
viewerRef.current = viewer;
imageryLayerRef.current = imageryLayer;
terrainRef.current = terrain;
const rebuildGrid = () => rebuildElevatedGrid(viewer!, gridDataSource, presentationRef.current);
rebuildGridRef.current = rebuildGrid;
@ -479,35 +683,96 @@ export function CesiumMapRenderer({
onCameraChangeRef.current?.(getCameraView(viewer!));
});
const providerStatus: MapProviderStatus = {
imagery: config?.gatewayReady ? "loading" : "not-configured",
terrain: config?.gatewayReady ? "loading" : "not-configured",
buildings: config?.gatewayReady ? "loading" : "not-configured",
errors: config?.gatewayReady ? {} : {
imagery: "Platform Map Gateway недоступен",
terrain: "Platform Map Gateway недоступен",
buildings: "Platform Map Gateway недоступен",
},
};
const reportProvider = (provider: "imagery" | "terrain" | "buildings", state: MapProviderState, error?: unknown) => {
providerStatus[provider] = state;
if (state === "error") {
providerStatus.errors[provider] = error instanceof Error && error.message ? error.message : "provider_unavailable";
} else {
delete providerStatus.errors[provider];
}
if (!cancelled) onProviderStatus?.({ ...providerStatus, errors: { ...providerStatus.errors } });
};
onProviderStatus?.({ ...providerStatus, errors: { ...providerStatus.errors } });
let renderRecoveryScheduled = false;
removeRenderErrorListener = viewer.scene.renderError.addEventListener((_scene, error) => {
const raw = error instanceof Error ? error.message : "cesium_render_error";
const safe = raw.replace(/[^A-Za-z0-9_.:-]/g, "_").slice(0, 120) || "cesium_render_error";
reportProvider("imagery", "error", safe);
reportProvider("terrain", "error", safe);
reportProvider("buildings", "error", safe);
if (renderRecoveryScheduled) return;
renderRecoveryScheduled = true;
window.setTimeout(() => {
if (cancelled || !viewer || viewer.isDestroyed()) return;
// Cesium's default listener stops its render loop after a scene
// error. One retry handles a transient resource/resize race but
// remains bounded if the browser or GPU fault is persistent.
viewer.useDefaultRenderLoop = true;
viewer.scene.requestRender();
}, 0);
});
if (config?.gatewayReady) {
// Do not serialize provider startup. A failure in Bing imagery is
// recoverable and must not stop terrain, buildings, or the scene.
void loadEndpoint("2").then(async (endpoint) => {
if (endpoint.externalType !== "BING" || !endpoint.options?.url || endpoint.credentialMode !== "gateway") throw new Error("cesium_live_imagery_endpoint_invalid");
const imageryProvider = await BingMapsImageryProvider.fromUrl(buildResource(endpoint.options.url), {
// Cesium requires a key-shaped value to form its Bing URL. This
// public marker is stripped by Map Gateway before upstream use.
key: "nodedc-gateway",
mapStyle: (endpoint.options.mapStyle || "Aerial") as BingMapsStyle,
tileProtocol: "https",
});
if (cancelled || !viewer || viewer.isDestroyed()) return;
for (const attribution of endpoint.attributions) if (attribution.html) viewer.creditDisplay.addStaticCredit(new Credit(attribution.html, attribution.collapsible));
imageryLayerRef.current = viewer.imageryLayers.addImageryProvider(imageryProvider);
applyPresentation(viewer, imageryLayerRef.current, buildingsRef.current, terrain, presentationRef.current);
reportProvider("imagery", "ready");
}).catch((error) => reportProvider("imagery", "error", error));
void loadEndpoint("1").then(async (terrainEndpoint) => {
if (!terrainEndpoint.url || !terrainEndpoint.accessToken) throw new Error("terrain_endpoint_invalid");
const terrainResource = buildResource(terrainEndpoint.url, terrainEndpoint.accessToken);
if (!terrainEndpoint.url || terrainEndpoint.credentialMode !== "gateway") throw new Error("terrain_endpoint_invalid");
const terrainResource = buildResource(terrainEndpoint.url);
const world = await CesiumTerrainProvider.fromUrl(terrainResource, { requestVertexNormals: true, requestWaterMask: true });
if (cancelled || !viewer || viewer.isDestroyed()) return;
terrain.world = world;
for (const attribution of terrainEndpoint.attributions) if (attribution.html) viewer.creditDisplay.addStaticCredit(new Credit(attribution.html, attribution.collapsible));
applyPresentation(viewer, imageryLayer, buildingsRef.current, terrain, presentationRef.current);
}).catch(() => undefined);
applyPresentation(viewer, imageryLayerRef.current, buildingsRef.current, terrain, presentationRef.current);
reportProvider("terrain", "ready");
}).catch((error) => reportProvider("terrain", "error", error));
void loadEndpoint("96188").then(async (buildingsEndpoint) => {
if (!buildingsEndpoint.url || !buildingsEndpoint.accessToken) throw new Error("buildings_endpoint_invalid");
const buildingsResource = buildResource(buildingsEndpoint.url, buildingsEndpoint.accessToken);
if (!buildingsEndpoint.url || buildingsEndpoint.credentialMode !== "gateway") throw new Error("buildings_endpoint_invalid");
const buildingsResource = buildResource(buildingsEndpoint.url);
const buildings = await Cesium3DTileset.fromUrl(buildingsResource);
if (cancelled || !viewer || viewer.isDestroyed()) return;
viewer.scene.primitives.add(buildings);
buildingsRef.current = buildings;
for (const attribution of buildingsEndpoint.attributions) if (attribution.html) viewer.creditDisplay.addStaticCredit(new Credit(attribution.html, attribution.collapsible));
applyPresentation(viewer, imageryLayer, buildings, terrain, presentationRef.current);
}).catch(() => undefined);
applyPresentation(viewer, imageryLayerRef.current, buildings, terrain, presentationRef.current);
reportProvider("buildings", "ready");
}).catch((error) => reportProvider("buildings", "error", error));
if (config.gaussianSplatsReady && config.gaussianAssetId) {
const gaussianEndpoint = await loadEndpoint(config.gaussianAssetId);
if (!gaussianEndpoint.url || !gaussianEndpoint.accessToken) throw new Error("gaussian_endpoint_invalid");
const gaussianResource = buildResource(gaussianEndpoint.url, gaussianEndpoint.accessToken);
viewer.scene.primitives.add(await Cesium3DTileset.fromUrl(gaussianResource));
void loadEndpoint(config.gaussianAssetId).then(async (gaussianEndpoint) => {
if (!gaussianEndpoint.url || gaussianEndpoint.credentialMode !== "gateway") throw new Error("gaussian_endpoint_invalid");
const gaussianResource = buildResource(gaussianEndpoint.url);
const gaussian = await Cesium3DTileset.fromUrl(gaussianResource);
if (cancelled || !viewer || viewer.isDestroyed()) return;
viewer.scene.primitives.add(gaussian);
}).catch(() => undefined);
}
}
applyPresentation(viewer, imageryLayer, buildingsRef.current, terrain, presentationRef.current);
applyPresentation(viewer, imageryLayerRef.current, buildingsRef.current, terrain, presentationRef.current);
if (initialCamera) {
viewer.camera.setView({
@ -530,11 +795,23 @@ export function CesiumMapRenderer({
rebuildGrid();
onCameraChangeRef.current?.(getCameraView(viewer));
if (presentationRef.current.cacheRefresh && onCacheRefreshConsumedRef.current) {
// All root provider resources for the current view were created with
// `nodedc_cache_refresh=1`. Reset after the first render so later
// navigation goes back to the selected steady-state policy.
removeRefreshRenderListener = viewer.scene.postRender.addEventListener(() => {
removeRefreshRenderListener?.();
removeRefreshRenderListener = undefined;
if (!cancelled) onCacheRefreshConsumedRef.current?.();
});
viewer.scene.requestRender();
}
handler = new ScreenSpaceEventHandler(viewer.scene.canvas);
handler.setInputAction((movement: { position: Cartesian2 }) => {
const picked = viewer?.scene.pick(movement.position);
const entity = picked?.id instanceof Entity ? picked.id : undefined;
if (entity?.id) onSelect?.(entity.id);
if (entity?.id) onSelectRef.current?.(entity.id);
}, ScreenSpaceEventType.LEFT_CLICK);
resizeObserver = new ResizeObserver(() => {
if (!viewer || viewer.isDestroyed()) return;
@ -542,9 +819,19 @@ export function CesiumMapRenderer({
viewer.scene.requestRender();
});
resizeObserver.observe(containerRef.current);
} catch {
// The canvas stays available even if an optional provider fails. Its
// detailed state belongs to the Inspector, not to a map overlay label.
} catch (error) {
// Failure before viewer creation is distinct from a provider failure;
// surface it through the Inspector without exposing any credentials.
if (!cancelled) onProviderStatus?.({
imagery: "error",
terrain: "error",
buildings: "error",
errors: {
imagery: error instanceof Error ? error.message : "map_start_failed",
terrain: error instanceof Error ? error.message : "map_start_failed",
buildings: error instanceof Error ? error.message : "map_start_failed",
},
});
}
};
@ -553,6 +840,8 @@ export function CesiumMapRenderer({
cancelled = true;
resizeObserver?.disconnect();
removeGridCameraListener?.();
removeRefreshRenderListener?.();
removeRenderErrorListener?.();
handler?.destroy();
if (viewer && !viewer.isDestroyed()) viewer.destroy();
viewerRef.current = null;
@ -560,8 +849,9 @@ export function CesiumMapRenderer({
buildingsRef.current = null;
terrainRef.current = null;
rebuildGridRef.current = null;
runtimeDataSourcesRef.current.clear();
};
}, [onGatewayHealth, onSelect]);
}, [onGatewayHealth, onProviderStatus]);
return (
<div className="catalog-cesium-map">

View File

@ -1,6 +1,7 @@
import { forwardRef, lazy, Suspense, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type CSSProperties, type PointerEvent } from "react";
import { Button, Checker, ColorField, ControlRow, GlassSurface, Icon, IconButton, Inspector, RangeControl, Window } from "@nodedc/ui-react";
import type { MapCameraView, MapGatewayHealth, MapPresentation } from "./CesiumMapRenderer.js";
import type { MapCameraView, MapGatewayHealth, MapPresentation, MapProviderStatus } from "./CesiumMapRenderer.js";
import { mapRuntimeEntityId, useMapDataProductRuntime } from "./useMapDataProductRuntime.js";
import sceneFixture from "../../../registry/fixtures/map/map-operational-v0.1.json";
const CesiumMapRenderer = lazy(() => import("./CesiumMapRenderer.js").then((module) => ({ default: module.CesiumMapRenderer })));
@ -8,6 +9,67 @@ const CesiumMapRenderer = lazy(() => import("./CesiumMapRenderer.js").then((modu
type PreviewFeatures = { inspector?: boolean; toolbar?: boolean; assistant?: boolean };
export type MapPageSettings = Omit<MapPresentation, "cacheRefresh">;
type MapRuntimeConfig = { gatewayHealthUrl?: string | null; resourceProxyBase?: string | null };
type GatewayCheckState = "idle" | "checking" | "ready" | "stale" | "error";
type GatewayHealthOrder = { nextEpoch: number; latestStartedEpoch: number };
const RENDERER_GATEWAY_HEALTH_EPOCH = 1;
function beginGatewayHealthEpoch(order: GatewayHealthOrder) {
order.nextEpoch += 1;
order.latestStartedEpoch = order.nextEpoch;
return order.nextEpoch;
}
function isLatestGatewayHealthEpoch(order: GatewayHealthOrder, epoch: number) {
return order.latestStartedEpoch === epoch;
}
function safeGatewayCheckCode(value: unknown, fallback = "gateway_not_ready") {
const code = value instanceof Error && value.message ? value.message : fallback;
return code.replace(/[^A-Za-z0-9_.:-]/g, "_").slice(0, 80) || fallback;
}
function gatewayCheckMessage(code: string, stale: boolean) {
if (code === "persistent_cache_unavailable") {
return "Persistent TileCache не подключён: карта не должна продолжать работу с локальной временной папкой.";
}
const prefix = stale ? "Текущая проверка не прошла" : "Проверка Platform Map Gateway не прошла";
const suffix = stale
? "Показаны последние успешно полученные данные TileCache."
: "TileCache и runtime profile не изменялись.";
return `${prefix} (${code}). ${suffix}`;
}
/**
* Provider-neutral visual binding. Foundry stores this on an Application page
* instance; a future data binding resolves the live source behind `source`.
*/
export type MapPinBinding = {
id: string;
subjectId: string;
kind: "elevated-spike";
label: string;
status: string;
coordinates: { longitude: number; latitude: number; heightMeters: number };
source: { entityId: string; streamId: string; displayFields: string[] };
attributes: Record<string, string | number | boolean>;
};
/**
* A renderer-neutral declaration of an entity stream assigned to this page.
*
* It intentionally contains no provider endpoint, tenant/connection scope,
* credential reference, or browser token. The Foundry runtime resolves the
* matching server-side consumer grant from the application/page/binding
* target before it asks the External Data Plane for a snapshot or patches.
*/
export type MapDataProductBinding = {
id: string;
dataProductId: string;
slotId: string;
delivery: "snapshot+patch";
semanticTypes: string[];
fieldProjection: string[];
};
export type MapPageLayout = {
schemaVersion: 1;
@ -15,6 +77,8 @@ export type MapPageLayout = {
settings: MapPageSettings;
mapHeight: number;
camera: MapCameraView;
pinBindings: MapPinBinding[];
dataProductBindings: MapDataProductBinding[];
savedAt?: string;
};
@ -26,6 +90,7 @@ const initialMapSettings: MapPageSettings = {
imagerySource: "cesium-live",
imageryVisible: true,
cacheEnabled: true,
cacheNoOverwrite: true,
terrainEnabled: true,
terrainExaggeration: 1,
monochrome: false,
@ -82,23 +147,73 @@ const fallbackMapCamera: MapCameraView = {
roll: 0,
};
const initialProviderStatus: MapProviderStatus = {
imagery: "loading",
terrain: "loading",
buildings: "loading",
errors: {},
};
const providerStateLabel: Record<MapProviderStatus["imagery"], string> = {
loading: "загружается",
ready: "готов",
error: "недоступен",
"not-configured": "не настроен",
};
export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
features?: PreviewFeatures;
expanded?: boolean;
initialLayout?: MapPageLayout | null;
}>(function MapFixturePreview({ features = { inspector: true, toolbar: true, assistant: false }, expanded = false, initialLayout = null }, ref) {
const selectable = useMemo(() => [
applicationId?: string;
pageId?: string;
}>(function MapFixturePreview({ features = { inspector: true, toolbar: true, assistant: false }, expanded = false, initialLayout = null, applicationId, pageId }, ref) {
const fixtureSelectable = useMemo(() => [
...sceneFixture.scene.movingObjects.map((entity) => ({ id: entity.id, title: entity.label.text, kind: entity.objectType, status: entity.status })),
...sceneFixture.scene.stations.map((entity) => ({ id: entity.id, title: entity.label.text, kind: `${entity.stationType} station`, status: undefined })),
], []);
const [selectedId, setSelectedId] = useState(sceneFixture.selection.entityId ?? selectable[0]?.id);
const [selectedId, setSelectedId] = useState(sceneFixture.selection.entityId ?? fixtureSelectable[0]?.id);
const [inspectorOpen, setInspectorOpen] = useState(false);
const [layersOpen, setLayersOpen] = useState(false);
const [toolbarOpen, setToolbarOpen] = useState(Boolean(features.toolbar));
const [assistantOpen, setAssistantOpen] = useState(false);
const [mapSettings, setMapSettings] = useState<MapPageSettings>(() => ({ ...initialMapSettings, ...initialLayout?.settings }));
const [mapSettings, setMapSettings] = useState<MapPageSettings>(() => ({
...initialMapSettings,
...initialLayout?.settings,
// Layouts saved before the cache policy field existed retain the safe
// append-only default when they are opened again.
cacheNoOverwrite: initialLayout?.settings?.cacheNoOverwrite ?? true,
}));
const [mapHeight, setMapHeight] = useState(() => initialLayout?.mapHeight ?? (expanded ? 620 : 470));
const [mapCamera, setMapCamera] = useState<MapCameraView>(initialLayout?.camera ?? fallbackMapCamera);
// Map pin bindings belong to the application page instance. They are kept
// intact when a human changes camera or visual settings and presses Save.
const [pinBindings] = useState<MapPinBinding[]>(() => initialLayout?.pinBindings ?? []);
// Data-product bindings are provisioned by Foundry MCP / Platform and do
// not belong to the visual inspector. Preserve them verbatim when a human
// edits camera or presentation settings and saves the page layout.
const [dataProductBindings] = useState<MapDataProductBinding[]>(() => initialLayout?.dataProductBindings ?? []);
const runtimeBindings = useMapDataProductRuntime({
applicationId,
pageId,
bindings: dataProductBindings,
enabled: Boolean(applicationId && pageId),
});
const selectable = useMemo(() => [
...fixtureSelectable,
...runtimeBindings.flatMap((binding) => binding.facts.map((fact) => {
const attributes = fact.attributes;
const label = [attributes.label, attributes.name, attributes.title, attributes.subject_id]
.find((value) => typeof value === "string" && value.trim());
const status = typeof attributes.status === "string" ? attributes.status : undefined;
return {
id: mapRuntimeEntityId(binding.bindingId, fact),
title: typeof label === "string" ? label : fact.sourceId,
kind: fact.semanticType,
status,
};
})),
], [fixtureSelectable, runtimeBindings]);
// The header Save action can be pressed immediately after Cesium finishes
// constructing the scene. Keep the last camera synchronously as well as in
// state, so the imperative page-layout contract never waits for React's
@ -106,16 +221,42 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
const mapCameraRef = useRef<MapCameraView>(initialLayout?.camera ?? fallbackMapCamera);
const [rendererRevision, setRendererRevision] = useState(0);
const [gatewayHealth, setGatewayHealth] = useState<MapGatewayHealth | null>(null);
const gatewayHealthRef = useRef<MapGatewayHealth | null>(null);
// The renderer owns epoch 1 as a bootstrap health source. As soon as the UI
// starts an explicit verification, its higher epoch becomes authoritative;
// a slower renderer request can no longer overwrite that newer result.
const gatewayHealthOrderRef = useRef<GatewayHealthOrder>({
nextEpoch: RENDERER_GATEWAY_HEALTH_EPOCH,
latestStartedEpoch: RENDERER_GATEWAY_HEALTH_EPOCH,
});
const gatewayCheckRequestRef = useRef<{ id: symbol; controller: AbortController; promise: Promise<void> } | null>(null);
const [gatewayEndpoint, setGatewayEndpoint] = useState<string | null>(null);
const [gatewayCheckState, setGatewayCheckState] = useState<"idle" | "checking" | "ready" | "error">("idle");
const [gatewayCheckState, setGatewayCheckState] = useState<GatewayCheckState>("idle");
const [gatewayCheckError, setGatewayCheckError] = useState<string | null>(null);
const [gatewayLastVerifiedAt, setGatewayLastVerifiedAt] = useState<Date | null>(null);
const [providerStatus, setProviderStatus] = useState<MapProviderStatus>(initialProviderStatus);
const [cacheRefresh, setCacheRefresh] = useState(false);
const selected = selectable.find((entity) => entity.id === selectedId) ?? selectable[0];
const presentation: MapPresentation = { ...mapSettings, cacheRefresh: false };
const presentation: MapPresentation = { ...mapSettings, cacheRefresh };
const updateMapSettings = (patch: Partial<MapPageSettings>) => setMapSettings((current) => ({ ...current, ...patch }));
const setCacheEnabled = (cacheEnabled: boolean) => {
updateMapSettings({ cacheEnabled });
setRendererRevision((value) => value + 1);
};
const setCacheNoOverwrite = (cacheNoOverwrite: boolean) => {
updateMapSettings({ cacheNoOverwrite });
setCacheRefresh(false);
setRendererRevision((value) => value + 1);
};
const refreshCurrentViewport = () => {
if (!mapSettings.cacheEnabled) return;
setCacheRefresh(true);
setRendererRevision((value) => value + 1);
};
const handleCacheRefreshConsumed = useCallback(() => {
setCacheRefresh(false);
setRendererRevision((value) => value + 1);
}, []);
const handleCameraChange = useCallback((camera: MapCameraView) => {
mapCameraRef.current = camera;
@ -129,50 +270,132 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
settings: mapSettings,
mapHeight: Math.round(mapHeight),
camera: mapCameraRef.current ?? mapCamera,
pinBindings,
dataProductBindings,
}),
}), [mapCamera, mapHeight, mapSettings]);
}), [dataProductBindings, mapCamera, mapHeight, mapSettings, pinBindings]);
const handleSelect = useCallback((entityId: string) => {
if (!selectable.some((entity) => entity.id === entityId)) return;
setSelectedId(entityId);
}, [selectable]);
const verifyGateway = useCallback(async () => {
const rememberGatewayHealth = useCallback((health: MapGatewayHealth) => {
gatewayHealthRef.current = health;
setGatewayHealth(health);
setGatewayLastVerifiedAt(new Date());
}, []);
const beginGatewayHealthVerification = useCallback(() => {
return beginGatewayHealthEpoch(gatewayHealthOrderRef.current);
}, []);
const isLatestGatewayHealthRequest = useCallback((epoch: number) => (
isLatestGatewayHealthEpoch(gatewayHealthOrderRef.current, epoch)
), []);
const handleRendererGatewayHealth = useCallback((health: MapGatewayHealth | null) => {
if (!isLatestGatewayHealthRequest(RENDERER_GATEWAY_HEALTH_EPOCH)) return;
if (health?.cache?.persistent === true) {
rememberGatewayHealth(health);
setGatewayCheckError(null);
setGatewayCheckState("ready");
return;
}
const stale = Boolean(gatewayHealthRef.current);
const code = health ? "persistent_cache_unavailable" : "gateway_health_unavailable";
setGatewayCheckError(gatewayCheckMessage(code, stale));
setGatewayCheckState(stale ? "stale" : "error");
}, [isLatestGatewayHealthRequest, rememberGatewayHealth]);
const verifyGateway = useCallback(() => {
const pending = gatewayCheckRequestRef.current;
if (pending) return pending.promise;
const epoch = beginGatewayHealthVerification();
const id = Symbol("gateway-check");
const controller = new AbortController();
let timedOut = false;
let stage: "runtime" | "gateway_health" = "runtime";
setGatewayCheckState("checking");
setGatewayCheckError(null);
try {
const runtimeResponse = await fetch("/api/map/runtime-config");
const runtime = runtimeResponse.ok ? await runtimeResponse.json() as MapRuntimeConfig : null;
if (!runtime?.gatewayHealthUrl) throw new Error("gateway_not_configured");
const healthResponse = await fetch(runtime.gatewayHealthUrl);
if (!healthResponse.ok) throw new Error("gateway_not_ready");
const health = await healthResponse.json() as MapGatewayHealth;
if (health.cache?.persistent !== true) throw new Error("persistent_cache_unavailable");
setGatewayHealth(health);
setGatewayEndpoint(new URL(runtime.gatewayHealthUrl).origin);
setGatewayCheckState("ready");
} catch (error) {
const code = error instanceof Error ? error.message : "gateway_not_ready";
setGatewayHealth(null);
setGatewayEndpoint(null);
setGatewayCheckError(code === "persistent_cache_unavailable"
? "Persistent TileCache не подключён: карта не должна продолжать работу с локальной временной папкой."
: "Platform Map Gateway недоступен: проверьте runtime profile и persistent volume.");
setGatewayCheckState("error");
}
}, []);
const timeout = window.setTimeout(() => {
timedOut = true;
controller.abort();
}, 10_000);
const promise = (async () => {
try {
const runtimeResponse = await fetch("/api/map/runtime-config", { cache: "no-store", signal: controller.signal });
if (!runtimeResponse.ok) throw new Error(`runtime_http_${runtimeResponse.status}`);
let runtime: MapRuntimeConfig;
try {
runtime = await runtimeResponse.json() as MapRuntimeConfig;
} catch {
throw new Error("runtime_invalid_response");
}
if (!runtime?.gatewayHealthUrl) throw new Error("gateway_not_configured");
stage = "gateway_health";
const healthResponse = await fetch(runtime.gatewayHealthUrl, { cache: "no-store", signal: controller.signal });
if (!healthResponse.ok) throw new Error(`gateway_health_http_${healthResponse.status}`);
let health: MapGatewayHealth;
try {
health = await healthResponse.json() as MapGatewayHealth;
} catch {
throw new Error("gateway_health_invalid_response");
}
if (health.cache?.persistent !== true) throw new Error("persistent_cache_unavailable");
if (!isLatestGatewayHealthRequest(epoch)) return;
rememberGatewayHealth(health);
// Runtime configuration deliberately exposes a same-origin relative
// route. Resolve it against the current browser origin before displaying
// the connection; new URL("/api/…") without a base throws and used to
// turn a healthy Gateway into a false "unavailable" state.
setGatewayEndpoint(new URL(runtime.gatewayHealthUrl, window.location.origin).origin);
setGatewayCheckState("ready");
} catch (error) {
if (controller.signal.aborted && !timedOut) return;
if (!isLatestGatewayHealthRequest(epoch)) return;
const code = timedOut
? "gateway_check_timeout"
: error instanceof TypeError
? `${stage}_network_error`
: safeGatewayCheckCode(error);
const stale = Boolean(gatewayHealthRef.current);
if (!stale) setGatewayEndpoint(null);
setGatewayCheckError(gatewayCheckMessage(code, stale));
setGatewayCheckState(stale ? "stale" : "error");
} finally {
window.clearTimeout(timeout);
if (gatewayCheckRequestRef.current?.id === id) gatewayCheckRequestRef.current = null;
}
})();
gatewayCheckRequestRef.current = { id, controller, promise };
return promise;
}, [beginGatewayHealthVerification, isLatestGatewayHealthRequest, rememberGatewayHealth]);
useEffect(() => {
if (!inspectorOpen && !layersOpen) return;
void verifyGateway();
const interval = window.setInterval(() => void verifyGateway(), 5000);
return () => window.clearInterval(interval);
const interval = window.setInterval(() => void verifyGateway(), 15_000);
return () => {
window.clearInterval(interval);
const pending = gatewayCheckRequestRef.current;
if (pending) {
gatewayCheckRequestRef.current = null;
pending.controller.abort();
}
};
}, [inspectorOpen, layersOpen, verifyGateway]);
const liveCacheStatus = gatewayHealth?.cache;
const liveCacheSummary = liveCacheStatus
? `${liveCacheStatus.entries ?? 0} объектов · ${Math.round((liveCacheStatus.bytes ?? 0) / 1024 / 1024)} MB`
? `${liveCacheStatus.entries ?? 0} объектов · ${Math.round((liveCacheStatus.bytes ?? 0) / 1024 / 1024)} / ${Math.round((liveCacheStatus.maxBytes ?? 0) / 1024 / 1024) || "?"} MB`
: "индекс ещё не получен";
const transportDiagnostic = gatewayHealth?.diagnostics?.lastFailure
? `Последняя transport-ошибка: ${gatewayHealth.diagnostics.lastFailure}${gatewayHealth.diagnostics.lastFailureAt ? ` · ${new Date(gatewayHealth.diagnostics.lastFailureAt).toLocaleTimeString()}` : ""}`
: null;
const gatewayHealthAge = gatewayCheckState === "stale" && gatewayLastVerifiedAt
? `Последняя успешная проверка: ${gatewayLastVerifiedAt.toLocaleTimeString()}`
: null;
const startResize = (event: PointerEvent<HTMLButtonElement>) => {
event.preventDefault();
@ -201,6 +424,8 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
content: <>
<ControlRow label="Подложка"><strong>Cesium World Imagery</strong></ControlRow>
<small className="catalog-map-inspector__note">Текущий официальный provider. Другие provider-слои появятся только после отдельного asset-контракта Platform.</small>
<ControlRow label="Live providers"><span>Imagery: {providerStateLabel[providerStatus.imagery]} · Terrain: {providerStateLabel[providerStatus.terrain]} · 3D: {providerStateLabel[providerStatus.buildings]}</span></ControlRow>
{Object.entries(providerStatus.errors).map(([provider, error]) => <small key={provider} className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{provider}: {error}</small>)}
<Checker checked={mapSettings.terrainEnabled} label="Terrain" description="Рельеф — отдельный слой под imagery." onChange={(terrainEnabled) => updateMapSettings({ terrainEnabled })} />
<RangeControl label="Вертикальное преувеличение рельефа" value={mapSettings.terrainExaggeration * 100} min={25} max={300} formatValue={(value) => `${(value / 100).toFixed(2)}×`} onChange={(value) => updateMapSettings({ terrainExaggeration: value / 100 })} />
<Checker checked={mapSettings.monochrome} label="Монохромная поверхность" onChange={(monochrome) => updateMapSettings({ monochrome })} />
@ -275,16 +500,22 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
description: "Platform Map Gateway",
group: "Хранение",
content: <>
<small className="catalog-map-inspector__note">По умолчанию официальный live-маршрут. Включите запись, чтобы одновременно смотреть карту и пополнять серверный cache.</small>
<small className="catalog-map-inspector__note">Общий persistent cache Platform: он не принадлежит приложению, странице или пользователю.</small>
<Checker className="catalog-map-inspector__cache-toggle" checked={mapSettings.cacheEnabled} label="Кэшировать live-данные" onChange={setCacheEnabled} />
<ControlRow label="Режим"><strong>{mapSettings.cacheEnabled ? "Live + Cache" : "Live"}</strong></ControlRow>
<Checker checked={mapSettings.cacheNoOverwrite} disabled={!mapSettings.cacheEnabled} label="Не перезаписывать уже полученный cache" description="Cache hit отдаётся как есть; новый tile записывается только при miss." onChange={setCacheNoOverwrite} />
<ControlRow label="Режим"><strong>{mapSettings.cacheEnabled ? mapSettings.cacheNoOverwrite ? "Live + Cache · append-only" : "Live + Cache · обновление разрешено" : "Live без persistent cache"}</strong></ControlRow>
<ControlRow label="Хранилище"><strong>Platform Map Gateway</strong></ControlRow>
<ControlRow label="Подключение"><span>{gatewayEndpoint ?? "runtime profile · не проверено"}</span></ControlRow>
<ControlRow label="Записано"><strong>{liveCacheSummary}</strong></ControlRow>
<ControlRow label="Политика"><strong>{gatewayHealth?.cache?.writePolicy ?? "append-only · проверяется"}</strong></ControlRow>
<Button variant="secondary" shape="pill" icon={<Icon name="refresh" />} onClick={refreshCurrentViewport} disabled={!mapSettings.cacheEnabled || cacheRefresh}> {cacheRefresh ? "Обновляем viewport…" : "Обновить текущий viewport"}</Button>
<Button variant="secondary" shape="pill" icon={<Icon name="refresh" />} onClick={() => void verifyGateway()} disabled={gatewayCheckState === "checking"}>{gatewayCheckState === "checking" ? "Проверяем Gateway…" : "Проверить подключение"}</Button>
<small className="catalog-map-inspector__note">Live Cache: {liveCacheSummary} · {gatewayHealth?.cache?.mode ?? "проверяется"}</small>
<small className="catalog-map-inspector__note">{mapSettings.cacheEnabled ? "Hybrid: provider остаётся официальным, новые tiles сохраняются в серверный cache." : "Real-time: provider остаётся официальным, чтение и запись persistent cache выключены."}</small>
{gatewayCheckState === "error" ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{gatewayCheckError}</small> : null}
{transportDiagnostic ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{transportDiagnostic}</small> : null}
{gatewayHealthAge ? <small className="catalog-map-inspector__note">{gatewayHealthAge}</small> : null}
<small className="catalog-map-inspector__note">{mapSettings.cacheEnabled ? mapSettings.cacheNoOverwrite ? "Новые miss дописываются; при заполнении объёма Gateway продолжит live-маршрут без удаления прежних tiles." : "Новые запросы этого Application могут явно обновлять уже записанные tiles." : "Real-time: provider остаётся официальным, чтение и запись persistent cache выключены."}</small>
{gatewayHealth?.cache?.atCapacity ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">TileCache заполнен: новые tiles показываются live, но не записываются. Существующий cache не удаляется.</small> : null}
{gatewayCheckState === "error" || gatewayCheckState === "stale" ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{gatewayCheckError}</small> : null}
</>,
},
{
@ -306,7 +537,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
aria-label="Map Page Cesium adapter"
>
<Suspense fallback={<div className="catalog-map-fixture__loading">Загрузка карты</div>}>
<CesiumMapRenderer key={rendererRevision} onSelect={handleSelect} onGatewayHealth={setGatewayHealth} onCameraChange={handleCameraChange} initialCamera={mapCamera ?? undefined} presentation={presentation} />
<CesiumMapRenderer key={rendererRevision} onSelect={handleSelect} onGatewayHealth={handleRendererGatewayHealth} onProviderStatus={setProviderStatus} onCameraChange={handleCameraChange} onCacheRefreshConsumed={handleCacheRefreshConsumed} initialCamera={mapCamera ?? undefined} presentation={presentation} runtimeBindings={runtimeBindings} />
</Suspense>
<div className="catalog-map-fixture__actions">
@ -319,12 +550,20 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
{layersOpen ? (
<GlassSurface className="catalog-map-fixture__layers" tone="strong" radius="card" padding="sm" aria-label="Настройки слоёв карты">
<div className="catalog-map-fixture__layers-head"><strong>Слои карты</strong><IconButton label="Закрыть слои" onClick={() => setLayersOpen(false)}><Icon name="close" /></IconButton></div>
<div className="catalog-map-fixture__provider"><strong>Cesium World Imagery</strong><small>официальный live provider</small></div>
<div className="catalog-map-fixture__provider">
<strong>Cesium World Imagery</strong>
<small>официальный live provider · imagery: {providerStateLabel[providerStatus.imagery]} · terrain: {providerStateLabel[providerStatus.terrain]}</small>
</div>
{Object.entries(providerStatus.errors).map(([provider, error]) => <small key={provider} className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{provider}: {error}</small>)}
<Checker checked={mapSettings.terrainEnabled} label="Terrain" onChange={(terrainEnabled) => updateMapSettings({ terrainEnabled })} />
<Checker checked={mapSettings.buildingsVisible} label="3D здания" onChange={(buildingsVisible) => updateMapSettings({ buildingsVisible })} />
<Checker checked={mapSettings.gridVisible} label="Планетарная сетка" onChange={(gridVisible) => updateMapSettings({ gridVisible })} />
<small className="catalog-map-inspector__note">Live Cache: {liveCacheSummary}</small>
{transportDiagnostic ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{transportDiagnostic}</small> : null}
{gatewayHealthAge ? <small className="catalog-map-inspector__note">{gatewayHealthAge}</small> : null}
{gatewayCheckState === "error" || gatewayCheckState === "stale" ? <small className="catalog-map-inspector__note catalog-map-inspector__note--error" role="alert">{gatewayCheckError}</small> : null}
<Checker className="catalog-map-fixture__cache-toggle" checked={mapSettings.cacheEnabled} label="Кэшировать live-данные" onChange={setCacheEnabled} />
<Checker className="catalog-map-fixture__cache-toggle" checked={mapSettings.cacheNoOverwrite} disabled={!mapSettings.cacheEnabled} label="Не перезаписывать cache" onChange={setCacheNoOverwrite} />
</GlassSurface>
) : null}

View File

@ -38,7 +38,9 @@ textarea {
isolation: isolate;
overflow: hidden;
border-radius: var(--nodedc-radius-card);
background: var(--nodedc-canvas-soft) url("/launcher-stage-poster.png") center / cover no-repeat;
/* The actual saved video is the first visual frame. Never show the bundled
pink sample poster while its runtime media stream is starting. */
background: var(--nodedc-canvas-soft);
box-shadow: 0 44px 150px color-mix(in srgb, var(--nodedc-canvas) 70%, transparent);
}

View File

@ -0,0 +1,301 @@
import { useEffect, useMemo, useState } from "react";
import type { MapDataProductBinding } from "./MapFixturePreview.js";
export type DataProductPoint = {
type: "Point";
coordinates: [number, number];
};
/**
* The only entity shape the map runtime accepts from Platform. It is the
* canonical data-product fact, not a provider payload and not a Cesium model.
*/
export type MapRuntimeFact = {
sourceId: string;
semanticType: string;
observedAt: string;
receivedAt: string;
attributes: Record<string, unknown>;
geometry: DataProductPoint | null;
};
export type MapRuntimeBinding = {
bindingId: string;
dataProductId: string;
slotId: string;
facts: MapRuntimeFact[];
cursor: string | null;
state: "idle" | "loading" | "ready" | "reconnecting" | "error";
};
type SnapshotEnvelope = {
schemaVersion: "nodedc.data-product.snapshot/v1";
dataProduct: { id: string; version: string };
generatedAt: string;
cursor: string;
facts: MapRuntimeFact[];
};
type PatchEnvelope = {
schemaVersion: "nodedc.data-product.patch/v1";
dataProduct: { id: string; version: string };
cursor: string;
previousCursor: string;
emittedAt: string;
operations: Array<{ op: "upsert"; fact: MapRuntimeFact }>;
};
type BindingState = {
binding: MapDataProductBinding;
cursor: string | null;
facts: Record<string, MapRuntimeFact>;
state: MapRuntimeBinding["state"];
};
const identifier = /^[A-Za-z0-9._:-]{1,160}$/;
const cursorPattern = /^(?:0|[1-9]\d*)$/;
function factKey(fact: MapRuntimeFact) {
return `${fact.semanticType}\u0000${fact.sourceId}`;
}
export function mapRuntimeEntityId(bindingId: string, fact: Pick<MapRuntimeFact, "sourceId" | "semanticType">) {
return `nodedc-runtime:${bindingId}:${fact.semanticType}:${fact.sourceId}`;
}
function isIsoTimestamp(value: unknown): value is string {
return typeof value === "string" && !Number.isNaN(Date.parse(value));
}
function asPoint(value: unknown): DataProductPoint | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const candidate = value as { type?: unknown; coordinates?: unknown };
if (candidate.type !== "Point" || !Array.isArray(candidate.coordinates) || candidate.coordinates.length !== 2) return null;
const [longitude, latitude] = candidate.coordinates;
if (typeof longitude !== "number" || !Number.isFinite(longitude) || typeof latitude !== "number" || !Number.isFinite(latitude)) return null;
return { type: "Point", coordinates: [longitude, latitude] };
}
function asFact(value: unknown, binding: MapDataProductBinding): MapRuntimeFact | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const candidate = value as Record<string, unknown>;
const sourceId = typeof candidate.sourceId === "string" ? candidate.sourceId : "";
const semanticType = typeof candidate.semanticType === "string" ? candidate.semanticType : "";
if (!identifier.test(sourceId) || !identifier.test(semanticType) || !binding.semanticTypes.includes(semanticType)) return null;
if (!isIsoTimestamp(candidate.observedAt) || !isIsoTimestamp(candidate.receivedAt)) return null;
const sourceAttributes = candidate.attributes && typeof candidate.attributes === "object" && !Array.isArray(candidate.attributes)
? candidate.attributes as Record<string, unknown>
: {};
// A data-product binding is also a field-level browser projection. Treat an
// empty projection as no optional attributes rather than as "all fields".
const allowed = new Set(binding.fieldProjection);
const attributes = Object.fromEntries(Object.entries(sourceAttributes).filter(([key]) => allowed.has(key)));
return {
sourceId,
semanticType,
observedAt: candidate.observedAt,
receivedAt: candidate.receivedAt,
attributes,
geometry: asPoint(candidate.geometry),
};
}
function asSnapshot(value: unknown, binding: MapDataProductBinding): SnapshotEnvelope | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const candidate = value as Record<string, unknown>;
const product = candidate.dataProduct;
if (candidate.schemaVersion !== "nodedc.data-product.snapshot/v1" || !product || typeof product !== "object" || Array.isArray(product)) return null;
if ((product as { id?: unknown }).id !== binding.dataProductId || !cursorPattern.test(String(candidate.cursor || "")) || !isIsoTimestamp(candidate.generatedAt) || !Array.isArray(candidate.facts)) return null;
return {
schemaVersion: "nodedc.data-product.snapshot/v1",
dataProduct: { id: binding.dataProductId, version: String((product as { version?: unknown }).version || "") },
generatedAt: candidate.generatedAt,
cursor: String(candidate.cursor),
facts: candidate.facts.map((fact) => asFact(fact, binding)).filter((fact): fact is MapRuntimeFact => Boolean(fact)),
};
}
function asPatch(value: unknown, binding: MapDataProductBinding): PatchEnvelope | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const candidate = value as Record<string, unknown>;
const product = candidate.dataProduct;
if (candidate.schemaVersion !== "nodedc.data-product.patch/v1" || !product || typeof product !== "object" || Array.isArray(product)) return null;
if ((product as { id?: unknown }).id !== binding.dataProductId || !cursorPattern.test(String(candidate.cursor || "")) || !cursorPattern.test(String(candidate.previousCursor || "")) || !isIsoTimestamp(candidate.emittedAt) || !Array.isArray(candidate.operations)) return null;
const operations = candidate.operations.flatMap((operation) => {
if (!operation || typeof operation !== "object" || Array.isArray(operation)) return [];
const value = operation as { op?: unknown; fact?: unknown };
if (value.op !== "upsert") return [];
const fact = asFact(value.fact, binding);
return fact ? [{ op: "upsert" as const, fact }] : [];
});
return {
schemaVersion: "nodedc.data-product.patch/v1",
dataProduct: { id: binding.dataProductId, version: String((product as { version?: unknown }).version || "") },
cursor: String(candidate.cursor),
previousCursor: String(candidate.previousCursor),
emittedAt: candidate.emittedAt,
operations,
};
}
function replaceSnapshot(current: BindingState, snapshot: SnapshotEnvelope): BindingState {
return {
...current,
cursor: snapshot.cursor,
state: "ready",
facts: Object.fromEntries(snapshot.facts.map((fact) => [factKey(fact), fact])),
};
}
function applyPatch(current: BindingState, patch: PatchEnvelope): BindingState | null {
// A durable stream must advance from the exact snapshot/previous patch
// cursor. Any gap is recoverable: re-fetch a current snapshot before the
// browser applies more updates.
if (current.cursor !== patch.previousCursor) return null;
const facts = { ...current.facts };
for (const operation of patch.operations) facts[factKey(operation.fact)] = operation.fact;
return { ...current, facts, cursor: patch.cursor, state: "ready" };
}
function stateFor(binding: MapDataProductBinding, state: MapRuntimeBinding["state"] = "idle"): BindingState {
return { binding, cursor: null, facts: {}, state };
}
function runtimePath(applicationId: string, pageId: string, bindingId: string, resource: "snapshot" | "stream") {
return `/api/applications/${encodeURIComponent(applicationId)}/pages/${encodeURIComponent(pageId)}/data-bindings/${encodeURIComponent(bindingId)}/${resource}`;
}
/**
* Opens a Platform data product only while the page is mounted. The browser
* knows the target binding but never a provider URL, tenant/connection scope,
* or reader credential: those are resolved by Foundry's same-origin BFF.
*/
export function useMapDataProductRuntime({
applicationId,
pageId,
bindings,
enabled = true,
}: {
applicationId?: string;
pageId?: string;
bindings: MapDataProductBinding[];
enabled?: boolean;
}) {
const [records, setRecords] = useState<Record<string, BindingState>>({});
const bindingSignature = useMemo(
() => JSON.stringify(bindings.map((binding) => ({
id: binding.id,
dataProductId: binding.dataProductId,
slotId: binding.slotId,
semanticTypes: binding.semanticTypes,
fieldProjection: binding.fieldProjection,
}))),
[bindings],
);
useEffect(() => {
if (!enabled || !applicationId || !pageId || bindings.length === 0) {
setRecords({});
return;
}
let disposed = false;
const sources = new Map<string, EventSource>();
const controllers = new Set<AbortController>();
const retries = new Map<string, number>();
const latest = new Map<string, BindingState>();
const update = (binding: MapDataProductBinding, updater: (current: BindingState) => BindingState) => {
if (disposed) return stateFor(binding);
const next = updater(latest.get(binding.id) ?? stateFor(binding));
latest.set(binding.id, next);
setRecords((current) => ({ ...current, [binding.id]: next }));
return next;
};
const scheduleReconnect = (binding: MapDataProductBinding) => {
if (disposed || retries.has(binding.id)) return;
update(binding, (current) => ({ ...current, state: "reconnecting" }));
const timer = window.setTimeout(() => {
retries.delete(binding.id);
void loadSnapshotAndStream(binding);
}, 1_500);
retries.set(binding.id, timer);
};
const loadSnapshotAndStream = async (binding: MapDataProductBinding) => {
const existing = sources.get(binding.id);
if (existing) {
existing.close();
sources.delete(binding.id);
}
update(binding, (current) => ({ ...current, state: "loading" }));
const controller = new AbortController();
controllers.add(controller);
try {
const snapshotResponse = await fetch(runtimePath(applicationId, pageId, binding.id, "snapshot"), {
cache: "no-store",
credentials: "same-origin",
signal: controller.signal,
});
if (!snapshotResponse.ok) throw new Error(`snapshot_${snapshotResponse.status}`);
const snapshot = asSnapshot(await snapshotResponse.json(), binding);
if (!snapshot) throw new Error("snapshot_contract_invalid");
if (disposed) return;
update(binding, (current) => replaceSnapshot(current, snapshot));
const source = new EventSource(`${runtimePath(applicationId, pageId, binding.id, "stream")}?after=${encodeURIComponent(snapshot.cursor)}`, { withCredentials: true });
sources.set(binding.id, source);
source.addEventListener("nodedc.data-product.patch.v1", (event) => {
let patch: PatchEnvelope | null = null;
try {
patch = asPatch(JSON.parse((event as MessageEvent<string>).data), binding);
} catch {
return;
}
if (!patch) return;
const next = update(binding, (current) => {
const next = applyPatch(current, patch);
return next ?? current;
});
if (next.cursor !== patch.cursor) void loadSnapshotAndStream(binding);
});
source.addEventListener("nodedc.data-product.resync-required.v1", () => {
void loadSnapshotAndStream(binding);
});
source.onerror = () => {
// EventSource will retry transient transport failures itself. A
// permanent upstream rejection closes the stream; loading a fresh
// snapshot also handles an expired outbox cursor deterministically.
if (source.readyState === EventSource.CLOSED) scheduleReconnect(binding);
};
} catch (error) {
if (disposed || controller.signal.aborted) return;
update(binding, (current) => ({ ...current, state: "error" }));
scheduleReconnect(binding);
} finally {
controllers.delete(controller);
}
};
for (const binding of bindings) void loadSnapshotAndStream(binding);
return () => {
disposed = true;
for (const source of sources.values()) source.close();
for (const controller of controllers) controller.abort();
for (const timer of retries.values()) window.clearTimeout(timer);
};
}, [applicationId, bindingSignature, bindings, enabled, pageId]);
return useMemo<MapRuntimeBinding[]>(() => bindings.map((binding) => {
const record = records[binding.id] ?? stateFor(binding);
return {
bindingId: binding.id,
dataProductId: binding.dataProductId,
slotId: binding.slotId,
facts: Object.values(record.facts).sort((left, right) => factKey(left).localeCompare(factKey(right))),
cursor: record.cursor,
state: record.state,
};
}), [bindings, records]);
}

View File

@ -160,9 +160,9 @@ Pill navigation для верхней панели и компактного п
`DragHandle`, `DragDropRoot`, `DraggableItem`, `DropZone`, `SortableScope`, `SortableItem` и `SortableList` образуют общий React-контракт переноса и сортировки. Он перенесён из рабочего Engine-паттерна: drag начинается только за шесть точек и только после движения на `6 px`, поэтому обычный клик по строке не конфликтует с навигацией.
Sortable-строки ограничены вертикальной осью: горизонтальное движение указателя не сдвигает navigation или composition layout. `DragDropRoot` возвращает реальные active/over rectangles, поэтому consumer может принять перенос только после достаточного перекрытия destination. Module Studio использует порог `20%` ширины переносимой строки: короткое движение, движение влево или касание границы не создаёт page instance.
Sortable-строки ограничены вертикальной осью: горизонтальное движение указателя не сдвигает navigation или composition layout. `DragDropRoot` возвращает реальные active/over rectangles, поэтому consumer может принять перенос только после достаточного перекрытия destination. Module Foundry использует порог `20%` ширины переносимой строки: короткое движение, движение влево или касание границы не создаёт page instance.
В Module Studio исходная строка Page Template остаётся в Page Library после переноса. Каждый подтверждённый drop создаёт новый page instance с уникальным id; состав приложения и левая навигация читают один массив страниц и потому всегда перестраиваются синхронно. Удаление instance выполняется стандартным `ConfirmationModal`, а не мгновенным действием крестика.
В Module Foundry исходная строка Page Template остаётся в Page Library после переноса. Каждый подтверждённый drop создаёт новый page instance с уникальным id; состав приложения и левая навигация читают один массив страниц и потому всегда перестраиваются синхронно. Удаление instance выполняется стандартным `ConfirmationModal`, а не мгновенным действием крестика.
## Icon

View File

@ -0,0 +1,49 @@
# Foundry Data Product bindings
`NDC Foundry Binding` is a deploy/control-plane operation. Realtime facts do
not pass through the node: Foundry persists an approved page-slot binding and
its same-origin BFF reads snapshot + patch from External Data Plane.
## Private workload API
```text
GET /internal/foundry/v1/data-products
POST /internal/foundry/v1/data-product-bindings
Authorization: Bearer ndc_fndbg_<opaque capability>
```
The POST body is `nodedc.foundry.binding-upsert/v1`. It contains only
application, page, binding, Data Product projection and a stable idempotency
key. Provider, tenant, connection, endpoint, URL, raw payload and credentials
are rejected.
This credential is not a Foundry MCP `fnd1.*` capability. MCP capabilities are
short-lived interactive grants for AI Workspace. It is also not
`NODEDC_INTERNAL_ACCESS_TOKEN`, a browser session, or an EDP writer/reader
grant.
## Workload grant
The opaque value never enters a Foundry env file or application manifest.
Foundry hashes it with SHA-256 and resolves a root-owned, non-writable record
from `NODEDC_FOUNDRY_BINDING_GRANTS_DIR` on every request. Removing or replacing
the record therefore revokes or rotates access immediately.
A `nodedc.module-foundry.binding-grant.v1` record fixes:
- expiry and actions (`catalog.read`, `map-data-product.upsert`);
- actor and owner used by the existing replay-safe Foundry write path;
- exact allowlisted tuples of application, page, binding, slot, Data Product,
semantic types and field projection.
No authorization claim is accepted from request headers or workflow data.
Before persisting a binding, Foundry verifies that the target-specific EDP
reader grant exists, is active, permits the selected product and exposes a
`snapshot+patch` product. The reader grant filename remains
`sha256(applicationId/pageId/bindingId)`. If it is not ready, POST fails with
`409 data_product_reader_grant_not_ready`; an unusable visual binding is never
saved.
The canonical Map entity-stream slot is `points`. Other templates may define
their own typed slots, but the workload grant must name them explicitly.

View File

@ -0,0 +1,847 @@
# Foundry Map Page + Cesium: production canon
Статус: **действующий production-контракт**
Область: Module Foundry Map Page, Platform Map Gateway, общий TileCache, DC AMD Proxy и DC AMD Connector
Контрольная дата: 2026-07-16
Этот документ — воспроизводимый канон интеграции Cesium в NODE.DC. Он описывает не только текущий Foundry Map Page, но и обязательные границы для следующих продуктов NODE.DC, которым потребуются Cesium Terrain, imagery, 3D Tiles или другие разрешённые provider assets.
Текущий проверенный deploy baseline:
- `dc-amd-proxy-transport-lifecycle-20260716-005`;
- `platform-map-gateway-hot-path-20260716-015`;
- `module-foundry-map-hot-path-20260716-012`.
Идентификаторы baseline нужны для аудита. Их нельзя повторно использовать для новых изменений: каждый следующий deploy получает новый patch id и новый digest.
## 1. Главные инварианты
1. Browser никогда не получает Cesium Ion master token, asset-scoped token, Bing key, Map Gateway admin secret или egress/connector secret.
2. Foundry не хранит Cesium token в `.env`, runtime layout, Application Manifest, browser storage, deploy artifact или исходном коде.
3. Единственный владелец Cesium master token и provider endpoint credentials — private Platform Map Gateway.
4. Все browser-запросы карты идут same-origin через Foundry BFF. Browser не обращается напрямую ни к Map Gateway, ни к AMD Proxy, ни к Cesium/Bing.
5. Один Platform Map Gateway и один NAS-resident mutable TileCache обслуживают все Foundry Applications, страницы, пользователей и browser-инстансы.
6. Warm cache hit читается прямо с NAS и не требует Ion token refresh, AMD Proxy, Windows-машины, VPN или доступности Cesium.
7. Только cold miss или явный refresh проходит по внешнему маршруту `Map Gateway → DC AMD Proxy → DC AMD Connector → VPN → Cesium/Bing`.
8. NAS networking, DNS, default route, Tailscale и VPN не изменяются. Через AMD-машину идёт только allowlisted Cesium/Bing traffic.
9. TileCache не лежит в Git, Docker image layer или application volume. Это отдельный persistent NAS bind directory.
10. Записанный объект не удаляется автоматически и не перезаписывается без явного `nodedc_cache_refresh=1`.
11. Partial response никогда не становится cache entry. Object публикуется atomic rename, а fill считается завершённым только после durable snapshot индекса.
12. Все deploy-изменения доставляются data-only artifact через canonical runner: сначала `plan` точного файла, затем `apply` этого же файла. Live edit и ручной `docker compose up` на NAS не являются deploy-каноном.
## 2. Источники истины в репозитории
Канон должен обновляться вместе с этими файлами:
- Foundry BFF и admin settings: `server/catalog-server.mjs`;
- Foundry session/auth boundary: `server/nodedc-auth.mjs`;
- UI TileCache и health state: `apps/catalog/src/MapFixturePreview.tsx`;
- Cesium adapter и provider startup: `apps/catalog/src/CesiumMapRenderer.tsx`;
- Platform provider/cache boundary: `../../platform/services/map-gateway/src/server.mjs`;
- Map Gateway runtime topology: `../../platform/infra/synology/docker-compose.platform-http.yml`;
- NAS egress service: `../../platform/services/dc-amd-proxy/server.mjs`;
- AMD egress installation, recovery and migration runbook: `../../platform/services/dc-amd-proxy/OPERATIONS.md`;
- Windows connector: `../../platform/services/dc-amd-connector/`;
- одноразовое pairing: `../../platform/tools/dc-amd-pair/`;
- canonical artifact builders: `../../platform/infra/deploy-runner/`.
Если текст этого документа расходится с уже задеплоенным кодом, сначала фиксируется расхождение в Ops, затем синхронно исправляются код, тесты и этот канон. Нельзя молча считать устаревший текст runtime-поведением.
## 3. Архитектура и владельцы
```text
Authenticated browser
│ same-origin GET/HEAD + session cookie
Foundry BFF
│ trusted subject header, no provider credential
Platform Map Gateway
├─ warm hit ───────────────► NAS live TileCache ─► browser
└─ miss / explicit refresh
│ private egress token
DC AMD Proxy on NAS 172.22.0.222:8790
│ authenticated CONNECT transport
DC AMD Connector on Windows 172.22.0.183:8791
│ workstation VPN exit
Cesium Ion / Cesium assets / Bing imagery
│ streamed response
├─► browser immediately
└─► private temp file ─atomic rename─► NAS TileCache
```
### 3.1 Responsibility table
| Компонент | Владеет | Не владеет |
| --- | --- | --- |
| Map Page | provider-neutral scene, presentation, camera, cache intent | tokens, upstream host policy, cache files, VPN |
| Cesium adapter | преобразование scene в Cesium objects, запрос allowlisted asset endpoints | master token, direct provider networking |
| Foundry BFF | user session, same-origin proxy, admin authorization, HMAC signing | provider credentials, TileCache, VPN route |
| Map Gateway | provider allowlist, credential injection, endpoint metadata, TileCache policy/index, upstream diagnostics | UI layout, Windows VPN configuration |
| NAS live TileCache | общие immutable-by-default provider objects и индекс | Git source, per-user state, offline license |
| DC AMD Proxy | узкий authenticated egress, pooling, retry/redirect safety, transport metrics | general proxying, NAS route, provider token persistence |
| DC AMD Connector | CONNECT byte forwarding через текущий Windows VPN | provider URL parsing above host:443, master token, NAS routing |
| Windows workstation | стабильный LAN endpoint, Docker Desktop, VPN exit | Foundry/Map Gateway runtime state |
| deploy runner | root-owned secrets, runtime directories, atomic artifact apply/rollback | application UI, token entry |
### 3.2 Один writer для общего cache
Текущая topology предполагает один активный `map-gateway` process, который является единственным writer для `index.json`. Все Foundry-инстансы используют его через private Docker network.
Масштабирование Foundry горизонтально безопасно: cache общий. Горизонтальное масштабирование самого Map Gateway без доработки запрещено, потому что tile singleflight и coalescing индекса сейчас process-local. Перед запуском нескольких Gateway replicas нужен распределённый per-key lock и согласованный single-writer/transactional index.
## 4. Два независимых маршрута
### 4.1 Control plane: настройка Cesium Ion
```text
Foundry admin browser
→ PUT /api/platform-settings/cesium-ion
→ Foundry revalidates admin session
→ HMAC-signed PUT /api/map/admin/cesium-ion
→ Map Gateway verifies assets 1, 2, 96188
→ atomic private token write
```
Порядок принципиален:
1. UI принимает новое значение как password field по существующей HTTPS-сессии.
2. Foundry повторно проверяет server-side роль; скрытая кнопка сама по себе не является защитой.
3. Foundry не сохраняет token, а формирует HMAC v2 по method, pathname, timestamp, actor id, SHA-256 body и нормализованному Foundry referer.
4. Map Gateway принимает admin request только с валидной подписью и timestamp в пределах 60 секунд.
5. Candidate token проверяется параллельно по canonical assets:
- `1` — Cesium World Terrain;
- `2` — World Imagery/Bing endpoint;
- `96188` — Cesium OSM Buildings.
6. Только если все три проверки успешны, рабочее значение атомарно заменяется.
7. Ошибочный candidate не уничтожает предыдущий рабочий token.
8. Browser получает только `configured`, `verification`, `updatedAt`, `updatedBy`; значение token не возвращается.
Успешная rotation увеличивает внутреннее поколение token, очищает in-memory и disk endpoint credentials для allowlisted assets и не трогает уже записанные tile objects. In-flight refresh старого поколения не может вернуть старый credential после rotation.
### 4.2 Data plane: рендер карты
При старте Map Page Cesium adapter независимо и параллельно запрашивает imagery, terrain и buildings. Отказ imagery не блокирует terrain; отказ buildings не останавливает остальную сцену.
```text
GET /api/map/runtime-config
GET /api/map-gateway/api/map/ion/assets/2/endpoint
GET /api/map-gateway/api/map/ion/assets/1/endpoint
GET /api/map-gateway/api/map/ion/assets/96188/endpoint
```
Endpoint response содержит публичный URL, attribution, тип и `credentialMode: "gateway"`. Затем Cesium `DefaultProxy` направляет все derived resources через:
```text
/api/map-gateway/api/map/cache?url=<encoded-provider-url>
```
Master token или asset credential в этом URL отсутствует. Map Gateway добавляет credential server-side только после cache lookup и только если действительно нужен upstream request.
## 5. Credential model
### 5.1 Cesium Ion master token
Canonical location внутри mutable Map Gateway volume:
```text
/var/lib/nodedc-map-live-cache/secrets/cesium-ion-token
```
Физически на NAS:
```text
/volume1/docker/nodedc-platform/map-gateway/live-tile-cache/secrets/cesium-ion-token
```
Directory создаётся с mode `0700`, token и metadata — `0600`. Private NAS file имеет приоритет над transitional `CESIUM_ION_TOKEN` bootstrap после restart.
Нельзя:
- помещать token в Foundry `.env`;
- собирать его во frontend bundle;
- вставлять в Application Manifest;
- отправлять в chat, Ops comment, diagnostic archive или deploy artifact;
- читать его обратно через UI;
- включать `live-tile-cache/secrets` в обычный tile export или SMB-операции.
### 5.2 Map Gateway admin secret
Runner-owned path:
```text
/volume1/docker/nodedc-platform/secrets/map-gateway-admin-secret
```
Он монтируется read-only в Foundry и Map Gateway как `/run/nodedc-secrets/map-gateway-admin-secret`. Это ключ межсервисной подписи, а не Cesium token и не продуктовая настройка. Он никогда не вводится пользователем.
### 5.3 Map egress proxy token
Runner-managed path:
```text
/volume1/docker/nodedc-platform/secrets/map-egress-proxy-token
```
Map Gateway читает его из read-only mount и ставит в `x-proxy-token` только при запросе к DC AMD Proxy. Browser и Windows connector его не получают.
### 5.4 AMD connector access token
Windows installer создаёт локальный random value в:
```text
C:\NODEDC\dc-amd-connector\runtime\connector-access
```
Одноразовый pairing script читает его внутри запущенного container и передаёт напрямую на NAS без печати. NAS сохраняет paired value в private runtime `dc-amd-proxy`; deploy artifact его не содержит.
### 5.5 Asset-scoped endpoint credentials
Ответ Ion endpoint API может содержать временный `accessToken` либо Bing key. Map Gateway хранит эти значения только в памяти и private files:
```text
live-tile-cache/ion-endpoints/<assetId>.json
```
Файлы имеют mode `0600` и считаются service-sensitive. Master token в них не записывается.
Перед выдачей endpoint browser-у Gateway удаляет credential query parameters. Перед upstream miss Gateway:
1. снова удаляет user-supplied `token`, `key`, `signature` и аналогичные параметры;
2. сопоставляет resource с endpoint по точному origin и наиболее длинному разрешённому path scope;
3. для file endpoint, например `tileset.json`, разрешает sibling resources только внутри его directory;
4. никогда не превращает root-level file endpoint в credential scope всего host;
5. добавляет соответствующий asset credential или Bing key.
Endpoint cache имеет TTL 300 секунд и refresh-ahead 60 секунд по умолчанию. Refresh одного asset singleflight-ится. Истёкший JWT не выдаётся, не сохраняется и не подставляется.
### 5.6 Referer restrictions
Если Cesium token ограничен URL/referer policy, production `FOUNDRY_PUBLIC_URL` должен совпадать с разрешённым Foundry origin. Foundry делегирует только собственный configured origin, а не произвольный browser header. Нормализованный referer входит в HMAC admin request и передаётся в provider requests.
Прямая terminal-проверка token без того же referer может вернуть `401`, хотя production flow работает. Каноническая проверка — через Foundry settings/Map Gateway, потому что она воспроизводит реальный транспорт и проверяет все три обязательных asset.
## 6. Физическое устройство TileCache
### 6.1 NAS paths
Mutable live cache:
```text
/volume1/docker/nodedc-platform/map-gateway/live-tile-cache
```
Container mount:
```text
/var/lib/nodedc-map-live-cache
```
Read-only offline snapshot:
```text
/volume1/docker/nodedc-platform/map-gateway/offline-snapshot
```
Container mount:
```text
/var/lib/nodedc-map-offline-snapshot
```
Обе host directory создаёт root-owned deploy runner. `docker compose down`, image rebuild и service restart их не удаляют.
### 6.2 On-disk format
```text
live-tile-cache/
├── index.json
├── objects/
│ └── <first-two-hash-chars>/
│ └── <sha256-cache-key>.bin
├── ion-endpoints/
│ ├── 1.json
│ ├── 2.json
│ └── 96188.json
└── secrets/
├── cesium-ion-token
└── cesium-ion-token.metadata.json
```
`objects` и `index.json` — собственно TileCache. `ion-endpoints` и `secrets` — private service state; они не являются пользовательскими cache objects.
`index.json` version 1 содержит для каждого SHA-256 key:
- relative file path;
- byte count;
- content type;
- provider ETag, если он корректен;
- `savedAt`, `lastAccessAt`, `expiresAt`.
Binary object сохраняется с расширением `.bin` независимо от media type; content type берётся из индекса. Cache key — SHA-256 canonical provider URL.
### 6.3 Canonical URL and deduplication
До вычисления key удаляются:
- `nodedc_client_revision`;
- `nodedc_cache_profile`;
- `nodedc_cache_mode`;
- `nodedc_cache_refresh`;
- credential-like query parameters.
Query parameters сортируются. Bing subdomains `ecn.t0`…`ecn.t3` нормализуются в один logical host, чтобы одинаковый quadkey не создавал четыре копии.
Следствие: token rotation, новый browser build или другой Foundry Application не создают новый tile object для тех же provider bytes.
## 7. Product semantics of cache controls
### 7.1 `Кэшировать live-данные` выключено
Adapter добавляет `nodedc_cache_mode=passthrough`. Запрос всё равно проходит через Foundry и Map Gateway, поэтому credential/security boundary сохраняется, но live persistent cache не читается и не записывается.
Это не direct browser-to-Cesium режим.
### 7.2 Cache включён + `Не перезаписывать уже полученный cache` включено
Это основной steady-state режим:
- hit немедленно отдаётся с NAS;
- miss идёт к official upstream и после полного получения дописывается;
- существующий объект не заменяется даже после его provider TTL;
- отключение AMD/VPN не влияет на уже записанную область.
Галка не замораживает cache целиком. Она запрещает перезапись существующих key, но новые tiles, которые пользователь впервые открыл, продолжают долетать и записываться на NAS.
### 7.3 Cache включён + `Не перезаписывать` выключено
Adapter добавляет `nodedc_cache_refresh=1` в provider resource root. Derived requests текущего Application могут заменить существующие objects. Это явно разрешённый update mode, а не автоматический фоновый refresh всего cache.
Обновляются только ресурсы, которые реально запросил renderer; мировой dataset не обходится целиком.
### 7.4 `Обновить текущий viewport`
Это одноразовый refresh. Renderer пересоздаётся с `nodedc_cache_refresh=1`, помечает root provider resources текущего view, после первого render возвращается к сохранённой steady-state policy.
### 7.5 Gateway-level modes
| `MAP_CACHE_MODE` | Hit | Miss | Запись |
| --- | --- | --- | --- |
| `readwrite` | NAS | official upstream | да, после полного response |
| `readonly` | NAS | live pass-through | нет |
| `offline` | NAS/offline policy | `504` | нет и никакого egress |
Application UI не может изменить server-level `MAP_CACHE_MODE`.
### 7.6 Live vs offline profile
`nodedc_cache_profile=live` выбирает mutable store. `offline` выбирает read-only snapshot и никогда не допускает pass-through.
Offline mode дополнительно требует hostname в `MAP_GATEWAY_OFFLINE_PROVIDER_ALLOWLIST`. Пустой список по умолчанию означает осознанный запрет offline redistribution. Наличие технически сохранённых Cesium bytes само по себе не создаёт право на их offline distribution.
## 8. Request algorithm
Для каждого `/api/map/cache` Gateway выполняет следующий порядок:
1. Проверяет trusted subject, method и route.
2. Парсит и валидирует HTTPS target, host allowlist, URL length и отсутствие embedded credentials.
3. Нормализует cache profile/mode/refresh intent и удаляет их из provider URL.
4. Вычисляет credential-free canonical key.
5. Для `passthrough` пропускает persistent store и идёт к upstream через тот же security boundary.
6. Для normal mode сначала ищет object в выбранном store.
7. Если по этому key уже идёт fill/refresh, новый request становится follower и ждёт его commit.
8. Offline/legacy profile никогда не превращает miss в неожиданный upstream fetch.
9. Normal hit без explicit refresh сразу отдаётся с NAS, в том числе stale hit.
10. Только после отсутствия пригодного hit Gateway получает/обновляет endpoint credential и подставляет его в upstream URL.
11. `readonly` отдаёт live response без записи.
12. Range request проксируется как range и пока не записывается partial object.
13. Full miss/refresh запускает streaming fill.
14. При provider failure explicit refresh возвращает предыдущий cached object как `live-stale-upstream-error`, если он существует.
15. При заполненном cache cold miss показывается live как `live-pass-through-cache-full`; существующие objects не удаляются.
Этот порядок гарантирует ключевой fail-safe: warm cache не зависит от control plane и egress.
## 9. Concurrency and performance
### 9.1 Что происходит при 20 одновременных пользователях
Для одного и того же missing key:
1. Первый request становится leader и открывает один upstream stream.
2. Response body делится через stream tee.
3. Client branch начинает передаваться первому browser сразу после provider headers.
4. Cache branch независимо пишется во временный файл.
5. Остальные request того же key становятся followers и не создают новые provider downloads.
6. После atomic commit followers читают опубликованный NAS file.
7. Если один browser закрыл вкладку, server-owned cache fill не отменяется и продолжает обслуживать других.
Для разных keys requests выполняются параллельно. Capacity reservations сериализуют только расчёт общего объёма, а не весь network path.
### 9.2 Publication safety
Cache fill:
- пишет только в private `*.tmp`;
- считает фактические bytes и прерывает object сверх лимита;
- резервирует capacity с учётом replacement;
- делает atomic rename только после полного EOF;
- затем добавляет entry в memory index и ждёт durable index snapshot;
- при abort/error удаляет temp и не публикует index entry.
### 9.3 Index strategy
Warm hit не обновляет `lastAccessAt` и вообще не переписывает `index.json`. Это осознанное следствие no-eviction policy: запись полного O(N) JSON на каждый 20 KB tile делала warm views медленнее provider.
Завершения разных new objects объединяются в короткое 10 ms окно. Один atomic index snapshot включает все накопленные revisions; waiter каждого object завершается только после durable revision.
### 9.4 ETag and browser cache
NAS hit получает безопасный ETag: валидный provider ETag либо deterministic NODE.DC ETag. `If-None-Match` возвращает `304` без чтения body и без записи index.
Foundry BFF разрешает browser cache только для подтверждённых persistent hit/stale states:
```text
Cache-Control: private, max-age=300, stale-while-revalidate=60
Vary: Cookie
```
Provider pass-through, endpoint metadata, health, explicit refresh и errors получают `no-store`. Shared reverse proxy не должен раздавать authenticated map response между пользователями.
### 9.5 AMD connection pool
DC AMD Proxy держит отдельный HTTP/1.1 keep-alive pool на каждый approved origin:
- максимум 8 active sockets per origin;
- максимум 4 idle sockets per origin;
- LIFO reuse;
- keep-alive 30 секунд;
- excess requests ждут в agent queue за bounded socket pool; её фактический пик обязательно контролируется через `maxQueuedRequests`.
Это устраняет новый `NAS → AMD CONNECT → TLS` handshake для каждого tile. При насыщении throughput ограничивается VPN/provider, а не созданием неограниченного числа tunnel. `maxQueuedRequests`, queue/connection/TTFB/total timings показывают, где возникает задержка.
Только idempotent `GET`/`HEAD`, потерявший уже reused keep-alive socket, повторяется один раз. Fresh-socket failure не ретраится автоматически. При cross-origin redirect `Authorization` удаляется; master/API bearer не может уйти с `api.cesium.com` на assets/Bing origin.
## 10. Timeouts, aborts and recovery
### 10.1 Foundry BFF
- headers deadline: 15 секунд по умолчанию;
- body idle deadline: 30 секунд без прогресса;
- body timeout сбрасывается каждым chunk;
- после upstream EOF медленный browser drain не ограничивается абсолютным таймером;
- browser abort отменяет BFF upstream request;
- ошибка после отправки headers закрывает stream, а не пытается отправить второй JSON response.
### 10.2 Map Gateway
- upstream timeout: 30 секунд по умолчанию;
- warm hit не запускает этот timeout;
- refresh failure с прежним object даёт stale fallback;
- client abort leader response не отменяет cache branch;
- headers-sent error уничтожает только response, не Gateway process.
### 10.3 DC AMD Proxy
- connector/TLS connection timeout: 20 секунд;
- provider headers/attempt timeout: 30 секунд;
- response body idle timeout: 30 секунд без bytes;
- abort проходит через queue, CONNECT, TLS, headers, redirect drain, retry и body;
- каждый logical request получает ровно один terminal accounting outcome;
- partial bytes учитываются отдельно от complete response bytes.
### 10.4 Cesium renderer
Provider startup независим. Render error не показывает стандартную Cesium modal с `[object Object]`; UI получает safe code и делает одну bounded попытку восстановить render loop. Постоянный GPU/browser fault не запускает бесконечный retry.
## 11. Authentication and authorization
Production Foundry всегда использует Launcher/Authentik session. Map tile burst не должен валидировать Launcher на каждый object, поэтому:
- validation TTL по умолчанию 20 секунд и зажат в production диапазоне 1530 секунд;
- запросы одной session делят singleflight validation;
- transient Launcher error использует 2-секундный retry backoff;
- last-known-good identity допускается не более 30 секунд grace и только для `GET`/`HEAD`;
- mutation во время transient auth failure получает `503`;
- только явный `{ok:true, active:false}` удаляет session/cookie;
- timeout, network error и Launcher non-2xx не уничтожают рабочую cookie;
- session хранится process-local, поэтому после Foundry restart старая opaque cookie истекает и нужен новый handoff.
Foundry roles:
- `nodedc:module-foundry:admin` — Platform settings;
- `nodedc:module-foundry:user` — обычная работа без settings;
- `nodedc:module-foundry:blocked` — deny-first;
- `nodedc:module-foundry:access` — transitional user role;
- `nodedc:superadmin` и `user_root` — break-glass admin.
Raw groups не возвращаются browser-у. Map Gateway production route требует trusted `x-nodedc-user-id`, который создаёт только Foundry BFF/private health check. Gateway нельзя публиковать напрямую в Internet.
## 12. DC AMD route
### 12.1 NAS service
`dc-amd-proxy` работает с `network_mode: host`, но слушает только `172.22.0.222:8790`. Host networking нужен, чтобы one-time pairing видел реальный source IP Windows host; service не bind-ится на все NAS interfaces.
Он принимает:
- `GET /healthz` и `GET /status` — safe state/metrics;
- `POST /api/pair` — один раз и только с configured AMD LAN source;
- `GET|HEAD /proxy/cesium/fetch` — только с runner-synchronised map egress token.
Target обязан быть HTTPS port 443 и входить в фиксированный Cesium/Bing allowlist. Direct NAS egress fallback отсутствует (`directEgress: false`).
### 12.2 Windows connector
`dc-amd-connector` bind-ится к стабильному LAN IP `172.22.0.183:8791`, а не к OpenVPN address или public exit IP. Windows Firewall разрешает inbound только с NAS `172.22.0.222`.
Connector:
- принимает только authenticated HTTP `CONNECT`;
- разрешает только port 443 и тот же host allowlist;
- не расшифровывает end-to-end TLS и не видит Ion bearer;
- не является системным Windows proxy;
- не меняет routing/DNS/VPN других приложений;
- ограничен 1 CPU, 256 MB, 64 processes и 4096 file descriptors.
Docker Compose использует `restart: unless-stopped`. Docker Desktop на WSL2 восстанавливается через Startup launcher после входа configured Windows user. До sign-in это не unattended Windows service; в это окно warm TileCache продолжает работать, а cold misses ожидаемо недоступны.
### 12.3 Pairing and moving to another machine
Первичная установка выполняется из elevated PowerShell внутри проверенного connector package:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\install.ps1
```
После того как NAS proxy показывает `state=awaiting_pair`, на Windows запускается package из `platform/tools/dc-amd-pair`:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\pair-dc-amd-proxy.ps1
```
Ожидаемый результат: `AMD connector paired with NAS. No secret value was displayed.`
Перенос:
1. Выбрать новый стабильный LAN IP и проверить VPN на новой машине.
2. Установить тот же versioned connector package с явными `-BindAddress` и `-NasAddress`.
3. Проверить local container health и permitted CONNECT.
4. Подготовить новый NAS proxy artifact/config с новым connector host и pair source.
5. Реализовать и проверить runner-owned pair-rotation transition: текущий NAS pair write-once и не принимает fresh credential поверх существующего state.
6. Через этот transition вывести старую pairing identity, выполнить canonical `plan`, `apply`, one-time pairing и end-to-end validation.
7. Остановить старый connector только после успешного cold-request acceptance на новой машине.
Текущий runner ещё не предоставляет pair rotation как готовую операцию. До её появления миграция с fresh credential не является полностью канонической: нельзя вручную удалять NAS `runtime/connector-access`, копировать старый secret, ослаблять `/api/pair` или одновременно держать две pairing identity. Сначала создаётся отдельная Ops/change задача на audited rotation, затем выполняется перенос по `platform/services/dc-amd-proxy/OPERATIONS.md`.
## 13. Health and observability
### 13.1 Safe checks on NAS
```bash
curl -fsS http://172.22.0.222:8790/status
curl -fsS \
-H 'x-nodedc-user-id: healthcheck' \
http://127.0.0.1:18103/healthz
curl -fsS http://172.22.0.222:9920/healthz
```
Эти команды не печатают secrets. Для endpoint acceptance нужно выводить только safe fields, а не private files:
```bash
curl -fsS \
-H 'x-nodedc-user-id: healthcheck' \
http://127.0.0.1:18103/api/map/ion/assets/1/endpoint \
| /usr/local/bin/docker exec -i nodedc-platform-map-gateway-1 \
node -e "let s='';process.stdin.on('data',c=>s+=c).on('end',()=>{const v=JSON.parse(s);console.log(JSON.stringify({ok:v.ok,assetId:v.assetId,type:v.type,credentialMode:v.credentialMode,cache:v.cache}))})"
```
Если конкретное имя container изменилось, использовать `docker compose ps`, а не угадывать новое имя.
### 13.2 Map Gateway health fields
`/healthz` возвращает:
- live/offline cache entry count and bytes;
- `mode`, `writePolicy`, `maxBytes`, `atCapacity`, `persistent`;
- hit/stale/miss/refresh/passthrough/fallback counters;
- upstream and egress request/failure counters;
- slow upstream count;
- index snapshot count;
- safe `lastFailure` и timestamp;
- `ionConfigured` и asset allowlist.
Он не возвращает token, provider URL с credential или endpoint credential.
### 13.3 AMD status fields
`/status` обязан показывать:
- `state=paired`;
- `forwarding=amd_connector_only`;
- `directEgress=false`;
- active/idle/queued sockets и pool limits per origin;
- logical, terminal, in-flight, complete, failed and aborted requests;
- opened/failed tunnels, socket reuse, bounded retries;
- complete/partial bytes;
- queue, connection, TTFB, retry, attempt and total timings;
- safe last error code.
Инвариант accounting:
```text
terminalRequests + inFlightRequests == requests
```
После завершившейся нагрузки `inFlightRequests` должен вернуться в `0`.
### 13.4 UI health state
Когда Inspector или Layers открыт, Foundry:
- проверяет runtime config и Gateway health каждые 15 секунд;
- держит только один concurrent health request;
- отменяет check через 10 секунд;
- различает runtime HTTP, health HTTP, invalid response, network и timeout;
- сохраняет last-known-good stats как `stale`, если новая проверка не прошла;
- не даёт запоздалому renderer health overwrite более свежий explicit poll.
Красное сообщение `gateway_not_configured` относится к отсутствию runtime profile, а не автоматически к AMD/VPN. Код ошибки должен сохраняться до UI, иначе оператор ищет проблему не в том слое.
## 14. Failure matrix
| Симптом / safe code | Слой | Что продолжает работать | Проверка / действие |
| --- | --- | --- | --- |
| `module_foundry_auth_required` | Foundry session | public health | пройти Launcher handoff; не обходить BFF |
| `module_foundry_auth_unavailable` | Launcher validation | bounded read-only grace, если ещё активен | Launcher health/internal token; mutation не повторять вслепую |
| `map_gateway_not_configured` | Foundry runtime config | Foundry shell | проверить `NODEDC_MAP_GATEWAY_INTERNAL_URL` и private Docker network |
| `map_gateway_headers_timeout` | BFF→Gateway | shell и уже browser-cached objects | Gateway health, CPU/I/O, request queue |
| `map_gateway_body_idle_timeout` | BFF stream | прочие resources | Gateway/AMD body progress, VPN stalls |
| `map_gateway_auth_required` | Gateway trusted subject | admin signed route/health с корректным header | BFF header boundary, не включать anonymous в production |
| `cesium_ion_not_configured` | Gateway credential store | existing object hits | admin settings; не добавлять token в Foundry env |
| `cesium_ion_token_verification_failed` | provider/control plane | предыдущий token и cache | assets 1/2/96188 permissions, Foundry referer, AMD route |
| `cesium_asset_not_allowed` | asset policy | canonical assets | изменить root-owned allowlist только с reviewed use case |
| `cesium_ion_endpoint_unavailable` | Ion API | warm object hits, usable cached endpoint | AMD state/timings, VPN, provider HTTP status, token/referer policy |
| `map_egress_amd_connector_not_paired` | NAS proxy pairing | warm hits | `/status`, затем controlled pairing |
| `map_egress_amd_connector_timeout` | NAS→Windows | warm hits/stale refresh fallback | Windows host online, LAN/firewall, connector container |
| `map_egress_amd_upstream_tls_timeout` | Windows/VPN→provider | warm hits/stale refresh fallback | VPN exit, DNS/provider reachability on Windows |
| `map_upstream_timeout` | Gateway upstream | warm hits/stale refresh fallback | distinguish queue/CONNECT/TLS/TTFB via AMD metrics |
| `map_cache_capacity_reached` | NAS capacity policy | old hits and live pass-through | provision capacity/export; do not delete arbitrary objects |
| `map_offline_snapshot_miss` | offline snapshot | other snapshot objects | expected miss; seed licensed dataset, never fall through live |
| `map_provider_offline_not_permitted` | provider policy | live mode if allowed | legal/provider review and explicit allowlist |
| `map_cache_object_too_large` | object guard | other objects/live policy | inspect asset type; change limit only after measured review |
| `live-stale-upstream-error` | explicit refresh failed | previous object | expected resilience; investigate upstream asynchronously |
| renderer `cesium_render_error` | browser/GPU/Cesium | shell and provider diagnostics | browser console/GPU; one recovery already attempted |
### 14.1 Reading latency correctly
- Высокий `queueMs`, нормальный connection/TTFB: pool saturated; проверить request fan-out и VPN throughput.
- Высокий `connectionMs`: дорогой NAS→AMD CONNECT или TLS setup; socket reuse недостаточен либо Windows/VPN нестабилен.
- Низкий connection, высокий `ttfbMs`: VPN/provider latency.
- Нормальный AMD timing, высокий Foundry body idle: проблема stream progress между Gateway и BFF либо NAS I/O.
- Много `cacheHits`, но медленная карта: проверить BFF/browser ETag, NAS read latency, renderer/GPU; AMD здесь не должен участвовать.
- `egressRequests` растёт на повторном одинаковом append-only viewport: cache key/refresh policy нарушена либо requests действительно относятся к новым tiles/LOD.
## 15. Canonical deploy and rollback
### 15.1 Pre-deploy validation
Foundry:
```bash
cd NODEDC_DESIGN_GUIDELINE
node --test server/*.test.mjs
npm run build
```
Map Gateway:
```bash
cd platform/services/map-gateway
npm run test:credential-boundary
npm run test:admin-token-boundary
npm run test:live-cache-fallback
npm run test:streaming-cache-fill
```
DC AMD Proxy:
```bash
cd platform/services/dc-amd-proxy
npm run test:connection-pool
docker compose config >/dev/null
```
Production images должны дополнительно собираться с нуля до публикации artifact. Ни один test fixture token не переносится в artifact.
### 15.2 Build data-only artifacts
Из repository root с новыми уникальными patch ids:
```bash
node platform/infra/deploy-runner/build-dc-amd-proxy-artifact.mjs \
dc-amd-proxy-<change>-YYYYMMDD-NNN
node platform/infra/deploy-runner/build-map-gateway-artifact.mjs \
platform-map-gateway-<change>-YYYYMMDD-NNN
node platform/infra/deploy-runner/build-module-foundry-artifact.mjs \
module-foundry-<change>-YYYYMMDD-NNN
```
Artifact обязан содержать только `manifest.env`, `files.txt`, `payload/**`; запрещены secrets, live `.env`, runtime data, cache objects, symlinks, hooks и AppleDouble `._*`.
Создать SHA-256 sidecar, проверить tar listing и скопировать `.tgz` + `.sha256` через SMB в:
```text
/volume1/docker/nodedc-deploy/inbox
```
### 15.3 Plan before apply
На NAS:
```bash
sudo /usr/local/sbin/nodedc-deploy verify-install
sudo /usr/local/sbin/nodedc-deploy plan \
/volume1/docker/nodedc-deploy/inbox/<exact-dc-amd-proxy-artifact>.tgz
sudo /usr/local/sbin/nodedc-deploy plan \
/volume1/docker/nodedc-deploy/inbox/<exact-platform-artifact>.tgz
sudo /usr/local/sbin/nodedc-deploy plan \
/volume1/docker/nodedc-deploy/inbox/<exact-module-foundry-artifact>.tgz
```
До `apply` оператор проверяет digest, component, type=`app-overlay`, payload/compose roots, services, runtime secret mounts, exact files и `state=new`. `sha-already-applied`, неожиданный file или component — стоп, а не повод обходить runner.
### 15.4 Apply order
```text
1. dc-amd-proxy
2. platform / map-gateway
3. module-foundry
```
Команды используют те же exact paths, что прошли `plan`:
```bash
sudo /usr/local/sbin/nodedc-deploy apply \
/volume1/docker/nodedc-deploy/inbox/<exact-dc-amd-proxy-artifact>.tgz
sudo /usr/local/sbin/nodedc-deploy apply \
/volume1/docker/nodedc-deploy/inbox/<exact-platform-artifact>.tgz
sudo /usr/local/sbin/nodedc-deploy apply \
/volume1/docker/nodedc-deploy/inbox/<exact-module-foundry-artifact>.tgz
```
Нельзя использовать `apply latest`, wildcard или вручную распаковывать payload в live roots. Runner создаёт backup и выполняет component health gate. Ошибка одного слоя должна остановить цепочку до следующего apply.
### 15.5 Post-deploy acceptance
1. Все три containers `running` и `healthy`.
2. AMD `/status`: `paired`, `amd_connector_only`, `directEgress=false`.
3. Gateway `/healthz`: `cache.persistent=true`, ожидаемые paths/limits, `ionConfigured=true`.
4. Canonical endpoints 1, 2, 96188 возвращают соответствующие types и `credentialMode=gateway` без credentials.
5. Первый cold viewport начинает рисоваться до завершения disk commit.
6. Повторный viewport увеличивает cache hits, но не egress requests для тех же objects.
7. Выключение Windows VPN не ломает warm viewport; новый uncached viewport даёт диагностируемый transport error.
8. Explicit refresh при недоступном provider отдаёт старый object, если он был.
9. Foundry UI показывает last-known-good cache stats как stale, а не стирает их generic error.
10. Никакой token/key не встречается в browser Network response, logs, artifact listing или Ops comment.
## 16. Reuse in future NODE.DC products
Новый продукт с Cesium не копирует token handling или TileCache внутрь себя. Он подключается к существующей платформенной capability.
Обязательный шаблон:
1. Provider-neutral domain/page contract живёт в продукте.
2. Renderer adapter получает только same-origin runtime config и sanitised asset endpoint.
3. Product BFF подтверждает свою user/session policy и проксирует только разрешённые Map Gateway routes.
4. BFF передаёт trusted subject из server-side identity, а не browser header.
5. Все provider resources используют Gateway proxy; direct provider URL без proxy запрещён.
6. Новый asset id добавляется в reviewed allowlist с ожидаемым type и acceptance test.
7. Cache intent кодируется теми же `profile/mode/refresh` semantics.
8. Cache физически остаётся общим Platform cache; продукт не создаёт per-instance copy.
9. Admin token rotation остаётся одной Platform setting, а не повторяется в каждом продукте.
10. Health UI различает runtime, Gateway, cache и egress layers и сохраняет safe error code.
Если продукту нужен другой provider или offline dataset, сначала расширяется Platform Map Gateway policy. Нельзя просто добавить arbitrary host в frontend. Требуются license review, host/path allowlist, credential scope, cache policy, diagnostics и tests.
## 17. Explicit decisions and forbidden anti-patterns
### Решения
- Shared NAS cache выбран вместо cache на browser/Foundry instance.
- Append-only/no-eviction выбран для предсказуемости; capacity exhaustion не удаляет уже собранную карту.
- Cache-first выполняется до credential injection, чтобы offline resilience была реальной.
- First miss streaming выбран вместо «сначала полностью записать, потом показать».
- One Map Gateway writer выбран до появления distributed locking.
- AMD route ограничен provider hosts и не затрагивает NAS default networking.
- Endpoint credentials кэшируются private и refresh-ятся заранее, master token остаётся отдельным.
- Browser cache разрешён только для подтверждённых NAS hits и только private.
### Запрещено
- token в browser bundle, source, `.env` Foundry, Envoyer-like UI или Application artifact;
- direct Cesium calls из browser;
- proxy всего NAS/Windows traffic через VPN ради Cesium;
- open/general-purpose HTTP proxy на AMD или NAS;
- использование changing VPN IP вместо stable AMD LAN IP;
- cache в Docker writable layer или anonymous volume;
- отдельный TileCache на каждого пользователя/Application/Foundry replica;
- credential query в cache key;
- автоматический refresh каждого hit без product intent;
- полный `index.json` rewrite на каждый warm hit;
- новый CONNECT/TLS tunnel на каждый tile;
- безусловный retry non-idempotent request или fresh-socket failure;
- перенос `Authorization` через cross-origin redirect;
- absolute body deadline, который убивает большой, но прогрессирующий response;
- публикация partial file/index entry;
- автоматический LRU delete без отдельной принятой retention policy;
- silent direct NAS egress, если AMD/VPN недоступен;
- трактовка сохранённых Cesium bytes как разрешения на offline distribution;
- скрытие provider credits на внешнем/коммерческом surface. Текущее скрытие credit container допустимо только как зафиксированный sandbox debt;
- ручной `sudo`/live edit вместо exact runner `plan` + `apply`.
## 18. Known boundaries and next extensions
1. Range requests сейчас проходят live и не записываются как chunk cache. Range-aware cache добавляется только после измерения реальных 3D Tiles/Gaussian assets.
2. Runtime cache сам не prefetch-ит мир. Для offline regions нужен отдельный licensed seed/prefetch job и versioned export/import artifact.
3. Offline snapshot read-only; miss никогда не становится live request.
4. `index.json` подходит текущему single-writer scale. Multiple Gateway writers требуют нового storage contract.
5. Windows Docker Desktop recovery начинается после user sign-in. Если потребуется unattended SLA, connector должен стать отдельно спроектированным Windows service/host, а не скрытым изменением текущей схемы.
6. DC AMD Proxy можно расширять на новые потоки только отдельным policy change. Нельзя добавлять arbitrary URL forwarding к существующему Cesium route.
7. Provider attribution metadata сохраняется, но visible credit surface должен быть возвращён до внешнего release.
## 19. Definition of done for any Cesium change
Изменение считается завершённым только если:
- credential boundary доказан automated test;
- cache hit не делает provider/egress request;
- same-key concurrency делает один upstream fill;
- abort/partial response не публикует object;
- refresh failure сохраняет старый object;
- endpoint rotation не возвращает credential старого поколения;
- BFF корректно передаёт conditional headers и abort;
- session validation не умножается на tile count;
- AMD metrics сохраняют exact terminal accounting;
- logs не содержат URL query, token, cookie, session/user id или secret;
- production images собираются с нуля;
- data-only artifacts проходят inspection и checksum;
- canonical runner показывает ожидаемый `plan` до `apply`;
- post-deploy acceptance проверяет cold, warm, VPN-off и refresh-failure scenarios;
- Ops card и этот документ обновлены вместе с кодом.
Итоговая модель проста: **Platform Map Gateway владеет credential и общим NAS TileCache; Foundry владеет authenticated same-origin BFF и UI intent; AMD-машина предоставляет только узкий VPN egress. Warm data всегда возвращается локально, а live route используется только там, где cache действительно не хватает или оператор явно разрешил refresh.**

View File

@ -0,0 +1,23 @@
# Platform settings in Module Foundry
`Page Library → Настройки Platform` is visible only to a Foundry administrator. It currently manages the Cesium Ion master token for the shared Platform Map Gateway.
The UI is deliberately not an editor for Foundry `.env`, Envoyer, Docker Compose or a deploy artifact. Those paths are root/operator-owned and would expose a provider secret to the wrong persistence boundary.
The request path is:
```text
admin browser → same-origin Foundry API → HMAC-signed private Gateway route → NAS live cache volume/secrets
```
The browser submits a password field over the existing HTTPS session. Foundry checks the revalidated Launcher/Authentik identity server-side, signs the one request with the internal service secret, and forwards the value without storing it in Foundry runtime data. Before replacing the current private value, Map Gateway verifies the candidate token against the canonical Cesium terrain and imagery assets. Only then does it atomically replace its private token file. The browser receives only `configured`, audit metadata, and the safe verification state — never a token or endpoint credential.
## Foundry groups
- `nodedc:module-foundry:admin` — can see the Page Library gear and change Platform secrets.
- `nodedc:module-foundry:user` — may enter Foundry but cannot access Platform settings.
- `nodedc:module-foundry:blocked` — deny-first; Foundry rejects the session even if another Foundry role was left assigned.
- `nodedc:module-foundry:access` — legacy member role and currently equivalent to `user` while existing Launcher grants are migrated.
- `nodedc:superadmin` and `user_root` — admin for break-glass/bootstrap continuity.
Raw groups never go back to the browser. Foundry returns only the resolved `admin` or `user` role in `/api/session/profile`; every settings endpoint rechecks admin access independently of the UI.

View File

@ -1,13 +1,13 @@
# Map Template
`Map Page 0.1.0` — первый готовый функциональный шаблон NDC Module Studio. Он описывает пространственный интерфейс через стабильные доменные сущности, а не через API конкретного renderer.
`Map Page 0.1.0` — первый готовый функциональный шаблон NDC Module Foundry. Он описывает пространственный интерфейс через стабильные доменные сущности, а не через API конкретного renderer.
## Границы ответственности
- Page Template владеет компоновкой страницы, Inspector, Toolbar, Assistant entry point, слотами данных и системными действиями.
- Map Scene Fixture задаёт минимальный проверочный набор пространственных сущностей и состояний.
- Application Manifest фиксирует экземпляр страницы, выбранный Design Profile и feature visibility.
- Platform capability binding позже связывает слоты шаблона с NDC runtime и потоками данных.
- Platform capability binding связывает слоты шаблона с scoped data products через Foundry runtime BFF.
- Renderer adapter преобразует provider-neutral scene в Cesium или другой поддерживаемый renderer.
Engine/NDC остаётся средой создания и исполнения автоматизаций. Он не является владельцем визуального языка Map Template и не экспортирует в Studio внутренние Cesium objects.
@ -46,22 +46,22 @@ Fixtures малы и детерминированы. Большие геодан
Server-side runtime contract:
- `GET /api/map/runtime-config` сообщает версию renderer и readiness возможностей, но не возвращает master token;
- `GET /api/map/ion/assets/:assetId/endpoint` разрешает только allowlisted assets и обменивает server-side master token на asset-scoped endpoint token;
- `CESIUM_ION_TOKEN` передаётся процессу через deployment environment/secret;
- `GET /api/map/ion/assets/:assetId/endpoint` разрешает только allowlisted assets и возвращает public provider URL без credential; private Gateway добавляет asset credential только к своему upstream request;
- `CESIUM_ION_TOKEN` передаётся только private Platform Map Gateway через deployment environment/secret, не в Foundry;
- `CESIUM_ION_ASSET_ALLOWLIST` ограничивает terrain/buildings/Gaussian assets;
- production endpoint переезжает в `platform/services/map-gateway`.
Development gateway уже позволяет проверить World Terrain и 3D Buildings, не сериализуя master token во frontend. Asset-scoped token является частью официального ion endpoint contract и ограничен конкретным asset.
Development и production gateway позволяют проверить World Terrain и 3D Buildings, не сериализуя никакой Cesium/Bing credential во frontend.
## TileCache boundary
Runtime tile cache не является исходным кодом и не хранится в Git или Docker image layer. Целевой `map-gateway` использует отдельный persistent volume либо object storage и поддерживает:
Runtime tile cache не является исходным кодом и не хранится в Git или Docker image layer. Production `map-gateway` использует NAS bind directory `/volume1/docker/nodedc-platform/map-gateway/live-tile-cache` и поддерживает:
- online-record и offline-fallback;
- cache-first read/write и явный live-refresh;
- нормализацию cache key без credentials;
- upstream allowlist и SSRF protection;
- ограничения размера объекта и общего объёма;
- LRU/eviction, stats, health и наблюдаемость;
- append-only no-overwrite по умолчанию, явный refresh viewport, stats, health и наблюдаемость;
- отдельный export/import версионируемых seed snapshots при необходимости.
Существующий Engine cache используется как donor поведения, но его runtime-файлы не копируются в Studio.
@ -76,5 +76,23 @@ Runtime tile cache не является исходным кодом и не х
2. спроектировать batching/instancing contract для больших потоков объектов и геозон;
3. вынести development gateway в `platform/services/map-gateway` и подключить persistent cache;
4. добавить реальный Gaussian Splat 3D Tiles acceptance asset;
5. добавить capability binding между NDC runtime и slots страницы;
5. расширить entity-stream adapter с `points` на traces, routes и zones;
6. оформить renderer adapter capability matrix для Cesium и будущих providers.
## Live data-product runtime
`Map Page` хранит только provider-neutral binding: `dataProductId`, approved
semantic types, field projection и `slotId` (`points` для live point entities).
При открытии Application page browser делает same-origin запрос к Foundry:
```text
Application/Page/Binding → Foundry BFF → External Data Plane snapshot → SSE patch
```
Foundry сопоставляет target с root-owned opaque reader grant в закрытом
deployment directory. Browser, manifest и Cesium adapter не получают provider
endpoint, tenant/connection scope, reader token или raw provider payload.
Сначала приходит snapshot, затем только patch events с cursor; при пропуске
cursor BFF требует resync snapshot. Renderer держит отдельный `CustomDataSource`
на binding и обновляет stable entity id без пересоздания viewer. History и
sampling остаются политикой Data Plane, а не Map Template.

161
docs/MODULE_FOUNDRY_MCP.md Normal file
View File

@ -0,0 +1,161 @@
# NDC Module Foundry — базовый MCP-контур
## Назначение
`NDC Module Foundry` — модульный контур поверх канонического `NODE.DC Design Guideline`. Он создаёт и редактирует **экземпляры** готовых страниц в `Applications`; сам `Page Library` и его шаблоны остаются неизменяемым каноном.
MCP не вводит новую оркестрацию. Доступ выдаётся уже существующему AI Workspace Assistant через его generic entitlement adapter. Поэтому ассистент может продолжать работать с Engine, Ops, Ontology и другими подключёнными модулями по действующим правилам платформы, а Foundry определяет только границы действий **внутри Foundry**.
## Границы v0.1
| Разрешено | Не разрешено |
| --- | --- |
| Просматривать Page Library и экземпляры Applications | Менять канонический Page Library или дизайн-компоненты |
| Создавать application instance | Удалять application instance через MCP |
| Изменять название, slug и описание instance | Создавать свободный canvas или произвольный React-интерфейс |
| Добавлять повторные instances зарегистрированной страницы | Вызывать Engine, провайдера карт или внешний API из Foundry MCP |
| Создавать/обновлять provider-neutral map pin bindings | Хранить provider tokens, transport payload или credentials в manifest |
| Связывать versioned data product с approved Map entity-stream slot | Хранить provider ID, tenant/connection, endpoint или credential в data binding |
Каждая запись требует `idempotencyKey`. Операция сохраняется в persistent runtime volume и повторный вызов с тем же ключом и тем же входом вернёт исходный результат без дублирования. Повтор ключа с иным входом завершается конфликтом.
## MCP endpoint
`POST /api/mcp`
Поддерживается MCP protocol `2025-06-18`. Endpoint принимает JSON-RPC `initialize`, `tools/list`, `tools/call` и `ping`.
Обязательные HTTP-заголовки каждого вызова:
```http
Authorization: Bearer <short-lived server-issued Foundry capability>
MCP-Protocol-Version: 2025-06-18
```
Capability выдаётся только сервером Foundry после штатной entitlement-проверки
AI Workspace. Она подписана существующим внутренним service credential,
привязана к `actorId` и `ownerKey`, живёт не более 10 минут и не даёт worker
доступа к самому platform credential. Заголовки с actor/owner от worker не
принимаются: контекст извлекается только из подписанной capability.
Доступные инструменты:
- `foundry_status`
- `foundry_list_applications`
- `foundry_get_application`
- `foundry_create_application`
- `foundry_update_application_metadata`
- `foundry_add_page_instance`
- `foundry_upsert_map_pin_binding`
- `foundry_upsert_map_data_product_binding`
`foundry_upsert_map_pin_binding` хранит только визуальную, provider-neutral привязку `elevated-spike`: стабильный id, subject, координаты, semantic status и ссылку на источник сущности. Поток живых данных не передаётся в MCP по одной позиции: визуальная привязка и поток данных будут связываться следующими contract/ontology слоями.
`foundry_upsert_map_data_product_binding` сохраняет только декларацию
`data product → Map entity-stream slot`: versioned data product id, semantic
types и допустимую field projection. Endpoint, provider, tenant, connection,
token, credential и raw payload валидатор отклоняет. Page runtime получает
scoped snapshot/patch поток через Platform, но не через Foundry MCP.
## Runtime data-product boundary
Для Map Page Foundry предоставляет только same-origin runtime routes:
```text
GET /api/applications/:applicationId/pages/:pageId/data-bindings/:bindingId/snapshot
GET /api/applications/:applicationId/pages/:pageId/data-bindings/:bindingId/stream?after=:cursor
```
Маршрут разрешает persisted binding, а затем серверно находит opaque EDP reader
grant по `sha256(applicationId/pageId/bindingId)`. Grant находится в
root-owned read-only directory, передаётся только как `Authorization` во
внутренний External Data Plane и никогда не попадает в browser, manifest,
MCP или лог. В browser отдаётся только canonical data-product envelope:
snapshot, safe `nodedc.data-product.patch/v1` upserts и cursor. `Last-Event-ID`
авторитетнее старого `after` при автоматическом SSE reconnect.
## Entitlement для AI Workspace
`POST /api/ai-workspace/entitlements`
Endpoint предназначен для уже существующего AI Workspace generic adapter. Он принимает его штатный request-контракт и выдаёт динамический MCP grant только если совпала одна из политик:
- `FOUNDRY_ALLOW_ALL_AUTHENTICATED=true` — временно разрешает всем уже аутентифицированным пользователям;
- `FOUNDRY_ALLOWED_OWNER_IDS` — список дополнительных разрешённых user id / owner key;
- `FOUNDRY_ALLOWED_OWNER_GROUPS` — список дополнительных разрешённых групп.
По умолчанию доступ получает `user_root` (`dcctouch@gmail.com`) и техническая
группа Authentik `nodedc:module-foundry:access`. В Launcher эта группа выдаёт
роль сервиса `member`; все остальные пользователи получают «нет доступа» до
явной выдачи права администратором Hub. Многопользовательское владение
Applications в v0.1 не вводится: это общий внутренний контур с аудитом actor id.
Для production предпочтительны явно заданные users/groups; режим `ALLOW_ALL_AUTHENTICATED` допустим только для внутренней песочницы.
Штатный запрос adapter выглядит так:
```json
{
"schemaVersion": "ai-workspace.entitlement-request.v1",
"appId": "module-foundry",
"owner": {
"userId": "authenticated-user-id",
"key": "owner-or-workspace-key",
"groups": ["optional-group"]
},
"activeContext": {},
"runContext": {},
"requestedAt": "2026-07-13T00:00:00.000Z"
}
```
## Server-side configuration
Все значения находятся только в server environment. В browser bundle не передаются tokens, entitlement keys и runtime volume paths.
```dotenv
FOUNDRY_PUBLIC_URL=https://<future-foundry-domain>
FOUNDRY_MCP_URL=https://<future-foundry-domain>/api/mcp
# Existing NODE.DC server-to-server credential; never send it to a browser or worker.
NODEDC_INTERNAL_ACCESS_TOKEN=<existing-platform-service-value>
FOUNDRY_MCP_CAPABILITY_TTL_MS=600000
# Optional exceptional identities, not the default dcctouch superadmin grant.
FOUNDRY_ALLOWED_OWNER_IDS=<comma-separated-extra-ids>
FOUNDRY_ALLOWED_OWNER_GROUPS=<comma-separated-extra-groups>
FOUNDRY_ALLOW_ALL_AUTHENTICATED=false
FOUNDRY_MCP_ALLOWED_ORIGINS=https://<ai-workspace-domain>
```
Foundry не требует у пользователя новый secret. `NODEDC_INTERNAL_ACCESS_TOKEN`
уже существующий server-to-server credential платформы: он находится только в
server environment и используется и для Launcher/Authentiк handoff, и для
внутренней entitlement-проверки. Worker получает лишь короткоживущую capability.
После появления домена в deployment AI Workspace добавляется только штатная настройка adapter; новый worker, отдельная оркестрация или специальный bridge не нужны:
```text
AI_WORKSPACE_ENTITLEMENT_ADAPTERS_JSON='{"module-foundry":{"url":"https://<future-foundry-domain>/api/ai-workspace/entitlements","required":false}}'
```
Если в переменной уже есть другие adapters, `module-foundry` добавляется в тот же JSON-объект — существующие `ops`, `engine`, `launcher` и другие grants не заменяются.
## Deployment package
Foundry подготовлен к отдельному deployment как stateless server + один named persistent volume. Runtime volume содержит Applications, Design Profiles, idempotency audit и media uploads. Он является единственным источником изменяемого Foundry state; Git не используется для runtime-данных и tile cache.
```bash
cp .env.example .env
# использовать уже имеющийся NODEDC_INTERNAL_ACCESS_TOKEN из server environment
docker compose --env-file .env -f infra/docker-compose.module-foundry.yml up -d --build
curl http://127.0.0.1:9920/healthz
```
Внешний маршрут подготовлен: `https://foundry.nodedc.ru``172.22.0.222:9920` → container `:3333`. Compose на NAS должен слушать именно `172.22.0.222:9920`, а не все интерфейсы. До запуска Foundry proxy закономерно возвращает `502`; это проверяемый признак, что DNS/TLS/proxy уже доходят до внутреннего destination. Публикация допустима только после проверки Launcher's handoff, server-side session revalidation, синхронизации Authentik-группы и выдачи Hub access. Только после этого добавляется Foundry entitlement adapter в platform AI Workspace deployment.
## Следующие слои
1. Онтологический contract приложения, страницы, page instance, Map Page и visual entities.
2. Runtime adapter между ontology/gateway потоками и сохранёнными visual bindings.
3. Реальные `elevated-spike`, targets, zones, routes и toolbar commands на instance Map Page.
4. Access/Hub registration и публикация module route.
5. Отдельная пустая программируемая страница — только после того, как готовые page templates и их MCP-контуры отработаны.

View File

@ -1,6 +1,6 @@
# NDC Module Studio
# NDC Module Foundry
Текущий living catalog эволюционирует в Module Studio без создания второго визуального приложения. Существующие разделы остаются `Visual Library`; рядом живут `Page Library` и `Applications`. Первый вертикальный срез не подключает Engine, Cesium, Authentik, Hub или Deploy.
Текущий living catalog эволюционирует в **NDC Module Foundry** без создания второго визуального приложения. Существующие разделы остаются `Visual Library`; рядом живут `Page Library` и `Applications`. Foundry — модульный контур для создания экземпляров готовых приложений, а `Design Guideline` остаётся каноническим языком и каталогом компонентов.
## Page Template contract v0.1
@ -86,3 +86,15 @@ Visual Library использует отдельный Hub-style selector про
- `DELETE /api/applications/:id` с переносом draft в локальное deleted-storage.
Catalog server проверяет полный layout-контракт Design Profile и существование выбранной Application Manifest ссылки при создании и сохранении модуля. Опубликованные снимки лежат отдельно от draft-head в `runtime-data/design-profile-releases` и исключены из Git; в production этот lifecycle должен перейти в Platform `design-profile-core` без изменения публичного контракта.
## Baseline MCP
Foundry предоставляет базовый MCP-контур для уже существующего сквозного AI Workspace Assistant. Он не создаёт отдельную оркестрацию: штатный entitlement adapter выдаёт доступ к Foundry только авторизованному пользователю, после чего ассистент получает ограниченный набор инструментов.
- `Page Library` и канонические шаблоны доступны только на чтение;
- изменяются только экземпляры в `Applications`;
- доступны создание модуля, изменение его metadata, добавление экземпляра готовой страницы и upsert provider-neutral map pin bindings;
- удаление модуля через MCP намеренно отсутствует;
- каждая write-операция требует idempotency key и сохраняет аудит операции в persistent runtime store.
Полная конфигурация, endpoint и границы MCP описаны в [MCP контуре Module Foundry](MODULE_FOUNDRY_MCP.md).

View File

@ -0,0 +1,71 @@
services:
nodedc-module-foundry:
build:
context: ..
dockerfile: Dockerfile
image: nodedc/module-foundry:local
restart: unless-stopped
env_file:
- ../.env
environment:
NODE_ENV: production
HOST: 0.0.0.0
PORT: 3333
FOUNDRY_RUNTIME_DIR: /var/lib/nodedc-module-foundry
NODEDC_FOUNDRY_AUTH_REQUIRED: "true"
NODEDC_FOUNDRY_SERVICE_SLUG: module-foundry
NODEDC_LAUNCHER_BASE_URL: ${NODEDC_LAUNCHER_BASE_URL:?set NODEDC_LAUNCHER_BASE_URL in .env}
NODEDC_LAUNCHER_INTERNAL_URL: ${NODEDC_LAUNCHER_INTERNAL_URL:?set NODEDC_LAUNCHER_INTERNAL_URL in .env}
NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN:?set the existing NODE.DC internal access value in .env}
# Runner-owned file mount. The secret signs private Gateway requests but
# never becomes a Foundry env value or browser-visible setting.
NODEDC_MAP_GATEWAY_ADMIN_SECRET_FILE: /run/nodedc-secrets/map-gateway-admin-secret
# Browser traffic stays same-origin through Foundry; this private address
# is used only by the Foundry server.
NODEDC_MAP_GATEWAY_INTERNAL_URL: ${NODEDC_MAP_GATEWAY_INTERNAL_URL:-http://map-gateway:18103}
# Platform EDP is never addressed by a browser. A page binding resolves
# its own opaque reader grant from the read-only directory below.
NODEDC_EXTERNAL_DATA_PLANE_INTERNAL_URL: ${NODEDC_EXTERNAL_DATA_PLANE_INTERNAL_URL:-http://external-data-plane:18106}
NODEDC_EXTERNAL_DATA_PLANE_READER_GRANTS_DIR: /run/nodedc-secrets/external-data-plane-reader-grants
# Long-lived L2 credentials are separate revocable workload grants. The
# directory contains only hashed grant records; opaque values stay in
# Engine credentials and never enter Foundry env or application state.
NODEDC_FOUNDRY_BINDING_GRANTS_DIR: /run/nodedc-secrets/foundry-binding-grants
NODEDC_FOUNDRY_COOKIE_SECURE: "true"
ports:
- "${FOUNDRY_HOST_BIND:-172.22.0.222:9920}:3333"
volumes:
- nodedc-module-foundry-runtime:/var/lib/nodedc-module-foundry
- type: bind
source: /volume1/docker/nodedc-platform/secrets/map-gateway-admin-secret
target: /run/nodedc-secrets/map-gateway-admin-secret
read_only: true
- type: bind
source: /volume1/docker/nodedc-platform/secrets/external-data-plane-reader-grants
target: /run/nodedc-secrets/external-data-plane-reader-grants
read_only: true
bind:
create_host_path: false
- type: bind
source: /volume1/docker/nodedc-platform/secrets/foundry-binding-grants
target: /run/nodedc-secrets/foundry-binding-grants
read_only: true
bind:
create_host_path: false
networks:
- platform-engine
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3333/healthz').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))"]
interval: 20s
timeout: 5s
retries: 5
start_period: 20s
volumes:
nodedc-module-foundry-runtime:
name: nodedc-module-foundry-runtime
networks:
platform-engine:
external: true
name: ${NODEDC_ENGINE_DOCKER_NETWORK:-nodedc-demo_default}

6
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "nodedc-design-guideline",
"version": "0.6.0",
"version": "0.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nodedc-design-guideline",
"version": "0.6.0",
"version": "0.7.0",
"workspaces": [
"packages/*",
"apps/*"
@ -25,7 +25,7 @@
},
"apps/catalog": {
"name": "@nodedc/ui-catalog",
"version": "0.6.0",
"version": "0.7.0",
"dependencies": {
"@nodedc/page-patterns": "0.1.0",
"@nodedc/ui-core": "0.6.0",

View File

@ -1,6 +1,6 @@
{
"name": "nodedc-design-guideline",
"version": "0.6.0",
"version": "0.7.0",
"private": true,
"description": "Canonical NODE.DC design system, reusable UI packages and living component catalog.",
"type": "module",
@ -14,7 +14,9 @@
"check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry",
"dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog",
"serve": "node server/catalog-server.mjs",
"validate:registry": "node scripts/validate-registry.mjs"
"validate:registry": "node scripts/validate-registry.mjs",
"test:platform-settings": "node scripts/smoke-platform-settings.mjs",
"test:data-product-runtime": "node scripts/smoke-data-product-runtime.mjs"
},
"engines": {
"node": ">=20"

View File

@ -302,7 +302,7 @@
"rules": [
"The six-dot handle is the only drag initiator; clicking the row keeps its normal navigation or selection action.",
"Sortable rows are visually constrained to the vertical axis and cannot pull their layout horizontally.",
"A transfer is accepted only when the dragged surface overlaps the destination by at least the consumer-defined threshold; Module Studio uses 20 percent.",
"A transfer is accepted only when the dragged surface overlaps the destination by at least the consumer-defined threshold; Module Foundry uses 20 percent.",
"Consumers own domain mutations while the package owns sensors, transforms, accessible drag attributes and geometry.",
"Repeated page-template drops create distinct application page instances instead of consuming the source template.",
"A reordered application page array is the single source of truth for both composition and navigation order."

View File

@ -19,7 +19,27 @@
{ "id": "label.place.large", "kind": "label", "variant": "place-large", "color": "#ffffff", "opacity": 1, "size": 28 },
{ "id": "label.entity.compact", "kind": "label", "variant": "entity-compact", "color": "#ffffff", "opacity": 0.92, "size": 13 },
{ "id": "label.station.medium", "kind": "label", "variant": "station-medium", "color": "#ffffff", "opacity": 0.95, "size": 16 },
{ "id": "pin.transport.active", "kind": "pin", "variant": "transport-active", "color": "#ff2f92", "opacity": 1, "size": 22 },
{
"id": "pin.transport.active",
"kind": "pin",
"variant": "transport-active",
"color": "#ff2f92",
"opacity": 1,
"size": 8,
"pinPresentation": {
"variant": "elevated-spike",
"stemHeightMeters": 120,
"headSizePx": 8,
"stemWidthPx": 2,
"outlineColor": "#0c0d12",
"outlineOpacity": 0.6,
"outlineWidthPx": 1,
"labelOffsetX": 10,
"labelOffsetY": 0,
"pinHideCameraHeightMeters": 120000,
"labelHideCameraHeightMeters": 85000
}
},
{ "id": "pin.station.metro", "kind": "pin", "variant": "station-metro", "color": "#8f72dc", "opacity": 1, "size": 26 },
{ "id": "pin.station.rail", "kind": "pin", "variant": "station-rail", "color": "#ffffff", "opacity": 1, "size": 22 },
{ "id": "line.route.primary", "kind": "line", "variant": "route-primary", "color": "#ff2f92", "opacity": 0.9, "width": 4 },

View File

@ -1,6 +1,6 @@
{
"schemaVersion": "1.0.0",
"libraryVersion": "0.6.0",
"libraryVersion": "0.7.0",
"name": "NODE.DC Design System",
"purpose": "Canonical code-level UI system for current and future NODE.DC applications.",
"packages": [
@ -45,7 +45,7 @@
"adoption": "../docs/ADOPTION.md",
"candidates": "../docs/CANDIDATES.md",
"applicationTemplate": "../docs/APPLICATION_TEMPLATE.md",
"moduleStudio": "../docs/MODULE_STUDIO.md",
"moduleFoundry": "../docs/MODULE_STUDIO.md",
"mapTemplate": "../docs/MAP_TEMPLATE.md",
"icons": "../docs/ICONS.md",
"consumption": "../docs/CONSUMPTION.md"

View File

@ -102,7 +102,27 @@
"color": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" },
"opacity": { "type": "number", "minimum": 0, "maximum": 1 },
"width": { "type": "number", "exclusiveMinimum": 0 },
"size": { "type": "number", "exclusiveMinimum": 0 }
"size": { "type": "number", "exclusiveMinimum": 0 },
"pinPresentation": { "$ref": "#/$defs/pinPresentation" }
}
},
"pinPresentation": {
"type": "object",
"description": "Provider-neutral elevated-spike presentation for a map.pin. A renderer adapter maps these values to its own primitives.",
"additionalProperties": false,
"required": ["variant", "stemHeightMeters", "headSizePx", "stemWidthPx", "outlineColor", "outlineOpacity", "outlineWidthPx", "labelOffsetX", "labelOffsetY"],
"properties": {
"variant": { "enum": ["elevated-spike"] },
"stemHeightMeters": { "type": "number", "minimum": 0 },
"headSizePx": { "type": "number", "exclusiveMinimum": 0 },
"stemWidthPx": { "type": "number", "exclusiveMinimum": 0 },
"outlineColor": { "type": "string", "pattern": "^#[0-9a-fA-F]{6}$" },
"outlineOpacity": { "type": "number", "minimum": 0, "maximum": 1 },
"outlineWidthPx": { "type": "number", "minimum": 0 },
"labelOffsetX": { "type": "number" },
"labelOffsetY": { "type": "number" },
"pinHideCameraHeightMeters": { "type": "number", "exclusiveMinimum": 0 },
"labelHideCameraHeightMeters": { "type": "number", "exclusiveMinimum": 0 }
}
},
"visibility": {

View File

@ -0,0 +1,82 @@
{
"schemaVersion": "0.1.0",
"id": "default",
"name": "NODE.DC Default",
"version": "0.6.0",
"status": "published",
"layout": {
"theme": "dark",
"accentHex": "#dfdfdf",
"materialByTheme": {
"dark": {
"panelHex": "#151517",
"panelOpacity": 100,
"fieldHex": "#2a2a2c",
"fieldOpacity": 100,
"nestedHex": "#0b0b0d"
},
"light": {
"panelHex": "#ffffff",
"panelOpacity": 100,
"fieldHex": "#ffffff",
"fieldOpacity": 100,
"nestedHex": "#f4f4f4"
}
},
"environment": {
"lightColor": "#ff2f92",
"brightness": 49,
"glowDistance": 105,
"connectionType": "spline",
"connectionColor": "#404040",
"usePortColors": false,
"fillColor": "#ff6caf",
"fillOpacity": 100,
"strokeColor": "#2b2b36",
"strokeOpacity": 100
},
"media": {
"source": "file",
"url": "",
"fileName": "possible shapes.mp4",
"fileSrc": "/uploads/1783715822132-possible-shapes.mp4",
"visible": true,
"logoSource": "file",
"logoUrl": "",
"logoFileName": "nodedc-mark.svg",
"logoFileSrc": "/nodedc-mark.svg",
"faviconFileName": "icon-adaptive.svg",
"faviconAssets": {
"ico": "/favicon/favicon.ico",
"apple": "/favicon/apple-touch-icon.png",
"icon192": "/favicon/icon-192.png",
"icon512": "/favicon/icon-512.png"
}
},
"glass": {
"version": 4,
"tintHex": "#0b0b0e",
"tintOpacity": 94,
"blur": 62,
"saturation": 170,
"brightness": 129,
"outlineOpacity": 11,
"shadowOpacity": 88
},
"toolbar": {
"placement": "bottom",
"background": "#111115",
"border": "#111117",
"outline": "#1c1c1c",
"minSize": 25,
"maxSize": 78,
"lensCount": 3,
"autoHide": true
}
},
"timestamps": {
"createdAt": "2026-07-11T17:03:22.341Z",
"updatedAt": "2026-07-11T17:03:22.341Z",
"publishedAt": "2026-07-11T19:08:54.044Z"
}
}

View File

@ -0,0 +1,83 @@
{
"schemaVersion": "0.1.0",
"id": "default",
"name": "NODE.DC Default",
"version": "0.6.0",
"status": "draft",
"layout": {
"theme": "dark",
"accentHex": "#dfdfdf",
"materialByTheme": {
"dark": {
"panelHex": "#151517",
"panelOpacity": 100,
"fieldHex": "#2a2a2c",
"fieldOpacity": 100,
"nestedHex": "#0b0b0d"
},
"light": {
"panelHex": "#ffffff",
"panelOpacity": 100,
"fieldHex": "#ffffff",
"fieldOpacity": 100,
"nestedHex": "#f4f4f4"
}
},
"environment": {
"lightColor": "#ff2f92",
"brightness": 49,
"glowDistance": 105,
"connectionType": "spline",
"connectionColor": "#404040",
"usePortColors": false,
"fillColor": "#ff6caf",
"fillOpacity": 100,
"strokeColor": "#2b2b36",
"strokeOpacity": 100
},
"media": {
"source": "file",
"url": "",
"fileName": "possible shapes.mp4",
"fileSrc": "/uploads/1783715822132-possible-shapes.mp4",
"visible": true,
"logoSource": "file",
"logoUrl": "",
"logoFileName": "nodedc-mark.svg",
"logoFileSrc": "/nodedc-mark.svg",
"faviconFileName": "icon-adaptive.svg",
"faviconAssets": {
"ico": "/favicon/favicon.ico",
"apple": "/favicon/apple-touch-icon.png",
"icon192": "/favicon/icon-192.png",
"icon512": "/favicon/icon-512.png"
}
},
"glass": {
"version": 4,
"tintHex": "#0b0b0e",
"tintOpacity": 94,
"blur": 62,
"saturation": 170,
"brightness": 129,
"outlineOpacity": 11,
"shadowOpacity": 88
},
"toolbar": {
"placement": "bottom",
"background": "#111115",
"border": "#111117",
"outline": "#1c1c1c",
"minSize": 25,
"maxSize": 78,
"lensCount": 3,
"autoHide": true
},
"savedAt": "2026-07-11T10:03:43.238Z",
"schemaVersion": 1
},
"timestamps": {
"createdAt": "2026-07-11T17:03:22.341Z",
"updatedAt": "2026-07-11T17:03:22.341Z"
}
}

72
runtime-seed/layout.json Normal file
View File

@ -0,0 +1,72 @@
{
"theme": "dark",
"accentHex": "#dfdfdf",
"materialByTheme": {
"dark": {
"panelHex": "#151517",
"panelOpacity": 100,
"fieldHex": "#2a2a2c",
"fieldOpacity": 100,
"nestedHex": "#0b0b0d"
},
"light": {
"panelHex": "#ffffff",
"panelOpacity": 100,
"fieldHex": "#ffffff",
"fieldOpacity": 100,
"nestedHex": "#f4f4f4"
}
},
"environment": {
"lightColor": "#ff2f92",
"brightness": 49,
"glowDistance": 105,
"connectionType": "spline",
"connectionColor": "#404040",
"usePortColors": false,
"fillColor": "#ff6caf",
"fillOpacity": 100,
"strokeColor": "#2b2b36",
"strokeOpacity": 100
},
"media": {
"source": "file",
"url": "",
"fileName": "possible shapes.mp4",
"fileSrc": "/uploads/1783715822132-possible-shapes.mp4",
"visible": true,
"logoSource": "file",
"logoUrl": "",
"logoFileName": "nodedc-mark.svg",
"logoFileSrc": "/nodedc-mark.svg",
"faviconFileName": "icon-adaptive.svg",
"faviconAssets": {
"ico": "/favicon/favicon.ico",
"apple": "/favicon/apple-touch-icon.png",
"icon192": "/favicon/icon-192.png",
"icon512": "/favicon/icon-512.png"
}
},
"glass": {
"version": 4,
"tintHex": "#0b0b0e",
"tintOpacity": 94,
"blur": 62,
"saturation": 170,
"brightness": 129,
"outlineOpacity": 11,
"shadowOpacity": 88
},
"toolbar": {
"placement": "bottom",
"background": "#111115",
"border": "#111117",
"outline": "#1c1c1c",
"minSize": 25,
"maxSize": 78,
"lensCount": 3,
"autoHide": true
},
"savedAt": "2026-07-11T10:03:43.238Z",
"schemaVersion": 1
}

View File

@ -0,0 +1,61 @@
{
"schemaVersion": 1,
"pageId": "map",
"settings": {
"imagerySource": "cesium-live",
"imageryVisible": true,
"cacheEnabled": true,
"terrainEnabled": true,
"terrainExaggeration": 1,
"monochrome": false,
"monochromeColor": "#15151b",
"imageryGamma": 57,
"imageryHue": 13,
"imageryAlpha": 27,
"globeColor": "#15151b",
"backgroundColor": "#08090d",
"atmosphereEnabled": false,
"atmosphereHue": 0,
"atmosphereSaturation": 0,
"atmosphereBrightness": 0,
"fogEnabled": true,
"fogDensity": 2,
"sunEnabled": true,
"sunHour": 12,
"sunIntensity": 200,
"shadowsEnabled": true,
"buildingsVisible": true,
"buildingsColor": "#a27aff",
"buildingsOpacity": 1,
"buildingsDetail": 4,
"imageryBrightness": 118,
"imageryContrast": 102,
"imagerySaturation": 0,
"gridVisible": true,
"gridLodEnabled": true,
"gridHeightMeters": 500,
"gridLod1MaxHeightKm": 10,
"gridLod1StepKm": 1,
"gridLod2MaxHeightKm": 50,
"gridLod2StepKm": 5,
"gridLod3StepKm": 25,
"gridRadiusKm": 40,
"gridLineWidth": 4,
"gridColor": "#f5f5f5",
"gridOpacity": 12,
"gridDotsEnabled": true,
"gridDotsSize": 7,
"gridDotsColor": "#ffffff",
"gridDotsOpacity": 58
},
"mapHeight": 620,
"camera": {
"longitude": 37.59970476482114,
"latitude": 55.72879732544905,
"height": 1988.144614878829,
"heading": 0.5569709604106112,
"pitch": -0.652025683687945,
"roll": 6.283173705528431
},
"savedAt": "2026-07-13T09:49:36.548Z"
}

View File

@ -0,0 +1,10 @@
{
"version": "1.0.1",
"files": [
"design-profiles/default.json",
"design-profile-releases/default/0.6.0.json",
"layout.json",
"page-layouts/map.json",
"uploads/1783715822132-possible-shapes.mp4"
]
}

Binary file not shown.

View File

@ -0,0 +1,374 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { once } from "node:events";
import { mkdtemp, mkdir, readFile, rm, writeFile, chmod } from "node:fs/promises";
import { createServer, request as httpRequest } from "node:http";
import { spawn } from "node:child_process";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
FOUNDRY_BINDING_CATALOG_ACTION,
FOUNDRY_BINDING_GRANT_SCHEMA_VERSION,
FOUNDRY_BINDING_UPSERT_ACTION,
FOUNDRY_BINDING_UPSERT_SCHEMA_VERSION,
createFoundryBindingGrantToken,
foundryBindingGrantFileName,
} from "../server/foundry-binding-api.mjs";
const root = new URL("..", import.meta.url).pathname;
const applicationId = "11111111-1111-4111-8111-111111111111";
const pageId = "map";
const bindingId = "fleet-positions";
const readerToken = `ndc_edprb_${"a".repeat(43)}`;
const targetKey = createHash("sha256").update(`${applicationId}/${pageId}/${bindingId}`, "utf8").digest("hex");
const runtimeDir = await mkdtemp(join(tmpdir(), "nodedc-foundry-runtime-"));
const grantDir = await mkdtemp(join(tmpdir(), "nodedc-foundry-reader-grants-"));
const bindingGrantDir = await mkdtemp(join(tmpdir(), "nodedc-foundry-binding-grants-"));
const bindingGrantToken = createFoundryBindingGrantToken();
let foundry;
let edp;
const pressure = { sent: 0, backpressured: false, closed: false };
const shutdownStream = { opened: false, closed: false };
function listen(server) {
return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => resolve(server.address().port));
});
}
async function waitFor(url) {
let lastError;
for (let attempt = 0; attempt < 80; attempt += 1) {
try {
const response = await fetch(url, { cache: "no-store" });
if (response.ok) return;
} catch (error) {
lastError = error;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw lastError || new Error("foundry_start_timeout");
}
async function waitUntil(predicate, label, timeoutMs = 5_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (predicate()) return;
await new Promise((resolve) => setTimeout(resolve, 25));
}
throw new Error(`${label}_timeout`);
}
function waitForDrainOrClose(response) {
return new Promise((resolve) => {
let settled = false;
const settle = (value) => {
if (settled) return;
settled = true;
response.off("drain", onDrain);
response.off("close", onClose);
response.off("error", onError);
resolve(value);
};
const onDrain = () => settle(true);
const onClose = () => settle(false);
const onError = () => settle(false);
response.once("drain", onDrain);
response.once("close", onClose);
response.once("error", onError);
});
}
function openPausedStream(url) {
return new Promise((resolve, reject) => {
const request = httpRequest(url, { headers: { accept: "text/event-stream" } }, (response) => {
response.pause();
resolve({ request, response });
});
request.once("error", reject);
request.end();
});
}
function canonicalFact({ longitude, name }) {
return {
sourceId: "vehicle-001",
semanticType: "map.moving_object",
observedAt: "2026-07-15T12:00:00.000Z",
receivedAt: "2026-07-15T12:00:01.000Z",
attributes: { name, private: "must-not-reach-browser", token: "must-not-reach-browser" },
geometry: { type: "Point", coordinates: [longitude, 55.75] },
};
}
try {
await mkdir(join(runtimeDir, "applications"), { recursive: true });
const layout = JSON.parse(await readFile(join(root, "runtime-seed", "page-layouts", "map.json"), "utf8"));
layout.dataProductBindings = [];
await writeFile(join(runtimeDir, "applications", `${applicationId}.json`), `${JSON.stringify({
schemaVersion: "0.1.0",
id: applicationId,
status: "draft",
version: "0.1.0",
metadata: { name: "Runtime test", slug: "runtime-test", description: "" },
designProfile: { id: "default", version: "0.6.0", status: "published", theme: "dark" },
pages: [{
id: pageId,
title: "Map",
path: "/",
template: { id: "map", version: "0.1.0" },
navigation: { visible: true, label: "Map", order: 0 },
features: { inspector: true, toolbar: true, assistant: false },
layout: { map: layout },
}],
favicon: { source: "design-profile" },
timestamps: { createdAt: "2026-07-15T12:00:00.000Z", updatedAt: "2026-07-15T12:00:00.000Z" },
}, null, 2)}\n`, "utf8");
await writeFile(join(grantDir, targetKey), `${readerToken}\n`, "utf8");
await chmod(join(grantDir, targetKey), 0o400);
const issuedAt = new Date(Date.now() - 60_000).toISOString();
const expiresAt = new Date(Date.now() + 60 * 60_000).toISOString();
const bindingGrantPath = join(bindingGrantDir, foundryBindingGrantFileName(bindingGrantToken));
await writeFile(bindingGrantPath, `${JSON.stringify({
schemaVersion: FOUNDRY_BINDING_GRANT_SCHEMA_VERSION,
grantId: "foundry-runtime-smoke-grant",
tokenHash: foundryBindingGrantFileName(bindingGrantToken),
active: true,
issuedAt,
expiresAt,
actorId: "engine-agent-smoke",
ownerKey: "workspace-smoke",
actions: [FOUNDRY_BINDING_CATALOG_ACTION, FOUNDRY_BINDING_UPSERT_ACTION],
targets: [{
applicationId,
pageId,
bindingId,
dataProductId: "fleet.positions.current.v1",
slotId: "points",
semanticTypes: ["map.moving_object"],
fieldProjection: ["name"],
}],
})}\n`, "utf8");
await chmod(bindingGrantPath, 0o400);
edp = createServer((request, response) => {
assert.equal(request.headers.authorization, `Bearer ${readerToken}`);
assert.equal(request.headers["x-nodedc-tenant-id"], undefined);
assert.equal(request.headers["x-nodedc-connection-id"], undefined);
if (request.url === "/internal/data-plane/v1/reader/data-products") {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({
ok: true,
dataProducts: [{
id: "fleet.positions.current.v1",
version: "1.0.0",
deliveryMode: "snapshot+patch",
semanticTypes: ["map.moving_object"],
active: true,
}],
}));
return;
}
if (request.url === "/internal/data-plane/v1/data-products/fleet.positions.current.v1/snapshot") {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({
schemaVersion: "nodedc.data-product.snapshot/v1",
dataProduct: { id: "fleet.positions.current.v1", version: "1.0.0" },
generatedAt: "2026-07-15T12:00:01.000Z",
cursor: "7",
facts: [canonicalFact({ longitude: 37.61, name: "Vehicle 001" })],
}));
return;
}
if (request.url === "/internal/data-plane/v1/data-products/fleet.positions.current.v1/stream?after=1") {
response.writeHead(409, { "content-type": "application/json" });
response.end(JSON.stringify({ ok: false, error: "resync_required" }));
return;
}
if (request.url === "/internal/data-plane/v1/data-products/fleet.positions.current.v1/stream?after=7") {
response.writeHead(200, { "content-type": "text/event-stream" });
response.write("event: nodedc.data-product.ready.v1\n");
response.write(`data: ${JSON.stringify({ schemaVersion: "nodedc.data-product.ready/v1", dataProductId: "fleet.positions.current.v1", cursor: "7", emittedAt: "2026-07-15T12:00:01.000Z" })}\n\n`);
response.write("id: 8\n");
response.write("event: nodedc.data-product.patch.v1\n");
response.write(`data: ${JSON.stringify({
schemaVersion: "nodedc.data-product.patch/v1",
dataProduct: { id: "fleet.positions.current.v1", version: "1.0.0" },
cursor: "8",
previousCursor: "7",
emittedAt: "2026-07-15T12:00:02.000Z",
operations: [{ op: "upsert", fact: canonicalFact({ longitude: 37.62, name: "Vehicle 001 updated" }) }],
})}\n\n`);
response.end();
return;
}
if (request.url === "/internal/data-plane/v1/data-products/fleet.positions.current.v1/stream?after=9") {
response.writeHead(200, { "content-type": "text/event-stream" });
response.once("close", () => { pressure.closed = true; });
void (async () => {
const limit = 2_048;
const largeName = "vehicle-position-".padEnd(16 * 1024, "x");
for (let index = 0; index < limit && !response.destroyed; index += 1) {
const cursor = String(10 + index);
const previousCursor = String(9 + index);
const frame = `id: ${cursor}\nevent: nodedc.data-product.patch.v1\ndata: ${JSON.stringify({
schemaVersion: "nodedc.data-product.patch/v1",
dataProduct: { id: "fleet.positions.current.v1", version: "1.0.0" },
cursor,
previousCursor,
emittedAt: "2026-07-15T12:00:02.000Z",
operations: [{ op: "upsert", fact: canonicalFact({ longitude: 37.62, name: largeName }) }],
})}\n\n`;
pressure.sent += 1;
if (!response.write(frame)) {
pressure.backpressured = true;
if (!(await waitForDrainOrClose(response))) return;
}
}
if (!response.destroyed) response.end();
})();
return;
}
if (request.url === "/internal/data-plane/v1/data-products/fleet.positions.current.v1/stream?after=10") {
response.writeHead(200, { "content-type": "text/event-stream" });
shutdownStream.opened = true;
const heartbeat = setInterval(() => response.write(": keepalive\n\n"), 50);
response.once("close", () => {
clearInterval(heartbeat);
shutdownStream.closed = true;
});
response.write(": keepalive\n\n");
return;
}
response.writeHead(404).end();
});
const edpPort = await listen(edp);
const foundryPortServer = createServer();
const foundryPort = await listen(foundryPortServer);
foundryPortServer.close();
await once(foundryPortServer, "close");
foundry = spawn(process.execPath, ["server/catalog-server.mjs"], {
cwd: root,
env: {
...process.env,
NODE_ENV: "test",
HOST: "127.0.0.1",
PORT: String(foundryPort),
FOUNDRY_RUNTIME_DIR: runtimeDir,
NODEDC_FOUNDRY_AUTH_REQUIRED: "false",
NODEDC_EXTERNAL_DATA_PLANE_INTERNAL_URL: `http://127.0.0.1:${edpPort}`,
NODEDC_EXTERNAL_DATA_PLANE_READER_GRANTS_DIR: grantDir,
NODEDC_FOUNDRY_BINDING_GRANTS_DIR: bindingGrantDir,
},
stdio: ["ignore", "pipe", "pipe"],
});
let stderr = "";
foundry.stderr.on("data", (chunk) => { stderr += String(chunk); });
await waitFor(`http://127.0.0.1:${foundryPort}/healthz`);
const workloadHeaders = {
authorization: `Bearer ${bindingGrantToken}`,
"content-type": "application/json",
};
const catalogResponse = await fetch(`http://127.0.0.1:${foundryPort}/internal/foundry/v1/data-products`, {
headers: { authorization: workloadHeaders.authorization },
});
assert.equal(catalogResponse.status, 200);
assert.deepEqual(await catalogResponse.json(), {
ok: true,
dataProducts: [{ id: "fleet.positions.current.v1", semanticTypes: ["map.moving_object"] }],
});
const bindingCommand = {
schemaVersion: FOUNDRY_BINDING_UPSERT_SCHEMA_VERSION,
applicationId,
pageId,
idempotencyKey: "foundry-runtime-smoke-upsert",
binding: {
id: bindingId,
dataProductId: "fleet.positions.current.v1",
slotId: "points",
semanticTypes: ["map.moving_object"],
fieldProjection: ["name"],
},
};
const bindingResponse = await fetch(`http://127.0.0.1:${foundryPort}/internal/foundry/v1/data-product-bindings`, {
method: "POST",
headers: workloadHeaders,
body: JSON.stringify(bindingCommand),
});
assert.equal(bindingResponse.status, 200);
assert.equal((await bindingResponse.json()).idempotency.replayed, false);
const replayResponse = await fetch(`http://127.0.0.1:${foundryPort}/internal/foundry/v1/data-product-bindings`, {
method: "POST",
headers: workloadHeaders,
body: JSON.stringify(bindingCommand),
});
assert.equal(replayResponse.status, 200);
assert.equal((await replayResponse.json()).idempotency.replayed, true);
const base = `http://127.0.0.1:${foundryPort}/api/applications/${applicationId}/pages/${pageId}/data-bindings/${bindingId}`;
const snapshotResponse = await fetch(`${base}/snapshot`);
assert.equal(snapshotResponse.status, 200);
const snapshot = await snapshotResponse.json();
assert.equal(snapshot.cursor, "7");
assert.equal(snapshot.facts.length, 1);
assert.deepEqual(snapshot.facts[0].attributes, { name: "Vehicle 001" });
assert.equal(JSON.stringify(snapshot).includes("must-not-reach-browser"), false);
const resyncResponse = await fetch(`${base}/stream?after=1`, { headers: { accept: "text/event-stream" } });
assert.equal(resyncResponse.status, 200);
assert.match(await resyncResponse.text(), /nodedc\.data-product\.resync-required\.v1/);
const streamResponse = await fetch(`${base}/stream?after=7`, { headers: { accept: "text/event-stream" } });
assert.equal(streamResponse.status, 200);
const streamText = await streamResponse.text();
assert.match(streamText, /event: nodedc\.data-product\.ready\.v1/);
assert.match(streamText, /event: nodedc\.data-product\.patch\.v1/);
assert.match(streamText, /"cursor":"8"/);
assert.match(streamText, /Vehicle 001 updated/);
assert.equal(streamText.includes("must-not-reach-browser"), false);
assert.equal(stderr, "");
await rm(join(grantDir, targetKey));
const denied = await fetch(`${base}/snapshot`);
assert.equal(denied.status, 403);
assert.deepEqual(await denied.json(), { error: "data_product_reader_grant_not_found" });
await writeFile(join(grantDir, targetKey), `${readerToken}\n`, "utf8");
await chmod(join(grantDir, targetKey), 0o400);
const paused = await openPausedStream(`${base}/stream?after=9`);
await waitUntil(() => pressure.backpressured, "upstream_backpressure");
await waitUntil(() => pressure.sent > 0 && pressure.sent < 2_048, "bounded_upstream_read");
await new Promise((resolve) => setTimeout(resolve, 150));
assert.ok(pressure.sent < 2_048, "Foundry must stop draining the upstream while the browser is backpressured");
paused.response.destroy();
paused.request.destroy();
await waitUntil(() => pressure.closed, "upstream_close_after_browser_disconnect");
const shutdownClient = await openPausedStream(`${base}/stream?after=10`);
await waitUntil(() => shutdownStream.opened, "shutdown_stream_open");
const exit = once(foundry, "exit");
foundry.kill("SIGTERM");
const [exitCode, exitSignal] = await Promise.race([
exit,
new Promise((_, reject) => setTimeout(() => reject(new Error("foundry_bounded_shutdown_timeout")), 2_000)),
]);
assert.equal(exitCode, 0);
assert.equal(exitSignal, null);
await waitUntil(() => shutdownStream.closed, "upstream_close_after_shutdown");
shutdownClient.response.destroy();
shutdownClient.request.destroy();
assert.equal(stderr, "");
console.log("foundry data-product runtime BFF: ok");
} finally {
if (foundry && !foundry.killed) {
foundry.kill("SIGTERM");
await once(foundry, "exit").catch(() => undefined);
}
if (edp) await new Promise((resolve) => edp.close(resolve));
await rm(runtimeDir, { recursive: true, force: true });
await rm(grantDir, { recursive: true, force: true });
await rm(bindingGrantDir, { recursive: true, force: true });
}

View File

@ -0,0 +1,183 @@
import assert from "node:assert/strict";
import { once } from "node:events";
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
import { createServer as createHttpServer } from "node:http";
import { createServer } from "node:net";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { spawn } from "node:child_process";
const foundryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const gatewayRoot = resolve(foundryRoot, "../platform/services/map-gateway");
const root = await mkdtemp(join(tmpdir(), "nodedc-foundry-platform-settings-smoke-"));
const gatewayPort = await freePort();
const foundryPort = await freePort();
const providerPort = await freePort();
const internalSecret = "foundry-gateway-admin-smoke-secret";
const providerToken = "cesium-private-token-must-not-reach-browser";
const ionReferer = "https://foundry.example.test/page-library/";
const adminSecretFile = join(root, "map-gateway-admin-secret");
let gateway;
let foundry;
let provider;
try {
await writeFile(adminSecretFile, `${"a".repeat(64)}\n`, { mode: 0o640 });
await chmod(adminSecretFile, 0o640);
provider = createCesiumIonMock(providerToken);
provider.listen(providerPort, "127.0.0.1");
await once(provider, "listening");
gateway = spawn(process.execPath, ["src/server.mjs"], {
cwd: gatewayRoot,
env: {
...process.env,
PORT: String(gatewayPort),
MAP_CACHE_DIR: join(root, "gateway-cache"),
MAP_GATEWAY_ALLOW_ANONYMOUS: "true",
MAP_CACHE_MODE: "readwrite",
NODE_ENV: "test",
CESIUM_ION_TOKEN: "",
CESIUM_ION_API_BASE_URL: `http://127.0.0.1:${providerPort}/`,
NODEDC_MAP_GATEWAY_ADMIN_SECRET_FILE: adminSecretFile,
},
stdio: ["ignore", "pipe", "pipe"],
});
await waitForService(gatewayPort, gateway, "gateway");
foundry = spawn(process.execPath, ["server/catalog-server.mjs"], {
cwd: foundryRoot,
env: {
...process.env,
NODE_ENV: "development",
HOST: "127.0.0.1",
PORT: String(foundryPort),
FOUNDRY_RUNTIME_DIR: join(root, "foundry-runtime"),
NODEDC_FOUNDRY_AUTH_REQUIRED: "false",
NODEDC_MAP_GATEWAY_INTERNAL_URL: `http://127.0.0.1:${gatewayPort}`,
NODEDC_INTERNAL_ACCESS_TOKEN: internalSecret,
NODEDC_MAP_GATEWAY_ADMIN_SECRET_FILE: adminSecretFile,
FOUNDRY_PUBLIC_URL: "https://foundry.example.test",
},
stdio: ["ignore", "pipe", "pipe"],
});
await waitForService(foundryPort, foundry, "foundry");
const profile = await fetch(`http://127.0.0.1:${foundryPort}/api/session/profile`).then((response) => response.json());
assert.equal(profile.access.role, "admin");
const initial = await fetch(`http://127.0.0.1:${foundryPort}/api/platform-settings/cesium-ion`, { headers: { referer: ionReferer } });
assert.equal(initial.status, 200);
assert.equal((await initial.json()).configured, false);
const saved = await fetch(`http://127.0.0.1:${foundryPort}/api/platform-settings/cesium-ion`, {
method: "PUT",
headers: { "content-type": "application/json", referer: ionReferer },
body: JSON.stringify({ token: providerToken }),
});
const savedRaw = await saved.text();
assert.equal(saved.status, 200);
assert.equal(savedRaw.includes(providerToken), false);
assert.equal(JSON.parse(savedRaw).configured, true);
assert.equal(JSON.parse(savedRaw).verification, "verified");
const status = await fetch(`http://127.0.0.1:${foundryPort}/api/platform-settings/cesium-ion`, { headers: { referer: ionReferer } });
const statusRaw = await status.text();
assert.equal(status.status, 200);
assert.equal(statusRaw.includes(providerToken), false);
assert.equal(JSON.parse(statusRaw).configured, true);
assert.equal(JSON.parse(statusRaw).verification, "verified");
const runtime = await fetch(`http://127.0.0.1:${foundryPort}/api/map/runtime-config`);
assert.equal(runtime.status, 200);
const runtimeBody = await runtime.json();
assert.equal(runtimeBody.gatewayReady, true);
assert.equal(runtimeBody.assetEndpointBase, "/api/map-gateway/api/map/ion/assets");
const terrain = await fetch(`http://127.0.0.1:${foundryPort}/api/map-gateway/api/map/ion/assets/1/endpoint`, { headers: { referer: ionReferer } });
const terrainRaw = await terrain.text();
assert.equal(terrain.status, 200);
assert.equal(terrainRaw.includes(providerToken), false);
assert.equal(JSON.parse(terrainRaw).credentialMode, "gateway");
const buildings = await fetch(`http://127.0.0.1:${foundryPort}/api/map-gateway/api/map/ion/assets/96188/endpoint`, { headers: { referer: ionReferer } });
const buildingsRaw = await buildings.text();
assert.equal(buildings.status, 200);
assert.equal(buildingsRaw.includes(providerToken), false);
assert.equal(JSON.parse(buildingsRaw).credentialMode, "gateway");
const health = await fetch(`http://127.0.0.1:${foundryPort}/api/map-gateway/healthz`);
assert.equal(health.status, 200);
assert.equal((await health.json()).cache.persistent, true);
console.log("ok: Foundry admin settings use the private signed Gateway path without returning the token");
} finally {
await stop(foundry);
await stop(gateway);
if (provider?.listening) {
provider.close();
await once(provider, "close");
}
await rm(root, { recursive: true, force: true });
}
function createCesiumIonMock(expectedToken) {
return createHttpServer((request, response) => {
if (request.headers.authorization !== `Bearer ${expectedToken}`) {
response.writeHead(401, { "content-type": "application/json" });
response.end(JSON.stringify({ error: "unauthorized" }));
return;
}
if (request.headers.referer !== ionReferer) {
response.writeHead(401, { "content-type": "application/json" });
response.end(JSON.stringify({ error: "referer_required" }));
return;
}
const assetId = new URL(request.url || "/", "http://127.0.0.1").pathname.match(/^\/v1\/assets\/(\d+)\/endpoint$/)?.[1];
const body = assetId === "1"
? { type: "TERRAIN", url: "https://assets.ion.cesium.com/1/", accessToken: "terrain-private-endpoint-key" }
: assetId === "2"
? { type: "IMAGERY", externalType: "BING", options: { url: "https://dev.virtualearth.net/REST/v1/Imagery/Metadata/Aerial", key: "bing-private-endpoint-key", mapStyle: "Aerial" } }
: assetId === "96188"
? { type: "3DTILES", url: "https://assets.ion.cesium.com/96188/", accessToken: "buildings-private-endpoint-key" }
: null;
if (!body) {
response.writeHead(404, { "content-type": "application/json" });
response.end(JSON.stringify({ error: "not_found" }));
return;
}
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify(body));
});
}
async function freePort() {
const server = createServer();
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
assert(address && typeof address === "object");
const port = address.port;
server.close();
await once(server, "close");
return port;
}
async function waitForService(port, child, label) {
let output = "";
child.stderr.on("data", (chunk) => { output += String(chunk); });
for (let attempt = 0; attempt < 80; attempt += 1) {
if (child.exitCode !== null) throw new Error(`${label}_exited:${child.exitCode}:${output}`);
try {
const response = await fetch(`http://127.0.0.1:${port}/healthz`);
if (response.ok) return;
} catch { /* service is still starting */ }
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(`${label}_start_timeout:${output}`);
}
async function stop(child) {
if (child && !child.killed) {
child.kill("SIGTERM");
await once(child, "exit").catch(() => undefined);
}
}

View File

@ -0,0 +1,231 @@
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { once } from "node:events";
import { mkdtemp, rm } from "node:fs/promises";
import { createServer as createHttpServer, request as httpRequest } from "node:http";
import { createServer as createNetServer } from "node:net";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
const foundryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
test("Map Gateway BFF caches only confirmed hits, honors validators and aborts abandoned streams", async () => {
const root = await mkdtemp(join(tmpdir(), "nodedc-foundry-map-proxy-"));
const gatewayPort = await freePort();
const foundryPort = await freePort();
let conditionalHeader = "";
let resolveSlowClosed;
const slowClosed = new Promise((resolveClosed) => { resolveSlowClosed = resolveClosed; });
let resolveIdleClosed;
const idleClosed = new Promise((resolveClosed) => { resolveIdleClosed = resolveClosed; });
const gateway = createHttpServer((request, response) => {
const url = new URL(request.url || "/", "http://gateway.local");
if (url.pathname === "/healthz") {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ cache: { persistent: true, entries: 1, bytes: 4 } }));
return;
}
if (url.pathname.startsWith("/api/map/ion/assets/")) {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ ok: true, credentialMode: "gateway" }));
return;
}
if (url.pathname !== "/api/map/cache") {
response.writeHead(404);
response.end();
return;
}
const target = String(url.searchParams.get("url") || "");
conditionalHeader = String(request.headers["if-none-match"] || conditionalHeader);
if (target.includes("/provider-error")) {
response.writeHead(401, { "content-type": "application/json" });
response.end(JSON.stringify({ error: "provider_unauthorized" }));
return;
}
const headers = {
"content-type": "application/octet-stream",
etag: '"tile-v1"',
"x-nodedc-map-cache": target.includes("nodedc_cache_refresh=1") ? "live-stale-upstream-error" : "live-cache-hit",
};
if (target.includes("/headers-slow")) {
const timer = setTimeout(() => {
if (response.destroyed) return;
response.writeHead(200, { ...headers, "content-length": "4" });
response.end("tile");
}, 180);
response.once("close", () => clearTimeout(timer));
return;
}
if (target.includes("/body-idle")) {
response.writeHead(200, headers);
response.write("start");
const timer = setTimeout(() => response.end("late"), 500);
response.once("close", () => {
clearTimeout(timer);
resolveIdleClosed();
});
return;
}
if (target.includes("/progress")) {
response.writeHead(200, { ...headers, "content-length": String(8 * 256) });
let chunks = 0;
const interval = setInterval(() => {
chunks += 1;
response.write(Buffer.alloc(256, chunks));
if (chunks === 8) {
clearInterval(interval);
response.end();
}
}, 30);
response.once("close", () => clearInterval(interval));
return;
}
if (target.includes("/slow")) {
response.writeHead(200, headers);
response.write(Buffer.alloc(1024, 1));
const interval = setInterval(() => response.write(Buffer.alloc(1024, 2)), 20);
response.once("close", () => {
clearInterval(interval);
resolveSlowClosed();
});
return;
}
response.writeHead(200, { ...headers, "content-length": "4" });
response.end("tile");
});
let foundry;
try {
gateway.listen(gatewayPort, "127.0.0.1");
await once(gateway, "listening");
foundry = spawn(process.execPath, ["server/catalog-server.mjs"], {
cwd: foundryRoot,
env: {
...process.env,
NODE_ENV: "development",
HOST: "127.0.0.1",
PORT: String(foundryPort),
FOUNDRY_RUNTIME_DIR: root,
NODEDC_FOUNDRY_AUTH_REQUIRED: "false",
NODEDC_MAP_GATEWAY_INTERNAL_URL: `http://127.0.0.1:${gatewayPort}`,
NODEDC_MAP_GATEWAY_HEADERS_TIMEOUT_MS: "100",
NODEDC_MAP_GATEWAY_BODY_IDLE_TIMEOUT_MS: "80",
},
stdio: ["ignore", "pipe", "pipe"],
});
await waitForService(foundryPort, foundry);
const base = `http://127.0.0.1:${foundryPort}`;
const proxied = (target) => `${base}/api/map-gateway/api/map/cache?url=${encodeURIComponent(target)}`;
const hit = await fetch(proxied("https://assets.ion.cesium.com/1/tile.bin"));
assert.equal(hit.status, 200);
assert.equal(await hit.text(), "tile");
assert.equal(hit.headers.get("cache-control"), "private, max-age=300, stale-while-revalidate=60");
assert.equal(hit.headers.get("vary"), "Cookie");
assert.equal(hit.headers.get("etag"), '"tile-v1"');
const conditional = await fetch(proxied("https://assets.ion.cesium.com/1/tile.bin"), {
headers: { "if-none-match": 'W/"tile-v1"' },
});
assert.equal(conditional.status, 304);
assert.equal(conditionalHeader, 'W/"tile-v1"');
assert.equal(conditional.headers.get("cache-control"), "private, max-age=300, stale-while-revalidate=60");
const refresh = await fetch(proxied("https://assets.ion.cesium.com/1/tile.bin?nodedc_cache_refresh=1"));
assert.equal(refresh.status, 200);
assert.equal(refresh.headers.get("cache-control"), "no-store");
const providerError = await fetch(proxied("https://assets.ion.cesium.com/1/provider-error"));
assert.equal(providerError.status, 401);
assert.equal(providerError.headers.get("cache-control"), "no-store");
const endpoint = await fetch(`${base}/api/map-gateway/api/map/ion/assets/1/endpoint`);
assert.equal(endpoint.status, 200);
assert.equal(endpoint.headers.get("cache-control"), "no-store");
const health = await fetch(`${base}/api/map-gateway/healthz`);
assert.equal(health.status, 200);
assert.equal(health.headers.get("cache-control"), "no-store");
const progressing = await fetch(proxied("https://assets.ion.cesium.com/1/progress"));
assert.equal(progressing.status, 200);
assert.equal((await progressing.arrayBuffer()).byteLength, 8 * 256);
const headersTimeout = await fetch(proxied("https://assets.ion.cesium.com/1/headers-slow"));
assert.equal(headersTimeout.status, 504);
assert.equal((await headersTimeout.json()).error, "map_gateway_headers_timeout");
const idle = await fetch(proxied("https://assets.ion.cesium.com/1/body-idle"));
assert.equal(idle.status, 200);
await assert.rejects(idle.arrayBuffer());
await Promise.race([
idleClosed,
new Promise((_, reject) => setTimeout(() => reject(new Error("upstream_body_idle_abort_timeout")), 2_000)),
]);
await abortAfterFirstChunk(proxied("https://assets.ion.cesium.com/1/slow"));
await Promise.race([
slowClosed,
new Promise((_, reject) => setTimeout(() => reject(new Error("upstream_abort_timeout")), 2_000)),
]);
const stillHealthy = await fetch(`${base}/healthz`);
assert.equal(stillHealthy.status, 200);
} finally {
await stop(foundry);
if (gateway.listening) {
gateway.close();
await once(gateway, "close");
}
await rm(root, { recursive: true, force: true });
}
});
function abortAfterFirstChunk(url) {
return new Promise((resolveAbort, rejectAbort) => {
const request = httpRequest(url, (response) => {
response.once("data", () => {
response.destroy();
request.destroy();
resolveAbort();
});
});
request.once("error", (error) => {
if (error?.code === "ECONNRESET") resolveAbort();
else rejectAbort(error);
});
request.end();
});
}
async function freePort() {
const server = createNetServer();
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
assert(address && typeof address === "object");
const port = address.port;
server.close();
await once(server, "close");
return port;
}
async function waitForService(port, child) {
let output = "";
child.stderr.on("data", (chunk) => { output += String(chunk); });
for (let attempt = 0; attempt < 100; attempt += 1) {
if (child.exitCode !== null) throw new Error(`foundry_exited:${child.exitCode}:${output}`);
try {
const response = await fetch(`http://127.0.0.1:${port}/healthz`);
if (response.ok) return;
} catch { /* service is still starting */ }
await new Promise((resolveWait) => setTimeout(resolveWait, 50));
}
throw new Error(`foundry_start_timeout:${output}`);
}
async function stop(child) {
if (!child || child.exitCode !== null) return;
child.kill("SIGTERM");
await once(child, "exit").catch(() => undefined);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,456 @@
import { createHash, randomBytes } from "node:crypto";
import { constants as fsConstants } from "node:fs";
import { open } from "node:fs/promises";
import { resolve } from "node:path";
export const FOUNDRY_BINDING_GRANT_SCHEMA_VERSION = "nodedc.module-foundry.binding-grant.v1";
export const FOUNDRY_BINDING_UPSERT_SCHEMA_VERSION = "nodedc.foundry.binding-upsert/v1";
export const FOUNDRY_BINDING_CATALOG_ACTION = "foundry.data-product.catalog.read";
export const FOUNDRY_BINDING_UPSERT_ACTION = "foundry.map-data-product.upsert";
export const FOUNDRY_BINDING_CATALOG_PATH = "/internal/foundry/v1/data-products";
export const FOUNDRY_BINDING_UPSERT_PATH = "/internal/foundry/v1/data-product-bindings";
const TOKEN_PREFIX = "ndc_fndbg_";
const TOKEN_PATTERN = /^ndc_fndbg_[A-Za-z0-9_-]{43}$/;
const HASH_PATTERN = /^[a-f0-9]{64}$/;
const IDENTIFIER = /^[A-Za-z0-9._:-]{1,160}$/;
// Keep caller-controlled identifiers compatible with
// @nodedc/external-provider-contract. Grant metadata has its own, deliberately
// broader IDENTIFIER grammar above, while binding values use the canonical
// lower-case identifier grammar.
const CONTRACT_IDENTIFIER = /^[a-z][a-z0-9._:-]{2,127}$/;
const APPLICATION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const PAGE_ID = /^[a-z0-9][a-z0-9-]{0,79}$/;
const SLOT_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9-]{0,79}$/;
const SECRET_LIKE_KEY = /(token|secret|password|authorization|access[_-]?token|refresh[_-]?token|api[_-]?key)/i;
const SECRET_LIKE_VALUE = /(?:ndc_(?:edp(?:wb|rb)|fndbg)_[A-Za-z0-9_-]+|(?:bearer|basic)\s+\S+|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/i;
const TRANSPORT_OR_SCOPE_KEY = /(provider|tenant|connection|endpoint|url|credential|payload)/i;
const ALLOWED_ACTIONS = new Set([FOUNDRY_BINDING_CATALOG_ACTION, FOUNDRY_BINDING_UPSERT_ACTION]);
const GRANT_KEYS = new Set([
"schemaVersion",
"grantId",
"tokenHash",
"active",
"issuedAt",
"expiresAt",
"actorId",
"ownerKey",
"actions",
"targets",
]);
const TARGET_KEYS = new Set([
"applicationId",
"pageId",
"bindingId",
"dataProductId",
"slotId",
"semanticTypes",
"fieldProjection",
]);
const REQUEST_KEYS = new Set(["schemaVersion", "applicationId", "pageId", "idempotencyKey", "binding"]);
const BINDING_KEYS = new Set(["id", "dataProductId", "slotId", "semanticTypes", "fieldProjection"]);
const MAX_REQUEST_BYTES = 64 * 1024;
const MAX_GRANT_BYTES = 128 * 1024;
const DEFAULT_MAX_GRANT_TTL_MS = 90 * 24 * 60 * 60 * 1000;
export function createFoundryBindingGrantToken() {
return `${TOKEN_PREFIX}${randomBytes(32).toString("base64url")}`;
}
export function foundryBindingGrantFileName(token) {
if (!TOKEN_PATTERN.test(String(token || ""))) throw apiError("foundry_binding_unauthorized", 401);
return createHash("sha256").update(token, "utf8").digest("hex");
}
/**
* Handles only the private Foundry binding workload surface. Authentication is
* an opaque, revocable workload grant. Browser sessions, shared Platform
* credentials and EDP reader/writer tokens are deliberately unsupported.
*/
export async function handleFoundryBindingApiRequest(request, response, options = {}) {
try {
const url = new URL(request.url || "/", `http://${request.headers.host || "foundry.local"}`);
if (url.pathname !== FOUNDRY_BINDING_CATALOG_PATH && url.pathname !== FOUNDRY_BINDING_UPSERT_PATH) {
return sendJson(response, 404, { ok: false, error: "foundry_binding_route_not_found" });
}
// Authorization and grant records are evaluated on every request so
// revocation/rotation takes effect immediately. Never let an intermediary
// replay a previously authorized catalog or binding result.
if (request.headers.origin) throw apiError("foundry_binding_browser_origin_forbidden", 403);
const expectedMethod = url.pathname === FOUNDRY_BINDING_CATALOG_PATH ? "GET" : "POST";
if (request.method !== expectedMethod) {
response.setHeader("allow", expectedMethod);
throw apiError("method_not_allowed", 405);
}
const token = bearerToken(request);
const grant = await resolveGrant(token, options);
if (url.pathname === FOUNDRY_BINDING_CATALOG_PATH) {
assertAction(grant, FOUNDRY_BINDING_CATALOG_ACTION);
return sendJson(response, 200, {
ok: true,
dataProducts: grantedDataProducts(grant),
});
}
assertAction(grant, FOUNDRY_BINDING_UPSERT_ACTION);
const contentType = String(request.headers["content-type"] || "").split(";", 1)[0].trim().toLowerCase();
if (contentType !== "application/json") throw apiError("foundry_binding_content_type_required", 415);
const input = validateUpsertInput(await readJsonBody(request));
const target = findExactTarget(grant, input);
if (!target) throw apiError("foundry_binding_target_forbidden", 403);
if (typeof options.preflightReaderGrant !== "function") {
throw apiError("foundry_binding_reader_grant_preflight_unavailable", 503);
}
if (typeof options.upsertBinding !== "function") {
throw apiError("foundry_binding_upsert_unavailable", 503);
}
const actor = Object.freeze({ actorId: grant.actorId, ownerKey: grant.ownerKey });
const preflight = await options.preflightReaderGrant({
applicationId: input.applicationId,
pageId: input.pageId,
bindingId: input.binding.id,
dataProductId: input.binding.dataProductId,
actor,
grantId: grant.grantId,
});
if (preflight === false) throw apiError("data_product_reader_grant_not_ready", 409);
const result = await options.upsertBinding(input, actor);
// The callback may return a complete application/page object. This private
// workload surface deliberately projects only the already-authorized
// binding instead of reflecting callback-owned state back to L2.
const resultBinding = { ...input.binding, delivery: "snapshot+patch" };
const replayed = result?.idempotency?.replayed === true;
return sendJson(response, 200, {
ok: true,
applicationId: input.applicationId,
pageId: input.pageId,
binding: resultBinding,
idempotency: { replayed },
});
} catch (error) {
const normalized = normalizeError(error);
return sendJson(response, normalized.statusCode, { ok: false, error: normalized.code });
}
}
async function resolveGrant(token, options) {
const fileName = foundryBindingGrantFileName(token);
const grantsDir = String(options.grantsDir || "").trim();
if (!grantsDir) throw apiError("foundry_binding_grants_not_configured", 503);
const path = resolve(grantsDir, fileName);
const flags = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0);
let handle;
try {
handle = await open(path, flags);
} catch (error) {
if (error?.code === "ENOENT") throw apiError("foundry_binding_unauthorized", 401);
if (error?.code === "ELOOP") throw apiError("foundry_binding_grant_file_invalid", 503);
throw apiError("foundry_binding_grant_file_unavailable", 503);
}
try {
const metadata = await handle.stat();
if (!metadata.isFile() || metadata.size < 2 || metadata.size > MAX_GRANT_BYTES) {
throw apiError("foundry_binding_grant_file_invalid", 503);
}
// Grant files are immutable capability records. They may be replaced
// atomically for rotation/revocation, but the open inode itself is never
// writable. Production additionally requires root ownership.
if ((metadata.mode & 0o222) !== 0) throw apiError("foundry_binding_grant_file_invalid", 503);
const production = options.production ?? process.env.NODE_ENV === "production";
if (production && metadata.uid !== 0) throw apiError("foundry_binding_grant_file_invalid", 503);
let value;
try {
value = JSON.parse(await handle.readFile("utf8"));
} catch {
throw apiError("foundry_binding_grant_file_invalid", 503);
}
return validateGrant(value, fileName, options);
} finally {
await handle.close();
}
}
function validateGrant(value, expectedTokenHash, options) {
if (!isPlainObject(value) || !hasOnlyKeys(value, GRANT_KEYS)) {
throw apiError("foundry_binding_grant_invalid", 503);
}
if (value.schemaVersion !== FOUNDRY_BINDING_GRANT_SCHEMA_VERSION) {
throw apiError("foundry_binding_grant_invalid", 503);
}
const grantId = requireIdentifier(value.grantId, "foundry_binding_grant_invalid");
const tokenHash = String(value.tokenHash || "");
if (!HASH_PATTERN.test(tokenHash) || tokenHash !== expectedTokenHash) {
throw apiError("foundry_binding_grant_invalid", 503);
}
if (value.active !== true) throw apiError("foundry_binding_grant_inactive", 401);
const actorId = requireIdentifier(value.actorId, "foundry_binding_grant_invalid");
const ownerKey = requireIdentifier(value.ownerKey, "foundry_binding_grant_invalid");
const issuedAt = timestamp(value.issuedAt, "foundry_binding_grant_invalid");
const expiresAt = timestamp(value.expiresAt, "foundry_binding_grant_invalid");
const now = nowMs(options.now);
const maxTtlMs = boundedMaxTtl(options.maxGrantTtlMs);
if (issuedAt > now + 60_000 || expiresAt <= issuedAt || expiresAt - issuedAt > maxTtlMs) {
throw apiError("foundry_binding_grant_invalid", 503);
}
if (expiresAt <= now) throw apiError("foundry_binding_grant_expired", 401);
const actions = uniqueStrings(value.actions, (item) => ALLOWED_ACTIONS.has(item), 1, ALLOWED_ACTIONS.size);
if (!actions) throw apiError("foundry_binding_grant_invalid", 503);
if (!Array.isArray(value.targets) || value.targets.length < 1 || value.targets.length > 128) {
throw apiError("foundry_binding_grant_invalid", 503);
}
const targets = value.targets.map(validateGrantTarget);
const targetKeys = new Set(targets.map(targetKey));
if (targetKeys.size !== targets.length) throw apiError("foundry_binding_grant_invalid", 503);
return Object.freeze({ grantId, actorId, ownerKey, actions, targets });
}
function validateGrantTarget(value) {
if (!isPlainObject(value) || !hasOnlyKeys(value, TARGET_KEYS)) {
throw apiError("foundry_binding_grant_invalid", 503);
}
return Object.freeze({
applicationId: requireApplicationId(value.applicationId, "foundry_binding_grant_invalid"),
pageId: requirePageId(value.pageId, "foundry_binding_grant_invalid"),
bindingId: requireContractIdentifier(value.bindingId, "foundry_binding_grant_invalid"),
dataProductId: requireContractIdentifier(value.dataProductId, "foundry_binding_grant_invalid"),
slotId: requireSlot(value.slotId, "foundry_binding_grant_invalid"),
semanticTypes: Object.freeze(identifierList(value.semanticTypes, 1, 8, "foundry_binding_grant_invalid")),
fieldProjection: Object.freeze(fieldList(value.fieldProjection, "foundry_binding_grant_invalid")),
});
}
function validateUpsertInput(value) {
if (containsSecretMaterial(value)) throw apiError("foundry_binding_secret_material_forbidden", 400);
if (containsTransportOrScopeKey(value)) throw apiError("foundry_binding_transport_or_scope_forbidden", 400);
if (!isPlainObject(value) || !hasOnlyKeys(value, REQUEST_KEYS) || !isPlainObject(value.binding) || !hasOnlyKeys(value.binding, BINDING_KEYS)) {
throw apiError("foundry_binding_request_invalid", 400);
}
if (value.schemaVersion !== FOUNDRY_BINDING_UPSERT_SCHEMA_VERSION) {
throw apiError("foundry_binding_schema_version_unsupported", 400);
}
const input = {
schemaVersion: FOUNDRY_BINDING_UPSERT_SCHEMA_VERSION,
applicationId: requireApplicationId(value.applicationId, "foundry_binding_request_invalid"),
pageId: requirePageId(value.pageId, "foundry_binding_request_invalid"),
idempotencyKey: requireIdempotencyKey(value.idempotencyKey),
binding: validateBinding(value.binding),
};
return Object.freeze({ ...input, binding: Object.freeze(input.binding) });
}
function validateBinding(value) {
if (!isPlainObject(value) || !hasOnlyKeys(value, BINDING_KEYS)) {
throw apiError("foundry_binding_request_invalid", 400);
}
const semanticTypes = Object.freeze(identifierList(value.semanticTypes, 1, 8, "foundry_binding_request_invalid"));
const fieldProjection = Object.freeze(fieldList(value.fieldProjection, "foundry_binding_request_invalid"));
if (fieldProjection.some((field) => SECRET_LIKE_KEY.test(field))) {
throw apiError("foundry_binding_secret_material_forbidden", 400);
}
return {
id: requireContractIdentifier(value.id, "foundry_binding_request_invalid"),
dataProductId: requireContractIdentifier(value.dataProductId, "foundry_binding_request_invalid"),
slotId: requireSlot(value.slotId, "foundry_binding_request_invalid"),
semanticTypes,
fieldProjection,
};
}
function findExactTarget(grant, input) {
const candidate = {
applicationId: input.applicationId,
pageId: input.pageId,
bindingId: input.binding.id,
dataProductId: input.binding.dataProductId,
slotId: input.binding.slotId,
semanticTypes: input.binding.semanticTypes,
fieldProjection: input.binding.fieldProjection,
};
const key = targetKey(candidate);
return grant.targets.find((target) => targetKey(target) === key) || null;
}
function targetKey(target) {
return JSON.stringify([
target.applicationId,
target.pageId,
target.bindingId,
target.dataProductId,
target.slotId,
sorted(target.semanticTypes),
sorted(target.fieldProjection),
]);
}
function grantedDataProducts(grant) {
const products = new Map();
for (const target of grant.targets) {
const existing = products.get(target.dataProductId) || new Set();
for (const semanticType of target.semanticTypes) existing.add(semanticType);
products.set(target.dataProductId, existing);
}
return [...products.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([id, semanticTypes]) => ({ id, semanticTypes: [...semanticTypes].sort() }));
}
function assertAction(grant, action) {
if (!grant.actions.includes(action)) throw apiError("foundry_binding_action_forbidden", 403);
}
async function readJsonBody(request) {
const chunks = [];
let size = 0;
for await (const chunk of request) {
size += chunk.length;
if (size > MAX_REQUEST_BYTES) throw apiError("foundry_binding_payload_too_large", 413);
chunks.push(chunk);
}
try {
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
} catch {
throw apiError("foundry_binding_invalid_json", 400);
}
}
function bearerToken(request) {
const value = String(request.headers.authorization || "");
const match = /^Bearer ([^\s]+)$/.exec(value);
if (!match || !TOKEN_PATTERN.test(match[1])) throw apiError("foundry_binding_unauthorized", 401);
return match[1];
}
function requireIdentifier(value, code) {
const normalized = String(value || "").trim();
if (!IDENTIFIER.test(normalized)) throw apiError(code, code.endsWith("grant_invalid") ? 503 : 400);
return normalized;
}
function requireContractIdentifier(value, code) {
const normalized = typeof value === "string" ? value : "";
if (!CONTRACT_IDENTIFIER.test(normalized)) throw apiError(code, code.endsWith("grant_invalid") ? 503 : 400);
return normalized;
}
function requireSlot(value, code) {
const normalized = typeof value === "string" ? value : "";
if (!SLOT_IDENTIFIER.test(normalized)) throw apiError(code, code.endsWith("grant_invalid") ? 503 : 400);
return normalized;
}
function requireApplicationId(value, code) {
const normalized = typeof value === "string" ? value : "";
if (!APPLICATION_ID.test(normalized)) throw apiError(code, code.endsWith("grant_invalid") ? 503 : 400);
return normalized;
}
function requirePageId(value, code) {
const normalized = typeof value === "string" ? value : "";
if (!PAGE_ID.test(normalized)) throw apiError(code, code.endsWith("grant_invalid") ? 503 : 400);
return normalized;
}
function requireIdempotencyKey(value) {
const normalized = typeof value === "string" ? value : "";
if (!CONTRACT_IDENTIFIER.test(normalized)) throw apiError("foundry_binding_request_invalid", 400);
return normalized;
}
function identifierList(value, min, max, code) {
const list = uniqueStrings(value, (item) => CONTRACT_IDENTIFIER.test(item), min, max);
if (!list) throw apiError(code, code.endsWith("grant_invalid") ? 503 : 400);
return list;
}
function fieldList(value, code) {
const list = uniqueStrings(value, (item) => CONTRACT_IDENTIFIER.test(item), 0, 32);
if (!list) throw apiError(code, code.endsWith("grant_invalid") ? 503 : 400);
return list;
}
function uniqueStrings(value, predicate, min, max) {
if (!Array.isArray(value) || value.length < min || value.length > max) return null;
const normalized = value.map((item) => typeof item === "string" ? item : "");
if (normalized.some((item) => !item || !predicate(item))) return null;
if (new Set(normalized).size !== normalized.length) return null;
return normalized;
}
function timestamp(value, code) {
const number = Date.parse(String(value || ""));
if (!Number.isFinite(number)) throw apiError(code, 503);
return number;
}
function nowMs(value) {
const candidate = typeof value === "function" ? value() : value;
if (candidate instanceof Date) return candidate.getTime();
if (candidate === undefined) return Date.now();
const number = Number(candidate);
if (!Number.isFinite(number)) throw apiError("foundry_binding_clock_invalid", 503);
return number;
}
function boundedMaxTtl(value) {
if (value === undefined) return DEFAULT_MAX_GRANT_TTL_MS;
const number = Number(value);
if (!Number.isInteger(number) || number < 60_000 || number > 365 * 24 * 60 * 60 * 1000) {
throw apiError("foundry_binding_max_ttl_invalid", 503);
}
return number;
}
function containsSecretMaterial(value) {
if (typeof value === "string") return SECRET_LIKE_VALUE.test(value);
if (Array.isArray(value)) return value.some(containsSecretMaterial);
if (!isPlainObject(value)) return false;
return Object.entries(value).some(([key, child]) => SECRET_LIKE_KEY.test(key) || containsSecretMaterial(child));
}
function containsTransportOrScopeKey(value) {
if (Array.isArray(value)) return value.some(containsTransportOrScopeKey);
if (!isPlainObject(value)) return false;
return Object.entries(value).some(([key, child]) => TRANSPORT_OR_SCOPE_KEY.test(key) || containsTransportOrScopeKey(child));
}
function hasOnlyKeys(value, allowed) {
return Object.keys(value).every((key) => allowed.has(key));
}
function sorted(value) {
return [...value].sort();
}
function isPlainObject(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function apiError(code, statusCode) {
return Object.assign(new Error(code), { code, statusCode });
}
function normalizeError(error) {
const statusCode = Number(error?.statusCode);
const code = String(error?.code || error?.message || "");
if (Number.isInteger(statusCode) && statusCode >= 400 && statusCode <= 599 && /^[a-z0-9_.:-]{1,120}$/i.test(code)) {
return { statusCode, code };
}
return { statusCode: 500, code: "foundry_binding_internal_error" };
}
function sendJson(response, statusCode, payload) {
if (response.writableEnded) return;
response.writeHead(statusCode, {
"cache-control": "no-store, max-age=0",
"content-type": "application/json; charset=utf-8",
pragma: "no-cache",
vary: "authorization",
});
response.end(JSON.stringify(payload));
}

View File

@ -0,0 +1,311 @@
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { chmod, mkdtemp, rename, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import {
FOUNDRY_BINDING_CATALOG_ACTION,
FOUNDRY_BINDING_CATALOG_PATH,
FOUNDRY_BINDING_GRANT_SCHEMA_VERSION,
FOUNDRY_BINDING_UPSERT_SCHEMA_VERSION,
FOUNDRY_BINDING_UPSERT_ACTION,
FOUNDRY_BINDING_UPSERT_PATH,
createFoundryBindingGrantToken,
foundryBindingGrantFileName,
handleFoundryBindingApiRequest,
} from "./foundry-binding-api.mjs";
const NOW = Date.parse("2026-07-15T12:00:00.000Z");
const TARGET = Object.freeze({
applicationId: "11111111-1111-4111-8111-111111111111",
pageId: "map-page-1",
bindingId: "fleet-live-points",
dataProductId: "fleet.positions.current.v1",
slotId: "points",
semanticTypes: ["map.moving_object"],
fieldProjection: ["course", "name", "speed"],
});
test("Foundry binding workload API is scoped, revocable and replay-safe at its operation boundary", async (t) => {
const grantsDir = await mkdtemp(join(tmpdir(), "nodedc-foundry-binding-grants-"));
const token = createFoundryBindingGrantToken();
const calls = { preflight: [], upsert: [] };
let readerGrantReady = true;
const options = {
grantsDir,
production: false,
now: () => NOW,
async preflightReaderGrant(input) {
calls.preflight.push(structuredClone(input));
return readerGrantReady;
},
async upsertBinding(input, actor) {
calls.upsert.push({ input: structuredClone(input), actor: structuredClone(actor) });
return {
application: { privateManifestMaterial: "must-not-leak" },
page: { unrelatedBindings: ["must-not-leak"] },
binding: { ...input.binding, delivery: "snapshot+patch" },
idempotency: { replayed: calls.upsert.length > 1 },
};
},
};
await writeGrant(grantsDir, token, grantFor(token));
const server = createServer((request, response) => {
void handleFoundryBindingApiRequest(request, response, options);
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
const baseUrl = `http://127.0.0.1:${address.port}`;
t.after(async () => {
await new Promise((resolve) => server.close(resolve));
await rm(grantsDir, { recursive: true, force: true });
});
await t.test("catalog exposes only grant-allowlisted products", async () => {
const response = await request(baseUrl, FOUNDRY_BINDING_CATALOG_PATH, { token });
assert.equal(response.status, 200);
assert.equal(response.headers.get("cache-control"), "no-store, max-age=0");
assert.equal(response.headers.get("pragma"), "no-cache");
assert.equal(response.headers.get("vary"), "authorization");
assert.deepEqual(response.body, {
ok: true,
dataProducts: [{ id: TARGET.dataProductId, semanticTypes: TARGET.semanticTypes }],
});
});
await t.test("POST forwards the stable idempotency key and grant actor without leaking full manifests", async () => {
const payload = upsertPayload();
const first = await request(baseUrl, FOUNDRY_BINDING_UPSERT_PATH, {
token,
method: "POST",
body: payload,
headers: { "X-NODEDC-Actor": "attacker-supplied-actor" },
});
const second = await request(baseUrl, FOUNDRY_BINDING_UPSERT_PATH, { token, method: "POST", body: payload });
assert.equal(first.status, 200);
assert.equal(second.status, 200);
assert.equal(first.body.idempotency.replayed, false);
assert.equal(second.body.idempotency.replayed, true);
assert.equal(first.body.application, undefined);
assert.equal(first.body.page, undefined);
assert.deepEqual(calls.upsert.map((call) => call.input.idempotencyKey), [payload.idempotencyKey, payload.idempotencyKey]);
assert.deepEqual(calls.upsert.map((call) => call.actor), [
{ actorId: "engine-agent-42", ownerKey: "workspace-42" },
{ actorId: "engine-agent-42", ownerKey: "workspace-42" },
]);
assert.deepEqual(calls.preflight[0], {
applicationId: TARGET.applicationId,
pageId: TARGET.pageId,
bindingId: TARGET.bindingId,
dataProductId: TARGET.dataProductId,
actor: { actorId: "engine-agent-42", ownerKey: "workspace-42" },
grantId: "foundry-binding-grant-42",
});
});
await t.test("action scope and reader-grant preflight fail closed", async () => {
const catalogDeniedToken = createFoundryBindingGrantToken();
await writeGrant(grantsDir, catalogDeniedToken, grantFor(catalogDeniedToken, {
actions: [FOUNDRY_BINDING_UPSERT_ACTION],
}));
let response = await request(baseUrl, FOUNDRY_BINDING_CATALOG_PATH, { token: catalogDeniedToken });
assert.equal(response.status, 403);
assert.equal(response.body.error, "foundry_binding_action_forbidden");
const baselineUpserts = calls.upsert.length;
readerGrantReady = false;
response = await request(baseUrl, FOUNDRY_BINDING_UPSERT_PATH, {
token,
method: "POST",
body: upsertPayload(),
});
readerGrantReady = true;
assert.equal(response.status, 409);
assert.equal(response.body.error, "data_product_reader_grant_not_ready");
assert.equal(calls.upsert.length, baselineUpserts);
});
await t.test("exact target scope rejects any changed dimension before callbacks", async () => {
const baselinePreflight = calls.preflight.length;
const baselineUpsert = calls.upsert.length;
const mutations = [
(body) => { body.applicationId = "22222222-2222-4222-8222-222222222222"; },
(body) => { body.pageId = "map-page-2"; },
(body) => { body.binding.id = "fleet-live-points-2"; },
(body) => { body.binding.dataProductId = "fleet.positions.current.v2"; },
(body) => { body.binding.slotId = "traces"; },
(body) => { body.binding.semanticTypes = ["map.route"]; },
(body) => { body.binding.fieldProjection = ["name"]; },
];
for (const mutate of mutations) {
const body = upsertPayload();
mutate(body);
const response = await request(baseUrl, FOUNDRY_BINDING_UPSERT_PATH, { token, method: "POST", body });
assert.equal(response.status, 403);
assert.equal(response.body.error, "foundry_binding_target_forbidden");
}
assert.equal(calls.preflight.length, baselinePreflight);
assert.equal(calls.upsert.length, baselineUpsert);
});
await t.test("wire schema enforces the canonical UUID and identifier grammar", async () => {
const mutations = [
(body) => { body.applicationId = "00000000-0000-0000-0000-000000000000"; },
(body) => { body.binding.id = "Fleet-Live-Points"; },
(body) => { body.binding.dataProductId = "Fleet.Positions.Current.V1"; },
(body) => { body.binding.slotId = "-points"; },
(body) => { body.binding.semanticTypes = ["Map.Moving_Object"]; },
(body) => { body.binding.fieldProjection = ["Display_Name"]; },
(body) => { body.idempotencyKey = "X"; },
(body) => { body.binding.dataProductId = ` ${TARGET.dataProductId}`; },
];
for (const mutate of mutations) {
const body = upsertPayload();
mutate(body);
const response = await request(baseUrl, FOUNDRY_BINDING_UPSERT_PATH, { token, method: "POST", body });
assert.equal(response.status, 400);
assert.equal(response.body.error, "foundry_binding_request_invalid");
}
});
await t.test("secret material, transport scope, browser origin and shared tokens are rejected", async () => {
const wrongSchema = upsertPayload();
wrongSchema.schemaVersion = "nodedc.foundry.binding-upsert/v2";
let response = await request(baseUrl, FOUNDRY_BINDING_UPSERT_PATH, { token, method: "POST", body: wrongSchema });
assert.equal(response.status, 400);
assert.equal(response.body.error, "foundry_binding_schema_version_unsupported");
const withSecret = upsertPayload();
withSecret.binding.token = "ndc_edprb_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
response = await request(baseUrl, FOUNDRY_BINDING_UPSERT_PATH, { token, method: "POST", body: withSecret });
assert.equal(response.status, 400);
assert.equal(response.body.error, "foundry_binding_secret_material_forbidden");
const withProvider = upsertPayload();
withProvider.binding.providerId = "gelios";
response = await request(baseUrl, FOUNDRY_BINDING_UPSERT_PATH, { token, method: "POST", body: withProvider });
assert.equal(response.status, 400);
assert.equal(response.body.error, "foundry_binding_transport_or_scope_forbidden");
response = await request(baseUrl, FOUNDRY_BINDING_CATALOG_PATH, {
token,
headers: { Origin: "https://foundry.nodedc.ru" },
});
assert.equal(response.status, 403);
assert.equal(response.body.error, "foundry_binding_browser_origin_forbidden");
response = await request(baseUrl, FOUNDRY_BINDING_CATALOG_PATH, { rawAuthorization: "Bearer shared-platform-token" });
assert.equal(response.status, 401);
assert.equal(response.body.error, "foundry_binding_unauthorized");
response = await request(baseUrl, FOUNDRY_BINDING_CATALOG_PATH, {
headers: { Cookie: "nodedc_foundry_session=browser-session" },
});
assert.equal(response.status, 401);
assert.equal(response.body.error, "foundry_binding_unauthorized");
});
await t.test("writable and symlink grant records are rejected", async () => {
const writableToken = createFoundryBindingGrantToken();
await writeGrant(grantsDir, writableToken, grantFor(writableToken));
const writablePath = join(grantsDir, foundryBindingGrantFileName(writableToken));
await chmod(writablePath, 0o600);
let response = await request(baseUrl, FOUNDRY_BINDING_CATALOG_PATH, { token: writableToken });
assert.equal(response.status, 503);
assert.equal(response.body.error, "foundry_binding_grant_file_invalid");
const symlinkToken = createFoundryBindingGrantToken();
const sourcePath = join(grantsDir, `source-${randomSuffix()}`);
await writeFile(sourcePath, `${JSON.stringify(grantFor(symlinkToken))}\n`, { mode: 0o400 });
await chmod(sourcePath, 0o400);
await symlink(sourcePath, join(grantsDir, foundryBindingGrantFileName(symlinkToken)));
response = await request(baseUrl, FOUNDRY_BINDING_CATALOG_PATH, { token: symlinkToken });
assert.equal(response.status, 503);
assert.equal(response.body.error, "foundry_binding_grant_file_invalid");
});
await t.test("grant expiry is enforced", async () => {
const expiredToken = createFoundryBindingGrantToken();
await writeGrant(grantsDir, expiredToken, grantFor(expiredToken, {
issuedAt: "2026-07-14T10:00:00.000Z",
expiresAt: "2026-07-15T11:59:59.000Z",
}));
const response = await request(baseUrl, FOUNDRY_BINDING_CATALOG_PATH, { token: expiredToken });
assert.equal(response.status, 401);
assert.equal(response.body.error, "foundry_binding_grant_expired");
});
await t.test("revocation and rotation take effect on the next request", async () => {
let response = await request(baseUrl, FOUNDRY_BINDING_CATALOG_PATH, { token });
assert.equal(response.status, 200);
await writeGrant(grantsDir, token, grantFor(token, { active: false }));
response = await request(baseUrl, FOUNDRY_BINDING_CATALOG_PATH, { token });
assert.equal(response.status, 401);
assert.equal(response.body.error, "foundry_binding_grant_inactive");
const rotatedToken = createFoundryBindingGrantToken();
await writeGrant(grantsDir, rotatedToken, grantFor(rotatedToken));
response = await request(baseUrl, FOUNDRY_BINDING_CATALOG_PATH, { token: rotatedToken });
assert.equal(response.status, 200);
});
});
function grantFor(token, overrides = {}) {
return {
schemaVersion: FOUNDRY_BINDING_GRANT_SCHEMA_VERSION,
grantId: "foundry-binding-grant-42",
tokenHash: foundryBindingGrantFileName(token),
active: true,
issuedAt: "2026-07-15T11:00:00.000Z",
expiresAt: "2026-07-16T12:00:00.000Z",
actorId: "engine-agent-42",
ownerKey: "workspace-42",
actions: [FOUNDRY_BINDING_CATALOG_ACTION, FOUNDRY_BINDING_UPSERT_ACTION],
targets: [structuredClone(TARGET)],
...overrides,
};
}
function upsertPayload() {
return {
schemaVersion: FOUNDRY_BINDING_UPSERT_SCHEMA_VERSION,
applicationId: TARGET.applicationId,
pageId: TARGET.pageId,
idempotencyKey: "foundry-binding-3b7e28b52f7212a843fa5aa4809b03ea",
binding: {
id: TARGET.bindingId,
dataProductId: TARGET.dataProductId,
slotId: TARGET.slotId,
semanticTypes: [...TARGET.semanticTypes],
fieldProjection: [...TARGET.fieldProjection],
},
};
}
async function writeGrant(grantsDir, token, grant) {
const target = join(grantsDir, foundryBindingGrantFileName(token));
const temp = `${target}.${randomSuffix()}.tmp`;
await writeFile(temp, `${JSON.stringify(grant)}\n`, { mode: 0o400 });
await chmod(temp, 0o400);
await rename(temp, target);
}
function randomSuffix() {
return Math.random().toString(16).slice(2);
}
async function request(baseUrl, path, { token, rawAuthorization, method = "GET", body, headers = {} } = {}) {
const response = await fetch(`${baseUrl}${path}`, {
method,
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(rawAuthorization ? { Authorization: rawAuthorization } : {}),
...(body ? { "Content-Type": "application/json" } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});
return { status: response.status, headers: response.headers, body: await response.json() };
}

425
server/foundry-mcp.mjs Normal file
View File

@ -0,0 +1,425 @@
import { createHmac, timingSafeEqual } from "node:crypto";
const MCP_PROTOCOL_VERSION = "2025-06-18";
const MAX_BODY_BYTES = 1024 * 1024;
function sendJson(response, statusCode, payload) {
response.writeHead(statusCode, {
"cache-control": "no-store",
"content-type": "application/json; charset=utf-8",
});
response.end(JSON.stringify(payload));
}
async function readJsonBody(request, maxBytes = MAX_BODY_BYTES) {
const chunks = [];
let size = 0;
for await (const chunk of request) {
size += chunk.length;
if (size > maxBytes) throw new Error("payload_too_large");
chunks.push(chunk);
}
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}
function timingSafeTokenEqual(left, right) {
const leftBuffer = Buffer.from(String(left || ""));
const rightBuffer = Buffer.from(String(right || ""));
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
}
function bearerToken(request) {
const value = String(request.headers.authorization || "");
return value.startsWith("Bearer ") ? value.slice("Bearer ".length).trim() : "";
}
function originAllowed(request, allowedOrigins) {
const origin = String(request.headers.origin || "").trim();
if (!origin) return true;
return allowedOrigins.has(origin);
}
function base64UrlJson(value) {
return Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
}
function parseBase64UrlJson(value) {
try {
return JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
} catch {
return null;
}
}
function signCapability(encodedPayload, secret) {
return createHmac("sha256", secret)
.update(`nodedc.module-foundry.mcp-capability.v1.${encodedPayload}`)
.digest("base64url");
}
function mintMcpCapability(identity, config) {
const now = Date.now();
const payload = {
schemaVersion: "nodedc.module-foundry.mcp-capability.v1",
service: "module-foundry",
actorId: identity.actorId,
ownerKey: identity.ownerKey,
issuedAt: now,
expiresAt: now + config.mcpCapabilityTtlMs,
};
const encodedPayload = base64UrlJson(payload);
return `fnd1.${encodedPayload}.${signCapability(encodedPayload, config.capabilitySecret)}`;
}
function actorFromMcpCapability(token, config) {
const [prefix, encodedPayload, signature, ...rest] = String(token || "").split(".");
if (prefix !== "fnd1" || !encodedPayload || !signature || rest.length || !config.capabilitySecret) return null;
const expectedSignature = signCapability(encodedPayload, config.capabilitySecret);
if (!timingSafeTokenEqual(signature, expectedSignature)) return null;
const payload = parseBase64UrlJson(encodedPayload);
if (!payload || payload.schemaVersion !== "nodedc.module-foundry.mcp-capability.v1" || payload.service !== "module-foundry") return null;
const actorId = String(payload.actorId || "").trim();
const ownerKey = String(payload.ownerKey || "").trim();
const expiresAt = Number(payload.expiresAt);
const issuedAt = Number(payload.issuedAt);
if (!actorId || !ownerKey || !Number.isFinite(expiresAt) || !Number.isFinite(issuedAt)) return null;
if (issuedAt > Date.now() + 60_000 || expiresAt <= Date.now() || expiresAt - issuedAt > config.mcpCapabilityTtlMs + 60_000) return null;
return { actorId, ownerKey };
}
function mcpError(id, code, message, data) {
return {
jsonrpc: "2.0",
id: id ?? null,
error: {
code,
message,
...(data === undefined ? {} : { data }),
},
};
}
function mcpResult(id, result) {
return { jsonrpc: "2.0", id: id ?? null, result };
}
function toolText(payload, isError = false) {
return {
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
structuredContent: payload,
...(isError ? { isError: true } : {}),
};
}
function normalizeMcpError(error) {
const code = String(error?.message || "foundry_operation_failed");
if (code === "application_not_found") return { code, status: 404 };
if (code.startsWith("invalid_") || code.startsWith("unknown_") || code.startsWith("map_") || code.endsWith("_required")) return { code, status: 400 };
if (code.startsWith("duplicate_") || code.includes("conflict") || code.includes("mismatch")) return { code, status: 409 };
return { code: "foundry_operation_failed", status: 500 };
}
const tools = [
{
name: "foundry_status",
title: "NDC Module Foundry status",
description: "Read the availability and baseline scope of NDC Module Foundry. Canonical Page Library templates are read-only.",
inputSchema: { type: "object", additionalProperties: false, properties: {} },
},
{
name: "foundry_list_applications",
title: "List application instances",
description: "List editable application instances in NDC Module Foundry. This does not mutate Page Library.",
inputSchema: { type: "object", additionalProperties: false, properties: {} },
},
{
name: "foundry_get_application",
title: "Get application instance",
description: "Read one editable application instance, its page instances, features and map pin bindings.",
inputSchema: {
type: "object",
additionalProperties: false,
required: ["applicationId"],
properties: { applicationId: { type: "string", description: "UUID of an application instance." } },
},
},
{
name: "foundry_create_application",
title: "Create application instance",
description: "Create a draft application instance from a registered page template. The canonical template itself remains unchanged. Application deletion is deliberately unavailable.",
inputSchema: {
type: "object",
additionalProperties: false,
required: ["name", "idempotencyKey"],
properties: {
name: { type: "string", description: "Human-readable application name." },
idempotencyKey: { type: "string", description: "Stable key for replay-safe writes." },
slug: { type: "string" },
description: { type: "string" },
templateId: { type: "string", description: "Registered Page Library template id. Defaults to map." },
templateVersion: { type: "string" },
theme: { type: "string", enum: ["dark", "light"] },
},
},
},
{
name: "foundry_update_application_metadata",
title: "Update application metadata",
description: "Rename or describe an application instance. It cannot change canonical templates or delete an application.",
inputSchema: {
type: "object",
additionalProperties: false,
required: ["applicationId", "idempotencyKey"],
properties: {
applicationId: { type: "string" },
idempotencyKey: { type: "string" },
name: { type: "string" },
slug: { type: "string" },
description: { type: "string" },
},
},
},
{
name: "foundry_add_page_instance",
title: "Add page instance to application",
description: "Create one more instance of a registered Page Library template inside an application. A template may be used repeatedly; Page Library remains read-only.",
inputSchema: {
type: "object",
additionalProperties: false,
required: ["applicationId", "templateId", "idempotencyKey"],
properties: {
applicationId: { type: "string" },
templateId: { type: "string", description: "Registered Page Library template id." },
templateVersion: { type: "string" },
idempotencyKey: { type: "string" },
title: { type: "string" },
navigationLabel: { type: "string" },
},
},
},
{
name: "foundry_upsert_map_pin_binding",
title: "Upsert map pin binding",
description: "Create or update a visual map-pin binding on a Map Page instance. This stores a stable visual binding; it does not call a provider, Engine or an external data source.",
inputSchema: {
type: "object",
additionalProperties: false,
required: ["applicationId", "pageId", "idempotencyKey", "binding"],
properties: {
applicationId: { type: "string" },
pageId: { type: "string", description: "Map Page instance id inside the application." },
idempotencyKey: { type: "string" },
binding: {
type: "object",
description: "Provider-neutral map-pin binding. Coordinates and source entity are mandatory.",
required: ["id", "subjectId", "coordinates", "source"],
properties: {
id: { type: "string" },
subjectId: { type: "string" },
label: { type: "string" },
status: { type: "string" },
coordinates: {
type: "object",
required: ["longitude", "latitude"],
properties: {
longitude: { type: "number" },
latitude: { type: "number" },
heightMeters: { type: "number" },
},
},
source: {
type: "object",
required: ["entityId"],
properties: {
entityId: { type: "string" },
streamId: { type: "string" },
displayFields: { type: "array", items: { type: "string" } },
},
},
attributes: { type: "object" },
},
},
},
},
},
{
name: "foundry_upsert_map_data_product_binding",
title: "Upsert Map data product binding",
description: "Bind one approved provider-neutral data product to an entity-stream slot of a Map Page instance. The binding stores no provider transport, endpoint, tenant, connection or credential.",
inputSchema: {
type: "object",
additionalProperties: false,
required: ["applicationId", "pageId", "idempotencyKey", "binding"],
properties: {
applicationId: { type: "string" },
pageId: { type: "string", description: "Map Page instance id inside the application." },
idempotencyKey: { type: "string" },
binding: {
type: "object",
required: ["id", "dataProductId", "slotId", "semanticTypes"],
properties: {
id: { type: "string" },
dataProductId: { type: "string", description: "Versioned provider-neutral product, for example fleet.positions.current.v1." },
slotId: { type: "string", description: "Approved Map Page entity-stream slot." },
semanticTypes: { type: "array", items: { type: "string" } },
fieldProjection: { type: "array", items: { type: "string" } },
},
},
},
},
},
];
function toolMap(operations) {
return {
foundry_status: () => operations.status(),
foundry_list_applications: () => operations.listApplications(),
foundry_get_application: (input) => operations.getApplication(input.applicationId),
foundry_create_application: (input, actor) => operations.createApplication(input, actor),
foundry_update_application_metadata: (input, actor) => operations.updateApplicationMetadata(input, actor),
foundry_add_page_instance: (input, actor) => operations.addPageInstance(input, actor),
foundry_upsert_map_pin_binding: (input, actor) => operations.upsertMapPinBinding(input, actor),
foundry_upsert_map_data_product_binding: (input, actor) => operations.upsertMapDataProductBinding(input, actor),
};
}
export async function handleFoundryMcpRequest(request, response, options) {
const config = options.getConfig();
const allowedOrigins = new Set(config.mcpAllowedOrigins || []);
if (request.method !== "POST") {
response.writeHead(405, { Allow: "POST" });
response.end();
return;
}
if (!originAllowed(request, allowedOrigins)) return sendJson(response, 403, { error: "mcp_origin_not_allowed" });
if (!config.capabilitySecret) return sendJson(response, 503, { error: "foundry_mcp_not_configured" });
const actor = actorFromMcpCapability(bearerToken(request), config);
if (!actor) return sendJson(response, 401, { error: "mcp_unauthorized" });
let message;
try {
message = await readJsonBody(request);
} catch (error) {
return sendJson(response, error?.message === "payload_too_large" ? 413 : 400, { error: error?.message === "payload_too_large" ? "payload_too_large" : "invalid_json" });
}
if (!message || message.jsonrpc !== "2.0" || typeof message.method !== "string") {
return sendJson(response, 400, mcpError(message?.id, -32600, "Invalid Request"));
}
const id = message.id;
if (message.method === "initialize") {
return sendJson(response, 200, mcpResult(id, {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: { tools: { listChanged: false } },
serverInfo: { name: "nodedc_module_foundry", version: "0.1.0" },
instructions: "NDC Module Foundry edits application instances only. Page Library is read-only. Application deletion is unavailable.",
}));
}
if (message.method === "notifications/initialized") return sendJson(response, 202, {});
if (message.method === "ping") return sendJson(response, 200, mcpResult(id, {}));
if (message.method === "tools/list") return sendJson(response, 200, mcpResult(id, { tools }));
if (message.method !== "tools/call") return sendJson(response, 200, mcpError(id, -32601, "Method not found"));
const params = message.params && typeof message.params === "object" ? message.params : {};
const name = String(params.name || "");
const handler = toolMap(options.operations)[name];
if (!handler) return sendJson(response, 200, mcpError(id, -32602, "Unknown tool"));
try {
const result = await handler(params.arguments && typeof params.arguments === "object" ? params.arguments : {}, actor);
return sendJson(response, 200, mcpResult(id, toolText(result)));
} catch (error) {
const normalized = normalizeMcpError(error);
return sendJson(response, 200, mcpResult(id, toolText({ error: normalized.code }, true)));
}
}
function ownerIdentity(owner) {
const source = owner && typeof owner === "object" ? owner : {};
const actorId = String(source.userId || source.user_id || source.id || "").trim();
const ownerKey = String(source.key || source.ownerKey || source.owner_key || actorId).trim();
const groups = [source.groups, source.roles, source.roleKeys]
.flatMap((value) => Array.isArray(value) ? value : [])
.map((value) => String(value || "").trim().toLowerCase())
.filter(Boolean);
return { actorId, ownerKey, groups };
}
function hasFoundryAccess(identity, config) {
if (!identity.actorId || !identity.ownerKey) return false;
// A Launcher/Authentik block revokes every Foundry surface, including a
// previously discoverable AI Workspace entitlement.
if (identity.groups.includes("nodedc:module-foundry:blocked")) return false;
if (config.allowAllAuthenticated) return true;
if (config.allowedOwnerIds.includes(identity.actorId) || config.allowedOwnerIds.includes(identity.ownerKey)) return true;
return identity.groups.some((group) => config.allowedOwnerGroups.includes(group));
}
export async function handleFoundryEntitlementRequest(request, response, options) {
const config = options.getConfig();
if (request.method !== "POST") {
response.writeHead(405, { Allow: "POST" });
response.end();
return;
}
if (!config.internalAccessToken || !config.capabilitySecret) return sendJson(response, 503, { ok: false, error: "foundry_entitlement_not_configured" });
if (!timingSafeTokenEqual(bearerToken(request), config.internalAccessToken)) return sendJson(response, 401, { ok: false, error: "entitlement_unauthorized" });
let body;
try {
body = await readJsonBody(request);
} catch (error) {
return sendJson(response, error?.message === "payload_too_large" ? 413 : 400, { ok: false, error: "invalid_json" });
}
if (body?.schemaVersion !== "ai-workspace.entitlement-request.v1") return sendJson(response, 400, { ok: false, error: "unsupported_entitlement_schema" });
if (body?.appId !== "module-foundry") return sendJson(response, 400, { ok: false, error: "unsupported_entitlement_app" });
const identity = ownerIdentity(body.owner);
const accessAllowed = hasFoundryAccess(identity, config);
const grantBase = {
appId: "module-foundry",
appTitle: "NDC Module Foundry",
surface: "module-foundry",
scopes: [
"foundry.application.read",
"foundry.application.create",
"foundry.application.update",
"foundry.page-instance.create",
"foundry.map-pin.upsert",
"foundry.map-data-product.upsert",
],
};
if (!accessAllowed) {
return sendJson(response, 200, {
appGrants: {
"module-foundry": {
...grantBase,
enabled: false,
denied: true,
reason: "foundry_access_not_granted",
deniedText: "NDC Module Foundry недоступен в текущем контексте.",
mcpServers: [],
},
},
});
}
if (!config.mcpUrl) return sendJson(response, 503, { ok: false, error: "foundry_mcp_not_configured" });
const capability = mintMcpCapability(identity, config);
return sendJson(response, 200, {
appGrants: {
"module-foundry": {
...grantBase,
enabled: true,
context: { actorId: identity.actorId },
mcpServers: [{
serverName: "nodedc_module_foundry",
url: config.mcpUrl,
enabled: true,
required: false,
startupTimeoutSec: 20,
toolTimeoutSec: 60,
httpHeaders: {
Authorization: `Bearer ${capability}`,
"MCP-Protocol-Version": MCP_PROTOCOL_VERSION,
},
}],
},
},
});
}

556
server/nodedc-auth.mjs Normal file
View File

@ -0,0 +1,556 @@
import { randomBytes } from "node:crypto";
import { extname } from "node:path";
const DEFAULT_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
const DEFAULT_SESSION_VALIDATION_TTL_MS = 20_000;
const MIN_SESSION_VALIDATION_TTL_MS = 15_000;
const MAX_SESSION_VALIDATION_TTL_MS = 30_000;
const DEFAULT_SESSION_VALIDATION_GRACE_MS = 30_000;
const MAX_SESSION_VALIDATION_GRACE_MS = 60_000;
const DEFAULT_SESSION_VALIDATION_RETRY_MS = 2_000;
function parseBoolean(value, fallback) {
if (value === undefined || value === null || value === "") return fallback;
return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase());
}
function boundedInteger(value, fallback, minimum, maximum) {
const parsed = Number.parseInt(String(value ?? ""), 10);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(maximum, Math.max(minimum, parsed));
}
function normalizedBaseUrl(value, fallback) {
return String(value || fallback).trim().replace(/\/$/, "");
}
function sanitizeReturnTo(value, fallback = "/") {
return typeof value === "string" && value.startsWith("/") && !value.startsWith("//") ? value : fallback;
}
function parseCookies(cookieHeader) {
if (!cookieHeader) return {};
return Object.fromEntries(
String(cookieHeader)
.split(";")
.flatMap((part) => {
const separator = part.indexOf("=");
if (separator === -1) return [];
const key = part.slice(0, separator).trim();
const rawValue = part.slice(separator + 1).trim();
try {
return [[key, decodeURIComponent(rawValue)]];
} catch {
return [[key, rawValue]];
}
})
);
}
function appendSetCookie(response, cookie) {
const current = response.getHeader("Set-Cookie");
if (!current) {
response.setHeader("Set-Cookie", cookie);
return;
}
response.setHeader("Set-Cookie", Array.isArray(current) ? [...current, cookie] : [current, cookie]);
}
function sendJson(response, statusCode, body) {
response.statusCode = statusCode;
response.setHeader("Content-Type", "application/json; charset=utf-8");
response.setHeader("Cache-Control", "no-store");
response.end(JSON.stringify(body));
}
function sendText(response, statusCode, body) {
response.statusCode = statusCode;
response.setHeader("Content-Type", "text/plain; charset=utf-8");
response.setHeader("Cache-Control", "no-store");
response.end(body);
}
function isHtmlNavigation(request, url) {
if (request.method !== "GET" && request.method !== "HEAD") return false;
if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/auth/")) return false;
const accept = String(request.headers.accept || "");
return url.pathname === "/" || (!extname(url.pathname) && accept.includes("text/html"));
}
function identityGroupNames(user) {
if (!user || typeof user !== "object") return new Set();
const values = [user.groups, user.roles, user.roleKeys, user.permissions];
const names = new Set();
for (const value of values) {
const items = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
for (const item of items) {
const name = typeof item === "string"
? item
: item && typeof item === "object"
? item.name || item.key || item.slug || item.id
: "";
const normalized = String(name || "").trim().toLowerCase();
if (normalized) names.add(normalized);
}
}
return names;
}
function resolveFoundryAccess(user, authRequired) {
// Local development deliberately remains operable without a Launcher. In a
// deployed instance every decision below comes only from the revalidated
// Launcher/Authentik session identity, never from a browser header.
if (!authRequired && !user) return { role: "admin", allowed: true, admin: true };
if (!user || typeof user !== "object") return { role: "none", allowed: false, admin: false };
const id = String(user.id || user.subject || user.sub || "").trim().toLowerCase();
const groups = identityGroupNames(user);
// A block is deny-first: it wins even over an accidentally retained admin
// group, so Launcher can revoke a service session deterministically.
if (groups.has("nodedc:module-foundry:blocked")) return { role: "blocked", allowed: false, admin: false };
if (id === "user_root" || groups.has("nodedc:superadmin") || groups.has("nodedc:module-foundry:admin")) {
return { role: "admin", allowed: true, admin: true };
}
if (groups.has("nodedc:module-foundry:user") || groups.has("nodedc:module-foundry:access")) {
return { role: "user", allowed: true, admin: false };
}
return { role: "none", allowed: false, admin: false };
}
export function createFoundryAuth(options = {}) {
const now = typeof options.now === "function" ? options.now : Date.now;
const fetchImpl = typeof options.fetch === "function" ? options.fetch : fetch;
const authRequired = parseBoolean(process.env.NODEDC_FOUNDRY_AUTH_REQUIRED, process.env.NODE_ENV === "production");
const serviceSlug = String(process.env.NODEDC_FOUNDRY_SERVICE_SLUG || "module-foundry").trim() || "module-foundry";
const launcherBaseUrl = normalizedBaseUrl(process.env.NODEDC_LAUNCHER_BASE_URL, "http://127.0.0.1:5173");
const launcherInternalUrl = normalizedBaseUrl(process.env.NODEDC_LAUNCHER_INTERNAL_URL, launcherBaseUrl);
const internalAccessToken = String(
process.env.NODEDC_INTERNAL_ACCESS_TOKEN || process.env.NODEDC_PLATFORM_SERVICE_TOKEN || ""
).trim();
const sessionCookie = String(process.env.NODEDC_FOUNDRY_SESSION_COOKIE || "nodedc_foundry_session").trim();
const sessionTtlMs = Math.max(
60 * 1000,
Number.parseInt(process.env.NODEDC_FOUNDRY_SESSION_TTL_MS || String(DEFAULT_SESSION_TTL_MS), 10) || DEFAULT_SESSION_TTL_MS
);
// Launcher revocation remains bounded while a tile burst no longer performs
// one control-plane validation per object. Production configuration is
// deliberately clamped to a narrow range; tests may inject deterministic
// values without changing runtime policy.
const sessionValidationTtlMs = options.validationTtlMs ?? boundedInteger(
process.env.NODEDC_FOUNDRY_SESSION_VALIDATION_TTL_MS,
DEFAULT_SESSION_VALIDATION_TTL_MS,
MIN_SESSION_VALIDATION_TTL_MS,
MAX_SESSION_VALIDATION_TTL_MS,
);
const sessionValidationGraceMs = options.validationGraceMs ?? boundedInteger(
process.env.NODEDC_FOUNDRY_SESSION_VALIDATION_GRACE_MS,
DEFAULT_SESSION_VALIDATION_GRACE_MS,
0,
MAX_SESSION_VALIDATION_GRACE_MS,
);
const sessionValidationRetryMs = options.validationRetryMs ?? boundedInteger(
process.env.NODEDC_FOUNDRY_SESSION_VALIDATION_RETRY_MS,
DEFAULT_SESSION_VALIDATION_RETRY_MS,
250,
5_000,
);
const cookieSecure = parseBoolean(process.env.NODEDC_FOUNDRY_COOKIE_SECURE, authRequired);
const cookieSameSite = String(process.env.NODEDC_FOUNDRY_COOKIE_SAMESITE || "Lax").trim() || "Lax";
const sessions = new Map();
function emitAuthDiagnostic({ outcome, reason, launcherStatus, durationMs }) {
// The allowlisted record intentionally excludes the local cookie id,
// Launcher session id, internal bearer token and user identity.
const record = Object.freeze({
event: "foundry_session_validation",
outcome,
reason,
launcherStatus: Number.isInteger(launcherStatus) ? launcherStatus : null,
durationMs: Math.max(0, Math.round(Number(durationMs) || 0)),
serviceSlug,
});
if (typeof options.onDiagnostic === "function") {
try {
options.onDiagnostic(record);
} catch {
// Diagnostics are best-effort and must never change an authorization
// decision or turn a successful validation into a transient failure.
}
return;
}
const method = outcome === "active" ? "info" : "warn";
try {
console[method](JSON.stringify(record));
} catch {
// A broken log sink must not affect the request path.
}
}
function buildCookie(value, maxAgeSeconds) {
const parts = [
`${sessionCookie}=${encodeURIComponent(value)}`,
"Path=/",
"HttpOnly",
`SameSite=${cookieSameSite}`,
`Max-Age=${Math.max(0, Math.floor(maxAgeSeconds))}`,
];
if (cookieSecure) parts.push("Secure");
return parts.join("; ");
}
function clearSession(request, response) {
const sessionId = parseCookies(request.headers.cookie || "")[sessionCookie];
if (sessionId) sessions.delete(sessionId);
appendSetCookie(response, buildCookie("", 0));
}
function pruneExpiredSessions() {
const currentTime = now();
for (const [id, session] of sessions.entries()) {
if (!session || session.expiresAt <= currentTime) sessions.delete(id);
}
}
function createSession(response, handoff) {
pruneExpiredSessions();
const createdAtMs = now();
const id = randomBytes(32).toString("base64url");
const session = {
id,
user: handoff.user || null,
launcherSessionId: typeof handoff.launcherSessionId === "string" ? handoff.launcherSessionId : null,
createdAt: new Date(createdAtMs).toISOString(),
expiresAt: createdAtMs + sessionTtlMs,
lastValidatedAt: createdAtMs,
validationInFlight: null,
validationRetryAt: 0,
lastValidationOutcome: "active",
};
sessions.set(id, session);
appendSetCookie(response, buildCookie(id, sessionTtlMs / 1000));
return session;
}
function currentSession(request) {
const sessionId = parseCookies(request.headers.cookie || "")[sessionCookie];
if (!sessionId) return null;
const session = sessions.get(sessionId);
if (!session || session.expiresAt <= now()) {
sessions.delete(sessionId);
return null;
}
return session;
}
function buildLauncherLaunchUrl(nextPath) {
const launchUrl = new URL(`/api/services/${encodeURIComponent(serviceSlug)}/launch`, launcherBaseUrl);
launchUrl.searchParams.set("returnTo", sanitizeReturnTo(nextPath));
return launchUrl.toString();
}
function buildLauncherLoginUrl(nextPath) {
const loginUrl = new URL("/auth/login", launcherBaseUrl);
const launchUrl = new URL(buildLauncherLaunchUrl(nextPath));
loginUrl.searchParams.set("returnTo", `${launchUrl.pathname}${launchUrl.search}`);
return loginUrl.toString();
}
async function launcherRequest(pathname, payload) {
if (!internalAccessToken) {
throw new Error("NODE.DC internal access configuration is not available.");
}
const response = await fetchImpl(new URL(pathname, launcherInternalUrl), {
method: "POST",
headers: {
Authorization: `Bearer ${internalAccessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(8_000),
});
const body = await response.json().catch(() => null);
return { response, body };
}
async function consumeHandoff(token) {
const { response, body } = await launcherRequest("/api/internal/handoff/consume", { token, serviceSlug });
if (!response.ok || !body?.ok) {
throw new Error(body?.error || `Launcher handoff failed: HTTP ${response.status}`);
}
return {
user: body.user || null,
launcherSessionId: typeof body.launcherSessionId === "string" ? body.launcherSessionId : null,
};
}
async function validateSession(session) {
if (!authRequired) return { outcome: "active", user: session?.user || null, reason: "auth_not_required" };
if (!session?.launcherSessionId) {
return { outcome: "inactive", reason: "launcher_session_missing", launcherStatus: null };
}
const { response, body } = await launcherRequest("/api/internal/session/validate", {
serviceSlug,
launcherSessionId: session.launcherSessionId,
});
if (response.ok && body?.ok === true && body.active === true) {
return {
outcome: "active",
user: body.user || session.user || null,
reason: "launcher_session_active",
launcherStatus: response.status,
};
}
// Launcher deliberately represents a revoked/expired user session as a
// successful validation response with active=false. HTTP failures here
// describe the internal validation service (including its own bearer-token
// configuration), so they must not destroy an otherwise valid user cookie.
if (response.ok && body?.ok === true && body.active === false) {
return {
outcome: "inactive",
reason: "launcher_session_inactive",
launcherStatus: response.status,
};
}
return {
outcome: "transient",
reason: response.status >= 500
? "launcher_unavailable"
: response.status === 401 || response.status === 403
? "launcher_internal_auth_rejected"
: "launcher_validation_invalid_response",
launcherStatus: response.status,
};
}
function isReadOnlyRequest(request) {
return request.method === "GET" || request.method === "HEAD";
}
function transientFailureReason(error) {
return error?.name === "TimeoutError" || error?.name === "AbortError"
? "launcher_timeout"
: "launcher_network_error";
}
function startSessionValidation(session) {
if (session.validationInFlight) return session.validationInFlight;
const startedAt = now();
const validation = (async () => {
try {
const result = await validateSession(session);
if (result.outcome === "active") {
session.user = result.user;
session.lastValidatedAt = now();
session.validationRetryAt = 0;
} else if (result.outcome === "transient") {
session.validationRetryAt = now() + sessionValidationRetryMs;
}
session.lastValidationOutcome = result.outcome;
emitAuthDiagnostic({
outcome: result.outcome,
reason: result.reason,
launcherStatus: result.launcherStatus,
durationMs: now() - startedAt,
});
return result;
} catch (error) {
const result = {
outcome: "transient",
reason: transientFailureReason(error),
launcherStatus: null,
};
session.validationRetryAt = now() + sessionValidationRetryMs;
session.lastValidationOutcome = result.outcome;
emitAuthDiagnostic({
...result,
durationMs: now() - startedAt,
});
return result;
} finally {
session.validationInFlight = null;
}
})();
session.validationInFlight = validation;
return validation;
}
function useSession(request, session) {
request.nodedcFoundrySession = session;
return session;
}
function canUseValidationGrace(request, session) {
if (!isReadOnlyRequest(request)) return false;
const validationAge = Math.max(0, now() - session.lastValidatedAt);
return validationAge <= sessionValidationTtlMs + sessionValidationGraceMs;
}
async function getValidatedSession(request, response) {
const session = currentSession(request);
if (!session) {
// Sessions intentionally remain process-local: after a deploy/restart the
// opaque cookie cannot be reconstructed safely. Expire a stale cookie and
// require a fresh Launcher handoff instead of treating it as recoverable.
const staleSessionId = parseCookies(request.headers.cookie || "")[sessionCookie];
if (staleSessionId) {
clearSession(request, response);
emitAuthDiagnostic({
outcome: "inactive",
reason: "local_session_missing_or_expired",
launcherStatus: null,
durationMs: 0,
});
}
return null;
}
if (now() - session.lastValidatedAt <= sessionValidationTtlMs) {
return useSession(request, session);
}
let validation;
if (session.lastValidationOutcome === "transient" && now() < session.validationRetryAt) {
validation = { outcome: "transient", reason: "launcher_retry_backoff", launcherStatus: null };
} else {
validation = await startSessionValidation(session);
}
// Logout or another explicit invalidation may have removed the process-local
// session while a shared validation was in flight. Never resurrect it.
if (sessions.get(session.id) !== session) return null;
if (validation.outcome === "active") return useSession(request, session);
if (validation.outcome === "inactive") {
clearSession(request, response);
return null;
}
if (canUseValidationGrace(request, session)) return useSession(request, session);
request.nodedcFoundryAuthUnavailable = true;
return null;
}
function sendAuthRequired(request, response, url) {
const nextPath = sanitizeReturnTo(`${url.pathname}${url.search || ""}`);
if (!internalAccessToken) {
sendText(response, 503, "Module Foundry access is not configured on this server.");
return;
}
const loginUrl = buildLauncherLoginUrl(nextPath);
if (isHtmlNavigation(request, url)) {
response.statusCode = 302;
response.setHeader("Location", loginUrl);
response.setHeader("Cache-Control", "no-store");
response.end();
return;
}
sendJson(response, 401, {
ok: false,
authenticated: false,
error: "module_foundry_auth_required",
loginUrl,
});
}
function sendAuthUnavailable(request, response) {
if (String(request.url || "").startsWith("/api/")) {
sendJson(response, 503, {
ok: false,
authenticated: true,
error: "module_foundry_auth_unavailable",
});
return;
}
sendText(response, 503, "Module Foundry session validation is temporarily unavailable.");
}
function sendAccessDenied(response, access) {
sendJson(response, 403, {
ok: false,
authenticated: true,
error: access?.role === "blocked" ? "module_foundry_access_blocked" : "module_foundry_access_denied",
});
}
async function handleLauncherHandoff(request, response, url) {
if (!authRequired) {
response.statusCode = 302;
response.setHeader("Location", sanitizeReturnTo(url.searchParams.get("next_path") || url.searchParams.get("returnTo") || "/"));
response.end();
return;
}
const token = String(url.searchParams.get("token") || "");
const nextPath = sanitizeReturnTo(url.searchParams.get("next_path") || url.searchParams.get("returnTo") || "/");
if (!token) {
sendText(response, 400, "Missing Launcher handoff token.");
return;
}
try {
const handoff = await consumeHandoff(token);
createSession(response, handoff);
response.statusCode = 302;
response.setHeader("Location", nextPath);
response.setHeader("Cache-Control", "no-store");
response.end();
} catch (error) {
sendText(response, 401, `Launcher handoff failed: ${error.message || "unknown error"}`);
}
}
function handleLogout(request, response) {
clearSession(request, response);
response.statusCode = 302;
response.setHeader("Location", "/");
response.setHeader("Cache-Control", "no-store");
response.end();
}
async function ensureRequestSession(request, response, url) {
if (!authRequired) return false;
if (url.pathname === "/auth/nodedc/handoff" || url.pathname === "/auth/logout") return false;
if (url.pathname === "/healthz") return false;
if (url.pathname === "/api/mcp" || url.pathname === "/api/ai-workspace/entitlements") return false;
if (await getValidatedSession(request, response)) {
const access = currentUserAccess(request);
if (access.allowed) return false;
sendAccessDenied(response, access);
return true;
}
if (request.nodedcFoundryAuthUnavailable) {
sendAuthUnavailable(request, response);
return true;
}
sendAuthRequired(request, response, url);
return true;
}
function currentUserAccess(request) {
return resolveFoundryAccess(request.nodedcFoundrySession?.user, authRequired);
}
function currentUserProfile(request) {
const user = request.nodedcFoundrySession?.user;
if (!user || typeof user !== "object") return null;
const id = String(user.id || user.subject || user.sub || "").trim();
const email = String(user.email || "").trim();
const displayName = String(user.name || user.displayName || email || "NODE.DC").trim().slice(0, 240);
const avatarCandidate = String(user.avatarUrl || user.avatar_url || user.picture || "").trim();
const avatarUrl = /^https:\/\//i.test(avatarCandidate) || avatarCandidate.startsWith("/") ? avatarCandidate : null;
const initials = displayName.split(/\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]).join("").toUpperCase() || "DC";
return { id, email, displayName, avatarUrl, initials };
}
return {
authRequired,
serviceSlug,
handleLauncherHandoff,
handleLogout,
ensureRequestSession,
currentUserAccess,
currentUserProfile,
profileUrl: new URL("/profile", launcherBaseUrl).toString(),
};
}

252
server/nodedc-auth.test.mjs Normal file
View File

@ -0,0 +1,252 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createFoundryAuth } from "./nodedc-auth.mjs";
const HANDOFF_TOKEN = "handoff-token-must-never-appear-in-diagnostics";
const LAUNCHER_SESSION_ID = "launcher-session-must-never-appear-in-diagnostics";
const INTERNAL_ACCESS_TOKEN = "internal-access-token-must-never-appear-in-diagnostics";
const USER = Object.freeze({
id: "user_root",
email: "root@example.test",
name: "DC SUDO",
groups: ["nodedc:superadmin"],
});
test("Foundry session validation is burst-safe, failure-tolerant and revocation-bounded", async (t) => {
const restoreEnvironment = setEnvironment({
NODE_ENV: "production",
NODEDC_FOUNDRY_AUTH_REQUIRED: "true",
NODEDC_FOUNDRY_COOKIE_SECURE: "false",
NODEDC_FOUNDRY_SESSION_TTL_MS: String(60 * 60 * 1000),
NODEDC_LAUNCHER_BASE_URL: "https://launcher.example.test",
NODEDC_LAUNCHER_INTERNAL_URL: "http://launcher.internal.test",
NODEDC_INTERNAL_ACCESS_TOKEN: INTERNAL_ACCESS_TOKEN,
});
t.after(restoreEnvironment);
let clock = 1_000_000;
let validationMode = "active";
let validationCalls = 0;
const diagnostics = [];
const fetchStub = async (url, init) => {
assert.equal(init.headers.Authorization, `Bearer ${INTERNAL_ACCESS_TOKEN}`);
const pathname = new URL(url).pathname;
const payload = JSON.parse(init.body);
if (pathname === "/api/internal/handoff/consume") {
assert.equal(payload.token, HANDOFF_TOKEN);
return jsonResponse(200, {
ok: true,
launcherSessionId: LAUNCHER_SESSION_ID,
user: USER,
});
}
assert.equal(pathname, "/api/internal/session/validate");
assert.equal(payload.launcherSessionId, LAUNCHER_SESSION_ID);
validationCalls += 1;
// Let every request in a simulated Cesium tile burst reach the shared
// validation promise before it resolves.
await new Promise((resolve) => setImmediate(resolve));
if (validationMode === "transient") {
return jsonResponse(503, { ok: false, active: false, error: "launcher_temporarily_unavailable" });
}
if (validationMode === "network") {
throw new TypeError(`network failed ${INTERNAL_ACCESS_TOKEN}`);
}
if (validationMode === "timeout") {
const error = new Error(`timeout ${LAUNCHER_SESSION_ID}`);
error.name = "TimeoutError";
throw error;
}
if (validationMode === "inactive") {
return jsonResponse(200, { ok: true, active: false, error: "service_access_denied" });
}
return jsonResponse(200, { ok: true, active: true, user: USER });
};
const auth = createFoundryAuth({
now: () => clock,
fetch: fetchStub,
validationTtlMs: 20,
validationGraceMs: 30,
validationRetryMs: 5,
onDiagnostic: (record) => diagnostics.push(record),
});
const cookie = await createSessionCookie(auth);
await t.test("concurrent tile burst performs one Launcher validation", async () => {
clock += 21;
const results = await Promise.all(
Array.from({ length: 64 }, (_, index) => authorize(auth, cookie, `/api/map-gateway/api/map/cache?tile=${index}`)),
);
assert.equal(validationCalls, 1);
assert.ok(results.every(({ handled }) => handled === false));
assert.ok(results.every(({ response }) => response.getHeader("set-cookie") === undefined));
const withinTtl = await Promise.all(
Array.from({ length: 64 }, (_, index) => authorize(auth, cookie, `/api/map-gateway/api/map/cache?warm=${index}`)),
);
assert.equal(validationCalls, 1);
assert.ok(withinTtl.every(({ handled }) => handled === false));
});
await t.test("transient Launcher failure keeps the cookie and uses bounded read-only grace", async () => {
validationMode = "transient";
clock += 21;
const graceResults = await Promise.all(
Array.from({ length: 32 }, (_, index) => authorize(auth, cookie, `/api/map-gateway/api/map/cache?grace=${index}`)),
);
assert.equal(validationCalls, 2);
assert.ok(graceResults.every(({ handled }) => handled === false));
assert.ok(graceResults.every(({ response }) => response.getHeader("set-cookie") === undefined));
const mutation = await authorize(auth, cookie, "/api/platform-settings/cesium-ion", "PUT");
assert.equal(mutation.handled, true);
assert.equal(mutation.response.statusCode, 503);
assert.equal(JSON.parse(mutation.response.body).error, "module_foundry_auth_unavailable");
assert.equal(mutation.response.getHeader("set-cookie"), undefined);
// The short retry backoff shares the transient result instead of creating a
// control-plane retry storm from the same burst.
assert.equal(validationCalls, 2);
clock += 31;
const outsideGrace = await authorize(auth, cookie, "/api/map/runtime-config");
assert.equal(outsideGrace.handled, true);
assert.equal(outsideGrace.response.statusCode, 503);
assert.equal(JSON.parse(outsideGrace.response.body).error, "module_foundry_auth_unavailable");
assert.equal(outsideGrace.response.getHeader("set-cookie"), undefined);
assert.equal(validationCalls, 3);
});
await t.test("network errors and timeouts keep the last-known-good read-only session", async () => {
validationMode = "active";
clock += 6;
const recovered = await authorize(auth, cookie, "/api/map/runtime-config");
assert.equal(recovered.handled, false);
assert.equal(validationCalls, 4);
validationMode = "network";
clock += 21;
const networkFailure = await authorize(auth, cookie, "/api/map/runtime-config");
assert.equal(networkFailure.handled, false);
assert.equal(networkFailure.response.getHeader("set-cookie"), undefined);
assert.equal(validationCalls, 5);
validationMode = "timeout";
clock += 6;
const timeout = await authorize(auth, cookie, "/api/map/runtime-config");
assert.equal(timeout.handled, false);
assert.equal(timeout.response.getHeader("set-cookie"), undefined);
assert.equal(validationCalls, 6);
});
await t.test("explicit Launcher inactive response revokes the local session", async () => {
validationMode = "inactive";
clock += 6;
const revoked = await authorize(auth, cookie, "/api/map/runtime-config");
assert.equal(revoked.handled, true);
assert.equal(revoked.response.statusCode, 401);
assert.equal(JSON.parse(revoked.response.body).error, "module_foundry_auth_required");
assert.match(String(revoked.response.getHeader("set-cookie")), /Max-Age=0/);
assert.equal(validationCalls, 7);
const afterRevoke = await authorize(auth, cookie, "/api/map/runtime-config");
assert.equal(afterRevoke.handled, true);
assert.equal(afterRevoke.response.statusCode, 401);
assert.equal(validationCalls, 7);
});
await t.test("diagnostics contain outcomes but no cookie, Launcher id or token", () => {
assert.ok(diagnostics.some((record) => record.outcome === "active"));
assert.ok(diagnostics.some((record) => record.outcome === "transient"));
assert.ok(diagnostics.some((record) => record.outcome === "inactive"));
const serialized = JSON.stringify(diagnostics);
assert.equal(serialized.includes(cookie), false);
assert.equal(serialized.includes(HANDOFF_TOKEN), false);
assert.equal(serialized.includes(LAUNCHER_SESSION_ID), false);
assert.equal(serialized.includes(INTERNAL_ACCESS_TOKEN), false);
assert.equal(serialized.includes(USER.email), false);
});
await t.test("a process restart expires an opaque process-local cookie safely", async () => {
let restartedValidationCalls = 0;
const restartDiagnostics = [];
const restartedAuth = createFoundryAuth({
now: () => clock,
fetch: async () => {
restartedValidationCalls += 1;
throw new Error("must not validate an unrecoverable local cookie");
},
validationTtlMs: 20,
validationGraceMs: 30,
validationRetryMs: 5,
onDiagnostic: (record) => restartDiagnostics.push(record),
});
const result = await authorize(restartedAuth, cookie, "/api/map/runtime-config");
assert.equal(result.handled, true);
assert.equal(result.response.statusCode, 401);
assert.match(String(result.response.getHeader("set-cookie")), /Max-Age=0/);
assert.equal(restartedValidationCalls, 0);
assert.ok(restartDiagnostics.some((record) => record.reason === "local_session_missing_or_expired"));
});
});
async function createSessionCookie(auth) {
const request = {
method: "GET",
url: `/auth/nodedc/handoff?token=${encodeURIComponent(HANDOFF_TOKEN)}&returnTo=%2F`,
headers: {},
};
const response = mockResponse();
await auth.handleLauncherHandoff(request, response, new URL(request.url, "https://foundry.example.test"));
assert.equal(response.statusCode, 302);
const setCookie = String(response.getHeader("set-cookie"));
assert.match(setCookie, /^nodedc_foundry_session=/);
return setCookie.split(";", 1)[0];
}
async function authorize(auth, cookie, path, method = "GET") {
const request = { method, url: path, headers: { cookie, accept: "application/json" } };
const response = mockResponse();
const handled = await auth.ensureRequestSession(
request,
response,
new URL(path, "https://foundry.example.test"),
);
return { handled, request, response };
}
function mockResponse() {
const headers = new Map();
return {
statusCode: 200,
body: "",
setHeader(name, value) {
headers.set(String(name).toLowerCase(), value);
},
getHeader(name) {
return headers.get(String(name).toLowerCase());
},
end(body = "") {
this.body = String(body);
},
};
}
function jsonResponse(status, body) {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
function setEnvironment(values) {
const original = Object.fromEntries(Object.keys(values).map((key) => [key, process.env[key]]));
for (const [key, value] of Object.entries(values)) process.env[key] = value;
return () => {
for (const [key, value] of Object.entries(original)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
};
}

View File

@ -0,0 +1,9 @@
import { spawn } from "node:child_process";
await import("./runtime-seed.mjs");
const [command, ...args] = process.argv.slice(2);
if (!command) throw new Error("foundry_runtime_command_required");
const child = spawn(command, args, { stdio: "inherit" });
child.on("exit", (code, signal) => process.exitCode = code ?? (signal ? 1 : 0));
child.on("error", (error) => { throw error; });

61
server/runtime-seed.mjs Normal file
View File

@ -0,0 +1,61 @@
import { cp, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
import { dirname, join, relative, resolve } from "node:path";
const root = resolve(new URL("..", import.meta.url).pathname);
const runtimeDir = process.env.FOUNDRY_RUNTIME_DIR ? resolve(root, process.env.FOUNDRY_RUNTIME_DIR) : join(root, "runtime-data");
const seedDir = resolve(process.env.FOUNDRY_RUNTIME_SEED_DIR || join(root, "runtime-seed"));
const manifestPath = join(seedDir, "seed-manifest.json");
function isInside(parent, target) {
const path = relative(parent, target);
return path && !path.startsWith("..") && !path.includes("../");
}
async function exists(path) {
try { await stat(path); return true; } catch (error) { if (error?.code === "ENOENT") return false; throw error; }
}
async function copySeedFile(file) {
const source = resolve(seedDir, file);
const target = resolve(runtimeDir, file);
if (!isInside(seedDir, source) || !isInside(runtimeDir, target)) throw new Error("runtime_seed_path_invalid");
if (!await exists(source)) throw new Error(`runtime_seed_file_missing:${file}`);
await mkdir(dirname(target), { recursive: true });
await cp(source, target, { force: true, preserveTimestamps: true });
}
async function isGeneratedFallback() {
const profilePath = join(runtimeDir, "design-profiles", "default.json");
if (!await exists(profilePath)) return true;
try {
const profile = JSON.parse(await readFile(profilePath, "utf8"));
// The server fallback has the same default media paths as the canonical
// profile, but it has never been persisted as a real Design Profile
// layout. `savedAt` is written by the baseline/profile save path. A
// subsequently edited user profile must never be replaced implicitly.
return profile?.id === "default"
&& profile?.name === "NODE.DC Default"
&& profile?.status === "draft"
&& !String(profile?.layout?.savedAt || "").trim()
&& String(profile?.timestamps?.createdAt || "") === String(profile?.timestamps?.updatedAt || "");
} catch {
return false;
}
}
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
if (!/^\d+\.\d+\.\d+$/.test(String(manifest.version || ""))) throw new Error("runtime_seed_version_invalid");
if (!Array.isArray(manifest.files) || !manifest.files.every((file) => typeof file === "string" && file.length > 0)) {
throw new Error("runtime_seed_manifest_invalid");
}
const marker = join(runtimeDir, "runtime-seed", `${manifest.version}.json`);
if (!await exists(marker)) {
const shouldApply = await isGeneratedFallback();
if (shouldApply) for (const file of manifest.files) await copySeedFile(file);
await mkdir(dirname(marker), { recursive: true });
const temporaryMarker = `${marker}.tmp`;
await writeFile(temporaryMarker, `${JSON.stringify({ version: manifest.version, appliedAt: new Date().toISOString(), applied: shouldApply }, null, 2)}\n`, "utf8");
await rename(temporaryMarker, marker);
console.log(`Foundry runtime seed ${manifest.version}: ${shouldApply ? "applied" : "preserved existing runtime"}`);
}