diff --git a/.env.example b/.env.example index e4686e1..67e758d 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,9 @@ 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= +NODEDC_EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_SERVICE_ID=nodedc-module-foundry +NODEDC_EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_KEY_ID=foundry-edp-managed-provisioner-v1 +NODEDC_EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_AUDIENCE=nodedc-external-data-plane.managed-provisioning.v1 # 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. @@ -43,6 +46,9 @@ 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= +# Internal-only Ontology Core address used by the Foundry Codex Agent transport. +# It is never written to Codex config and never returned to the browser. +NODEDC_ONTOLOGY_CORE_URL=http://ontology-core:18104 NODEDC_FOUNDRY_SESSION_COOKIE=nodedc_foundry_session NODEDC_FOUNDRY_SESSION_TTL_MS=43200000 # Production validation TTL is deliberately clamped to 15–30 seconds. A diff --git a/apps/catalog/src/CatalogApp.tsx b/apps/catalog/src/CatalogApp.tsx index 25e5635..a974a4b 100644 --- a/apps/catalog/src/CatalogApp.tsx +++ b/apps/catalog/src/CatalogApp.tsx @@ -18,10 +18,8 @@ import { FieldFrame, GlassSurface, GlassMaterialSurface, - HeaderAvatar, HeaderNavigation, HeaderProfile, - HeaderProfileButton, HeaderWorkspace, Icon, IconButton, @@ -40,6 +38,7 @@ import { TextAreaField, TextField, Toolbar, + UserProfileMenu, useApplicationWorkspace, Window, WindowFooterActions, @@ -62,6 +61,7 @@ import { type DesignProfileStatus, } from "./applicationManifest.js"; import { MapFixturePreview, type MapFixturePreviewHandle, type MapPageLayout } from "./MapFixturePreview.js"; +import { FoundrySettingsModal } from "./FoundrySettingsModal.js"; type CatalogSection = "controls" | "media" | "glass" | "modals" | "icons"; type StudioContext = "visual" | "pages" | "applications"; @@ -333,6 +333,7 @@ export function CatalogApp() { const [applicationError, setApplicationError] = useState(""); const [sessionProfile, setSessionProfile] = useState(null); const [platformSettingsOpen, setPlatformSettingsOpen] = useState(false); + const [foundrySettingsOpen, setFoundrySettingsOpen] = useState(false); const [cesiumIonToken, setCesiumIonToken] = useState(""); const [cesiumIonStatus, setCesiumIonStatus] = useState(null); const [cesiumIonSaveState, setCesiumIonSaveState] = useState<"idle" | "loading" | "saving" | "saved" | "error">("idle"); @@ -1815,15 +1816,15 @@ export function CatalogApp() { right={ - { if (sessionProfile?.profileUrl) window.location.assign(sessionProfile.profileUrl); }} - > - {sessionProfile?.user?.displayName || "Профиль"} - - setFoundrySettingsOpen(true) }, + { id: "logout", label: "Выйти", icon: "external", href: "/auth/logout" }, + ]} /> } @@ -2144,6 +2145,8 @@ export function CatalogApp() { + setFoundrySettingsOpen(false)} /> + (pathname: string, init?: RequestInit): Promise { + const response = await fetch(pathname, { + cache: "no-store", + ...init, + headers: { + accept: "application/json", + ...(init?.body ? { "content-type": "application/json" } : {}), + ...init?.headers, + }, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok || payload?.ok === false) throw new Error(payload?.error || `foundry_agent_api_${response.status}`); + return payload as T; +} + +function formatMoment(value: string | null) { + if (!value) return "—"; + const date = new Date(value); + return Number.isFinite(date.getTime()) ? date.toLocaleString("ru-RU") : value; +} + +export function FoundryCodexAgentSettings() { + const [agents, setAgents] = useState([]); + const [newAgentName, setNewAgentName] = useState("Foundry Codex"); + const [draftNames, setDraftNames] = useState>({}); + const [setupByAgent, setSetupByAgent] = useState>({}); + const [loading, setLoading] = useState(true); + const [pending, setPending] = useState(""); + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + + const load = useCallback(async () => { + setLoading(true); + setError(""); + try { + const payload = await api<{ agents: FoundryAgent[] }>("/api/foundry-agent-api/agents"); + setAgents(payload.agents); + setDraftNames(Object.fromEntries(payload.agents.map((agent) => [agent.id, agent.name]))); + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : "foundry_agent_load_failed"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { void load(); }, [load]); + + const run = async (key: string, action: () => Promise) => { + setPending(key); + setError(""); + setMessage(""); + try { + await action(); + } catch (actionError) { + setError(actionError instanceof Error ? actionError.message : "foundry_agent_operation_failed"); + } finally { + setPending(""); + } + }; + + const createAgent = () => run("create", async () => { + const payload = await api<{ agent: FoundryAgent }>("/api/foundry-agent-api/agents", { + method: "POST", + body: JSON.stringify({ name: newAgentName.trim() || "Foundry Codex" }), + }); + setAgents((current) => [payload.agent, ...current]); + setDraftNames((current) => ({ ...current, [payload.agent.id]: payload.agent.name })); + setMessage("Foundry Agent создан. Теперь можно получить одноразовую setup-команду."); + }); + + const saveAgent = (agent: FoundryAgent) => run(`save:${agent.id}`, async () => { + const payload = await api<{ agent: FoundryAgent }>(`/api/foundry-agent-api/agents/${encodeURIComponent(agent.id)}`, { + method: "PATCH", + body: JSON.stringify({ name: draftNames[agent.id]?.trim() || agent.name }), + }); + setAgents((current) => current.map((item) => item.id === agent.id ? payload.agent : item)); + setMessage("Имя агента сохранено."); + }); + + const revokeAgent = (agent: FoundryAgent) => run(`revoke:${agent.id}`, async () => { + const payload = await api<{ agent: FoundryAgent }>(`/api/foundry-agent-api/agents/${encodeURIComponent(agent.id)}/revoke`, { + method: "POST", + body: "{}", + }); + setAgents((current) => current.map((item) => item.id === agent.id ? payload.agent : item)); + setSetupByAgent((current) => { + const next = { ...current }; + delete next[agent.id]; + return next; + }); + setMessage("Agent отозван. Foundry и Ontology credentials этого агента больше не принимаются."); + }); + + const issueSetup = (agent: FoundryAgent) => run(`setup:${agent.id}`, async () => { + const payload = await api<{ install: SetupCommand }>(`/api/foundry-agent-api/agents/${encodeURIComponent(agent.id)}/setup-code`, { + method: "POST", + body: "{}", + }); + setSetupByAgent((current) => ({ ...current, [agent.id]: payload.install })); + setMessage("Одноразовая команда выпущена. Она установит Foundry MCP и отдельный read-only Ontology MCP."); + }); + + const copySetup = async (agentId: string) => { + const command = setupByAgent[agentId]?.command; + if (!command) return; + await navigator.clipboard.writeText(command); + setMessage("Setup-команда скопирована."); + }; + + return ( +
+
+
NODE.DC / SOURCE-FREE DEVELOPMENT

