diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a6f6213 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +node_modules +**/node_modules +**/dist +runtime-data +!runtime-seed/ +.env +.env.* +!.env.example +coverage +.vite +*.log diff --git a/.env.example b/.env.example index 865cf15..e4686e1 100644 --- a/.env.example +++ b/.env.example @@ -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("//"); 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 15–30 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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..da22d38 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index cc6e7ee..90fbed4 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/apps/catalog/package.json b/apps/catalog/package.json index 23dc4f9..2bc3411 100644 --- a/apps/catalog/package.json +++ b/apps/catalog/package.json @@ -1,6 +1,6 @@ { "name": "@nodedc/ui-catalog", - "version": "0.6.0", + "version": "0.7.0", "private": true, "type": "module", "scripts": { diff --git a/apps/catalog/src/CatalogApp.tsx b/apps/catalog/src/CatalogApp.tsx index 1b97152..31450e7 100644 --- a/apps/catalog/src/CatalogApp.tsx +++ b/apps/catalog/src/CatalogApp.tsx @@ -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 = { dark: { panelHex: "#151517", @@ -307,6 +328,12 @@ export function CatalogApp() { const [applicationDraft, setApplicationDraft] = useState(null); const [applicationSaveState, setApplicationSaveState] = useState("idle"); const [applicationError, setApplicationError] = useState(""); + const [sessionProfile, setSessionProfile] = useState(null); + const [platformSettingsOpen, setPlatformSettingsOpen] = useState(false); + const [cesiumIonToken, setCesiumIonToken] = useState(""); + const [cesiumIonStatus, setCesiumIonStatus] = useState(null); + const [cesiumIonSaveState, setCesiumIonSaveState] = useState<"idle" | "loading" | "saving" | "saved" | "error">("idle"); + const [cesiumIonError, setCesiumIonError] = useState(""); const [mapTemplateLayout, setMapTemplateLayout] = useState(null); const [mapTemplateSaveState, setMapTemplateSaveState] = useState<"idle" | "loading" | "saving" | "saved" | "error">("loading"); const mapTemplatePreviewRef = useRef(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(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 : 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 (
- +
CANONICAL GLASS @@ -1616,7 +1711,15 @@ export function CatalogApp() { })); return (
- + {applicationMode === "edit" ? (
@@ -1652,7 +1755,7 @@ export function CatalogApp() { <> - Профиль - + { if (sessionProfile?.profileUrl) window.location.assign(sessionProfile.profileUrl); }} + > + {sessionProfile?.user?.displayName || "Профиль"} + + } /> } stage={
-