Codex Agent API

+ Foundry + Ontology +
+

+ Одна setup-команда добавляет в Codex два независимых MCP: полный текущий Foundry contour и отдельную read-only Ontology. Sharing, роли и межпользовательская видимость в этот срез не входят. +

+ + {error ?
{error}
: null} + {message ?
{message}
: null} + + +
+ setNewAgentName(event.target.value)} /> + +
+
+ +
+ {loading ? Загружаю agents… : null} + {!loading && agents.length === 0 ? Агенты ещё не созданы. : null} + {agents.map((agent) => { + const setup = setupByAgent[agent.id]; + const active = agent.status === "active"; + return ( + {active ? "Активен" : "Отозван"}} + > +
+ setDraftNames((current) => ({ ...current, [agent.id]: event.target.value }))} + /> +
+ + + +
+
+ {setup ? ( +
+
Одноразовая setup-командаДействует до {formatMoment(setup.expiresAt)}. После выполнения полностью перезапустите Codex Desktop.
+ {setup.command} + +
+ ) : null} +
+ ); + })} +
+
+ ); +} diff --git a/apps/catalog/src/FoundrySettingsModal.tsx b/apps/catalog/src/FoundrySettingsModal.tsx new file mode 100644 index 0000000..a9c8387 --- /dev/null +++ b/apps/catalog/src/FoundrySettingsModal.tsx @@ -0,0 +1,24 @@ +import { FeatureSettingsWindow } from "@nodedc/ui-react"; +import { FoundryCodexAgentSettings } from "./FoundryCodexAgentSettings.js"; + +interface FoundrySettingsModalProps { + open: boolean; + onClose: () => void; +} + +export function FoundrySettingsModal({ open, onClose }: FoundrySettingsModalProps) { + return ( + undefined} + onClose={onClose} + > + + + ); +} diff --git a/apps/catalog/src/MapFixturePreview.tsx b/apps/catalog/src/MapFixturePreview.tsx index f52964e..52c24b1 100644 --- a/apps/catalog/src/MapFixturePreview.tsx +++ b/apps/catalog/src/MapFixturePreview.tsx @@ -270,7 +270,7 @@ export const MapFixturePreview = forwardRef typeof value === "string" && value.trim()); - const status = typeof attributes.status === "string" ? attributes.status : undefined; + const status = fact.presentationStatus || (typeof attributes.status === "string" ? attributes.status : undefined); return { id: mapRuntimeEntityId(binding.bindingId, fact), title: typeof label === "string" ? label : fact.sourceId, diff --git a/apps/catalog/src/styles.css b/apps/catalog/src/styles.css index 5b07864..0ca631a 100644 --- a/apps/catalog/src/styles.css +++ b/apps/catalog/src/styles.css @@ -1073,6 +1073,120 @@ textarea { gap: 0.75rem; } +.catalog-codex-agent-settings { + display: grid; + gap: 1rem; +} + +.catalog-codex-agent-settings__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.catalog-codex-agent-settings__header > div { + display: grid; + gap: 0.28rem; +} + +.catalog-codex-agent-settings__header span, +.catalog-codex-agent-settings__header h2, +.catalog-codex-agent-settings__lead { + margin: 0; +} + +.catalog-codex-agent-settings__header > div > span { + color: var(--nodedc-text-muted); + font-size: 0.68rem; + font-weight: 850; + letter-spacing: 0.1em; +} + +.catalog-codex-agent-settings__header h2 { + font-size: clamp(1.45rem, 2.2vw, 2rem); +} + +.catalog-codex-agent-settings__lead { + max-width: 58rem; + color: var(--nodedc-text-secondary); + font-size: 0.88rem; + line-height: 1.6; +} + +.catalog-codex-agent-settings__message { + border-radius: var(--nodedc-radius-control-compact); + background: rgb(var(--nodedc-success-rgb) / 0.12); + color: color-mix(in srgb, rgb(var(--nodedc-success-rgb)) 80%, var(--nodedc-text-primary)); + padding: 0.75rem 0.9rem; + font-size: var(--nodedc-font-size-sm); + font-weight: 700; +} + +.catalog-codex-agent-settings__message[data-tone="error"] { + background: rgb(var(--nodedc-danger-rgb) / 0.12); + color: color-mix(in srgb, rgb(var(--nodedc-danger-rgb)) 80%, var(--nodedc-text-primary)); +} + +.catalog-codex-agent-settings__create, +.catalog-codex-agent-settings__agent-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: 0.8rem; +} + +.catalog-codex-agent-settings__agents { + display: grid; + gap: 0.8rem; +} + +.catalog-codex-agent-settings__empty { + color: var(--nodedc-text-muted); + padding: 1rem; + text-align: center; +} + +.catalog-codex-agent-settings__actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.45rem; +} + +.catalog-codex-agent-settings__setup { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.7rem; + margin-top: 0.9rem; + border-radius: var(--nodedc-radius-control); + background: var(--nodedc-nested-bg); + padding: 0.85rem; +} + +.catalog-codex-agent-settings__setup > div { + display: grid; + gap: 0.2rem; +} + +.catalog-codex-agent-settings__setup small { + color: var(--nodedc-text-muted); + line-height: 1.4; +} + +.catalog-codex-agent-settings__setup code { + grid-column: 1 / -1; + overflow: auto; + border-radius: var(--nodedc-radius-control-compact); + background: var(--nodedc-field-bg); + color: var(--nodedc-text-primary); + padding: 0.8rem; + font-size: 0.75rem; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-all; +} + .catalog-modal-groups { display: grid; gap: 1.25rem; @@ -1363,10 +1477,17 @@ textarea { } .catalog-application-draft__fields, - .catalog-application-draft__features { + .catalog-application-draft__features, + .catalog-codex-agent-settings__create, + .catalog-codex-agent-settings__agent-grid, + .catalog-codex-agent-settings__setup { grid-template-columns: 1fr; } + .catalog-codex-agent-settings__actions { + justify-content: flex-start; + } + .catalog-application-draft__fields .nodedc-field-frame:last-child { grid-column: auto; } diff --git a/apps/catalog/src/useMapDataProductRuntime.ts b/apps/catalog/src/useMapDataProductRuntime.ts index e9e562b..743dcb1 100644 --- a/apps/catalog/src/useMapDataProductRuntime.ts +++ b/apps/catalog/src/useMapDataProductRuntime.ts @@ -17,6 +17,7 @@ export type MapRuntimeFact = { receivedAt: string; attributes: Record; geometry: DataProductPoint | null; + presentationStatus: string; }; export type MapRuntimeBinding = { @@ -42,7 +43,16 @@ type PatchEnvelope = { cursor: string; previousCursor: string; emittedAt: string; - operations: Array<{ op: "upsert"; fact: MapRuntimeFact }>; + operations: Array< + | { op: "upsert"; fact: MapRuntimeFact } + | { op: "remove"; sourceId: string; semanticType: string; removedAt: string; reason: "tombstone" | "revoked" } + >; +}; + +type PresentationPatchEnvelope = { + schemaVersion: "nodedc.foundry.presentation-patch/v1"; + generatedAt: string; + operations: Array<{ op: "upsert"; sourceId: string; semanticType: string; status: string }>; }; type BindingState = { @@ -97,6 +107,9 @@ function asFact(value: unknown, binding: MapDataProductBinding): MapRuntimeFact receivedAt: candidate.receivedAt, attributes, geometry: asPoint(candidate.geometry), + presentationStatus: typeof candidate.presentationStatus === "string" && /^[a-z0-9_-]{1,64}$/.test(candidate.presentationStatus) + ? candidate.presentationStatus + : "active", }; } @@ -121,13 +134,27 @@ function asPatch(value: unknown, binding: MapDataProductBinding): PatchEnvelope 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 }] : []; - }); + const operations: PatchEnvelope["operations"] = []; + for (const operation of candidate.operations) { + if (!operation || typeof operation !== "object" || Array.isArray(operation)) continue; + const value = operation as { op?: unknown; fact?: unknown; sourceId?: unknown; semanticType?: unknown; removedAt?: unknown; reason?: unknown }; + if (value.op === "upsert") { + const fact = asFact(value.fact, binding); + if (fact) operations.push({ op: "upsert", fact }); + continue; + } + if ( + value.op === "remove" + && typeof value.sourceId === "string" + && identifier.test(value.sourceId) + && typeof value.semanticType === "string" + && binding.semanticTypes.includes(value.semanticType) + && isIsoTimestamp(value.removedAt) + && (value.reason === "tombstone" || value.reason === "revoked") + ) { + operations.push({ op: "remove", sourceId: value.sourceId, semanticType: value.semanticType, removedAt: value.removedAt, reason: value.reason }); + } + } return { schemaVersion: "nodedc.data-product.patch/v1", dataProduct: { id: binding.dataProductId, version: String((product as { version?: unknown }).version || "") }, @@ -138,6 +165,27 @@ function asPatch(value: unknown, binding: MapDataProductBinding): PatchEnvelope }; } +function asPresentationPatch(value: unknown, binding: MapDataProductBinding): PresentationPatchEnvelope | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const candidate = value as Record; + if (candidate.schemaVersion !== "nodedc.foundry.presentation-patch/v1" || !isIsoTimestamp(candidate.generatedAt) || !Array.isArray(candidate.operations)) return null; + const operations = candidate.operations.flatMap((operation) => { + if (!operation || typeof operation !== "object" || Array.isArray(operation)) return []; + const item = operation as { op?: unknown; sourceId?: unknown; semanticType?: unknown; status?: unknown }; + if ( + item.op !== "upsert" + || typeof item.sourceId !== "string" + || !identifier.test(item.sourceId) + || typeof item.semanticType !== "string" + || !binding.semanticTypes.includes(item.semanticType) + || typeof item.status !== "string" + || !/^[a-z0-9_-]{1,64}$/.test(item.status) + ) return []; + return [{ op: "upsert" as const, sourceId: item.sourceId, semanticType: item.semanticType, status: item.status }]; + }); + return { schemaVersion: "nodedc.foundry.presentation-patch/v1", generatedAt: candidate.generatedAt, operations }; +} + function replaceSnapshot(current: BindingState, snapshot: SnapshotEnvelope): BindingState { return { ...current, @@ -153,10 +201,22 @@ function applyPatch(current: BindingState, patch: PatchEnvelope): BindingState | // 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; + for (const operation of patch.operations) { + if (operation.op === "upsert") facts[factKey(operation.fact)] = operation.fact; + else delete facts[`${operation.semanticType}\u0000${operation.sourceId}`]; + } return { ...current, facts, cursor: patch.cursor, state: "ready" }; } +function applyPresentationPatch(current: BindingState, patch: PresentationPatchEnvelope): BindingState { + const facts = { ...current.facts }; + for (const operation of patch.operations) { + const key = `${operation.semanticType}\u0000${operation.sourceId}`; + if (facts[key]) facts[key] = { ...facts[key], presentationStatus: operation.status }; + } + return { ...current, facts }; +} + function stateFor(binding: MapDataProductBinding, state: MapRuntimeBinding["state"] = "idle"): BindingState { return { binding, cursor: null, facts: {}, state }; } @@ -263,6 +323,16 @@ export function useMapDataProductRuntime({ source.addEventListener("nodedc.data-product.resync-required.v1", () => { void loadSnapshotAndStream(binding); }); + source.addEventListener("nodedc.foundry.presentation-patch.v1", (event) => { + let patch: PresentationPatchEnvelope | null = null; + try { + patch = asPresentationPatch(JSON.parse((event as MessageEvent).data), binding); + } catch { + return; + } + if (!patch) return; + update(binding, (current) => applyPresentationPatch(current, patch)); + }); source.onerror = () => { // EventSource will retry transient transport failures itself. A // permanent upstream rejection closes the stream; loading a fresh diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index 5c74994..0d949f5 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -134,6 +134,29 @@ Pill navigation для верхней панели и компактного п В каноническом `ApplicationShell` шапка фиксирована. Её три оси не двигаются при открытии navigation/content и не зависят от ширины предметного контента. +## UserProfileMenu + +Каноническая выпадашка профиля для правой группы `AppHeader`. Она использует +общий portal `Dropdown`, показывает identity cover с аватаром, именем и +подписью и принимает список application-owned действий. + +Компонент владеет только геометрией, темизацией, закрытием по outside pointer и +Escape. Переход в профиль, открытие настроек, logout, permissions и продуктовые +правила остаются в приложении. Меню не должно содержать скрытый MCP или ACL +контракт. + +## FeatureSettingsWindow + +Большое модальное окно настроек по принятой механике нового Engine: identity +текущего модуля слева, сгруппированная навигация features и scrollable content +справа. Компонент переиспользует `Window`, поэтому не создаёт собственный +portal, backdrop или focus trap. + +`FeatureSettingsWindow` хранит только controlled active section. Формы, +setup-команды, сохранение, API и права принадлежат приложению. Цвет и материал +берутся из активного Design Profile; отдельная Engine/Foundry тема внутри +компонента запрещена. + ## ApplicationShell и ApplicationPanel Общий шаблон приложения по механике Launcher/Hub: фиксированный `AppHeader`, центральный stage, левая navigation panel и отдельное правое content window. diff --git a/docs/FOUNDRY_DATA_PRODUCT_BINDINGS.md b/docs/FOUNDRY_DATA_PRODUCT_BINDINGS.md index 0dbe341..4b70c17 100644 --- a/docs/FOUNDRY_DATA_PRODUCT_BINDINGS.md +++ b/docs/FOUNDRY_DATA_PRODUCT_BINDINGS.md @@ -2,7 +2,9 @@ `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. +its server-owned consumer reads snapshot and durable patch from External Data +Plane. The same-origin BFF serves the persisted projection and bounded history +without exposing the reader grant. ## Private workload API @@ -40,10 +42,42 @@ 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. +`snapshot+patch` product. The workload API remains fail-closed: if the grant is +not ready, POST fails with `409 data_product_reader_grant_not_ready`; an +unusable visual binding is never saved. + +The Foundry MCP consumer lifecycle owns managed grant creation. Its safe +`plan` asks EDP to resolve unique active writer coverage by Data Product id. +The response contains no provider, tenant or connection. Exact `apply` +generates an opaque target token inside the persistent Foundry runtime and +sends only its SHA-256 digest in a request signed by a dedicated Foundry +Ed25519 service identity. EDP persists a durable, explicitly revocable reader +binding. The signing private key is runner-managed and distinct from Engine; +EDP mounts only its public trust copy. Legacy root-owned deployment grants are +read-only compatibility fallback, not the normal lifecycle. The canonical Map entity-stream slot is `points`. Other templates may define their own typed slots, but the workload grant must name them explicitly. + +## Server-owned consumer lifecycle + +An approved binding is the only addressable consumer target. Foundry persists +consumer state under the runtime volume, keyed by application/page/binding; it +stores the binding identity, Data Product version, policy version, cursor, +snapshot generation, safe subjects, presentation status and reconnect +metadata. It never stores the reader capability value or EDP endpoint. + +Foundry MCP exposes exact `plan`/`apply`, safe `status`, bounded `accept` and +non-destructive `rollback` for this target. A missing legacy consumer record is +bootstrapped lazily from the already approved binding; a missing target grant +appears as the explicit `ensure-target-scoped-reader-grant` plan effect. An +existing Map Page therefore does not require a new provider workflow, native +n8n credential or manual pin migration. + +Snapshot replacement and patch application are atomic per target. Cursor is +committed before browser fan-out. Patch replay is idempotent; a gap causes +snapshot rebase. One active upstream stream is shared by every viewer of the +same target and is closed after the final viewer releases it. Temporary source +or network failure preserves subjects. Stale and removal use the versioned +provider-neutral policy registry: removal is allowed only by authoritative +snapshot absence or canonical tombstone/revocation. diff --git a/docs/MAP_TEMPLATE.md b/docs/MAP_TEMPLATE.md index 87dd8a3..c2710ff 100644 --- a/docs/MAP_TEMPLATE.md +++ b/docs/MAP_TEMPLATE.md @@ -86,13 +86,21 @@ semantic types, field projection и `slotId` (`points` для live point entitie При открытии Application page browser делает same-origin запрос к Foundry: ```text -Application/Page/Binding → Foundry BFF → External Data Plane snapshot → SSE patch +Application/Page/Binding → Foundry BFF → External Data Plane snapshot/history → SSE patch ``` -Foundry сопоставляет target с root-owned opaque reader grant в закрытом -deployment directory. Browser, manifest и Cesium adapter не получают provider +Foundry сопоставляет target с opaque reader grant в закрытом persistent +runtime. При первом exact consumer apply Foundry генерирует token локально и +передаёт EDP только его digest в запросе с отдельной service-подписью; EDP сам +разрешает unique active writer scope и fail-closed отклоняет ambiguity. Legacy +root-owned deployment directory используется только как совместимый fallback. +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. +Сначала server-owned Foundry consumer коммитит snapshot, затем применяет patch +events с exact cursor и только после commit делает fan-out всем viewers. При +пропуске cursor требуется snapshot rebase; несколько вкладок одного binding не +создают несколько upstream EDP subscriptions. Timeline использует provider-neutral +history route (`from/to/resolution/sourceIds/cursor`) через тот же BFF и не +занимает общую L2 execution queue. Renderer держит отдельный `CustomDataSource` +на binding и обновляет stable entity id без пересоздания viewer. Sampling и +retention остаются политикой Data Plane, а не Map Template. diff --git a/docs/MODULE_FOUNDRY_MCP.md b/docs/MODULE_FOUNDRY_MCP.md index 5a17c99..b190ba7 100644 --- a/docs/MODULE_FOUNDRY_MCP.md +++ b/docs/MODULE_FOUNDRY_MCP.md @@ -13,9 +13,10 @@ MCP не вводит новую оркестрацию. Доступ выдаё | Просматривать Page Library и экземпляры Applications | Менять канонический Page Library или дизайн-компоненты | | Создавать application instance | Удалять application instance через MCP | | Изменять название, slug и описание instance | Создавать свободный canvas или произвольный React-интерфейс | -| Добавлять повторные instances зарегистрированной страницы | Вызывать Engine, провайдера карт или внешний API из Foundry MCP | +| Добавлять повторные instances зарегистрированной страницы | Вызывать Engine, provider API или произвольный внешний endpoint из 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 | +| Управлять server-owned consumer только для persisted approved binding | Передавать reader capability, EDP URL или raw provider payload через MCP | Каждая запись требует `idempotencyKey`. Операция сохраняется в persistent runtime volume и повторный вызов с тем же ключом и тем же входом вернёт исходный результат без дублирования. Повтор ключа с иным входом завершается конфликтом. @@ -28,15 +29,22 @@ MCP не вводит новую оркестрацию. Доступ выдаё Обязательные HTTP-заголовки каждого вызова: ```http -Authorization: Bearer +Authorization: Bearer MCP-Protocol-Version: 2025-06-18 ``` -Capability выдаётся только сервером Foundry после штатной entitlement-проверки -AI Workspace. Она подписана существующим внутренним service credential, -привязана к `actorId` и `ownerKey`, живёт не более 10 минут и не даёт worker -доступа к самому platform credential. Заголовки с actor/owner от worker не -принимаются: контекст извлекается только из подписанной capability. +Endpoint поддерживает два существующих контура входа, не смешивая их: + +- AI Workspace получает короткоживущую Foundry capability после штатной + entitlement-проверки. Она подписана существующим внутренним service + credential, привязана к `actorId` и `ownerKey`, живёт не более 10 минут и не + раскрывает platform credential worker-у; +- внешний Codex получает отдельный durable Foundry Agent credential через + настройки текущего пользователя Foundry. Credential действует до явного + `revoke` и не является AI Workspace capability. + +Заголовки с actor/owner от клиента не принимаются: пользовательский контекст +извлекается только из проверенной capability или server-side записи Agent. Доступные инструменты: @@ -48,6 +56,11 @@ AI Workspace. Она подписана существующим внутрен - `foundry_add_page_instance` - `foundry_upsert_map_pin_binding` - `foundry_upsert_map_data_product_binding` +- `foundry_plan_map_data_product_consumer` +- `foundry_apply_map_data_product_consumer` +- `foundry_get_map_data_product_consumer_status` +- `foundry_accept_map_data_product_consumer` +- `foundry_rollback_map_data_product_consumer` `foundry_upsert_map_pin_binding` хранит только визуальную, provider-neutral привязку `elevated-spike`: стабильный id, subject, координаты, semantic status и ссылку на источник сущности. Поток живых данных не передаётся в MCP по одной позиции: визуальная привязка и поток данных будут связываться следующими contract/ontology слоями. @@ -57,23 +70,97 @@ types и допустимую field projection. Endpoint, provider, tenant, conn token, credential и raw payload валидатор отклоняет. Page runtime получает scoped snapshot/patch поток через Platform, но не через Foundry MCP. +Пять consumer-инструментов управляют тем же runtime, который обслуживает Map +Page. `plan` проверяет persisted binding, Data Product version/delivery, +versioned policy и безопасно показывает `readerGrantAction=ensure|reuse`. +Если grant отсутствует, EDP сам разрешает единственный active writer scope по +Data Product id; provider/tenant/connection в Foundry не возвращаются. `apply` +принимает exact `planId`, подписанно передаёт EDP только SHA-256 digest нового +target-scoped token, коммитит snapshot и включает consumer; `status` возвращает только safe cursor, +счётчики subject/status/reconnect и количество viewers/upstream streams; +`accept` берёт bounded diagnostic lease; `rollback` останавливает consumer, но +сохраняет последний safe snapshot. Capability value, grant path, internal URL и +fact attributes в MCP diagnostics не возвращаются. + ## 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/history?from=:iso&to=:iso&resolutionMs=:ms&sourceIds=:ids&limit=:n&cursor=:opaque 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` +Маршрут разрешает persisted binding, а затем server-owned consumer находит +opaque EDP reader grant по `sha256(applicationId/pageId/bindingId)`. Нормальный +grant создаётся внутри persistent private runtime Foundry; runner монтирует +отдельный Ed25519 private key только для подписи digest-only provisioner +request, а EDP получает только public trust. Старый root-owned read-only grant +directory остаётся fallback для уже выданных grant. Сам token передаётся +только как `Authorization` во внутренний External Data Plane и никогда не +попадает в browser, manifest, MCP или лог. В browser отдаётся только safe Foundry projection канонического data-product envelope: +snapshot, bounded `nodedc.data-product.history/v1`, safe +`nodedc.data-product.patch/v1` upserts и cursor. History query проходит через +тот же exact binding/read grant и не создаёт L2 execution на каждого viewer. +`Last-Event-ID` авторитетнее старого `after` при автоматическом SSE reconnect. +Consumer state хранится отдельно от application manifest в persistent runtime +volume. Snapshot atomically заменяет subject projection, включая явный empty +snapshot. Patch применяется только от exact previous cursor; новый cursor и +semantic state сначала сохраняются, затем fan-out отправляется browsers. +Повторный cursor идемпотентно игнорируется, gap вызывает snapshot rebase. +Несколько viewers одного binding делят один upstream EDP stream; закрытие +последнего viewer закрывает subscription, но не влияет на независимый producer +в Engine L2. + +Freshness и remove принадлежат versioned provider-neutral policy из +`registry/data-product-consumer-policies.json`. Stale вычисляется по +`observedAt`; terminal statuses сохраняются. Временная transport/credential +ошибка не удаляет subject. Удаление допустимо только при отсутствии в +авторитетном snapshot rebase или по canonical `tombstone`/`revoked` operation. +Renderer получает стабильный `sourceId + semanticType`, persisted coordinates +и Foundry-owned `presentationStatus`; provider identity на стиль и lifecycle не +влияет. + +## Внешний Codex: Foundry + отдельная Ontology MCP + +В профиле Foundry раздел `Настройки → Codex Agent API` создаёт Agent текущего +пользователя и выдаёт одноразовую setup-команду. Команда устанавливает в Codex +две независимые MCP-конфигурации: + +```text +nodedc_module_foundry → POST /api/mcp +nodedc_ontology → POST /api/ontology-mcp +``` + +Это одна операция установки, но не одна MCP. Foundry credential принимается +только `/api/mcp`, Ontology credential — только `/api/ontology-mcp`; их +перекрёстное использование отклоняется. Ontology route после своей проверки +проксирует MCP JSON-RPC во внутренний `Ontology Core /mcp` с уже существующим +server-only `NODEDC_INTERNAL_ACCESS_TOKEN`. Foundry не импортирует ontology +tools в свой список и не становится маршрутизатором платформенных MCP. + +Agent credentials не имеют календарного срока жизни: они валидны до явного +отзыва Agent. Setup code одноразовый и живёт 15 минут. Raw credentials +возвращаются только в момент redeem; в Foundry runtime сохраняются только +SHA-256 digests, device metadata и bounded audit. Один `revoke` закрывает обе +credentials конкретного Agent. + +Первый срез намеренно ограничен текущим аутентифицированным пользователем: +Codex может создавать и настраивать его Applications средствами Foundry MCP и +читать общую Ontology через отдельную read-only MCP. Sharing, роли, +межпользовательская видимость, release/publication и новые product ACL в этот +срез не входят. + +Установщик идемпотентно обновляет только блоки `nodedc_module_foundry` и +`nodedc_ontology` в `~/.codex/config.toml`, сохраняет остальные MCP (включая +Engine и Ops), делает backup конфигурации и устанавливает skill +`foundry-context`. Перед успешным завершением он выполняет `initialize` и +`tools/list` для обеих MCP. После установки Codex Desktop нужно полностью +перезапустить. + ## Entitlement для AI Workspace `POST /api/ai-workspace/entitlements` @@ -118,6 +205,8 @@ FOUNDRY_PUBLIC_URL=https:// FOUNDRY_MCP_URL=https:///api/mcp # Existing NODE.DC server-to-server credential; never send it to a browser or worker. NODEDC_INTERNAL_ACCESS_TOKEN= +# Internal-only Ontology Core address; never expose the Core directly. +NODEDC_ONTOLOGY_CORE_URL=http://ontology-core:8080 FOUNDRY_MCP_CAPABILITY_TTL_MS=600000 # Optional exceptional identities, not the default dcctouch superadmin grant. FOUNDRY_ALLOWED_OWNER_IDS= @@ -126,10 +215,12 @@ FOUNDRY_ALLOW_ALL_AUTHENTICATED=false FOUNDRY_MCP_ALLOWED_ORIGINS=https:// ``` -Foundry не требует у пользователя новый secret. `NODEDC_INTERNAL_ACCESS_TOKEN` — -уже существующий server-to-server credential платформы: он находится только в -server environment и используется и для Launcher/Authentiк handoff, и для -внутренней entitlement-проверки. Worker получает лишь короткоживущую capability. +Foundry не требует у пользователя вводить platform secret. +`NODEDC_INTERNAL_ACCESS_TOKEN` — уже существующий server-to-server credential +платформы: он находится только в server environment и используется для +Launcher/Authentiк handoff, внутренней entitlement-проверки и server-side +Ontology Core proxy. AI Workspace worker получает лишь короткоживущую +capability; внешний Codex — отдельные Agent credentials, но не internal token. После появления домена в deployment AI Workspace добавляется только штатная настройка adapter; новый worker, отдельная оркестрация или специальный bridge не нужны: @@ -141,7 +232,7 @@ AI_WORKSPACE_ENTITLEMENT_ADAPTERS_JSON='{"module-foundry":{"url":"https://