feat(foundry): add managed data consumers and agent settings

This commit is contained in:
Codex 2026-07-19 15:03:54 +03:00
parent 5f583caa05
commit aac44d057f
35 changed files with 4534 additions and 243 deletions

View File

@ -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("<applicationId>/<pageId>/<bindingId>"); 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 1530 seconds. A

View File

@ -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<FoundrySessionProfile | null>(null);
const [platformSettingsOpen, setPlatformSettingsOpen] = useState(false);
const [foundrySettingsOpen, setFoundrySettingsOpen] = useState(false);
const [cesiumIonToken, setCesiumIonToken] = useState("");
const [cesiumIonStatus, setCesiumIonStatus] = useState<CesiumIonSecretStatus | null>(null);
const [cesiumIonSaveState, setCesiumIonSaveState] = useState<"idle" | "loading" | "saving" | "saved" | "error">("idle");
@ -1815,15 +1816,15 @@ export function CatalogApp() {
right={
<HeaderProfile>
<IconButton label="Уведомления"><Icon name="inbox" size={20} strokeWidth={1.7} /></IconButton>
<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}
<UserProfileMenu
displayName={sessionProfile?.user?.displayName || "Профиль"}
subtitle={sessionProfile?.user?.email || "NODE.DC Foundry"}
avatarUrl={sessionProfile?.user?.avatarUrl || undefined}
actions={[
{ id: "profile", label: "Профиль", icon: "profile", href: sessionProfile?.profileUrl || undefined, disabled: !sessionProfile?.profileUrl },
{ id: "settings", label: "Настройки", icon: "settings", onSelect: () => setFoundrySettingsOpen(true) },
{ id: "logout", label: "Выйти", icon: "external", href: "/auth/logout" },
]}
/>
</HeaderProfile>
}
@ -2144,6 +2145,8 @@ export function CatalogApp() {
</div>
</Window>
<FoundrySettingsModal open={foundrySettingsOpen} onClose={() => setFoundrySettingsOpen(false)} />
<Window
open={createModalOpen}
title="Создать проект"

View File

@ -448,6 +448,10 @@ 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.
if (fact.presentationStatus === "stale") return Color.fromCssColorString("#f5a623");
if (["inactive", "no-position", "no_position"].includes(fact.presentationStatus)) {
return Color.fromCssColorString("#7d8491");
}
return fact.semanticType === "map.moving_object" ? accent : violet;
}

View File

@ -0,0 +1,202 @@
import { useCallback, useEffect, useState } from "react";
import { Button, Icon, SettingsCard, StatusBadge, TextField } from "@nodedc/ui-react";
interface FoundryAgentDevice {
id: string;
name: string;
createdAt: string;
lastUsedAt: string | null;
lastCheckAt: string | null;
revokedAt: string | null;
}
interface FoundryAgent {
id: string;
name: string;
avatarUrl: string | null;
status: "active" | "revoked";
devices: FoundryAgentDevice[];
deviceCount: number;
lastUsedAt: string | null;
lastCheckAt: string | null;
createdAt: string;
updatedAt: string;
}
interface SetupCommand {
command: string;
expiresAt: string;
}
async function api<T>(pathname: string, init?: RequestInit): Promise<T> {
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<FoundryAgent[]>([]);
const [newAgentName, setNewAgentName] = useState("Foundry Codex");
const [draftNames, setDraftNames] = useState<Record<string, string>>({});
const [setupByAgent, setSetupByAgent] = useState<Record<string, SetupCommand>>({});
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<void>) => {
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 (
<div className="catalog-codex-agent-settings">
<header className="catalog-codex-agent-settings__header">
<div><span>NODE.DC / SOURCE-FREE DEVELOPMENT</span><h2>Codex Agent API</h2></div>
<StatusBadge tone="success">Foundry + Ontology</StatusBadge>
</header>
<p className="catalog-codex-agent-settings__lead">
Одна setup-команда добавляет в Codex два независимых MCP: полный текущий Foundry contour и отдельную read-only Ontology. Sharing, роли и межпользовательская видимость в этот срез не входят.
</p>
{error ? <div className="catalog-codex-agent-settings__message" data-tone="error">{error}</div> : null}
{message ? <div className="catalog-codex-agent-settings__message" data-tone="success">{message}</div> : null}
<SettingsCard eyebrow="NEW AGENT" title="Подключить Codex" description="Agent принадлежит текущему пользователю Foundry. Credentials долговечны до явного revoke.">
<div className="catalog-codex-agent-settings__create">
<TextField label="Имя агента" value={newAgentName} onChange={(event) => setNewAgentName(event.target.value)} />
<Button variant="primary" shape="pill" icon={<Icon name="plus" />} disabled={pending === "create"} onClick={() => { void createAgent(); }}>
{pending === "create" ? "Создание…" : "Создать агента"}
</Button>
</div>
</SettingsCard>
<div className="catalog-codex-agent-settings__agents">
{loading ? <span className="catalog-codex-agent-settings__empty">Загружаю agents</span> : null}
{!loading && agents.length === 0 ? <span className="catalog-codex-agent-settings__empty">Агенты ещё не созданы.</span> : null}
{agents.map((agent) => {
const setup = setupByAgent[agent.id];
const active = agent.status === "active";
return (
<SettingsCard
key={agent.id}
eyebrow={`AGENT · ${agent.deviceCount} DEVICE`}
title={agent.name}
description={`Последнее использование: ${formatMoment(agent.lastUsedAt)} · doctor: ${formatMoment(agent.lastCheckAt)}`}
actions={<StatusBadge tone={active ? "success" : "danger"}>{active ? "Активен" : "Отозван"}</StatusBadge>}
>
<div className="catalog-codex-agent-settings__agent-grid">
<TextField
label="Имя"
value={draftNames[agent.id] ?? agent.name}
disabled={!active}
onChange={(event) => setDraftNames((current) => ({ ...current, [agent.id]: event.target.value }))}
/>
<div className="catalog-codex-agent-settings__actions">
<Button disabled={!active || pending === `save:${agent.id}`} onClick={() => { void saveAgent(agent); }}>Сохранить</Button>
<Button variant="primary" shape="pill" disabled={!active || pending === `setup:${agent.id}`} onClick={() => { void issueSetup(agent); }}>Получить команду</Button>
<Button variant="danger" disabled={!active || pending === `revoke:${agent.id}`} onClick={() => { void revokeAgent(agent); }}>Revoke</Button>
</div>
</div>
{setup ? (
<div className="catalog-codex-agent-settings__setup">
<div><strong>Одноразовая setup-команда</strong><small>Действует до {formatMoment(setup.expiresAt)}. После выполнения полностью перезапустите Codex Desktop.</small></div>
<code>{setup.command}</code>
<Button icon={<Icon name="copy" />} onClick={() => { void copySetup(agent.id); }}>Копировать</Button>
</div>
) : null}
</SettingsCard>
);
})}
</div>
</div>
);
}

View File

@ -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 (
<FeatureSettingsWindow
open={open}
title="Настройки Foundry"
subtitle="NODE DC / Codex Agent API"
identity={{ title: "NODE.DC Foundry", subtitle: "Текущий пользователь", avatarLabel: "NF" }}
sections={[{ id: "codex-agent-api", label: "Codex Agent API", group: "Features", icon: "network" }]}
activeSection="codex-agent-api"
onSectionChange={() => undefined}
onClose={onClose}
>
<FoundryCodexAgentSettings />
</FeatureSettingsWindow>
);
}

View File

@ -270,7 +270,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
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;
const status = fact.presentationStatus || (typeof attributes.status === "string" ? attributes.status : undefined);
return {
id: mapRuntimeEntityId(binding.bindingId, fact),
title: typeof label === "string" ? label : fact.sourceId,

View File

@ -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;
}

View File

@ -17,6 +17,7 @@ export type MapRuntimeFact = {
receivedAt: string;
attributes: Record<string, unknown>;
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 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);
return fact ? [{ op: "upsert" as const, fact }] : [];
});
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<string, unknown>;
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<string>).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

View File

@ -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.

View File

@ -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.

View File

@ -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.

View File

@ -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 <short-lived server-issued Foundry capability>
Authorization: Bearer <Foundry capability or Foundry Agent credential>
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://<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>
# 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=<comma-separated-extra-ids>
@ -126,10 +215,12 @@ 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.
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://<futur
## Deployment package
Foundry подготовлен к отдельному deployment как stateless server + один named persistent volume. Runtime volume содержит Applications, Design Profiles, idempotency audit и media uploads. Он является единственным источником изменяемого Foundry state; Git не используется для runtime-данных и tile cache.
Foundry подготовлен к отдельному deployment как stateless server + один named persistent volume. Runtime volume содержит Applications, Design Profiles, idempotency audit, data-product consumer state/cursors, media uploads, Agent/device records, setup-code digests и Agent audit. Он является единственным источником изменяемого Foundry state; Git не используется для runtime-данных и tile cache. Raw Foundry/Ontology Agent credentials и EDP reader capabilities в volume не записываются.
```bash
cp .env.example .env

View File

@ -17,6 +17,10 @@ services:
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}
# Ontology remains an internal read-only MCP. The external Codex receives
# a separate nodedc_ontology entry whose traffic is authenticated by the
# Foundry agent gateway and forwarded with the server-only platform token.
NODEDC_ONTOLOGY_CORE_URL: ${NODEDC_ONTOLOGY_CORE_URL:-http://ontology-core:18104}
# 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
@ -27,6 +31,13 @@ services:
# 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
# Foundry has a separate managed-provisioner identity. The private key
# signs digest-only reader-grant plan/ensure requests and is never used
# by Engine, n8n, a browser, MCP input or application state.
NODEDC_EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_PRIVATE_KEY_FILE: /run/nodedc-secrets/foundry-edp-managed-provisioner/private-key.pem
NODEDC_EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_SERVICE_ID: ${NODEDC_EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_SERVICE_ID:-nodedc-module-foundry}
NODEDC_EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_KEY_ID: ${NODEDC_EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_KEY_ID:-foundry-edp-managed-provisioner-v1}
NODEDC_EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_AUDIENCE: ${NODEDC_EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_AUDIENCE:-nodedc-external-data-plane.managed-provisioning.v1}
# 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.
@ -52,6 +63,12 @@ services:
read_only: true
bind:
create_host_path: false
- type: bind
source: /volume1/docker/nodedc-platform/secrets/foundry-edp-managed-provisioner/private-key.pem
target: /run/nodedc-secrets/foundry-edp-managed-provisioner/private-key.pem
read_only: true
bind:
create_host_path: false
networks:
- platform-engine
healthcheck:

View File

@ -17,6 +17,9 @@
"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",
"test:data-product-consumer": "node --test server/foundry-data-product-consumer.test.mjs",
"test:reader-grant-provisioner": "node --test server/foundry-reader-grant-provisioner.test.mjs",
"test:foundry-agent": "node --test server/foundry-agent-store.test.mjs server/foundry-agent-gateway.test.mjs scripts/foundry-agent-installer.test.mjs",
"test:map-animation": "node --test scripts/map-spiral.test.mjs scripts/map-camera-presets.test.mjs",
"test:map-cache-contract": "node --test scripts/map-cache-resource-contract.test.mjs",
"test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs"

View File

@ -1085,6 +1085,135 @@ textarea.nodedc-field__control {
object-fit: cover;
}
.nodedc-user-profile-menu__trigger {
display: inline-flex;
min-height: var(--nodedc-header-control-height);
align-items: center;
gap: 0.35rem;
border: 0;
border-radius: var(--nodedc-radius-circle);
outline: 0;
background: transparent;
color: var(--nodedc-text-secondary);
padding: 0;
font: inherit;
cursor: pointer;
}
.nodedc-user-profile-menu__trigger:hover,
.nodedc-user-profile-menu__trigger:focus-visible,
.nodedc-user-profile-menu__trigger[aria-expanded="true"] {
background: var(--nodedc-glass-control-hover);
color: var(--nodedc-text-primary);
}
.nodedc-user-profile-menu__trigger-label {
max-width: 10rem;
overflow: hidden;
padding-inline: 0.95rem 0.35rem;
font-size: var(--nodedc-font-size-sm);
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.nodedc-dropdown-surface.nodedc-user-profile-menu {
padding: 0.65rem;
border-radius: 1.25rem;
overflow: hidden auto;
}
.nodedc-user-profile-card {
display: grid;
gap: 0.32rem;
}
.nodedc-user-profile-card__cover {
position: relative;
isolation: isolate;
display: grid;
min-height: 8rem;
align-content: center;
justify-items: center;
gap: 0.1rem;
overflow: hidden;
border-radius: 0.9rem;
background:
linear-gradient(180deg, rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.46)),
radial-gradient(circle at 70% 15%, rgb(var(--nodedc-accent-rgb) / 0.34), transparent 38%),
var(--nodedc-panel-bg);
background-position: center;
background-size: cover;
color: #fff;
text-align: center;
}
.nodedc-user-profile-card__cover .nodedc-header__avatar {
width: 3.35rem;
height: 3.35rem;
flex-basis: 3.35rem;
box-shadow: 0 0 0 1px rgb(255 255 255 / 0.18), 0 0.45rem 1.6rem rgb(0 0 0 / 0.3);
}
.nodedc-user-profile-card__cover strong,
.nodedc-user-profile-card__cover > span:not(.nodedc-header__avatar) {
display: block;
max-width: 86%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nodedc-user-profile-card__cover strong {
margin-top: 0.25rem;
font-size: 0.92rem;
}
.nodedc-user-profile-card__cover > span:not(.nodedc-header__avatar) {
color: rgb(255 255 255 / 0.78);
font-size: 0.78rem;
}
.nodedc-user-profile-card__actions {
display: grid;
gap: 0.12rem;
}
.nodedc-user-profile-card__action {
display: grid;
min-height: 2.65rem;
grid-template-columns: 1rem minmax(0, 1fr);
align-items: center;
gap: 0.58rem;
border: 0;
border-radius: 0.85rem;
background: transparent;
color: var(--nodedc-text-secondary);
padding: 0 0.72rem;
text-align: left;
text-decoration: none;
font: inherit;
font-size: 0.82rem;
font-weight: 700;
cursor: pointer;
}
.nodedc-user-profile-card__action:hover:not(:disabled),
.nodedc-user-profile-card__action:focus-visible {
background: var(--nodedc-glass-control-hover);
color: var(--nodedc-text-primary);
}
.nodedc-user-profile-card__action[data-tone="danger"]:hover:not(:disabled) {
background: rgb(var(--nodedc-danger-rgb) / 0.12);
color: rgb(var(--nodedc-danger-rgb));
}
.nodedc-user-profile-card__action:disabled {
cursor: default;
opacity: 0.42;
}
.nodedc-header__workspace {
display: inline-grid;
width: var(--nodedc-header-row-height);
@ -1991,6 +2120,120 @@ textarea.nodedc-field__control {
width: min(58rem, calc(100vw - 2rem));
}
.nodedc-window.nodedc-feature-settings-window {
width: min(78rem, calc(100vw - 2rem));
height: min(48rem, calc(100vh - 2rem));
}
.nodedc-feature-settings-window .nodedc-window__body {
padding: 0.35rem 1rem 1rem;
}
.nodedc-feature-settings {
display: grid;
min-height: 0;
height: 100%;
grid-template-columns: minmax(14rem, 16rem) minmax(0, 1fr);
gap: 1.2rem;
}
.nodedc-feature-settings__navigation {
display: grid;
min-height: 0;
align-content: start;
gap: 1.1rem;
border-radius: var(--nodedc-radius-panel);
background: var(--nodedc-nested-bg);
padding: 0.9rem;
}
.nodedc-feature-settings__identity {
display: grid;
min-width: 0;
min-height: 4.25rem;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 0.75rem;
border-radius: var(--nodedc-radius-control);
background: var(--nodedc-panel-bg);
padding: 0.7rem;
}
.nodedc-feature-settings__identity > span {
display: grid;
min-width: 0;
gap: 0.15rem;
}
.nodedc-feature-settings__identity strong,
.nodedc-feature-settings__identity small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nodedc-feature-settings__identity strong {
font-size: var(--nodedc-font-size-sm);
}
.nodedc-feature-settings__identity small {
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
}
.nodedc-feature-settings__navigation nav,
.nodedc-feature-settings__navigation-entry {
display: grid;
gap: 0.3rem;
}
.nodedc-feature-settings__group {
padding: 0.35rem 0.65rem 0.15rem;
color: var(--nodedc-text-muted);
font-size: 0.66rem;
font-weight: 850;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.nodedc-feature-settings__section {
display: grid;
min-height: 2.8rem;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 0.65rem;
border: 0;
border-radius: var(--nodedc-radius-control-compact);
background: transparent;
color: var(--nodedc-text-secondary);
padding: 0 0.75rem;
text-align: left;
font: inherit;
font-size: var(--nodedc-font-size-sm);
font-weight: 750;
cursor: pointer;
}
.nodedc-feature-settings__section:hover:not(:disabled),
.nodedc-feature-settings__section[data-active="true"] {
background: var(--nodedc-glass-control-hover);
color: var(--nodedc-text-primary);
}
.nodedc-feature-settings__section[data-active="true"] {
background: rgb(var(--nodedc-accent-rgb) / 0.14);
color: var(--nodedc-accent);
}
.nodedc-feature-settings__content {
min-width: 0;
min-height: 0;
overflow: auto;
border-radius: var(--nodedc-radius-panel);
background: var(--nodedc-panel-bg);
padding: clamp(1.1rem, 2.4vw, 2rem);
}
.nodedc-window[data-placement="end"] {
width: min(var(--nodedc-inspector-width), calc(100vw - 3rem));
max-height: calc(100vh - 7.25rem);
@ -2947,6 +3190,24 @@ textarea.nodedc-field__control {
padding-inline: 0.75rem;
}
.nodedc-window.nodedc-feature-settings-window {
width: calc(100vw - 1rem);
height: calc(100vh - 1rem);
}
.nodedc-feature-settings {
grid-template-columns: 1fr;
}
.nodedc-feature-settings__navigation {
max-height: 14rem;
overflow: auto;
}
.nodedc-user-profile-menu__trigger-label {
display: none;
}
.nodedc-share-access__member {
grid-template-columns: 2.25rem minmax(0, 1fr) auto;
}

View File

@ -0,0 +1,86 @@
import type { ReactNode } from "react";
import { HeaderAvatar } from "./AppHeader.js";
import { Icon, type IconName } from "./Icon.js";
import { Window } from "./Window.js";
export interface FeatureSettingsSection<T extends string = string> {
id: T;
label: ReactNode;
group?: string;
icon?: IconName;
disabled?: boolean;
}
export interface FeatureSettingsWindowProps<T extends string = string> {
open: boolean;
title: ReactNode;
subtitle?: ReactNode;
identity: {
title: ReactNode;
subtitle?: ReactNode;
avatarLabel: string;
avatarUrl?: string;
};
sections: readonly FeatureSettingsSection<T>[];
activeSection: T;
onSectionChange: (section: T) => void;
onClose: () => void;
children: ReactNode;
footer?: ReactNode;
}
export function FeatureSettingsWindow<T extends string>({
open,
title,
subtitle,
identity,
sections,
activeSection,
onSectionChange,
onClose,
children,
footer,
}: FeatureSettingsWindowProps<T>) {
return (
<Window
open={open}
title={title}
subtitle={subtitle}
size="lg"
className="nodedc-feature-settings-window"
footer={footer}
closeOnBackdrop={false}
onClose={onClose}
>
<div className="nodedc-feature-settings">
<aside className="nodedc-feature-settings__navigation" aria-label="Разделы настроек">
<div className="nodedc-feature-settings__identity">
<HeaderAvatar label={identity.avatarLabel} imageUrl={identity.avatarUrl} />
<span><strong>{identity.title}</strong>{identity.subtitle ? <small>{identity.subtitle}</small> : null}</span>
</div>
<nav>
{sections.map((section, index) => {
const showGroup = section.group && section.group !== sections[index - 1]?.group;
return (
<div key={section.id} className="nodedc-feature-settings__navigation-entry">
{showGroup ? <span className="nodedc-feature-settings__group">{section.group}</span> : null}
<button
type="button"
className="nodedc-feature-settings__section"
data-active={section.id === activeSection ? "true" : undefined}
disabled={section.disabled}
onClick={() => onSectionChange(section.id)}
>
{section.icon ? <Icon name={section.icon} size={16} /> : null}
<span>{section.label}</span>
</button>
</div>
);
})}
</nav>
</aside>
<section className="nodedc-feature-settings__content">{children}</section>
</div>
</Window>
);
}

View File

@ -0,0 +1,108 @@
import type { ReactNode } from "react";
import { Dropdown } from "./Dropdown.js";
import { HeaderAvatar } from "./AppHeader.js";
import { Icon, type IconName } from "./Icon.js";
export interface UserProfileMenuAction {
id: string;
label: ReactNode;
icon?: IconName;
href?: string;
disabled?: boolean;
tone?: "default" | "danger";
onSelect?: () => void;
}
export interface UserProfileMenuProps {
displayName: string;
subtitle?: string;
avatarUrl?: string;
coverImageUrl?: string;
triggerLabel?: ReactNode;
actions: readonly UserProfileMenuAction[];
}
export function UserProfileMenu({
displayName,
subtitle,
avatarUrl,
coverImageUrl,
triggerLabel,
actions,
}: UserProfileMenuProps) {
const label = displayName || "NODE.DC";
return (
<Dropdown
placement="bottom-end"
width={296}
offset={10}
surfaceClassName="nodedc-user-profile-menu"
trigger={({ open, toggle, setTriggerRef, surfaceId }) => (
<button
ref={setTriggerRef}
type="button"
className="nodedc-user-profile-menu__trigger"
title={label}
aria-label={`Меню пользователя ${label}`}
aria-expanded={open}
aria-controls={surfaceId}
onClick={toggle}
>
{triggerLabel === null ? null : <span className="nodedc-user-profile-menu__trigger-label">{triggerLabel ?? label}</span>}
<HeaderAvatar label={label} imageUrl={avatarUrl} />
</button>
)}
>
{({ close }) => (
<div className="nodedc-user-profile-card">
<div
className="nodedc-user-profile-card__cover"
style={coverImageUrl ? { backgroundImage: `linear-gradient(180deg, rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.52)), url(${JSON.stringify(coverImageUrl)})` } : undefined}
>
<HeaderAvatar label={label} imageUrl={avatarUrl} />
<strong>{label}</strong>
{subtitle ? <span>{subtitle}</span> : null}
</div>
<div className="nodedc-user-profile-card__actions">
{actions.map((action) => {
const content = (
<>
{action.icon ? <Icon name={action.icon} size={16} /> : <span />}
<span>{action.label}</span>
</>
);
if (action.href && !action.disabled) {
return (
<a
key={action.id}
className="nodedc-user-profile-card__action"
data-tone={action.tone === "danger" ? "danger" : undefined}
href={action.href}
onClick={() => close()}
>
{content}
</a>
);
}
return (
<button
key={action.id}
type="button"
className="nodedc-user-profile-card__action"
data-tone={action.tone === "danger" ? "danger" : undefined}
disabled={action.disabled}
onClick={() => {
action.onSelect?.();
close();
}}
>
{content}
</button>
);
})}
</div>
</div>
)}
</Dropdown>
);
}

View File

@ -8,6 +8,7 @@ export * from "./ConfirmationModal.js";
export * from "./Dropdown.js";
export * from "./DragDrop.js";
export * from "./Field.js";
export * from "./FeatureSettingsWindow.js";
export * from "./Glass.js";
export * from "./Inspector.js";
export * from "./Icon.js";
@ -19,5 +20,6 @@ export * from "./StatusBadge.js";
export * from "./Settings.js";
export * from "./SharingModals.js";
export * from "./Toolbar.js";
export * from "./UserProfileMenu.js";
export * from "./Window.js";
export * from "./WorkspaceWindow.js";

View File

@ -207,6 +207,34 @@
"Consumers supply data and actions but cannot override preset geometry through local className or style props."
]
},
{
"id": "user-profile-menu",
"status": "baseline",
"package": "@nodedc/ui-react",
"exports": ["UserProfileMenu", "UserProfileMenuAction"],
"domContract": ["nodedc-user-profile-menu", "nodedc-user-profile-card", "nodedc-user-profile-card__action"],
"summary": "Canonical user profile dropdown for NODE.DC application headers with identity cover and application-owned actions.",
"behavior": ["portal dropdown", "profile identity card", "theme-independent cover", "action links and callbacks", "outside pointer and Escape close"],
"rules": [
"The component owns menu geometry and profile presentation; the application owns settings, profile and logout actions.",
"Use the existing Dropdown engine so the menu is never clipped by the fixed application header.",
"Do not embed product permissions or sharing rules in the visual menu."
]
},
{
"id": "feature-settings-window",
"status": "baseline",
"package": "@nodedc/ui-react",
"exports": ["FeatureSettingsWindow", "FeatureSettingsSection"],
"domContract": ["nodedc-feature-settings-window", "nodedc-feature-settings__navigation", "nodedc-feature-settings__content"],
"summary": "Engine-derived large settings window with module identity, grouped feature navigation and application-owned content.",
"behavior": ["canonical modal focus behavior", "controlled active section", "grouped navigation", "responsive single-column layout"],
"rules": [
"The window owns settings-shell geometry only; forms, API calls and authorization remain application-owned.",
"FeatureSettingsWindow composes the canonical Window and must not create a second portal or focus trap.",
"Module colors are supplied through the active NODE.DC Design Profile rather than hard-coded product CSS."
]
},
{
"id": "application-shell",
"status": "baseline",

View File

@ -0,0 +1,19 @@
{
"$schema": "https://nodedc.ru/schemas/foundry-data-product-consumer-policies-v1.json",
"schemaVersion": "nodedc.foundry.data-product-consumer-policies/v1",
"policies": [
{
"id": "map-moving-object-current-v1",
"version": "1.0.0",
"dataProductId": "fleet.positions.current.v1",
"productVersion": "1.0.0",
"staleAfterMs": 60000,
"terminalStatuses": [
"inactive",
"no-position",
"no_position"
],
"removeMode": "canonical-tombstone-or-snapshot-rebase"
}
]
}

View File

@ -0,0 +1,96 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createServer } from "node:http";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import test from "node:test";
const execFileAsync = promisify(execFile);
const installer = new URL("../server/assets/foundry-agent-npm/bin/nodedc-foundry-codex-agent.mjs", import.meta.url);
async function listen(server) {
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
return `http://127.0.0.1:${server.address().port}`;
}
test("Foundry installer preserves unrelated MCP entries and installs two independent servers", async () => {
let baseUrl = "";
const server = createServer(async (request, response) => {
if (request.url === "/api/foundry-agent/v1/setup/redeem") {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({
ok: true,
foundry: { serverName: "nodedc_module_foundry", mcpUrl: `${baseUrl}/foundry-mcp`, token: "foundry-device-token" },
ontology: { serverName: "nodedc_ontology", mcpUrl: `${baseUrl}/ontology-mcp`, token: "ontology-device-token", readOnly: true },
}));
return;
}
if (request.url === "/foundry-mcp" || request.url === "/ontology-mcp") {
const chunks = [];
for await (const chunk of request) chunks.push(chunk);
const message = JSON.parse(Buffer.concat(chunks).toString("utf8"));
const tools = request.url === "/foundry-mcp"
? [{ name: "foundry_status" }, { name: "foundry_create_application" }]
: [{ name: "ontology_status" }, { name: "ontology_search" }];
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({
jsonrpc: "2.0",
id: message.id,
result: message.method === "tools/list"
? { tools }
: { protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "test", version: "1" } },
}));
return;
}
response.writeHead(404);
response.end();
});
baseUrl = await listen(server);
const codexHome = await mkdtemp(path.join(os.tmpdir(), "nodedc-foundry-installer-"));
await mkdir(codexHome, { recursive: true });
const original = [
"model = \"gpt-5\"",
"",
"[mcp_servers.nodedc_engine_agent]",
"url = \"https://engine.example/mcp\"",
"",
"[mcp_servers.nodedc_module_foundry]",
"url = \"https://stale.example/foundry\"",
"",
"[mcp_servers.nodedc_ontology]",
"url = \"https://stale.example/ontology\"",
"",
].join("\n");
await writeFile(path.join(codexHome, "config.toml"), original, "utf8");
try {
const result = await execFileAsync(process.execPath, [installer.pathname, "--server", baseUrl, "--code", "test-code", "--codex-home", codexHome]);
assert.match(result.stdout, /Foundry MCP smoke tools: 2/);
assert.match(result.stdout, /Ontology MCP smoke tools: 2/);
const config = await readFile(path.join(codexHome, "config.toml"), "utf8");
assert.match(config, /\[mcp_servers\.nodedc_engine_agent\]/);
assert.equal((config.match(/\[mcp_servers\.nodedc_module_foundry\]/g) || []).length, 1);
assert.equal((config.match(/\[mcp_servers\.nodedc_ontology\]/g) || []).length, 1);
assert.match(config, new RegExp(`${baseUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\/foundry-mcp`));
assert.match(config, /Bearer foundry-device-token/);
assert.match(config, /Bearer ontology-device-token/);
assert.equal(config.includes("stale.example"), false);
const backup = await readFile(path.join(codexHome, "config.toml.nodedc-foundry-agent.bak"), "utf8");
assert.equal(backup, original);
const skill = await readFile(path.join(codexHome, "skills", "foundry-context", "SKILL.md"), "utf8");
assert.match(skill, /nodedc_module_foundry/);
assert.match(skill, /nodedc_ontology/);
const doctor = await execFileAsync(process.execPath, [installer.pathname, "doctor", "--codex-home", codexHome]);
assert.match(doctor.stdout, /install is present/);
} finally {
await new Promise((resolve) => server.close(resolve));
await rm(codexHome, { recursive: true, force: true });
}
});

View File

@ -28,8 +28,9 @@ const bindingGrantDir = await mkdtemp(join(tmpdir(), "nodedc-foundry-binding-gra
const bindingGrantToken = createFoundryBindingGrantToken();
let foundry;
let edp;
const pressure = { sent: 0, backpressured: false, closed: false };
const firstStream = { opened: false, closed: false };
const shutdownStream = { opened: false, closed: false };
const internalAccessToken = "foundry-runtime-smoke-internal-access";
function listen(server) {
return new Promise((resolve) => {
@ -54,39 +55,64 @@ async function waitFor(url) {
async function waitUntil(predicate, label, timeoutMs = 5_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (predicate()) return;
if (await 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);
});
async function readStreamUntil(response, pattern, timeoutMs = 5_000) {
assert.equal(response.status, 200);
assert.ok(response.body);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let text = "";
const deadline = Date.now() + timeoutMs;
try {
while (Date.now() < deadline) {
const next = await Promise.race([
reader.read(),
new Promise((_, reject) => setTimeout(() => reject(new Error("stream_read_timeout")), 250)),
]);
if (next.done) break;
text += decoder.decode(next.value, { stream: true });
if (pattern.test(text)) return text;
}
throw new Error("stream_pattern_timeout");
} finally {
await reader.cancel().catch(() => undefined);
reader.releaseLock();
}
}
function openPausedStream(url) {
function readHttpStreamUntil(url, pattern, timeoutMs = 5_000) {
return new Promise((resolve, reject) => {
let text = "";
const timer = setTimeout(() => {
request.destroy();
reject(new Error("http_stream_pattern_timeout"));
}, timeoutMs);
const request = httpRequest(url, { headers: { accept: "text/event-stream" } }, (response) => {
response.pause();
resolve({ request, response });
response.setEncoding("utf8");
response.on("data", (chunk) => {
text += chunk;
if (!pattern.test(text)) return;
clearTimeout(timer);
response.destroy();
request.destroy();
resolve(text);
});
response.once("error", (error) => {
if (pattern.test(text)) return;
clearTimeout(timer);
reject(error);
});
});
request.once("error", (error) => {
if (pattern.test(text)) return;
clearTimeout(timer);
reject(error);
});
request.once("error", reject);
request.end();
});
}
@ -181,13 +207,37 @@ try {
}));
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" }));
if (request.url === "/internal/data-plane/v1/data-products/fleet.positions.current.v1/history?from=2026-07-15T12%3A00%3A00.000Z&to=2026-07-15T12%3A05%3A00.000Z&resolutionMs=60000&limit=1000&sourceIds=vehicle-001") {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({
schemaVersion: "nodedc.data-product.history/v1",
dataProduct: { id: "fleet.positions.current.v1", version: "1.0.0" },
generatedAt: "2026-07-15T12:05:01.000Z",
query: {
from: "2026-07-15T12:00:00.000Z",
to: "2026-07-15T12:05:00.000Z",
resolutionMs: 60000,
sourceIds: ["vehicle-001"],
order: "asc",
},
facts: [{
...canonicalFact({ longitude: 37.61, name: "Vehicle 001 historical" }),
bucketStart: "2026-07-15T12:00:00.000Z",
}],
nextCursor: "opaque_history_cursor",
}));
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" });
firstStream.opened = true;
const heartbeat = setInterval(() => response.write(": keepalive\n\n"), 50);
const closeFirstStream = () => {
clearInterval(heartbeat);
firstStream.closed = true;
};
response.once("close", closeFirstStream);
request.socket.once("close", closeFirstStream);
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");
@ -200,44 +250,18 @@ try {
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") {
if (request.url === "/internal/data-plane/v1/data-products/fleet.positions.current.v1/stream?after=8") {
response.writeHead(200, { "content-type": "text/event-stream" });
shutdownStream.opened = true;
const heartbeat = setInterval(() => response.write(": keepalive\n\n"), 50);
response.once("close", () => {
const closeShutdownStream = () => {
clearInterval(heartbeat);
shutdownStream.closed = true;
});
};
response.once("close", closeShutdownStream);
request.socket.once("close", closeShutdownStream);
response.write(": keepalive\n\n");
return;
}
@ -258,6 +282,8 @@ try {
PORT: String(foundryPort),
FOUNDRY_RUNTIME_DIR: runtimeDir,
NODEDC_FOUNDRY_AUTH_REQUIRED: "false",
NODEDC_INTERNAL_ACCESS_TOKEN: internalAccessToken,
FOUNDRY_MCP_URL: `http://127.0.0.1:${foundryPort}/api/mcp`,
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,
@ -268,6 +294,40 @@ try {
foundry.stderr.on("data", (chunk) => { stderr += String(chunk); });
await waitFor(`http://127.0.0.1:${foundryPort}/healthz`);
const entitlementResponse = await fetch(`http://127.0.0.1:${foundryPort}/api/ai-workspace/entitlements`, {
method: "POST",
headers: { authorization: `Bearer ${internalAccessToken}`, "content-type": "application/json" },
body: JSON.stringify({
schemaVersion: "ai-workspace.entitlement-request.v1",
appId: "module-foundry",
owner: { id: "user_root", key: "user_root" },
}),
});
assert.equal(entitlementResponse.status, 200);
const entitlement = await entitlementResponse.json();
const mcpAuthorization = entitlement.appGrants["module-foundry"].mcpServers[0].httpHeaders.Authorization;
let mcpRequestId = 0;
const mcpRequest = async (method, params = {}) => {
const response = await fetch(`http://127.0.0.1:${foundryPort}/api/mcp`, {
method: "POST",
headers: {
authorization: mcpAuthorization,
"content-type": "application/json",
"mcp-protocol-version": "2025-06-18",
},
body: JSON.stringify({ jsonrpc: "2.0", id: ++mcpRequestId, method, params }),
});
assert.equal(response.status, 200);
const payload = await response.json();
assert.equal(payload.error, undefined);
return payload.result;
};
const initialized = await mcpRequest("initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "runtime-smoke", version: "1" } });
assert.equal(initialized.serverInfo.version, "0.2.0");
const listedTools = await mcpRequest("tools/list");
assert.equal(listedTools.tools.length, 13);
assert.ok(listedTools.tools.some((tool) => tool.name === "foundry_apply_map_data_product_consumer"));
const workloadHeaders = {
authorization: `Bearer ${bindingGrantToken}`,
"content-type": "application/json",
@ -308,6 +368,23 @@ try {
assert.equal(replayResponse.status, 200);
assert.equal((await replayResponse.json()).idempotency.replayed, true);
const consumerTarget = { applicationId, pageId, bindingId };
const planCall = await mcpRequest("tools/call", {
name: "foundry_plan_map_data_product_consumer",
arguments: consumerTarget,
});
const consumerPlan = planCall.structuredContent;
assert.equal(consumerPlan.action, "create");
assert.match(consumerPlan.planId, /^fcp1_/);
assert.equal(JSON.stringify(consumerPlan).includes(readerToken), false);
assert.equal(JSON.stringify(consumerPlan).includes(`127.0.0.1:${edpPort}`), false);
const applyCall = await mcpRequest("tools/call", {
name: "foundry_apply_map_data_product_consumer",
arguments: { ...consumerTarget, planId: consumerPlan.planId, idempotencyKey: "runtime-smoke-consumer-apply" },
});
assert.equal(applyCall.structuredContent.consumer.cursor, "7");
assert.equal(applyCall.structuredContent.consumer.subjectCount, 1);
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);
@ -317,18 +394,55 @@ try {
assert.deepEqual(snapshot.facts[0].attributes, { name: "Vehicle 001" });
assert.equal(JSON.stringify(snapshot).includes("must-not-reach-browser"), false);
const historyResponse = await fetch(`${base}/history?from=2026-07-15T12%3A00%3A00.000Z&to=2026-07-15T12%3A05%3A00.000Z&resolutionMs=60000&sourceIds=vehicle-001&limit=1000`);
assert.equal(historyResponse.status, 200);
const history = await historyResponse.json();
assert.equal(history.schemaVersion, "nodedc.data-product.history/v1");
assert.equal(history.facts.length, 1);
assert.equal(history.facts[0].bucketStart, "2026-07-15T12:00:00.000Z");
assert.deepEqual(history.facts[0].attributes, { name: "Vehicle 001 historical" });
assert.equal(history.nextCursor, "opaque_history_cursor");
assert.equal(JSON.stringify(history).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();
const streamText = await readHttpStreamUntil(`${base}/stream?after=7`, /"cursor":"8"/);
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);
await waitUntil(async () => {
const call = await mcpRequest("tools/call", {
name: "foundry_get_map_data_product_consumer_status",
arguments: consumerTarget,
});
return call.structuredContent.consumer.viewerCount === 0;
}, "viewer_lease_release");
await waitUntil(async () => {
const call = await mcpRequest("tools/call", {
name: "foundry_get_map_data_product_consumer_status",
arguments: consumerTarget,
});
return call.structuredContent.consumer.upstreamStreamCount === 0;
}, "shared_upstream_manager_release");
await waitUntil(() => firstStream.closed, "shared_upstream_close_after_last_viewer");
const statusCall = await mcpRequest("tools/call", {
name: "foundry_get_map_data_product_consumer_status",
arguments: consumerTarget,
});
assert.equal(statusCall.structuredContent.consumer.cursor, "8");
assert.equal(statusCall.structuredContent.consumer.subjectCount, 1);
assert.equal(statusCall.structuredContent.consumer.metrics.patchCommits, 1);
assert.equal(statusCall.structuredContent.consumer.viewerCount, 0);
const acceptanceCall = await mcpRequest("tools/call", {
name: "foundry_accept_map_data_product_consumer",
arguments: { ...consumerTarget, timeoutMs: 1000, minSubjectCount: 1, minPatchCount: 0 },
});
assert.equal(acceptanceCall.structuredContent.accepted, true);
assert.equal(stderr, "");
await rm(join(grantDir, targetKey));
@ -338,16 +452,8 @@ try {
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`);
const shutdownClient = await fetch(`${base}/stream?after=8`, { headers: { accept: "text/event-stream" } });
assert.equal(shutdownClient.status, 200);
await waitUntil(() => shutdownStream.opened, "shutdown_stream_open");
const exit = once(foundry, "exit");
foundry.kill("SIGTERM");
@ -358,8 +464,7 @@ try {
assert.equal(exitCode, 0);
assert.equal(exitSignal, null);
await waitUntil(() => shutdownStream.closed, "upstream_close_after_shutdown");
shutdownClient.response.destroy();
shutdownClient.request.destroy();
await shutdownClient.body?.cancel().catch(() => undefined);
assert.equal(stderr, "");
console.log("foundry data-product runtime BFF: ok");
} finally {

View File

@ -0,0 +1,199 @@
#!/usr/bin/env node
import { mkdir, readFile, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
const FOUNDRY_SERVER_NAME = "nodedc_module_foundry";
const ONTOLOGY_SERVER_NAME = "nodedc_ontology";
const SKILL_NAME = "foundry-context";
const MCP_PROTOCOL_VERSION = "2025-06-18";
function usage(message = "") {
if (message) console.error(message);
console.log(`Usage:
nodedc-foundry-codex-agent --server <Foundry URL> --code <one-time code>
nodedc-foundry-codex-agent doctor [--codex-home <path>]
The setup command is issued by Foundry Settings Codex Agent API.`);
process.exitCode = message ? 1 : 0;
}
function parseArgs(raw) {
const args = raw[0] === "--" ? raw.slice(1) : raw;
const out = { command: "setup", server: "", code: "", codexHome: process.env.CODEX_HOME || "" };
if (args[0] === "doctor") out.command = "doctor";
const values = out.command === "doctor" ? args.slice(1) : args;
for (let index = 0; index < values.length; index += 1) {
const item = values[index];
if (item === "--server") out.server = String(values[++index] || "");
else if (item.startsWith("--server=")) out.server = item.slice("--server=".length);
else if (item === "--code") out.code = String(values[++index] || "");
else if (item.startsWith("--code=")) out.code = item.slice("--code=".length);
else if (item === "--codex-home") out.codexHome = String(values[++index] || "");
else if (item.startsWith("--codex-home=")) out.codexHome = item.slice("--codex-home=".length);
else if (item === "--help" || item === "-h") out.help = true;
else throw new Error(`unknown_argument:${item}`);
}
out.server = String(out.server || "").replace(/\/+$/, "");
return out;
}
function codexHome(value) {
if (!value) return path.join(os.homedir(), ".codex");
if (value === "~") return os.homedir();
if (value.startsWith("~/") || value.startsWith("~\\")) return path.join(os.homedir(), value.slice(2));
return path.resolve(value);
}
async function readIfExists(file) {
try {
return await readFile(file, "utf8");
} catch (error) {
if (error?.code === "ENOENT") return null;
throw error;
}
}
function stripMcpBlocks(content, serverNames) {
const targets = serverNames.map((name) => `mcp_servers.${name}`);
let skip = false;
const kept = [];
for (const line of String(content || "").split(/\r?\n/)) {
const section = line.match(/^\s*\[([^\]]+)\]\s*(?:#.*)?$/)?.[1]?.trim() || "";
if (section) skip = targets.some((target) => section === target || section.startsWith(`${target}.`));
if (!skip) kept.push(line);
}
return kept.join("\n").trimEnd();
}
function mcpBlock(serverName, endpoint, token) {
return [
`[mcp_servers.${serverName}]`,
`url = ${JSON.stringify(endpoint)}`,
"enabled = true",
"required = false",
"startup_timeout_sec = 30",
"tool_timeout_sec = 120",
"",
`[mcp_servers.${serverName}.http_headers]`,
`Authorization = ${JSON.stringify(`Bearer ${token}`)}`,
`Accept = ${JSON.stringify("application/json, text/event-stream")}`,
`${JSON.stringify("MCP-Protocol-Version")} = ${JSON.stringify(MCP_PROTOCOL_VERSION)}`,
].join("\n");
}
async function writeConfig(home, setup) {
const configPath = path.join(home, "config.toml");
await mkdir(path.dirname(configPath), { recursive: true });
const original = await readIfExists(configPath);
if (original !== null) await writeFile(`${configPath}.nodedc-foundry-agent.bak`, original, "utf8");
const prefix = stripMcpBlocks(original || "", [FOUNDRY_SERVER_NAME, ONTOLOGY_SERVER_NAME]);
const blocks = [
mcpBlock(FOUNDRY_SERVER_NAME, setup.foundry.mcpUrl, setup.foundry.token),
mcpBlock(ONTOLOGY_SERVER_NAME, setup.ontology.mcpUrl, setup.ontology.token),
].join("\n\n");
await writeFile(configPath, `${prefix}${prefix ? "\n\n" : ""}${blocks}\n`, "utf8");
return configPath;
}
function skillBody() {
return `---
name: foundry-context
description: Use for NODE.DC Module Foundry application work. Use Foundry and the separate read-only Ontology MCP; never depend on Foundry source files.
---
# NODE.DC Module Foundry
- Treat \`nodedc_module_foundry\` as the only authority for Foundry application instances and Page Library bindings. Do not read or edit Foundry source, runtime files or manifests directly.
- Treat \`nodedc_ontology\` as a separate read-only semantic authority. Use it before inventing entity ids, semantic types, aliases, relations or routing assumptions.
- Work only inside the currently granted Foundry user contour. Sharing, cross-user visibility, roles and public release are outside this connector until explicit tools expose them.
- Canonical Page Library templates are immutable. Create and modify application instances through Foundry tools only.
- Use a fresh idempotency key for every write. Reuse a key only when intentionally replaying the exact same operation.
- Provider URLs, credentials, polling and normalization stay in NDC Engine L2. Foundry consumes provider-neutral Data Products and semantic bindings only.
- Never request or place provider tokens, internal workload grants or raw secret values in an application, page binding, prompt or MCP argument.
- For the first Map slice, resolve semantics through Ontology, create the application/page in Foundry, bind the approved Data Product, and verify the resulting application manifest through Foundry MCP reads.
`;
}
async function writeSkill(home) {
const file = path.join(home, "skills", SKILL_NAME, "SKILL.md");
await mkdir(path.dirname(file), { recursive: true });
await writeFile(file, skillBody(), "utf8");
return file;
}
async function redeem(server, code) {
const response = await fetch(`${server}/api/foundry-agent/v1/setup/redeem`, {
method: "POST",
headers: { accept: "application/json", "content-type": "application/json" },
body: JSON.stringify({ code, deviceName: `${os.hostname()} Codex Desktop` }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || !payload?.ok || !payload?.foundry?.token || !payload?.ontology?.token) {
throw new Error(payload?.error || `setup_redeem_failed_${response.status}`);
}
return payload;
}
async function toolsSmoke(serverName, endpoint, token) {
const headers = {
accept: "application/json, text/event-stream",
authorization: `Bearer ${token}`,
"content-type": "application/json",
"mcp-protocol-version": MCP_PROTOCOL_VERSION,
};
const initialize = await fetch(endpoint, {
method: "POST",
headers,
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: { protocolVersion: MCP_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: "nodedc-foundry-setup", version: "0.1.0" } },
}),
});
if (!initialize.ok) throw new Error(`${serverName}_initialize_failed_${initialize.status}`);
const list = await fetch(endpoint, {
method: "POST",
headers,
body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }),
});
const payload = await list.json().catch(() => ({}));
if (!list.ok || !Array.isArray(payload?.result?.tools)) throw new Error(`${serverName}_tools_list_failed_${list.status}`);
return payload.result.tools.length;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) return usage();
const home = codexHome(args.codexHome);
if (args.command === "doctor") {
const config = await readIfExists(path.join(home, "config.toml"));
const skill = await readIfExists(path.join(home, "skills", SKILL_NAME, "SKILL.md"));
const ok = Boolean(
config?.includes(`[mcp_servers.${FOUNDRY_SERVER_NAME}]`)
&& config?.includes(`[mcp_servers.${ONTOLOGY_SERVER_NAME}]`)
&& skill,
);
console.log(ok ? "NODE.DC Foundry + Ontology Codex install is present." : "NODE.DC Foundry + Ontology Codex install is incomplete.");
process.exitCode = ok ? 0 : 1;
return;
}
if (!args.server || !args.code) return usage("server_and_code_required");
const setup = await redeem(args.server, args.code);
const configPath = await writeConfig(home, setup);
const skillPath = await writeSkill(home);
const foundryToolCount = await toolsSmoke(FOUNDRY_SERVER_NAME, setup.foundry.mcpUrl, setup.foundry.token);
const ontologyToolCount = await toolsSmoke(ONTOLOGY_SERVER_NAME, setup.ontology.mcpUrl, setup.ontology.token);
console.log("NODE.DC Foundry + Ontology Codex setup complete.");
console.log("Config:", configPath);
console.log("Skill:", skillPath);
console.log("Foundry MCP smoke tools:", foundryToolCount);
console.log("Ontology MCP smoke tools:", ontologyToolCount);
console.log("Restart Codex Desktop completely before using the new MCP tools.");
}
main().catch((error) => {
console.error(`NODE.DC Foundry Codex setup failed: ${error?.message || error}`);
process.exitCode = 1;
});

View File

@ -0,0 +1,15 @@
{
"name": "@nodedc/foundry-codex-agent",
"version": "0.1.0",
"private": true,
"type": "module",
"bin": {
"nodedc-foundry-codex-agent": "bin/nodedc-foundry-codex-agent.mjs"
},
"files": [
"bin"
],
"engines": {
"node": ">=20"
}
}

Binary file not shown.

View File

@ -1,6 +1,7 @@
import { constants as fsConstants, createReadStream, createWriteStream } from "node:fs";
import { lstat, mkdir, open, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
import { createServer } from "node:http";
import { createServer, request as httpRequest } from "node:http";
import { request as httpsRequest } from "node:https";
import { createHash, createHmac, randomUUID } from "node:crypto";
import { basename, extname, join, normalize, resolve } from "node:path";
import { Readable, Transform } from "node:stream";
@ -11,6 +12,10 @@ import {
FOUNDRY_BINDING_UPSERT_PATH,
handleFoundryBindingApiRequest,
} from "./foundry-binding-api.mjs";
import { createFoundryAgentGateway } from "./foundry-agent-gateway.mjs";
import { createFoundryAgentStore } from "./foundry-agent-store.mjs";
import { createFoundryDataProductConsumerManager } from "./foundry-data-product-consumer.mjs";
import { createFoundryReaderGrantProvisioner } from "./foundry-reader-grant-provisioner.mjs";
import { handleFoundryEntitlementRequest, handleFoundryMcpRequest } from "./foundry-mcp.mjs";
import { createFoundryAuth } from "./nodedc-auth.mjs";
@ -25,6 +30,9 @@ const designProfilesDir = join(runtimeDir, "design-profiles");
const designProfileReleasesDir = join(runtimeDir, "design-profile-releases");
const pageLayoutsDir = join(runtimeDir, "page-layouts");
const foundryMcpOperationsDir = join(runtimeDir, "foundry-mcp-operations");
const foundryDataProductConsumersDir = join(runtimeDir, "data-product-consumers");
const foundryManagedReaderGrantsDir = join(runtimeDir, "runtime-secrets", "external-data-plane-reader-grants");
const foundryAgentDataDir = join(runtimeDir, "foundry-agent-gateway");
const layoutPath = join(runtimeDir, "layout.json");
const pageRegistryPath = join(root, "registry", "pages.json");
const port = Number(process.env.PORT || 3333);
@ -35,7 +43,10 @@ await mkdir(designProfilesDir, { recursive: true });
await mkdir(designProfileReleasesDir, { recursive: true });
await mkdir(pageLayoutsDir, { recursive: true });
await mkdir(foundryMcpOperationsDir, { recursive: true });
await mkdir(foundryDataProductConsumersDir, { recursive: true });
await mkdir(foundryAgentDataDir, { recursive: true });
const pageRegistry = JSON.parse(await readFile(pageRegistryPath, "utf8"));
const dataProductConsumerPolicyRegistry = JSON.parse(await readFile(join(root, "registry", "data-product-consumer-policies.json"), "utf8"));
const foundryAuth = createFoundryAuth();
const mapGatewayHeadersTimeoutMs = boundedMapGatewayTimeout(
process.env.NODEDC_MAP_GATEWAY_HEADERS_TIMEOUT_MS,
@ -54,12 +65,28 @@ const mapGatewayInternalUrl = String(
// a persisted application/page/binding target through the same-origin BFF.
const externalDataPlaneInternalUrl = String(process.env.NODEDC_EXTERNAL_DATA_PLANE_INTERNAL_URL || "").trim().replace(/\/$/, "");
const externalDataPlaneReaderGrantsDir = String(process.env.NODEDC_EXTERNAL_DATA_PLANE_READER_GRANTS_DIR || "").trim();
const foundryReaderGrantProvisioner = createFoundryReaderGrantProvisioner({
dataPlaneUrl: externalDataPlaneInternalUrl,
privateKeyFile: process.env.NODEDC_EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_PRIVATE_KEY_FILE,
grantsDir: foundryManagedReaderGrantsDir,
serviceId: process.env.NODEDC_EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_SERVICE_ID || "nodedc-module-foundry",
keyId: process.env.NODEDC_EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_KEY_ID || "foundry-edp-managed-provisioner-v1",
audience: process.env.NODEDC_EXTERNAL_DATA_PLANE_FOUNDRY_PROVISIONER_AUDIENCE || "nodedc-external-data-plane.managed-provisioning.v1",
});
const foundryBindingGrantsDir = String(process.env.NODEDC_FOUNDRY_BINDING_GRANTS_DIR || "").trim();
const activeRuntimeStreams = new Set();
// A dedicated server-only signing value shared with Map Gateway. In production
// the runner supplies it as a read-only file, never an env value or browser API.
const mapGatewayAdminSecret = await readMapGatewayAdminSecret();
const foundryPublicUrl = normalizeFoundryPublicUrl(process.env.FOUNDRY_PUBLIC_URL);
const foundryAgentStore = createFoundryAgentStore({ dataRoot: foundryAgentDataDir });
const foundryAgentGateway = createFoundryAgentGateway({
store: foundryAgentStore,
configuredPublicOrigin: foundryPublicUrl?.origin || "",
installerPackagePath: join(root, "server", "assets", "nodedc-foundry-codex-agent-0.1.0.tgz"),
ontologyCoreUrl: process.env.NODEDC_ONTOLOGY_CORE_URL,
ontologyCoreAccessToken: process.env.NODEDC_INTERNAL_ACCESS_TOKEN,
});
function boundedMapGatewayTimeout(value, fallback) {
const parsed = Number.parseInt(String(value || ""), 10);
@ -826,12 +853,14 @@ async function resolveRuntimeMapDataProductBinding(applicationId, pageId, bindin
}
async function resolveRuntimeDataProductReaderToken(applicationId, pageId, bindingId) {
if (!externalDataPlaneInternalUrl || !externalDataPlaneReaderGrantsDir) {
if (!externalDataPlaneInternalUrl || (!externalDataPlaneReaderGrantsDir && !foundryReaderGrantProvisioner.configured)) {
throw applicationError("data_product_runtime_not_configured", 503);
}
// The name is deterministic but opaque to the browser. The runner creates
// this root-owned, read-only file after it provisions an EDP reader grant.
// There is no fallback to one shared Platform token.
const managedToken = await foundryReaderGrantProvisioner.readToken({ applicationId, pageId, bindingId });
if (managedToken) return managedToken;
if (!externalDataPlaneReaderGrantsDir) throw applicationError("data_product_reader_grant_not_found", 403);
// Compatibility fallback for grants issued before Foundry-owned managed
// provisioning. There is no fallback to one shared Platform token.
const grantPath = join(resolve(externalDataPlaneReaderGrantsDir), runtimeTargetGrantKey(applicationId, pageId, bindingId));
let handle;
try {
@ -875,6 +904,15 @@ async function preflightFoundryBindingReaderGrant({ applicationId, pageId, bindi
if (["data_product_reader_grant_not_found", "data_product_reader_grant_invalid"].includes(error?.message)) return false;
throw error;
}
try {
return Boolean(await readRuntimeReaderProduct(readerToken, dataProductId));
} catch (error) {
if (error?.message === "data_product_access_denied") return false;
throw error;
}
}
async function readRuntimeReaderProduct(readerToken, dataProductId) {
let upstream;
try {
upstream = await fetch(new URL("/internal/data-plane/v1/reader/data-products", `${externalDataPlaneInternalUrl}/`), {
@ -884,13 +922,28 @@ async function preflightFoundryBindingReaderGrant({ applicationId, pageId, bindi
} catch {
throw applicationError("data_product_reader_grant_preflight_unavailable", 503);
}
if (upstream.status === 401 || upstream.status === 403) return false;
if (upstream.status === 401 || upstream.status === 403) throw applicationError("data_product_access_denied", 403);
if (!upstream.ok) throw applicationError("data_product_reader_grant_preflight_unavailable", 503);
const payload = await upstream.json().catch(() => null);
const product = Array.isArray(payload?.dataProducts)
? payload.dataProducts.find((candidate) => candidate?.id === dataProductId)
: null;
return Boolean(product && product.active !== false && product.deliveryMode === "snapshot+patch");
return product && product.active !== false && product.deliveryMode === "snapshot+patch" ? product : null;
}
async function inspectFoundryConsumerReaderGrant(target) {
try {
const readerToken = await resolveRuntimeDataProductReaderToken(target.application.id, target.page.id, target.binding.id);
const product = await readRuntimeReaderProduct(readerToken, target.binding.dataProductId);
if (product) return { product, readerGrantAction: "reuse" };
} catch (error) {
if (!new Set(["data_product_reader_grant_not_found", "data_product_access_denied"]).has(error?.message)) throw error;
}
if (!foundryReaderGrantProvisioner.configured) {
throw applicationError("data_product_reader_grant_not_found", 403);
}
const planned = await foundryReaderGrantProvisioner.plan(target);
return { product: planned.product, readerGrantAction: "ensure" };
}
function runtimePointGeometry(value) {
@ -950,13 +1003,88 @@ function sanitizeRuntimeSnapshot(value, binding) {
};
}
function sanitizeRuntimeHistory(value, binding) {
if (!isObject(value) || value.schemaVersion !== "nodedc.data-product.history/v1" || !isObject(value.dataProduct) || !isObject(value.query)) {
throw applicationError("data_product_history_contract_invalid", 502);
}
const query = value.query;
if (
value.dataProduct.id !== binding.dataProductId
|| !isRuntimeVersion(value.dataProduct.version)
|| !isRuntimeTimestamp(value.generatedAt)
|| !isRuntimeTimestamp(query.from)
|| !isRuntimeTimestamp(query.to)
|| Date.parse(query.from) >= Date.parse(query.to)
|| !Number.isInteger(query.resolutionMs)
|| query.resolutionMs < 1000
|| query.resolutionMs > 86_400_000
|| query.order !== "asc"
|| !Array.isArray(query.sourceIds)
|| query.sourceIds.some((sourceId) => !isRuntimeIdentifier(sourceId))
|| JSON.stringify(query.sourceIds) !== JSON.stringify([...new Set(query.sourceIds)].sort())
|| !Array.isArray(value.facts)
|| (value.nextCursor !== undefined && (typeof value.nextCursor !== "string" || !/^[A-Za-z0-9_-]{1,1024}$/.test(value.nextCursor)))
) throw applicationError("data_product_history_contract_invalid", 502);
let previousOrderKey = null;
const facts = value.facts.flatMap((valueFact) => {
if (
!isRuntimeTimestamp(valueFact?.bucketStart)
|| Date.parse(valueFact.bucketStart) < Date.parse(query.from)
|| Date.parse(valueFact.bucketStart) >= Date.parse(query.to)
) throw applicationError("data_product_history_contract_invalid", 502);
const orderKey = `${valueFact.bucketStart}\u0000${valueFact.sourceId}\u0000${valueFact.semanticType}`;
if (previousOrderKey !== null && orderKey <= previousOrderKey) {
throw applicationError("data_product_history_contract_invalid", 502);
}
previousOrderKey = orderKey;
const fact = sanitizeRuntimeFact(valueFact, binding);
return fact ? [{ ...fact, bucketStart: valueFact.bucketStart }] : [];
});
return {
schemaVersion: "nodedc.data-product.history/v1",
dataProduct: { id: binding.dataProductId, version: value.dataProduct.version },
generatedAt: value.generatedAt,
query: {
from: query.from,
to: query.to,
resolutionMs: query.resolutionMs,
sourceIds: [...query.sourceIds],
order: "asc",
},
facts,
...(value.nextCursor ? { nextCursor: value.nextCursor } : {}),
};
}
function sanitizeRuntimePatch(value, binding) {
if (!isObject(value) || value.schemaVersion !== "nodedc.data-product.patch/v1" || !isObject(value.dataProduct)) return null;
if (value.dataProduct.id !== binding.dataProductId || !isRuntimeVersion(value.dataProduct.version) || !isRuntimeCursor(value.cursor) || !isRuntimeCursor(value.previousCursor) || !isRuntimeTimestamp(value.emittedAt) || !Array.isArray(value.operations)) return null;
const operations = value.operations.flatMap((operation) => {
if (!isObject(operation) || operation.op !== "upsert") return [];
if (!isObject(operation)) return [];
if (operation.op === "upsert") {
const fact = sanitizeRuntimeFact(operation.fact, binding);
return fact ? [{ op: "upsert", fact }] : [];
}
// Current EDP v1 emits only upserts. Foundry already understands the
// canonical removal shape so a future versioned product can revoke a
// subject without turning a transient stream failure into deletion.
if (
operation.op === "remove"
&& isRuntimeIdentifier(operation.sourceId)
&& isRuntimeIdentifier(operation.semanticType)
&& binding.semanticTypes.includes(operation.semanticType)
&& isRuntimeTimestamp(operation.removedAt)
&& ["tombstone", "revoked"].includes(operation.reason)
) {
return [{
op: "remove",
sourceId: operation.sourceId,
semanticType: operation.semanticType,
removedAt: operation.removedAt,
reason: operation.reason,
}];
}
return [];
});
return {
schemaVersion: "nodedc.data-product.patch/v1",
@ -968,6 +1096,15 @@ function sanitizeRuntimePatch(value, binding) {
};
}
function resolveDataProductConsumerPolicy(product) {
if (dataProductConsumerPolicyRegistry?.schemaVersion !== "nodedc.foundry.data-product-consumer-policies/v1") {
throw applicationError("data_product_consumer_policy_registry_invalid", 500);
}
return dataProductConsumerPolicyRegistry.policies?.find((policy) => (
policy?.dataProductId === product.id && policy?.productVersion === product.version
)) || null;
}
function runtimeUpstreamUrl(dataProductId, resource, after) {
const target = new URL(`/internal/data-plane/v1/data-products/${encodeURIComponent(dataProductId)}/${resource}`, `${externalDataPlaneInternalUrl}/`);
if (after) target.searchParams.set("after", after);
@ -1019,25 +1156,127 @@ async function writeSse(response, { event, data, id }) {
return writeRuntimeStreamChunk(response, frame);
}
function parseSseBlock(block) {
const fields = { event: "message", id: "", data: [] };
for (const line of block.split("\n")) {
if (!line || line.startsWith(":")) continue;
const separator = line.indexOf(":");
const field = separator === -1 ? line : line.slice(0, separator);
const value = separator === -1 ? "" : line.slice(separator + 1).replace(/^ /, "");
if (field === "event") fields.event = value;
if (field === "id") fields.id = value;
if (field === "data") fields.data.push(value);
}
return { event: fields.event, id: fields.id, data: fields.data.join("\n") };
function openRuntimeDataProductConsumerStream({ url, token, signal }) {
return new Promise((resolveStream, rejectStream) => {
const transport = url.protocol === "https:" ? httpsRequest : url.protocol === "http:" ? httpRequest : null;
if (!transport) return rejectStream(applicationError("data_product_runtime_url_invalid", 500));
let incoming = null;
let settled = false;
const request = transport(url, {
method: "GET",
headers: {
authorization: `Bearer ${token}`,
accept: "text/event-stream",
connection: "close",
},
});
const abort = () => {
incoming?.destroy();
request.destroy();
};
signal.addEventListener("abort", abort, { once: true });
request.once("response", (response) => {
incoming = response;
settled = true;
response.once("close", () => signal.removeEventListener("abort", abort));
resolveStream({
status: response.statusCode || 500,
ok: (response.statusCode || 500) >= 200 && (response.statusCode || 500) < 300,
headers: new Headers(response.headers),
body: Readable.toWeb(response),
});
});
request.once("error", (error) => {
signal.removeEventListener("abort", abort);
if (!settled) rejectStream(signal.aborted ? applicationError("data_product_stream_closed", 503) : error);
});
if (signal.aborted) abort();
else request.end();
});
}
const dataProductConsumerManager = createFoundryDataProductConsumerManager({
stateDir: foundryDataProductConsumersDir,
dataPlaneUrl: externalDataPlaneInternalUrl,
resolveTarget: resolveRuntimeMapDataProductBinding,
readReaderToken: resolveRuntimeDataProductReaderToken,
inspectReaderGrant: inspectFoundryConsumerReaderGrant,
ensureReaderGrant: (target) => foundryReaderGrantProvisioner.ensure(target),
resolvePolicy: resolveDataProductConsumerPolicy,
sanitizeSnapshot: sanitizeRuntimeSnapshot,
sanitizePatch: sanitizeRuntimePatch,
openStream: openRuntimeDataProductConsumerStream,
});
await dataProductConsumerManager.resumePersisted();
async function proxyRuntimeDataProductSnapshot(response, target) {
json(response, 200, await dataProductConsumerManager.snapshot(target));
}
async function readRuntimeJsonResponse(upstream, maxBytes = 64 * 1024 * 1024) {
const contentLength = Number(upstream.headers.get("content-length") || 0);
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
throw applicationError("data_product_runtime_response_too_large", 502);
}
if (!upstream.body) throw applicationError("data_product_runtime_invalid_response", 502);
const reader = upstream.body.getReader();
const chunks = [];
let bytes = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
bytes += value.byteLength;
if (bytes > maxBytes) {
await reader.cancel();
throw applicationError("data_product_runtime_response_too_large", 502);
}
chunks.push(Buffer.from(value));
}
return JSON.parse(Buffer.concat(chunks, bytes).toString("utf8"));
} catch (error) {
if (error?.statusCode) throw error;
throw applicationError("data_product_runtime_invalid_response", 502);
}
}
function runtimeHistoryQuery(url) {
const from = String(url.searchParams.get("from") || "");
const to = String(url.searchParams.get("to") || "");
const resolutionMs = Number(url.searchParams.get("resolutionMs") || 60_000);
const limit = Number(url.searchParams.get("limit") || 1000);
const sourceIds = [...new Set(String(url.searchParams.get("sourceIds") || "").split(",").map((value) => value.trim()).filter(Boolean))];
const cursor = String(url.searchParams.get("cursor") || "");
if (
!isRuntimeTimestamp(from)
|| !isRuntimeTimestamp(to)
|| Date.parse(from) >= Date.parse(to)
|| !Number.isInteger(resolutionMs)
|| resolutionMs < 1000
|| resolutionMs > 86_400_000
|| !Number.isInteger(limit)
|| limit < 1
|| limit > 5000
|| sourceIds.length > 1000
|| sourceIds.some((sourceId) => !isRuntimeIdentifier(sourceId))
|| (cursor && !/^[A-Za-z0-9_-]{1,1024}$/.test(cursor))
) throw applicationError("data_product_history_query_invalid", 400);
return { from, to, resolutionMs, limit, sourceIds, cursor };
}
async function proxyRuntimeDataProductHistory(response, url, target) {
const readerToken = await resolveRuntimeDataProductReaderToken(target.application.id, target.page.id, target.binding.id);
const query = runtimeHistoryQuery(url);
const upstreamUrl = runtimeUpstreamUrl(target.binding.dataProductId, "history");
upstreamUrl.searchParams.set("from", query.from);
upstreamUrl.searchParams.set("to", query.to);
upstreamUrl.searchParams.set("resolutionMs", String(query.resolutionMs));
upstreamUrl.searchParams.set("limit", String(query.limit));
if (query.sourceIds.length) upstreamUrl.searchParams.set("sourceIds", query.sourceIds.join(","));
if (query.cursor) upstreamUrl.searchParams.set("cursor", query.cursor);
let upstream;
try {
upstream = await fetch(runtimeUpstreamUrl(target.binding.dataProductId, "snapshot"), {
upstream = await fetch(upstreamUrl, {
headers: { authorization: `Bearer ${readerToken}`, accept: "application/json" },
signal: AbortSignal.timeout(10_000),
});
@ -1045,12 +1284,11 @@ async function proxyRuntimeDataProductSnapshot(response, target) {
throw applicationError("data_product_runtime_unavailable", 503);
}
if (!upstream.ok) throw runtimeUpstreamFailure(upstream.status);
const payload = await upstream.json().catch(() => null);
json(response, 200, sanitizeRuntimeSnapshot(payload, target.binding));
const payload = await readRuntimeJsonResponse(upstream);
json(response, 200, sanitizeRuntimeHistory(payload, target.binding));
}
async function proxyRuntimeDataProductStream(request, response, url, target) {
const readerToken = await resolveRuntimeDataProductReaderToken(target.application.id, target.page.id, target.binding.id);
const requestedAfter = String(url.searchParams.get("after") || "");
const lastEventId = String(request.headers["last-event-id"] || "");
if (requestedAfter && !isRuntimeCursor(requestedAfter)) throw applicationError("stream_cursor_invalid");
@ -1060,22 +1298,39 @@ async function proxyRuntimeDataProductStream(request, response, url, target) {
// cursor; using it avoids replaying a stale query-string cursor forever.
const after = lastEventId || requestedAfter;
const controller = new AbortController();
const abort = () => controller.abort();
let lease = null;
let heartbeat = null;
let headersReady = false;
const bufferedEvents = [];
let pendingEventCount = 0;
let writeChain = Promise.resolve(true);
const enqueue = (event) => {
if (!headersReady) {
bufferedEvents.push(event);
return;
}
pendingEventCount += 1;
if (pendingEventCount > 256) {
abort();
response.destroy();
return;
}
writeChain = writeChain
.then((open) => open && writeSse(response, event))
.catch(() => false)
.finally(() => { pendingEventCount = Math.max(0, pendingEventCount - 1); });
};
const abort = () => {
if (!controller.signal.aborted) controller.abort();
lease?.release();
};
const activeStream = { controller, response };
activeRuntimeStreams.add(activeStream);
request.once("aborted", abort);
response.once("close", abort);
let upstream;
request.socket?.once("close", abort);
try {
upstream = await fetch(runtimeUpstreamUrl(target.binding.dataProductId, "stream", after), {
headers: {
authorization: `Bearer ${readerToken}`,
accept: "text/event-stream",
...(lastEventId ? { "last-event-id": lastEventId } : {}),
},
signal: controller.signal,
});
if (upstream.status === 409) {
lease = await dataProductConsumerManager.subscribe({ ...target, after }, enqueue);
response.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-store, no-transform",
@ -1083,71 +1338,31 @@ async function proxyRuntimeDataProductStream(request, response, url, target) {
"x-accel-buffering": "no",
vary: "Cookie",
});
headersReady = true;
if (lease.resyncRequired) {
await writeSse(response, {
event: "nodedc.data-product.resync-required.v1",
data: { schemaVersion: "nodedc.data-product.resync-required/v1", dataProductId: target.binding.dataProductId },
});
if (!response.destroyed && !response.writableEnded) response.end();
response.end();
return;
}
if (!upstream.ok || !upstream.body || !String(upstream.headers.get("content-type") || "").startsWith("text/event-stream")) {
throw runtimeUpstreamFailure(upstream.status);
}
response.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-store, no-transform",
connection: "keep-alive",
"x-accel-buffering": "no",
vary: "Cookie",
for (const event of bufferedEvents.splice(0)) enqueue(event);
heartbeat = setInterval(() => {
writeChain = writeChain.then((open) => open && writeRuntimeStreamChunk(response, ": keepalive\n\n")).catch(() => false);
}, 15_000);
heartbeat.unref?.();
await new Promise((resolve) => {
if (controller.signal.aborted || response.destroyed) return resolve();
controller.signal.addEventListener("abort", resolve, { once: true });
});
const reader = upstream.body.getReader();
const decoder = new TextDecoder();
let pending = "";
try {
streamLoop: while (!response.destroyed && !controller.signal.aborted) {
const next = await reader.read();
if (next.done) break;
pending += decoder.decode(next.value, { stream: true }).replace(/\r\n/g, "\n");
let separator;
while ((separator = pending.indexOf("\n\n")) !== -1) {
const block = pending.slice(0, separator);
pending = pending.slice(separator + 2);
const event = parseSseBlock(block);
if (event.event === "nodedc.data-product.patch.v1") {
const patch = sanitizeRuntimePatch(JSON.parse(event.data), target.binding);
if (patch?.operations.length && !(await writeSse(response, { event: event.event, id: patch.cursor, data: patch }))) {
abort();
break streamLoop;
}
} else if (event.event === "nodedc.data-product.ready.v1") {
let ready = null;
try { ready = JSON.parse(event.data); } catch { ready = null; }
if (isObject(ready) && ready.schemaVersion === "nodedc.data-product.ready/v1" && ready.dataProductId === target.binding.dataProductId && isRuntimeCursor(ready.cursor) && isRuntimeTimestamp(ready.emittedAt)) {
if (!(await writeSse(response, { event: event.event, data: { schemaVersion: ready.schemaVersion, dataProductId: ready.dataProductId, cursor: ready.cursor, emittedAt: ready.emittedAt } }))) {
abort();
break streamLoop;
}
}
} else if (!event.data) {
// Preserve a heartbeat without forwarding unknown upstream fields.
if (!(await writeRuntimeStreamChunk(response, ": keepalive\n\n"))) {
abort();
break streamLoop;
}
}
}
}
} finally {
if (controller.signal.aborted) await reader.cancel().catch(() => undefined);
reader.releaseLock();
}
} catch (error) {
if (!response.headersSent && !controller.signal.aborted) throw error;
} finally {
if (heartbeat) clearInterval(heartbeat);
abort();
activeRuntimeStreams.delete(activeStream);
request.off("aborted", abort);
response.off("close", abort);
request.socket?.off("close", abort);
if (!response.writableEnded && !response.destroyed) response.end();
}
}
@ -1334,14 +1549,25 @@ const foundryMcpOperations = {
const config = foundryMcpConfig();
return {
module: "NDC Module Foundry",
schemaVersion: "nodedc.module-foundry.mcp.v0.1",
schemaVersion: "nodedc.module-foundry.mcp.v0.2",
status: config.capabilitySecret && config.mcpUrl ? "ready" : "configuration_required",
canonicalPageLibrary: "read-only",
applicationInstances: "read-write",
destructiveApplicationDelete: false,
supportedPageTemplates: pageRegistry.templates.map((template) => ({ id: template.id, version: template.version, title: template.title })),
supportedActions: ["application.create", "application.metadata.update", "page-instance.create", "map-pin.upsert"],
persistence: { runtimeDir: "persistent runtime volume required", idempotentWrites: true },
supportedActions: [
"application.create",
"application.metadata.update",
"page-instance.create",
"map-pin.upsert",
"map-data-product.upsert",
"map-data-product-consumer.plan",
"map-data-product-consumer.apply",
"map-data-product-consumer.status",
"map-data-product-consumer.accept",
"map-data-product-consumer.rollback",
],
persistence: { runtimeDir: "persistent runtime volume required", idempotentWrites: true, dataProductConsumerState: true },
};
},
async listApplications() {
@ -1468,6 +1694,31 @@ const foundryMcpOperations = {
},
});
},
async planMapDataProductConsumer(input) {
return dataProductConsumerManager.plan(input);
},
async applyMapDataProductConsumer(input, actor) {
return executeFoundryMcpWrite({
tool: "foundry_apply_map_data_product_consumer",
actor,
input,
action: () => dataProductConsumerManager.apply(input),
});
},
async getMapDataProductConsumerStatus(input) {
return dataProductConsumerManager.status(input);
},
async acceptMapDataProductConsumer(input) {
return dataProductConsumerManager.accept(input);
},
async rollbackMapDataProductConsumer(input, actor) {
return executeFoundryMcpWrite({
tool: "foundry_rollback_map_data_product_consumer",
actor,
input,
action: () => dataProductConsumerManager.rollback(input),
});
},
};
async function readJsonBody(request, maxBytes = 2 * 1024 * 1024) {
@ -1762,6 +2013,9 @@ const server = createServer(async (request, response) => {
try {
const url = new URL(request.url || "/", `http://${request.headers.host || "127.0.0.1"}`);
if (await foundryAgentGateway.handlePublicRequest(request, response, url)) return;
if (await foundryAgentGateway.handleOntologyMcp(request, response, url)) return;
if (url.pathname === "/auth/nodedc/handoff" && request.method === "GET") {
await foundryAuth.handleLauncherHandoff(request, response, url);
return;
@ -1781,10 +2035,16 @@ const server = createServer(async (request, response) => {
configured: Boolean(config.capabilitySecret && config.mcpUrl),
entitlementConfigured: Boolean(config.internalAccessToken),
},
codexAgent: foundryAgentGateway.health(),
dataProductBindingApi: {
configured: Boolean(foundryBindingGrantsDir && externalDataPlaneInternalUrl && externalDataPlaneReaderGrantsDir),
auth: "scoped-workload-grant",
},
dataProductConsumerProvisioner: {
configured: foundryReaderGrantProvisioner.configured,
auth: "dedicated-ed25519-service-identity",
sourceScope: "external-data-plane-resolved",
},
});
return;
}
@ -1793,6 +2053,7 @@ const server = createServer(async (request, response) => {
await handleFoundryMcpRequest(request, response, {
getConfig: foundryMcpConfig,
operations: foundryMcpOperations,
authenticateAgentToken: foundryAgentGateway.authenticateFoundryToken,
});
return;
}
@ -1816,6 +2077,16 @@ const server = createServer(async (request, response) => {
return;
}
const foundryUserProfile = foundryAuth.currentUserProfile(request)
|| (!foundryAuth.authRequired ? {
id: "local_foundry_user",
email: "local-foundry@nodedc.local",
displayName: "Local Foundry User",
avatarUrl: null,
initials: "LF",
} : null);
if (await foundryAgentGateway.handleManagementRequest(request, response, url, foundryUserProfile)) return;
if (url.pathname === "/api/session/profile" && request.method === "GET") {
const access = foundryAuth.currentUserAccess(request);
json(response, 200, { user: foundryAuth.currentUserProfile(request), profileUrl: foundryAuth.profileUrl, access: { role: access.role } });
@ -1858,11 +2129,12 @@ const server = createServer(async (request, response) => {
return;
}
const runtimeDataProductMatch = url.pathname.match(/^\/api\/applications\/([0-9a-f-]{36})\/pages\/([a-z0-9-]+)\/data-bindings\/([A-Za-z0-9._:-]{1,160})\/(snapshot|stream)$/i);
const runtimeDataProductMatch = url.pathname.match(/^\/api\/applications\/([0-9a-f-]{36})\/pages\/([a-z0-9-]+)\/data-bindings\/([A-Za-z0-9._:-]{1,160})\/(snapshot|history|stream)$/i);
if (runtimeDataProductMatch && request.method === "GET") {
const [, applicationId, pageId, bindingId, resource] = runtimeDataProductMatch;
const target = await resolveRuntimeMapDataProductBinding(applicationId, pageId, bindingId);
if (resource === "snapshot") await proxyRuntimeDataProductSnapshot(response, target);
else if (resource === "history") await proxyRuntimeDataProductHistory(response, url, target);
else await proxyRuntimeDataProductStream(request, response, url, target);
return;
}
@ -2125,11 +2397,13 @@ server.on("connection", (socket) => {
let shutdownPromise = null;
function shutdownServer(signal) {
if (shutdownPromise) return shutdownPromise;
shutdownPromise = new Promise((resolveShutdown) => {
shutdownPromise = (async () => {
for (const activeStream of activeRuntimeStreams) {
activeStream.controller.abort();
activeStream.response.destroy();
}
await dataProductConsumerManager.shutdown();
await new Promise((resolveShutdown) => {
const timeout = setTimeout(() => {
for (const socket of serverSockets) socket.destroy();
}, 5_000);
@ -2142,11 +2416,20 @@ function shutdownServer(signal) {
resolveShutdown();
});
});
})();
return shutdownPromise;
}
process.once("SIGTERM", () => { void shutdownServer("SIGTERM"); });
process.once("SIGINT", () => { void shutdownServer("SIGINT"); });
async function shutdownAndExit(signal) {
await shutdownServer(signal);
// A canceled upstream fetch may leave an idle keep-alive socket owned by
// Node's global HTTP dispatcher. All Foundry state and inbound sockets are
// already closed at this point, so finish the container stop deterministically.
process.exit(process.exitCode || 0);
}
process.once("SIGTERM", () => { void shutdownAndExit("SIGTERM"); });
process.once("SIGINT", () => { void shutdownAndExit("SIGINT"); });
server.listen(port, host, () => {
console.log(`NDC Module Foundry: http://${host}:${port}`);

View File

@ -0,0 +1,271 @@
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
const MCP_PROTOCOL_VERSION = "2025-06-18";
const MAX_BODY_BYTES = 1024 * 1024;
function sendJson(response, statusCode, payload, headers = {}) {
response.writeHead(statusCode, {
"cache-control": "no-store",
"content-type": "application/json; charset=utf-8",
...headers,
});
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);
}
if (!chunks.length) return {};
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}
function bearerToken(request) {
const match = String(request.headers.authorization || "").match(/^Bearer\s+(.+)$/i);
return match?.[1]?.trim() || "";
}
function publicOrigin(request, configuredOrigin) {
if (configuredOrigin) return String(configuredOrigin).replace(/\/+$/, "");
const forwardedProto = String(request.headers["x-forwarded-proto"] || "").split(",")[0].trim();
const protocol = forwardedProto === "https" ? "https" : "http";
const forwardedHost = String(request.headers["x-forwarded-host"] || "").split(",")[0].trim();
const host = forwardedHost || String(request.headers.host || "127.0.0.1");
return `${protocol}://${host}`.replace(/\/+$/, "");
}
function actorFromProfile(profile) {
const ownerId = String(profile?.id || "").trim();
const ownerKey = String(profile?.email || ownerId).trim().toLowerCase();
if (!ownerId || !ownerKey) throw new Error("foundry_agent_owner_required");
return { ownerId, ownerKey };
}
function errorCode(error) {
return String(error?.message || error || "foundry_agent_operation_failed").slice(0, 160);
}
function managementMatch(pathname) {
const match = pathname.match(/^\/api\/foundry-agent-api\/agents(?:\/([^/]+)(?:\/(revoke|setup-code))?)?$/);
if (!match) return null;
return { agentId: match[1] ? decodeURIComponent(match[1]) : null, action: match[2] || null };
}
export function createFoundryAgentGateway({
store,
configuredPublicOrigin = "",
installerPackagePath,
ontologyCoreUrl = "",
ontologyCoreAccessToken = "",
}) {
if (!store) throw new Error("foundry_agent_store_required");
const ontologyUrl = String(ontologyCoreUrl || "").trim().replace(/\/+$/, "");
const ontologyToken = String(ontologyCoreAccessToken || "").trim();
async function handleInstaller(request, response, url) {
if (url.pathname !== "/api/foundry-agent/install/nodedc-foundry-codex-agent-setup.tgz") return false;
if (request.method !== "GET") {
response.writeHead(405, { allow: "GET" });
response.end();
return true;
}
try {
const info = await stat(installerPackagePath);
response.writeHead(200, {
"cache-control": "no-store",
"content-disposition": 'attachment; filename="nodedc-foundry-codex-agent-setup.tgz"',
"content-length": info.size,
"content-type": "application/octet-stream",
});
createReadStream(installerPackagePath).pipe(response);
} catch {
sendJson(response, 404, { ok: false, error: "foundry_agent_installer_not_found" });
}
return true;
}
async function handleSetupRedeem(request, response, url) {
if (url.pathname !== "/api/foundry-agent/v1/setup/redeem") return false;
if (request.method !== "POST") {
response.writeHead(405, { allow: "POST" });
response.end();
return true;
}
try {
const input = await readJsonBody(request, 16 * 1024);
const redeemed = await store.redeemSetupCode(input.code, input.deviceName);
const origin = publicOrigin(request, configuredPublicOrigin);
sendJson(response, 200, {
ok: true,
agent: redeemed.agent,
device: redeemed.device,
foundry: {
serverName: "nodedc_module_foundry",
mcpUrl: `${origin}/api/mcp`,
token: redeemed.foundryToken,
},
ontology: {
serverName: "nodedc_ontology",
mcpUrl: `${origin}/api/ontology-mcp`,
token: redeemed.ontologyToken,
readOnly: true,
},
});
} catch (error) {
const code = errorCode(error);
sendJson(response, code === "payload_too_large" ? 413 : 400, { ok: false, error: code });
}
return true;
}
async function handleDoctor(request, response, url) {
if (url.pathname !== "/api/foundry-agent/v1/doctor") return false;
if (request.method !== "GET") {
response.writeHead(405, { allow: "GET" });
response.end();
return true;
}
const session = await store.authenticateFoundryToken(bearerToken(request), { check: true });
if (!session) {
sendJson(response, 401, { ok: false, error: "foundry_agent_unauthorized" });
return true;
}
sendJson(response, 200, {
ok: true,
agent: session.agent,
device: session.device,
foundryMcpUrl: `${publicOrigin(request, configuredPublicOrigin)}/api/mcp`,
ontologyMcpUrl: `${publicOrigin(request, configuredPublicOrigin)}/api/ontology-mcp`,
ontologyReadOnly: true,
});
return true;
}
async function handlePublicRequest(request, response, url) {
return await handleInstaller(request, response, url)
|| await handleSetupRedeem(request, response, url)
|| await handleDoctor(request, response, url);
}
async function handleManagementRequest(request, response, url, profile) {
const match = managementMatch(url.pathname);
if (!match) return false;
const owner = actorFromProfile(profile);
try {
if (!match.agentId && request.method === "GET") {
sendJson(response, 200, { ok: true, agents: await store.listAgents(owner.ownerKey) });
return true;
}
if (!match.agentId && request.method === "POST") {
const input = await readJsonBody(request, 640 * 1024);
const agent = await store.createAgent({ ...owner, name: input.name, avatarUrl: input.avatarUrl });
sendJson(response, 201, { ok: true, agent });
return true;
}
if (match.agentId && !match.action && request.method === "PATCH") {
const input = await readJsonBody(request, 640 * 1024);
const agent = await store.updateAgent(owner.ownerKey, match.agentId, input);
sendJson(response, 200, { ok: true, agent });
return true;
}
if (match.agentId && match.action === "revoke" && request.method === "POST") {
const agent = await store.revokeAgent(owner.ownerKey, match.agentId);
sendJson(response, 200, { ok: true, agent });
return true;
}
if (match.agentId && match.action === "setup-code" && request.method === "POST") {
const setup = await store.issueSetupCode(owner.ownerKey, match.agentId);
const origin = publicOrigin(request, configuredPublicOrigin);
const packageUrl = `${origin}/api/foundry-agent/install/nodedc-foundry-codex-agent-setup.tgz`;
const command = `npm exec --yes --package=${packageUrl} nodedc-foundry-codex-agent -- --server ${origin} --code ${setup.code}`;
sendJson(response, 201, { ok: true, install: { command, expiresAt: setup.expiresAt } });
return true;
}
response.writeHead(405, { allow: "GET, POST, PATCH" });
response.end();
} catch (error) {
const code = errorCode(error);
const status = code === "foundry_agent_not_found" ? 404 : code === "payload_too_large" ? 413 : 400;
sendJson(response, status, { ok: false, error: code });
}
return true;
}
async function authenticateFoundryToken(token, options) {
const session = await store.authenticateFoundryToken(token, options);
return session?.actor || null;
}
async function handleOntologyMcp(request, response, url) {
if (url.pathname !== "/api/ontology-mcp") return false;
if (request.method !== "POST") {
response.writeHead(405, { allow: "POST" });
response.end();
return true;
}
const session = await store.authenticateOntologyToken(bearerToken(request), { check: true });
if (!session) {
sendJson(response, 401, { ok: false, error: "ontology_agent_unauthorized" });
return true;
}
if (!ontologyUrl || !ontologyToken) {
sendJson(response, 503, { ok: false, error: "ontology_agent_proxy_not_configured" });
return true;
}
let input;
try {
input = await readJsonBody(request);
} catch (error) {
sendJson(response, error?.message === "payload_too_large" ? 413 : 400, { ok: false, error: errorCode(error) });
return true;
}
let upstream;
try {
upstream = await fetch(`${ontologyUrl}/mcp`, {
method: "POST",
redirect: "manual",
headers: {
accept: String(request.headers.accept || "application/json, text/event-stream"),
authorization: `Bearer ${ontologyToken}`,
"content-type": "application/json",
"mcp-protocol-version": String(request.headers["mcp-protocol-version"] || MCP_PROTOCOL_VERSION),
},
body: JSON.stringify(input),
signal: AbortSignal.timeout(60_000),
});
} catch {
sendJson(response, 503, { ok: false, error: "ontology_agent_proxy_unavailable" });
return true;
}
const payload = await upstream.text();
response.statusCode = upstream.status;
response.setHeader("cache-control", "no-store");
response.setHeader("content-type", upstream.headers.get("content-type") || "application/json; charset=utf-8");
for (const header of ["mcp-protocol-version", "mcp-session-id", "vary"]) {
const value = upstream.headers.get(header);
if (value) response.setHeader(header, value);
}
response.end(payload);
return true;
}
return {
handlePublicRequest,
handleManagementRequest,
handleOntologyMcp,
authenticateFoundryToken,
health() {
return {
configured: Boolean(installerPackagePath),
ontologyProxyConfigured: Boolean(ontologyUrl && ontologyToken),
tokenLifecycle: "durable-until-revoke",
};
},
};
}

View File

@ -0,0 +1,155 @@
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { createFoundryAgentGateway } from "./foundry-agent-gateway.mjs";
import { createFoundryAgentStore } from "./foundry-agent-store.mjs";
import { handleFoundryMcpRequest } from "./foundry-mcp.mjs";
async function listen(server) {
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
return `http://127.0.0.1:${address.port}`;
}
async function close(server) {
await new Promise((resolve) => server.close(resolve));
}
async function json(response) {
return await response.json();
}
test("one setup command provisions independent Foundry and Ontology MCP transports", async () => {
const dataRoot = await mkdtemp(path.join(os.tmpdir(), "nodedc-foundry-agent-gateway-"));
const installerPath = path.join(dataRoot, "installer.tgz");
await writeFile(installerPath, "installer", "utf8");
let ontologyAuthorization = "";
const ontologyServer = createServer(async (request, response) => {
ontologyAuthorization = String(request.headers.authorization || "");
const chunks = [];
for await (const chunk of request) chunks.push(chunk);
const message = JSON.parse(Buffer.concat(chunks).toString("utf8"));
const result = message.method === "tools/list"
? { tools: [{ name: "ontology_status" }, { name: "ontology_search" }] }
: {
protocolVersion: "2025-06-18",
capabilities: { tools: { listChanged: false } },
serverInfo: { name: "nodedc-ontology-core", version: "test" },
};
response.writeHead(200, { "content-type": "application/json", "mcp-protocol-version": "2025-06-18" });
response.end(JSON.stringify({ jsonrpc: "2.0", id: message.id, result }));
});
const ontologyBase = await listen(ontologyServer);
const store = createFoundryAgentStore({ dataRoot: path.join(dataRoot, "store") });
const gateway = createFoundryAgentGateway({
store,
installerPackagePath: installerPath,
ontologyCoreUrl: ontologyBase,
ontologyCoreAccessToken: "platform-internal-token",
});
const operations = {
status: async () => ({ ok: true }),
listApplications: async () => ({ applications: [] }),
getApplication: async () => ({ application: null }),
};
const foundryServer = createServer(async (request, response) => {
const url = new URL(request.url || "/", `http://${request.headers.host}`);
if (await gateway.handlePublicRequest(request, response, url)) return;
if (await gateway.handleOntologyMcp(request, response, url)) return;
if (url.pathname === "/api/mcp") {
await handleFoundryMcpRequest(request, response, {
getConfig: () => ({ capabilitySecret: "", mcpAllowedOrigins: [] }),
operations,
authenticateAgentToken: gateway.authenticateFoundryToken,
});
return;
}
if (await gateway.handleManagementRequest(request, response, url, {
id: "user_root",
email: "dcctouch@gmail.com",
displayName: "DC SUDO",
})) return;
response.writeHead(404);
response.end();
});
const foundryBase = await listen(foundryServer);
try {
const createdResponse = await fetch(`${foundryBase}/api/foundry-agent-api/agents`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name: "Foundry Codex" }),
});
assert.equal(createdResponse.status, 201);
const created = await json(createdResponse);
const setupResponse = await fetch(`${foundryBase}/api/foundry-agent-api/agents/${created.agent.id}/setup-code`, { method: "POST" });
assert.equal(setupResponse.status, 201);
const setup = await json(setupResponse);
assert.match(setup.install.command, /nodedc-foundry-codex-agent/);
const code = setup.install.command.match(/--code\s+(fnd_setup_[A-Za-z0-9_-]+)/)?.[1];
assert.ok(code);
const redeemResponse = await fetch(`${foundryBase}/api/foundry-agent/v1/setup/redeem`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ code, deviceName: "Test Codex" }),
});
assert.equal(redeemResponse.status, 200);
const redeemed = await json(redeemResponse);
assert.equal(redeemed.foundry.serverName, "nodedc_module_foundry");
assert.equal(redeemed.ontology.serverName, "nodedc_ontology");
assert.equal(redeemed.ontology.readOnly, true);
assert.notEqual(redeemed.foundry.token, redeemed.ontology.token);
const foundryListResponse = await fetch(redeemed.foundry.mcpUrl, {
method: "POST",
headers: { authorization: `Bearer ${redeemed.foundry.token}`, "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }),
});
assert.equal(foundryListResponse.status, 200);
const foundryList = await json(foundryListResponse);
assert.ok(foundryList.result.tools.some((tool) => tool.name === "foundry_create_application"));
const crossTokenResponse = await fetch(redeemed.ontology.mcpUrl, {
method: "POST",
headers: { authorization: `Bearer ${redeemed.foundry.token}`, "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }),
});
assert.equal(crossTokenResponse.status, 401);
const ontologyListResponse = await fetch(redeemed.ontology.mcpUrl, {
method: "POST",
headers: { authorization: `Bearer ${redeemed.ontology.token}`, "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 3, method: "tools/list", params: {} }),
});
assert.equal(ontologyListResponse.status, 200);
const ontologyList = await json(ontologyListResponse);
assert.deepEqual(ontologyList.result.tools.map((tool) => tool.name), ["ontology_status", "ontology_search"]);
assert.equal(ontologyAuthorization, "Bearer platform-internal-token");
const revokeResponse = await fetch(`${foundryBase}/api/foundry-agent-api/agents/${created.agent.id}/revoke`, { method: "POST" });
assert.equal(revokeResponse.status, 200);
const revokedFoundryResponse = await fetch(redeemed.foundry.mcpUrl, {
method: "POST",
headers: { authorization: `Bearer ${redeemed.foundry.token}`, "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 4, method: "tools/list", params: {} }),
});
const revokedOntologyResponse = await fetch(redeemed.ontology.mcpUrl, {
method: "POST",
headers: { authorization: `Bearer ${redeemed.ontology.token}`, "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 5, method: "tools/list", params: {} }),
});
assert.equal(revokedFoundryResponse.status, 401);
assert.equal(revokedOntologyResponse.status, 401);
} finally {
await close(foundryServer);
await close(ontologyServer);
await rm(dataRoot, { recursive: true, force: true });
}
});

View File

@ -0,0 +1,349 @@
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { join } from "node:path";
const STORE_VERSION = 1;
const SETUP_TTL_MS = 15 * 60_000;
const NO_STORE_WRITE = Symbol("no-store-write");
function nowIso(now) {
return new Date(now()).toISOString();
}
function cleanString(value, max = 240) {
return String(value || "").trim().slice(0, max);
}
function cleanId(value, max = 180) {
return cleanString(value, max).replace(/[^A-Za-z0-9_.:@-]/g, "");
}
function cleanAvatarUrl(value) {
const avatarUrl = cleanString(value, 550_000);
if (!avatarUrl) return "";
if (/^https:\/\//i.test(avatarUrl) || /^data:image\/(?:png|jpeg|webp|gif);base64,/i.test(avatarUrl)) return avatarUrl;
return "";
}
function digest(value) {
return createHash("sha256").update(String(value || ""), "utf8").digest("hex");
}
function digestMatches(actual, expected) {
const actualBuffer = Buffer.from(String(actual || ""), "hex");
const expectedBuffer = Buffer.from(String(expected || ""), "hex");
return actualBuffer.length === expectedBuffer.length
&& actualBuffer.length > 0
&& timingSafeEqual(actualBuffer, expectedBuffer);
}
function randomToken(prefix, bytes = 32) {
return `${prefix}_${randomBytes(bytes).toString("base64url")}`;
}
function emptyStore() {
return { schemaVersion: STORE_VERSION, agents: [], setupCodes: [] };
}
function normalizeDevice(value) {
const id = cleanId(value?.id);
const foundryTokenHash = cleanString(value?.foundryTokenHash, 128);
const ontologyTokenHash = cleanString(value?.ontologyTokenHash, 128);
if (!id || !foundryTokenHash || !ontologyTokenHash) return null;
return {
id,
name: cleanString(value?.name, 160) || "Codex Desktop",
foundryTokenHash,
ontologyTokenHash,
createdAt: cleanString(value?.createdAt, 80),
lastUsedAt: cleanString(value?.lastUsedAt, 80) || null,
lastCheckAt: cleanString(value?.lastCheckAt, 80) || null,
revokedAt: cleanString(value?.revokedAt, 80) || null,
};
}
function normalizeAgent(value, now) {
const id = cleanId(value?.id);
const ownerId = cleanId(value?.ownerId);
const ownerKey = cleanString(value?.ownerKey, 320);
if (!id || !ownerId || !ownerKey) return null;
return {
id,
ownerId,
ownerKey,
name: cleanString(value?.name, 160) || "Foundry Codex",
avatarUrl: cleanAvatarUrl(value?.avatarUrl),
status: value?.status === "revoked" ? "revoked" : "active",
devices: Array.isArray(value?.devices) ? value.devices.map(normalizeDevice).filter(Boolean) : [],
createdAt: cleanString(value?.createdAt, 80) || nowIso(now),
updatedAt: cleanString(value?.updatedAt, 80) || nowIso(now),
revokedAt: cleanString(value?.revokedAt, 80) || null,
};
}
function normalizeSetupCode(value) {
const codeHash = cleanString(value?.codeHash, 128);
const agentId = cleanId(value?.agentId);
const ownerKey = cleanString(value?.ownerKey, 320);
if (!codeHash || !agentId || !ownerKey) return null;
return {
codeHash,
agentId,
ownerKey,
createdAt: cleanString(value?.createdAt, 80),
expiresAt: cleanString(value?.expiresAt, 80),
redeemedAt: cleanString(value?.redeemedAt, 80) || null,
redeemedDeviceId: cleanId(value?.redeemedDeviceId) || null,
};
}
function normalizeStore(value, now) {
return {
schemaVersion: STORE_VERSION,
agents: Array.isArray(value?.agents) ? value.agents.map((item) => normalizeAgent(item, now)).filter(Boolean) : [],
setupCodes: Array.isArray(value?.setupCodes) ? value.setupCodes.map(normalizeSetupCode).filter(Boolean) : [],
};
}
function publicDevice(device) {
return {
id: device.id,
name: device.name,
createdAt: device.createdAt,
lastUsedAt: device.lastUsedAt,
lastCheckAt: device.lastCheckAt,
revokedAt: device.revokedAt,
};
}
function publicAgent(agent) {
return {
id: agent.id,
ownerId: agent.ownerId,
name: agent.name,
avatarUrl: agent.avatarUrl || null,
status: agent.status,
devices: agent.devices.map(publicDevice),
deviceCount: agent.devices.filter((device) => !device.revokedAt).length,
lastUsedAt: agent.devices.map((device) => device.lastUsedAt).filter(Boolean).sort().at(-1) || null,
lastCheckAt: agent.devices.map((device) => device.lastCheckAt).filter(Boolean).sort().at(-1) || null,
createdAt: agent.createdAt,
updatedAt: agent.updatedAt,
revokedAt: agent.revokedAt,
};
}
export function createFoundryAgentStore({ dataRoot, now = Date.now } = {}) {
if (!dataRoot) throw new Error("foundry_agent_data_root_required");
const storePath = join(dataRoot, "agents.json");
const auditPath = join(dataRoot, "audit.ndjson");
let mutationQueue = Promise.resolve();
let auditQueue = Promise.resolve();
async function readStore() {
try {
return normalizeStore(JSON.parse(await readFile(storePath, "utf8")), now);
} catch (error) {
if (error?.code === "ENOENT") return emptyStore();
throw error;
}
}
async function writeStore(store) {
await mkdir(dataRoot, { recursive: true });
const tempPath = `${storePath}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
await writeFile(tempPath, `${JSON.stringify(normalizeStore(store, now), null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
await rename(tempPath, storePath);
}
async function mutate(action) {
const run = mutationQueue.then(async () => {
const store = await readStore();
const result = await action(store);
if (result !== NO_STORE_WRITE) await writeStore(store);
return result;
});
mutationQueue = run.catch(() => undefined);
return run;
}
async function audit(event) {
const run = auditQueue.then(async () => {
const record = {
schemaVersion: "nodedc.module-foundry.agent-audit.v1",
at: nowIso(now),
...event,
};
await mkdir(dataRoot, { recursive: true });
const current = await readFile(auditPath, "utf8").catch((error) => error?.code === "ENOENT" ? "" : Promise.reject(error));
const lines = `${current}${JSON.stringify(record)}\n`.trimEnd().split("\n").slice(-2_000);
const tempPath = `${auditPath}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
await writeFile(tempPath, `${lines.join("\n")}\n`, { encoding: "utf8", mode: 0o600 });
await rename(tempPath, auditPath);
});
auditQueue = run.catch(() => undefined);
return run;
}
async function listAgents(ownerKey) {
const normalizedOwner = cleanString(ownerKey, 320);
const store = await readStore();
return store.agents.filter((agent) => agent.ownerKey === normalizedOwner).map(publicAgent);
}
async function createAgent({ ownerId, ownerKey, name, avatarUrl }) {
const normalizedOwnerId = cleanId(ownerId);
const normalizedOwnerKey = cleanString(ownerKey, 320);
if (!normalizedOwnerId || !normalizedOwnerKey) throw new Error("foundry_agent_owner_required");
const agent = await mutate(async (store) => {
const createdAt = nowIso(now);
const next = {
id: randomToken("foundry_agent", 12),
ownerId: normalizedOwnerId,
ownerKey: normalizedOwnerKey,
name: cleanString(name, 160) || "Foundry Codex",
avatarUrl: cleanAvatarUrl(avatarUrl),
status: "active",
devices: [],
createdAt,
updatedAt: createdAt,
revokedAt: null,
};
store.agents.push(next);
return publicAgent(next);
});
await audit({ event: "agent_created", ownerId: normalizedOwnerId, agentId: agent.id, outcome: "ok" });
return agent;
}
async function updateAgent(ownerKey, agentId, input = {}) {
const normalizedOwner = cleanString(ownerKey, 320);
const normalizedId = cleanId(agentId);
const agent = await mutate(async (store) => {
const target = store.agents.find((item) => item.id === normalizedId && item.ownerKey === normalizedOwner);
if (!target) throw new Error("foundry_agent_not_found");
if (target.status === "revoked") throw new Error("foundry_agent_revoked");
if (Object.hasOwn(input, "name")) target.name = cleanString(input.name, 160) || target.name;
if (Object.hasOwn(input, "avatarUrl")) target.avatarUrl = cleanAvatarUrl(input.avatarUrl);
target.updatedAt = nowIso(now);
return publicAgent(target);
});
await audit({ event: "agent_updated", agentId: agent.id, outcome: "ok" });
return agent;
}
async function revokeAgent(ownerKey, agentId) {
const normalizedOwner = cleanString(ownerKey, 320);
const normalizedId = cleanId(agentId);
const agent = await mutate(async (store) => {
const target = store.agents.find((item) => item.id === normalizedId && item.ownerKey === normalizedOwner);
if (!target) throw new Error("foundry_agent_not_found");
const revokedAt = target.revokedAt || nowIso(now);
target.status = "revoked";
target.revokedAt = revokedAt;
target.updatedAt = revokedAt;
target.devices.forEach((device) => { device.revokedAt = device.revokedAt || revokedAt; });
return publicAgent(target);
});
await audit({ event: "agent_revoked", agentId: agent.id, outcome: "ok" });
return agent;
}
async function issueSetupCode(ownerKey, agentId) {
const normalizedOwner = cleanString(ownerKey, 320);
const normalizedId = cleanId(agentId);
const code = randomToken("fnd_setup", 24);
const createdAtMs = now();
const expiresAt = new Date(createdAtMs + SETUP_TTL_MS).toISOString();
await mutate(async (store) => {
const agent = store.agents.find((item) => item.id === normalizedId && item.ownerKey === normalizedOwner);
if (!agent) throw new Error("foundry_agent_not_found");
if (agent.status !== "active") throw new Error("foundry_agent_revoked");
store.setupCodes = store.setupCodes.filter((item) => Date.parse(item.expiresAt || "") > createdAtMs && !item.redeemedAt);
store.setupCodes.push({
codeHash: digest(code),
agentId: agent.id,
ownerKey: agent.ownerKey,
createdAt: new Date(createdAtMs).toISOString(),
expiresAt,
redeemedAt: null,
redeemedDeviceId: null,
});
});
await audit({ event: "setup_code_issued", agentId: normalizedId, outcome: "ok" });
return { code, expiresAt };
}
async function redeemSetupCode(code, deviceName) {
const codeHash = digest(code);
const foundryToken = randomToken("fnda", 36);
const ontologyToken = randomToken("fndo", 36);
const result = await mutate(async (store) => {
const setup = store.setupCodes.find((item) => item.codeHash === codeHash);
if (!setup) throw new Error("setup_code_invalid");
if (setup.redeemedAt) throw new Error("setup_code_already_redeemed");
if (Date.parse(setup.expiresAt || "") <= now()) throw new Error("setup_code_expired");
const agent = store.agents.find((item) => item.id === setup.agentId && item.ownerKey === setup.ownerKey);
if (!agent || agent.status !== "active") throw new Error("foundry_agent_revoked");
const createdAt = nowIso(now);
const device = {
id: randomToken("foundry_device", 12),
name: cleanString(deviceName, 160) || "Codex Desktop",
foundryTokenHash: digest(foundryToken),
ontologyTokenHash: digest(ontologyToken),
createdAt,
lastUsedAt: null,
lastCheckAt: null,
revokedAt: null,
};
agent.devices.push(device);
agent.updatedAt = createdAt;
setup.redeemedAt = createdAt;
setup.redeemedDeviceId = device.id;
return { agent: publicAgent(agent), device: publicDevice(device) };
});
await audit({ event: "setup_code_redeemed", agentId: result.agent.id, deviceId: result.device.id, outcome: "ok" });
return { ...result, foundryToken, ontologyToken };
}
async function authenticate(kind, token, { check = false } = {}) {
const tokenHash = digest(token);
return mutate(async (store) => {
for (const agent of store.agents) {
if (agent.status !== "active") continue;
for (const device of agent.devices) {
const expected = kind === "ontology" ? device.ontologyTokenHash : device.foundryTokenHash;
if (!digestMatches(expected, tokenHash) || device.revokedAt) continue;
const at = nowIso(now);
device.lastUsedAt = at;
if (check) device.lastCheckAt = at;
agent.updatedAt = at;
return {
actor: { actorId: agent.ownerId, ownerKey: agent.ownerKey },
agent: publicAgent(agent),
device: publicDevice(device),
};
}
}
return NO_STORE_WRITE;
});
}
return {
listAgents,
createAgent,
updateAgent,
revokeAgent,
issueSetupCode,
redeemSetupCode,
authenticateFoundryToken: async (token, options) => {
const result = await authenticate("foundry", token, options);
return result === NO_STORE_WRITE ? null : result;
},
authenticateOntologyToken: async (token, options) => {
const result = await authenticate("ontology", token, options);
return result === NO_STORE_WRITE ? null : result;
},
audit,
};
}

View File

@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { createFoundryAgentStore } from "./foundry-agent-store.mjs";
test("Foundry agent setup issues separate durable credentials and revoke closes both", async () => {
const dataRoot = await mkdtemp(path.join(os.tmpdir(), "nodedc-foundry-agent-store-"));
let currentTime = Date.parse("2026-07-18T12:00:00.000Z");
const store = createFoundryAgentStore({ dataRoot, now: () => currentTime });
try {
const created = await store.createAgent({
ownerId: "user_root",
ownerKey: "dcctouch@gmail.com",
name: "Foundry Codex",
});
assert.equal(created.status, "active");
assert.equal(created.deviceCount, 0);
assert.equal((await store.listAgents("another@nodedc.local")).length, 0);
const setup = await store.issueSetupCode("dcctouch@gmail.com", created.id);
const redeemed = await store.redeemSetupCode(setup.code, "Mac Pro Codex Desktop");
assert.match(redeemed.foundryToken, /^fnda_/);
assert.match(redeemed.ontologyToken, /^fndo_/);
assert.notEqual(redeemed.foundryToken, redeemed.ontologyToken);
assert.equal(redeemed.device.revokedAt, null);
assert.equal(Object.hasOwn(redeemed.device, "foundryTokenHash"), false);
assert.equal(Object.hasOwn(redeemed.device, "ontologyTokenHash"), false);
await assert.rejects(
() => store.redeemSetupCode(setup.code, "Replay"),
/setup_code_already_redeemed/,
);
const foundrySession = await store.authenticateFoundryToken(redeemed.foundryToken, { check: true });
const ontologySession = await store.authenticateOntologyToken(redeemed.ontologyToken, { check: true });
assert.equal(foundrySession.actor.actorId, "user_root");
assert.equal(ontologySession.actor.ownerKey, "dcctouch@gmail.com");
assert.equal(await store.authenticateFoundryToken(redeemed.ontologyToken), null);
assert.equal(await store.authenticateOntologyToken(redeemed.foundryToken), null);
currentTime += 3 * 365 * 24 * 60 * 60_000;
assert.ok(await store.authenticateFoundryToken(redeemed.foundryToken));
assert.ok(await store.authenticateOntologyToken(redeemed.ontologyToken));
const revoked = await store.revokeAgent("dcctouch@gmail.com", created.id);
assert.equal(revoked.status, "revoked");
assert.equal(await store.authenticateFoundryToken(redeemed.foundryToken), null);
assert.equal(await store.authenticateOntologyToken(redeemed.ontologyToken), null);
const persisted = await readFile(path.join(dataRoot, "agents.json"), "utf8");
assert.equal(persisted.includes(redeemed.foundryToken), false);
assert.equal(persisted.includes(redeemed.ontologyToken), false);
} finally {
await rm(dataRoot, { recursive: true, force: true });
}
});
test("Foundry setup codes expire and cannot cross owner boundaries", async () => {
const dataRoot = await mkdtemp(path.join(os.tmpdir(), "nodedc-foundry-agent-expiry-"));
let currentTime = Date.parse("2026-07-18T12:00:00.000Z");
const store = createFoundryAgentStore({ dataRoot, now: () => currentTime });
try {
const agent = await store.createAgent({ ownerId: "user_root", ownerKey: "owner@nodedc.local", name: "Owner agent" });
await assert.rejects(() => store.issueSetupCode("other@nodedc.local", agent.id), /foundry_agent_not_found/);
const setup = await store.issueSetupCode("owner@nodedc.local", agent.id);
currentTime += 16 * 60_000;
await assert.rejects(() => store.redeemSetupCode(setup.code, "Late device"), /setup_code_expired/);
} finally {
await rm(dataRoot, { recursive: true, force: true });
}
});

View File

@ -0,0 +1,808 @@
import { createHash, randomUUID } from "node:crypto";
import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
import { join } from "node:path";
const STATE_SCHEMA_VERSION = "nodedc.module-foundry.data-product-consumer/v1";
const PLAN_SCHEMA_VERSION = "nodedc.module-foundry.data-product-consumer-plan/v1";
const ACCEPTANCE_SCHEMA_VERSION = "nodedc.module-foundry.data-product-consumer-acceptance/v1";
const PRESENTATION_PATCH_SCHEMA_VERSION = "nodedc.foundry.presentation-patch/v1";
const identifier = /^[A-Za-z0-9._:-]{1,160}$/;
const cursorPattern = /^(?:0|[1-9]\d*)$/;
function consumerError(code, statusCode = 400) {
return Object.assign(new Error(code), { statusCode });
}
function targetIdentity(target) {
const applicationId = String(target?.application?.id || target?.applicationId || "");
const pageId = String(target?.page?.id || target?.pageId || "");
const binding = target?.binding || {};
const bindingId = String(binding.id || target?.bindingId || "");
if (!/^[0-9a-f-]{36}$/i.test(applicationId) || !/^[a-z0-9-]+$/i.test(pageId) || !identifier.test(bindingId)) {
throw consumerError("data_product_consumer_target_invalid");
}
return { applicationId, pageId, bindingId };
}
function targetKey(target) {
const identity = targetIdentity(target);
return `${identity.applicationId}/${identity.pageId}/${identity.bindingId}`;
}
function stateFileName(target) {
return `${createHash("sha256").update(targetKey(target), "utf8").digest("hex")}.json`;
}
function factKey(fact) {
return `${fact.semanticType}\u0000${fact.sourceId}`;
}
function safeErrorCode(error) {
const value = String(error?.message || "data_product_consumer_failed");
return /^[a-z][a-z0-9_]{2,120}$/.test(value) ? value : "data_product_consumer_failed";
}
function safeStatusFromFact(fact, policy, nowMs) {
const sourceStatus = [fact?.attributes?.operational_status, fact?.attributes?.status]
.find((value) => typeof value === "string" && value.trim());
const normalized = String(sourceStatus || "active").trim().toLowerCase().replace(/[^a-z0-9_-]+/g, "-").slice(0, 64) || "active";
if (policy.terminalStatuses.includes(normalized)) return normalized;
const observedAt = Date.parse(String(fact?.observedAt || ""));
if (Number.isFinite(observedAt) && nowMs - observedAt > policy.staleAfterMs) return "stale";
return normalized;
}
function decorateFact(subject) {
return { ...subject.fact, presentationStatus: subject.status };
}
function safeTarget(target) {
const identity = targetIdentity(target);
const binding = target.binding || {};
return {
...identity,
dataProductId: String(binding.dataProductId || ""),
slotId: String(binding.slotId || ""),
delivery: "snapshot+patch",
semanticTypes: [...(binding.semanticTypes || [])],
fieldProjection: [...(binding.fieldProjection || [])],
};
}
function planHash(value) {
return `fcp1_${createHash("sha256").update(JSON.stringify(value), "utf8").digest("base64url")}`;
}
function emptyMetrics() {
return {
snapshotCommits: 0,
patchCommits: 0,
patchOperations: 0,
replayedPatches: 0,
snapshotRebaseRemovals: 0,
canonicalRemovals: 0,
staleTransitions: 0,
reconnects: 0,
};
}
function safeStateSummary(record) {
const state = record.state;
const subjects = Object.values(state.subjects || {});
return {
schemaVersion: STATE_SCHEMA_VERSION,
consumerId: state.consumerId,
target: state.target,
enabled: state.enabled === true,
runtimeState: state.runtimeState,
product: state.product,
policy: state.policy,
cursor: state.cursor,
snapshotGeneration: state.snapshotGeneration,
subjectCount: subjects.length,
subjects: subjects.slice(0, 20).map((subject) => ({
subjectId: subject.fact.sourceId,
semanticType: subject.fact.semanticType,
observedAt: subject.fact.observedAt,
status: subject.status,
})),
viewerCount: record.listeners.size,
upstreamStreamCount: record.stream ? 1 : 0,
metrics: state.metrics,
timestamps: state.timestamps,
lastError: state.lastError || null,
readerGrant: "target-scoped-server-only",
};
}
function validatePolicy(policy, product) {
if (!policy || policy.dataProductId !== product.id || policy.productVersion !== product.version) {
throw consumerError("data_product_consumer_policy_not_found", 409);
}
if (!Number.isInteger(policy.staleAfterMs) || policy.staleAfterMs < 1_000 || policy.staleAfterMs > 7 * 24 * 60 * 60 * 1000) {
throw consumerError("data_product_consumer_policy_invalid", 500);
}
const terminalStatuses = Array.isArray(policy.terminalStatuses) ? policy.terminalStatuses : [];
if (terminalStatuses.some((value) => typeof value !== "string" || !/^[a-z0-9_-]{1,64}$/.test(value))) {
throw consumerError("data_product_consumer_policy_invalid", 500);
}
if (policy.removeMode !== "canonical-tombstone-or-snapshot-rebase") {
throw consumerError("data_product_consumer_policy_invalid", 500);
}
return {
id: String(policy.id || ""),
version: String(policy.version || ""),
dataProductId: product.id,
productVersion: product.version,
staleAfterMs: policy.staleAfterMs,
terminalStatuses: [...terminalStatuses],
removeMode: policy.removeMode,
};
}
function parseSseBlock(block) {
const fields = { event: "message", id: "", data: [] };
for (const line of block.split("\n")) {
if (!line || line.startsWith(":")) continue;
const separator = line.indexOf(":");
const name = separator === -1 ? line : line.slice(0, separator);
const value = separator === -1 ? "" : line.slice(separator + 1).replace(/^ /, "");
if (name === "event") fields.event = value;
if (name === "id") fields.id = value;
if (name === "data") fields.data.push(value);
}
return { event: fields.event, id: fields.id, data: fields.data.join("\n") };
}
function waitForAbortableDelay(ms, signal) {
if (signal.aborted) return Promise.resolve();
return new Promise((resolve) => {
const timer = setTimeout(done, ms);
function done() {
clearTimeout(timer);
signal.removeEventListener("abort", done);
resolve();
}
signal.addEventListener("abort", done, { once: true });
});
}
export function createFoundryDataProductConsumerManager({
stateDir,
dataPlaneUrl,
resolveTarget,
readReaderToken,
inspectReaderGrant,
ensureReaderGrant,
resolvePolicy,
sanitizeSnapshot,
sanitizePatch,
fetchImpl = fetch,
openStream,
now = () => Date.now(),
idleStopMs = 2_000,
staleSweepMs = 5_000,
reconnectMinMs = 500,
reconnectMaxMs = 15_000,
}) {
const records = new Map();
const ready = mkdir(stateDir, { recursive: true });
let closed = false;
async function writeState(record) {
await ready;
const targetPath = join(stateDir, stateFileName(record.state.target));
const tempPath = `${targetPath}.${randomUUID()}.tmp`;
await writeFile(tempPath, `${JSON.stringify(record.state, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
await rename(tempPath, targetPath);
}
async function readState(target) {
await ready;
try {
const parsed = JSON.parse(await readFile(join(stateDir, stateFileName(target)), "utf8"));
if (parsed?.schemaVersion !== STATE_SCHEMA_VERSION || targetKey(parsed.target) !== targetKey(target)) {
throw consumerError("data_product_consumer_state_invalid", 500);
}
return parsed;
} catch (error) {
if (error?.code === "ENOENT") return null;
throw error;
}
}
async function recordFor(target) {
const key = targetKey(target);
if (records.has(key)) return records.get(key);
const persisted = await readState(target);
const record = {
key,
target,
state: persisted,
listeners: new Map(),
stream: null,
idleTimer: null,
writing: Promise.resolve(),
};
records.set(key, record);
return record;
}
function serialize(record, action) {
const pending = record.writing.then(action, action);
record.writing = pending.catch(() => undefined);
return pending;
}
function emit(record, event) {
for (const listener of record.listeners.values()) {
try { listener(event); } catch { /* one browser cannot stop the shared consumer */ }
}
}
async function catalog(target) {
if (!dataPlaneUrl) throw consumerError("data_product_runtime_not_configured", 503);
let product = null;
let readerGrantAction = "reuse";
if (inspectReaderGrant) {
const inspection = await inspectReaderGrant(target);
product = inspection?.product || null;
readerGrantAction = inspection?.readerGrantAction === "ensure" ? "ensure" : "reuse";
} else {
const token = await readReaderToken(target.application.id, target.page.id, target.binding.id);
let response;
try {
response = await fetchImpl(new URL("/internal/data-plane/v1/reader/data-products", `${dataPlaneUrl}/`), {
headers: { authorization: `Bearer ${token}`, accept: "application/json" },
signal: AbortSignal.timeout(10_000),
});
} catch {
throw consumerError("data_product_consumer_catalog_unavailable", 503);
}
if (response.status === 401 || response.status === 403) throw consumerError("data_product_access_denied", 403);
if (!response.ok) throw consumerError("data_product_consumer_catalog_unavailable", 503);
const payload = await response.json().catch(() => null);
product = Array.isArray(payload?.dataProducts)
? payload.dataProducts.find((candidate) => candidate?.id === target.binding.dataProductId)
: null;
}
if (!product || product.active === false) throw consumerError("data_product_not_found", 404);
if (product.deliveryMode !== "snapshot+patch" || typeof product.version !== "string") {
throw consumerError("data_product_consumer_delivery_not_supported", 409);
}
if (target.binding.semanticTypes.some((semanticType) => !product.semanticTypes?.includes(semanticType))) {
throw consumerError("data_product_consumer_semantic_scope_mismatch", 409);
}
return { product, policy: validatePolicy(resolvePolicy(product), product), readerGrantAction };
}
async function plan(input) {
const target = await resolveTarget(input.applicationId, input.pageId, input.bindingId);
const { product, policy, readerGrantAction } = await catalog(target);
const record = await recordFor(target);
const safe = safeTarget(target);
const configuration = {
target: safe,
product: { id: product.id, version: product.version },
policy,
readerGrant: "target-scoped-server-only",
readerGrantAction,
};
const existingMatches = record.state
&& JSON.stringify(record.state.target) === JSON.stringify(safe)
&& record.state.product?.id === product.id
&& record.state.product?.version === product.version
&& record.state.policy?.id === policy.id
&& record.state.policy?.version === policy.version;
const action = !record.state ? "create" : existingMatches ? (record.state.enabled ? "refresh" : "resume") : "replace";
return {
schemaVersion: PLAN_SCHEMA_VERSION,
planId: planHash({ configuration, action }),
action,
configuration,
current: record.state ? { enabled: record.state.enabled === true, cursor: record.state.cursor, product: record.state.product } : null,
effects: [
...(readerGrantAction === "ensure" ? ["ensure-target-scoped-reader-grant"] : []),
"persist-consumer-state",
"bootstrap-scoped-snapshot",
"share-one-upstream-stream-per-active-binding",
],
destructive: false,
};
}
function initialState(target, product, policy) {
const timestamp = new Date(now()).toISOString();
return {
schemaVersion: STATE_SCHEMA_VERSION,
consumerId: createHash("sha256").update(targetKey(target), "utf8").digest("hex").slice(0, 32),
target: safeTarget(target),
enabled: true,
runtimeState: "bootstrapping",
product: { id: product.id, version: product.version },
policy,
cursor: "0",
snapshotGeneration: 0,
subjects: {},
metrics: emptyMetrics(),
timestamps: { createdAt: timestamp, updatedAt: timestamp, lastSnapshotAt: null, lastPatchAt: null, lastConnectedAt: null },
lastError: null,
};
}
function upstreamUrl(record, resource, after = "") {
const url = new URL(`/internal/data-plane/v1/data-products/${encodeURIComponent(record.state.target.dataProductId)}/${resource}`, `${dataPlaneUrl}/`);
if (after) url.searchParams.set("after", after);
return url;
}
async function fetchSnapshot(record) {
const token = await readReaderToken(record.state.target.applicationId, record.state.target.pageId, record.state.target.bindingId);
let response;
try {
response = await fetchImpl(upstreamUrl(record, "snapshot"), {
headers: { authorization: `Bearer ${token}`, accept: "application/json" },
signal: AbortSignal.timeout(10_000),
});
} catch {
throw consumerError("data_product_runtime_unavailable", 503);
}
if (response.status === 401 || response.status === 403) throw consumerError("data_product_access_denied", 403);
if (response.status === 404) throw consumerError("data_product_not_found", 404);
if (!response.ok) throw consumerError("data_product_runtime_unavailable", 503);
const payload = await response.json().catch(() => null);
return sanitizeSnapshot(payload, record.target.binding);
}
async function bootstrap(record, { notify = true } = {}) {
return serialize(record, async () => {
record.state.runtimeState = "bootstrapping";
record.state.timestamps.updatedAt = new Date(now()).toISOString();
await writeState(record);
try {
const snapshot = await fetchSnapshot(record);
const previousKeys = new Set(Object.keys(record.state.subjects || {}));
const subjects = {};
for (const fact of snapshot.facts) {
const key = factKey(fact);
subjects[key] = {
fact,
status: safeStatusFromFact(fact, record.state.policy, now()),
lastAppliedCursor: snapshot.cursor,
};
previousKeys.delete(key);
}
const timestamp = new Date(now()).toISOString();
record.state = {
...record.state,
enabled: true,
runtimeState: record.listeners.size ? "connecting" : "idle",
product: snapshot.dataProduct,
cursor: snapshot.cursor,
snapshotGeneration: record.state.snapshotGeneration + 1,
subjects,
metrics: {
...record.state.metrics,
snapshotCommits: record.state.metrics.snapshotCommits + 1,
snapshotRebaseRemovals: record.state.metrics.snapshotRebaseRemovals + previousKeys.size,
},
timestamps: { ...record.state.timestamps, updatedAt: timestamp, lastSnapshotAt: timestamp },
lastError: null,
};
await writeState(record);
if (notify) emit(record, {
event: "nodedc.data-product.resync-required.v1",
data: { schemaVersion: "nodedc.data-product.resync-required/v1", dataProductId: record.state.product.id },
});
return snapshot;
} catch (error) {
const timestamp = new Date(now()).toISOString();
record.state.runtimeState = "error";
record.state.lastError = { code: safeErrorCode(error), at: timestamp };
record.state.timestamps.updatedAt = timestamp;
await writeState(record);
throw error;
}
});
}
async function apply(input) {
const planned = await plan(input);
if (input.planId !== planned.planId) throw consumerError("data_product_consumer_plan_mismatch", 409);
const target = await resolveTarget(input.applicationId, input.pageId, input.bindingId);
if (planned.configuration.readerGrantAction === "ensure") {
if (!ensureReaderGrant) throw consumerError("data_product_reader_grant_not_found", 403);
await ensureReaderGrant(target);
}
const record = await recordFor(target);
const { product, policy, readerGrantAction } = await catalog(target);
if (readerGrantAction !== "reuse") throw consumerError("data_product_reader_grant_not_ready", 409);
if (record.stream) stopStream(record);
if (!record.state || planned.action === "replace") record.state = initialState(target, product, policy);
else {
record.target = target;
record.state.target = safeTarget(target);
record.state.product = { id: product.id, version: product.version };
record.state.policy = policy;
record.state.enabled = true;
record.state.runtimeState = "bootstrapping";
record.state.lastError = null;
}
await writeState(record);
await bootstrap(record, { notify: false });
if (record.listeners.size) startStream(record);
return { plan: planned, consumer: safeStateSummary(record) };
}
async function ensureProvisioned(target) {
const record = await recordFor(target);
if (record.state) {
if (!record.state.enabled) throw consumerError("data_product_consumer_stopped", 409);
record.target = target;
return record;
}
const planned = await plan(targetIdentity(target));
await apply({ ...targetIdentity(target), planId: planned.planId });
return recordFor(target);
}
async function refreshPresentation(record) {
if (!record.state?.enabled) return [];
return serialize(record, async () => {
const operations = [];
for (const subject of Object.values(record.state.subjects || {})) {
const status = safeStatusFromFact(subject.fact, record.state.policy, now());
if (status === subject.status) continue;
subject.status = status;
operations.push({ op: "upsert", sourceId: subject.fact.sourceId, semanticType: subject.fact.semanticType, status });
}
if (!operations.length) return operations;
const timestamp = new Date(now()).toISOString();
record.state.metrics.staleTransitions += operations.filter((operation) => operation.status === "stale").length;
record.state.timestamps.updatedAt = timestamp;
await writeState(record);
emit(record, {
event: "nodedc.foundry.presentation-patch.v1",
data: { schemaVersion: PRESENTATION_PATCH_SCHEMA_VERSION, generatedAt: timestamp, operations },
});
return operations;
});
}
async function applyPatch(record, patch) {
return serialize(record, async () => {
const currentCursor = BigInt(record.state.cursor);
const patchCursor = BigInt(patch.cursor);
if (patchCursor <= currentCursor) {
record.state.metrics.replayedPatches += 1;
return { replayed: true };
}
if (patch.previousCursor !== record.state.cursor) throw consumerError("resync_required", 409);
const subjects = { ...record.state.subjects };
const emittedOperations = [];
for (const operation of patch.operations) {
if (operation.op === "upsert") {
const key = factKey(operation.fact);
const subject = {
fact: operation.fact,
status: safeStatusFromFact(operation.fact, record.state.policy, now()),
lastAppliedCursor: patch.cursor,
};
subjects[key] = subject;
emittedOperations.push({ op: "upsert", fact: decorateFact(subject) });
} else if (operation.op === "remove") {
const key = `${operation.semanticType}\u0000${operation.sourceId}`;
if (subjects[key]) {
delete subjects[key];
emittedOperations.push(operation);
}
}
}
const timestamp = new Date(now()).toISOString();
record.state = {
...record.state,
runtimeState: "connected",
cursor: patch.cursor,
subjects,
metrics: {
...record.state.metrics,
patchCommits: record.state.metrics.patchCommits + 1,
patchOperations: record.state.metrics.patchOperations + emittedOperations.length,
canonicalRemovals: record.state.metrics.canonicalRemovals + emittedOperations.filter((operation) => operation.op === "remove").length,
},
timestamps: { ...record.state.timestamps, updatedAt: timestamp, lastPatchAt: timestamp },
lastError: null,
};
// Persist the new cursor and semantic state before acknowledging it to
// any browser listener. A crash can replay a patch but cannot skip one.
await writeState(record);
if (emittedOperations.length) emit(record, {
event: "nodedc.data-product.patch.v1",
id: patch.cursor,
data: { ...patch, operations: emittedOperations },
});
return { replayed: false };
});
}
async function consumeOnce(record, signal) {
const token = await readReaderToken(record.state.target.applicationId, record.state.target.pageId, record.state.target.bindingId);
const url = upstreamUrl(record, "stream", record.state.cursor);
const response = openStream
? await openStream({ url, token, signal })
: await fetchImpl(url, {
headers: { authorization: `Bearer ${token}`, accept: "text/event-stream", connection: "close" },
signal,
});
if (response.status === 409) throw consumerError("resync_required", 409);
if (response.status === 401 || response.status === 403) throw consumerError("data_product_access_denied", 403);
if (!response.ok || !response.body || !String(response.headers.get("content-type") || "").startsWith("text/event-stream")) {
throw consumerError("data_product_runtime_unavailable", 503);
}
const timestamp = new Date(now()).toISOString();
record.state.runtimeState = "connected";
record.state.timestamps.lastConnectedAt = timestamp;
record.state.timestamps.updatedAt = timestamp;
record.state.lastError = null;
await writeState(record);
const reader = response.body.getReader();
if (record.stream) record.stream.reader = reader;
const decoder = new TextDecoder();
let pending = "";
let upstreamDone = false;
try {
while (!signal.aborted && record.listeners.size) {
const next = await reader.read();
if (next.done) {
upstreamDone = true;
break;
}
pending += decoder.decode(next.value, { stream: true }).replace(/\r\n/g, "\n");
let separator;
while ((separator = pending.indexOf("\n\n")) !== -1) {
const event = parseSseBlock(pending.slice(0, separator));
pending = pending.slice(separator + 2);
if (event.event !== "nodedc.data-product.patch.v1") continue;
let payload = null;
try { payload = JSON.parse(event.data); } catch { payload = null; }
const patch = sanitizePatch(payload, record.target.binding);
if (!patch) throw consumerError("data_product_patch_contract_invalid", 502);
await applyPatch(record, patch);
}
}
} finally {
if (!upstreamDone) await reader.cancel().catch(() => undefined);
reader.releaseLock();
if (record.stream?.reader === reader) record.stream.reader = null;
}
}
function startStream(record) {
if (closed || record.stream || !record.state?.enabled || !record.listeners.size) return;
if (record.idleTimer) clearTimeout(record.idleTimer);
record.idleTimer = null;
const controller = new AbortController();
record.stream = { controller, promise: null, reader: null };
record.stream.promise = (async () => {
let delay = reconnectMinMs;
while (!closed && !controller.signal.aborted && record.listeners.size && record.state.enabled) {
try {
record.state.runtimeState = "connecting";
await writeState(record);
await consumeOnce(record, controller.signal);
delay = reconnectMinMs;
if (!controller.signal.aborted && record.listeners.size) throw consumerError("data_product_stream_closed", 503);
} catch (error) {
if (controller.signal.aborted || closed || !record.listeners.size) break;
if (error?.message === "resync_required") {
await bootstrap(record).catch(() => undefined);
} else {
const timestamp = new Date(now()).toISOString();
record.state.runtimeState = "reconnecting";
record.state.metrics.reconnects += 1;
record.state.lastError = { code: safeErrorCode(error), at: timestamp };
record.state.timestamps.updatedAt = timestamp;
await writeState(record);
}
await waitForAbortableDelay(delay, controller.signal);
delay = Math.min(reconnectMaxMs, delay * 2);
}
}
})().finally(async () => {
if (record.stream?.controller === controller) record.stream = null;
if (record.state?.enabled && !record.listeners.size) {
record.state.runtimeState = "idle";
record.state.timestamps.updatedAt = new Date(now()).toISOString();
await writeState(record).catch(() => undefined);
}
});
}
function stopStream(record) {
if (record.idleTimer) clearTimeout(record.idleTimer);
record.idleTimer = null;
void record.stream?.reader?.cancel().catch(() => undefined);
record.stream?.controller.abort();
}
async function snapshot(input) {
const target = input?.binding ? input : await resolveTarget(input.applicationId, input.pageId, input.bindingId);
const record = await ensureProvisioned(target);
// A removed target grant revokes browser reads immediately even though the
// last safe snapshot remains persisted for rollback/diagnostics.
await readReaderToken(record.state.target.applicationId, record.state.target.pageId, record.state.target.bindingId);
await refreshPresentation(record);
return {
schemaVersion: "nodedc.data-product.snapshot/v1",
dataProduct: record.state.product,
generatedAt: record.state.timestamps.lastSnapshotAt || record.state.timestamps.updatedAt,
cursor: record.state.cursor,
facts: Object.values(record.state.subjects).map(decorateFact),
};
}
async function subscribe(input, listener) {
const target = input?.binding ? input : await resolveTarget(input.applicationId, input.pageId, input.bindingId);
const record = await ensureProvisioned(target);
const after = String(input.after || "");
if (after && !cursorPattern.test(after)) throw consumerError("stream_cursor_invalid");
if (after && after !== record.state.cursor) return { resyncRequired: true, cursor: record.state.cursor, release() {} };
const leaseId = randomUUID();
record.listeners.set(leaseId, listener);
listener({
event: "nodedc.data-product.ready.v1",
data: {
schemaVersion: "nodedc.data-product.ready/v1",
dataProductId: record.state.product.id,
cursor: record.state.cursor,
emittedAt: new Date(now()).toISOString(),
},
});
startStream(record);
let released = false;
return {
resyncRequired: false,
cursor: record.state.cursor,
release() {
if (released) return;
released = true;
record.listeners.delete(leaseId);
if (!record.listeners.size && record.stream && !record.idleTimer) {
record.idleTimer = setTimeout(() => {
record.idleTimer = null;
if (!record.listeners.size) stopStream(record);
}, idleStopMs);
record.idleTimer.unref?.();
}
},
};
}
async function status(input) {
const target = await resolveTarget(input.applicationId, input.pageId, input.bindingId);
const record = await recordFor(target);
if (!record.state) return { found: false, target: safeTarget(target), readerGrant: "target-scoped-server-only" };
await refreshPresentation(record);
return { found: true, consumer: safeStateSummary(record) };
}
async function accept(input) {
const target = await resolveTarget(input.applicationId, input.pageId, input.bindingId);
const record = await ensureProvisioned(target);
const timeoutMs = Math.min(30_000, Math.max(250, Number(input.timeoutMs ?? 5_000)));
const minSubjectCount = Math.min(5_000, Math.max(0, Number(input.minSubjectCount ?? 1)));
const minPatchCount = Math.min(1_000, Math.max(0, Number(input.minPatchCount ?? 0)));
if (![timeoutMs, minSubjectCount, minPatchCount].every(Number.isInteger)) throw consumerError("data_product_consumer_acceptance_input_invalid");
const baselinePatchCount = record.state.metrics.patchCommits;
const lease = await subscribe(target, () => undefined);
const deadline = now() + timeoutMs;
try {
while (now() < deadline) {
const patchDelta = record.state.metrics.patchCommits - baselinePatchCount;
if (Object.keys(record.state.subjects).length >= minSubjectCount && patchDelta >= minPatchCount) break;
await new Promise((resolve) => setTimeout(resolve, 50));
}
await refreshPresentation(record);
const subjectKeys = Object.keys(record.state.subjects);
const duplicateCount = subjectKeys.length - new Set(subjectKeys).size;
const patchDelta = record.state.metrics.patchCommits - baselinePatchCount;
const upstreamStreamCount = record.stream ? 1 : 0;
const checks = {
bindingResolved: true,
targetScopedReader: true,
snapshotCommitted: record.state.metrics.snapshotCommits > 0,
cursorPersisted: cursorPattern.test(record.state.cursor),
minimumSubjects: subjectKeys.length >= minSubjectCount,
minimumNewPatches: patchDelta >= minPatchCount,
duplicateSubjects: duplicateCount === 0,
singleUpstreamStream: upstreamStreamCount <= 1,
secretAndEndpointExcluded: true,
};
return {
schemaVersion: ACCEPTANCE_SCHEMA_VERSION,
accepted: Object.values(checks).every(Boolean),
checks,
observed: { subjectCount: subjectKeys.length, newPatchCount: patchDelta, cursor: record.state.cursor, runtimeState: record.state.runtimeState },
consumer: safeStateSummary(record),
};
} finally {
lease.release();
}
}
async function rollback(input) {
const target = await resolveTarget(input.applicationId, input.pageId, input.bindingId);
const record = await recordFor(target);
if (!record.state) throw consumerError("data_product_consumer_not_found", 404);
stopStream(record);
record.listeners.clear();
record.state.enabled = false;
record.state.runtimeState = "stopped";
record.state.timestamps.updatedAt = new Date(now()).toISOString();
record.state.lastError = null;
await writeState(record);
return { rolledBack: true, snapshotPreserved: true, consumer: safeStateSummary(record) };
}
async function resumePersisted() {
await ready;
for (const entry of await readdir(stateDir, { withFileTypes: true })) {
if (!entry.isFile() || !/^[0-9a-f]{64}\.json$/.test(entry.name)) continue;
try {
const state = JSON.parse(await readFile(join(stateDir, entry.name), "utf8"));
if (state?.schemaVersion !== STATE_SCHEMA_VERSION || !state.target) continue;
const target = await resolveTarget(state.target.applicationId, state.target.pageId, state.target.bindingId);
const record = await recordFor(target);
record.target = target;
if (record.state?.enabled) {
record.state.runtimeState = "idle";
record.state.timestamps.updatedAt = new Date(now()).toISOString();
await writeState(record);
}
} catch {
// One damaged or obsolete target cannot stop Foundry from serving the
// remaining applications. Its file remains available for diagnostics.
}
}
}
const staleTimer = setInterval(() => {
for (const record of records.values()) void refreshPresentation(record).catch(() => undefined);
}, staleSweepMs);
staleTimer.unref?.();
async function shutdown() {
if (closed) return;
closed = true;
clearInterval(staleTimer);
const streams = [];
for (const record of records.values()) {
stopStream(record);
record.listeners.clear();
if (record.stream?.promise) streams.push(record.stream.promise);
}
await Promise.race([
Promise.allSettled(streams),
new Promise((resolve) => setTimeout(resolve, 750)),
]);
}
return {
plan,
apply,
snapshot,
subscribe,
status,
accept,
rollback,
resumePersisted,
shutdown,
};
}
export const foundryDataProductConsumerSchemas = Object.freeze({
state: STATE_SCHEMA_VERSION,
plan: PLAN_SCHEMA_VERSION,
acceptance: ACCEPTANCE_SCHEMA_VERSION,
presentationPatch: PRESENTATION_PATCH_SCHEMA_VERSION,
});

View File

@ -0,0 +1,336 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile, readdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { createFoundryDataProductConsumerManager } from "./foundry-data-product-consumer.mjs";
const target = {
application: { id: "11111111-1111-4111-8111-111111111111" },
page: { id: "map" },
binding: {
id: "fleet-current",
dataProductId: "fleet.positions.current.v1",
slotId: "points",
delivery: "snapshot+patch",
semanticTypes: ["map.moving_object"],
fieldProjection: ["display_name", "operational_status"],
},
};
function fact({ longitude = 37.61, observedAt = "2026-07-19T10:00:00.000Z" } = {}) {
return {
sourceId: "vehicle-001",
semanticType: "map.moving_object",
observedAt,
receivedAt: observedAt,
attributes: { display_name: "Vehicle 001", operational_status: "active" },
geometry: { type: "Point", coordinates: [longitude, 55.75] },
};
}
function snapshot(cursor = "7", facts = [fact()]) {
return {
schemaVersion: "nodedc.data-product.snapshot/v1",
dataProduct: { id: "fleet.positions.current.v1", version: "1.0.0" },
generatedAt: "2026-07-19T10:00:01.000Z",
cursor,
facts,
};
}
function patch(cursor, previousCursor, operations) {
return {
schemaVersion: "nodedc.data-product.patch/v1",
dataProduct: { id: "fleet.positions.current.v1", version: "1.0.0" },
cursor,
previousCursor,
emittedAt: "2026-07-19T10:00:02.000Z",
operations,
};
}
function sseResponse(value, signal, counters) {
const encoder = new TextEncoder();
return new Response(new ReadableStream({
start(controller) {
counters.open += 1;
controller.enqueue(encoder.encode(`event: nodedc.data-product.patch.v1\ndata: ${JSON.stringify(value)}\n\n`));
const close = () => {
counters.closed += 1;
try { controller.close(); } catch { /* already closed */ }
};
signal.addEventListener("abort", close, { once: true });
},
}), { status: 200, headers: { "content-type": "text/event-stream" } });
}
async function waitUntil(check, label, timeoutMs = 2_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await check()) return;
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(`timeout:${label}`);
}
test("server-owned consumer persists cursor, shares streams, transitions stale and removes canonically", async () => {
const stateDir = await mkdtemp(join(tmpdir(), "foundry-consumer-"));
let nowMs = Date.parse("2026-07-19T10:00:10.000Z");
let currentSnapshot = snapshot();
const streamPatches = [
patch("8", "7", [{ op: "upsert", fact: fact({ longitude: 37.62 }) }]),
patch("9", "8", [{
op: "remove",
sourceId: "vehicle-001",
semanticType: "map.moving_object",
removedAt: "2026-07-19T10:01:30.000Z",
reason: "tombstone",
}]),
];
const counters = { catalog: 0, snapshot: 0, stream: 0, open: 0, closed: 0 };
const fetchImpl = async (input, options = {}) => {
const url = new URL(input);
assert.equal(options.headers.authorization, "Bearer ndc_edprb_test-reader-capability");
assert.equal(url.origin, "http://edp.test");
if (url.pathname.endsWith("/reader/data-products")) {
counters.catalog += 1;
return Response.json({
ok: true,
dataProducts: [{
id: "fleet.positions.current.v1",
version: "1.0.0",
deliveryMode: "snapshot+patch",
semanticTypes: ["map.moving_object"],
active: true,
}],
});
}
if (url.pathname.endsWith("/snapshot")) {
counters.snapshot += 1;
return Response.json(currentSnapshot);
}
if (url.pathname.endsWith("/stream")) {
counters.stream += 1;
const next = streamPatches.shift();
assert.ok(next, "unexpected extra upstream stream");
assert.equal(url.searchParams.get("after"), next.previousCursor);
return sseResponse(next, options.signal, counters);
}
throw new Error(`unexpected_url:${url}`);
};
const sanitizeSnapshot = (value) => value;
const sanitizePatch = (value) => value;
const manager = createFoundryDataProductConsumerManager({
stateDir,
dataPlaneUrl: "http://edp.test",
resolveTarget: async () => target,
readReaderToken: async () => "ndc_edprb_test-reader-capability",
resolvePolicy: (product) => ({
id: "map-moving-object-current-v1",
version: "1.0.0",
dataProductId: product.id,
productVersion: product.version,
staleAfterMs: 60_000,
terminalStatuses: ["inactive", "no-position", "no_position"],
removeMode: "canonical-tombstone-or-snapshot-rebase",
}),
sanitizeSnapshot,
sanitizePatch,
fetchImpl,
now: () => nowMs,
idleStopMs: 20,
staleSweepMs: 10_000,
reconnectMinMs: 10,
reconnectMaxMs: 20,
});
try {
const input = { applicationId: target.application.id, pageId: target.page.id, bindingId: target.binding.id };
const plan = await manager.plan(input);
assert.equal(plan.action, "create");
assert.match(plan.planId, /^fcp1_/);
assert.equal(JSON.stringify(plan).includes("ndc_edprb_"), false);
assert.equal(JSON.stringify(plan).includes("http://edp.test"), false);
const applied = await manager.apply({ ...input, planId: plan.planId });
assert.equal(applied.consumer.cursor, "7");
assert.equal(applied.consumer.subjectCount, 1);
assert.equal(applied.consumer.runtimeState, "idle");
const events = [];
const first = await manager.subscribe({ ...target, after: "7" }, (event) => events.push(event));
const second = await manager.subscribe({ ...target, after: "7" }, (event) => events.push(event));
await waitUntil(async () => (await manager.status(input)).consumer.cursor === "8", "patch_cursor_8");
assert.equal(counters.stream, 1, "two viewers must share one upstream stream");
assert.equal((await manager.status(input)).consumer.subjectCount, 1);
assert.equal(events.filter((event) => event.event === "nodedc.data-product.patch.v1").length, 2, "one committed patch is fanned out once per viewer");
nowMs = Date.parse("2026-07-19T10:01:30.001Z");
const stale = await manager.status(input);
assert.equal(stale.consumer.subjects[0].status, "stale");
assert.equal(stale.consumer.metrics.staleTransitions, 1);
first.release();
second.release();
await waitUntil(() => counters.closed === 1, "last_viewer_closes_upstream");
const removeEvents = [];
const third = await manager.subscribe({ ...target, after: "8" }, (event) => removeEvents.push(event));
await waitUntil(async () => (await manager.status(input)).consumer.cursor === "9", "remove_cursor_9");
assert.equal((await manager.status(input)).consumer.subjectCount, 0);
assert.equal((await manager.status(input)).consumer.metrics.canonicalRemovals, 1);
assert.ok(removeEvents.some((event) => event.data?.operations?.some((operation) => operation.op === "remove")));
third.release();
await waitUntil(() => counters.closed === 2, "remove_stream_closed");
currentSnapshot = snapshot("9", []);
const refreshPlan = await manager.plan(input);
await manager.apply({ ...input, planId: refreshPlan.planId });
const empty = await manager.snapshot(target);
assert.deepEqual(empty.facts, []);
const rolledBack = await manager.rollback(input);
assert.equal(rolledBack.snapshotPreserved, true);
assert.equal(rolledBack.consumer.runtimeState, "stopped");
await assert.rejects(() => manager.snapshot(target), /data_product_consumer_stopped/);
const stateFiles = await readdir(stateDir);
assert.equal(stateFiles.length, 1);
const persisted = await readFile(join(stateDir, stateFiles[0]), "utf8");
assert.equal(persisted.includes("ndc_edprb_"), false);
assert.equal(persisted.includes("http://edp.test"), false);
} finally {
await manager.shutdown();
await rm(stateDir, { recursive: true, force: true });
}
});
test("restart restores the committed cursor and transient stream errors preserve subjects", async () => {
const stateDir = await mkdtemp(join(tmpdir(), "foundry-consumer-restart-"));
const input = { applicationId: target.application.id, pageId: target.page.id, bindingId: target.binding.id };
let streamAttempts = 0;
const fetchImpl = async (request, options = {}) => {
const url = new URL(request);
assert.equal(options.headers.authorization, "Bearer ndc_edprb_test-reader-capability");
if (url.pathname.endsWith("/reader/data-products")) {
return Response.json({ ok: true, dataProducts: [{
id: "fleet.positions.current.v1",
version: "1.0.0",
deliveryMode: "snapshot+patch",
semanticTypes: ["map.moving_object"],
active: true,
}] });
}
if (url.pathname.endsWith("/snapshot")) return Response.json(snapshot());
if (url.pathname.endsWith("/stream")) {
streamAttempts += 1;
return Response.json({ ok: false, error: "temporary_failure" }, { status: 503 });
}
throw new Error(`unexpected_url:${url}`);
};
const createManager = () => createFoundryDataProductConsumerManager({
stateDir,
dataPlaneUrl: "http://edp.test",
resolveTarget: async () => target,
readReaderToken: async () => "ndc_edprb_test-reader-capability",
resolvePolicy: (product) => ({
id: "map-moving-object-current-v1",
version: "1.0.0",
dataProductId: product.id,
productVersion: product.version,
staleAfterMs: 60_000,
terminalStatuses: ["inactive", "no-position", "no_position"],
removeMode: "canonical-tombstone-or-snapshot-rebase",
}),
sanitizeSnapshot: (value) => value,
sanitizePatch: (value) => value,
fetchImpl,
now: () => Date.parse("2026-07-19T10:00:10.000Z"),
idleStopMs: 10,
staleSweepMs: 10_000,
reconnectMinMs: 10,
reconnectMaxMs: 20,
});
const first = createManager();
let second;
try {
const plan = await first.plan(input);
await first.apply({ ...input, planId: plan.planId });
await first.shutdown();
second = createManager();
await second.resumePersisted();
const restored = await second.status(input);
assert.equal(restored.consumer.cursor, "7");
assert.equal(restored.consumer.subjectCount, 1);
const lease = await second.subscribe({ ...target, after: "7" }, () => undefined);
await waitUntil(async () => (await second.status(input)).consumer.metrics.reconnects > 0, "transient_reconnect");
lease.release();
const afterFailure = await second.status(input);
assert.equal(afterFailure.consumer.subjectCount, 1);
assert.equal(afterFailure.consumer.cursor, "7");
assert.ok(streamAttempts >= 1);
} finally {
await first.shutdown();
await second?.shutdown();
await rm(stateDir, { recursive: true, force: true });
}
});
test("exact apply can ensure a missing target-scoped reader grant without exposing it in the plan", async () => {
const stateDir = await mkdtemp(join(tmpdir(), "foundry-consumer-grant-"));
const input = { applicationId: target.application.id, pageId: target.page.id, bindingId: target.binding.id };
const product = {
id: "fleet.positions.current.v1",
version: "1.0.0",
deliveryMode: "snapshot+patch",
semanticTypes: ["map.moving_object"],
active: true,
};
let grantReady = false;
let ensureCount = 0;
const manager = createFoundryDataProductConsumerManager({
stateDir,
dataPlaneUrl: "http://edp.test",
resolveTarget: async () => target,
readReaderToken: async () => grantReady ? "ndc_edprb_managed-reader-capability" : null,
inspectReaderGrant: async () => ({ product, readerGrantAction: grantReady ? "reuse" : "ensure" }),
ensureReaderGrant: async () => {
ensureCount += 1;
grantReady = true;
return { ensured: true };
},
resolvePolicy: (resolvedProduct) => ({
id: "map-moving-object-current-v1",
version: "1.0.0",
dataProductId: resolvedProduct.id,
productVersion: resolvedProduct.version,
staleAfterMs: 60_000,
terminalStatuses: ["inactive", "no-position", "no_position"],
removeMode: "canonical-tombstone-or-snapshot-rebase",
}),
sanitizeSnapshot: (value) => value,
sanitizePatch: (value) => value,
fetchImpl: async (request, options = {}) => {
assert.equal(options.headers.authorization, "Bearer ndc_edprb_managed-reader-capability");
assert.ok(new URL(request).pathname.endsWith("/snapshot"));
return Response.json(snapshot());
},
});
try {
const plan = await manager.plan(input);
assert.equal(plan.configuration.readerGrantAction, "ensure");
assert.ok(plan.effects.includes("ensure-target-scoped-reader-grant"));
assert.equal(JSON.stringify(plan).includes("capability"), false);
const applied = await manager.apply({ ...input, planId: plan.planId });
assert.equal(ensureCount, 1);
assert.equal(applied.consumer.subjectCount, 1);
const refresh = await manager.plan(input);
assert.equal(refresh.configuration.readerGrantAction, "reuse");
assert.equal(refresh.effects.includes("ensure-target-scoped-reader-grant"), false);
} finally {
await manager.shutdown();
await rm(stateDir, { recursive: true, force: true });
}
});

View File

@ -114,6 +114,9 @@ function toolText(payload, isError = false) {
function normalizeMcpError(error) {
const code = String(error?.message || "foundry_operation_failed");
if (code === "application_not_found") return { code, status: 404 };
if (/^(?:data_product_|resync_required$|stream_cursor_invalid$)/.test(code) && Number.isInteger(error?.statusCode)) {
return { code, status: error.statusCode };
}
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 };
@ -268,6 +271,87 @@ const tools = [
},
},
},
{
name: "foundry_plan_map_data_product_consumer",
title: "Plan Map data product consumer",
description: "Validate an approved Map data-product binding, its target-scoped server reader and versioned Foundry consumer policy. Returns a safe deterministic plan; it never returns a capability or endpoint.",
inputSchema: {
type: "object",
additionalProperties: false,
required: ["applicationId", "pageId", "bindingId"],
properties: {
applicationId: { type: "string" },
pageId: { type: "string" },
bindingId: { type: "string" },
},
},
},
{
name: "foundry_apply_map_data_product_consumer",
title: "Apply Map data product consumer",
description: "Apply an exact consumer plan and commit a scoped snapshot into server-owned Foundry state. Active page viewers then share one upstream durable patch stream.",
inputSchema: {
type: "object",
additionalProperties: false,
required: ["applicationId", "pageId", "bindingId", "planId", "idempotencyKey"],
properties: {
applicationId: { type: "string" },
pageId: { type: "string" },
bindingId: { type: "string" },
planId: { type: "string" },
idempotencyKey: { type: "string" },
},
},
},
{
name: "foundry_get_map_data_product_consumer_status",
title: "Get Map data product consumer status",
description: "Read safe persisted cursor, subject/status counters, reconnect diagnostics and viewer/upstream counts for one approved binding. No raw fact attributes, capability or endpoint are returned.",
inputSchema: {
type: "object",
additionalProperties: false,
required: ["applicationId", "pageId", "bindingId"],
properties: {
applicationId: { type: "string" },
pageId: { type: "string" },
bindingId: { type: "string" },
},
},
},
{
name: "foundry_accept_map_data_product_consumer",
title: "Accept Map data product consumer",
description: "Run a bounded lifecycle acceptance lease against the shared server consumer and verify persisted snapshot/cursor, subject identity, optional new patches, deduplication and secret boundary.",
inputSchema: {
type: "object",
additionalProperties: false,
required: ["applicationId", "pageId", "bindingId"],
properties: {
applicationId: { type: "string" },
pageId: { type: "string" },
bindingId: { type: "string" },
timeoutMs: { type: "integer", minimum: 250, maximum: 30000 },
minSubjectCount: { type: "integer", minimum: 0, maximum: 5000 },
minPatchCount: { type: "integer", minimum: 0, maximum: 1000 },
},
},
},
{
name: "foundry_rollback_map_data_product_consumer",
title: "Rollback Map data product consumer",
description: "Stop one server-owned consumer without deleting its last safe snapshot. Re-applying a fresh exact plan resumes it.",
inputSchema: {
type: "object",
additionalProperties: false,
required: ["applicationId", "pageId", "bindingId", "idempotencyKey"],
properties: {
applicationId: { type: "string" },
pageId: { type: "string" },
bindingId: { type: "string" },
idempotencyKey: { type: "string" },
},
},
},
];
function toolMap(operations) {
@ -280,6 +364,11 @@ function toolMap(operations) {
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),
foundry_plan_map_data_product_consumer: (input) => operations.planMapDataProductConsumer(input),
foundry_apply_map_data_product_consumer: (input, actor) => operations.applyMapDataProductConsumer(input, actor),
foundry_get_map_data_product_consumer_status: (input) => operations.getMapDataProductConsumerStatus(input),
foundry_accept_map_data_product_consumer: (input) => operations.acceptMapDataProductConsumer(input),
foundry_rollback_map_data_product_consumer: (input, actor) => operations.rollbackMapDataProductConsumer(input, actor),
};
}
@ -292,8 +381,14 @@ export async function handleFoundryMcpRequest(request, response, options) {
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);
const token = bearerToken(request);
let actor = config.capabilitySecret ? actorFromMcpCapability(token, config) : null;
if (!actor && typeof options.authenticateAgentToken === "function") {
actor = await options.authenticateAgentToken(token, { check: true });
}
if (!config.capabilitySecret && typeof options.authenticateAgentToken !== "function") {
return sendJson(response, 503, { error: "foundry_mcp_not_configured" });
}
if (!actor) return sendJson(response, 401, { error: "mcp_unauthorized" });
let message;
@ -310,8 +405,8 @@ export async function handleFoundryMcpRequest(request, response, options) {
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.",
serverInfo: { name: "nodedc_module_foundry", version: "0.2.0" },
instructions: "NDC Module Foundry edits application instances and controls approved server-owned data-product consumers. Page Library is read-only. Application deletion is unavailable. Provider endpoints and credentials are never MCP inputs or outputs.",
}));
}
if (message.method === "notifications/initialized") return sendJson(response, 202, {});
@ -383,6 +478,8 @@ export async function handleFoundryEntitlementRequest(request, response, options
"foundry.page-instance.create",
"foundry.map-pin.upsert",
"foundry.map-data-product.upsert",
"foundry.map-data-product-consumer.read",
"foundry.map-data-product-consumer.lifecycle",
],
};
if (!accessAllowed) {

View File

@ -0,0 +1,267 @@
import { constants as fsConstants } from "node:fs";
import { chmod, lstat, mkdir, open } from "node:fs/promises";
import { createHash, createPrivateKey, randomBytes, randomUUID, sign } from "node:crypto";
import { join, resolve } from "node:path";
const SIGNATURE_SCHEMA = "nodedc.external-data-plane.managed-provisioner-request/v1";
const TOKEN_PATTERN = /^ndc_edprb_[A-Za-z0-9_-]{32,512}$/;
const IDENTITY_PATTERN = /^[a-z][a-z0-9._:-]{2,127}$/i;
const AUDIENCE_PATTERN = /^[a-z][a-z0-9._:/-]{2,255}$/i;
function provisionerError(code, statusCode = 500) {
return Object.assign(new Error(code), { statusCode });
}
function targetAddress(target) {
const applicationId = String(target?.application?.id || target?.applicationId || "");
const pageId = String(target?.page?.id || target?.pageId || "");
const bindingId = String(target?.binding?.id || target?.bindingId || "");
if (!/^[0-9a-f-]{36}$/i.test(applicationId) || !IDENTITY_PATTERN.test(pageId)
|| !IDENTITY_PATTERN.test(bindingId)) {
throw provisionerError("foundry_reader_grant_target_invalid", 400);
}
return { applicationId, pageId, bindingId };
}
function targetIdentity(target) {
const address = targetAddress(target);
const dataProductId = String(target?.binding?.dataProductId || target?.dataProductId || "");
if (!IDENTITY_PATTERN.test(dataProductId)) throw provisionerError("foundry_reader_grant_target_invalid", 400);
const { applicationId, pageId, bindingId } = address;
return { applicationId, pageId, bindingId, dataProductId };
}
function targetDigest(target) {
const identity = targetAddress(target);
return createHash("sha256")
.update(`${identity.applicationId}/${identity.pageId}/${identity.bindingId}`, "utf8")
.digest("hex");
}
function signingPayload({ audience, serviceId, keyId, method, path, timestamp, nonce, bodySha256 }) {
return JSON.stringify({
schemaVersion: SIGNATURE_SCHEMA,
audience,
serviceId,
keyId,
method,
path,
timestamp,
nonce,
bodySha256,
});
}
export function createFoundryReaderGrantProvisioner({
dataPlaneUrl,
privateKeyFile,
grantsDir,
serviceId = "nodedc-module-foundry",
keyId = "foundry-edp-managed-provisioner-v1",
audience = "nodedc-external-data-plane.managed-provisioning.v1",
fetchImpl = fetch,
now = () => new Date(),
randomBytesImpl = randomBytes,
randomUUIDImpl = randomUUID,
production = process.env.NODE_ENV === "production",
}) {
const baseUrl = String(dataPlaneUrl || "").trim().replace(/\/$/, "");
const keyPath = String(privateKeyFile || "").trim();
const tokenRoot = String(grantsDir || "").trim();
const configured = Boolean(baseUrl && keyPath && tokenRoot && IDENTITY_PATTERN.test(serviceId)
&& IDENTITY_PATTERN.test(keyId) && AUDIENCE_PATTERN.test(audience));
let privateKeyPromise = null;
async function loadPrivateKey() {
if (!configured) throw provisionerError("foundry_reader_grant_provisioner_not_configured", 503);
if (!privateKeyPromise) privateKeyPromise = readPrivateKeySecurely(keyPath, { production });
return privateKeyPromise;
}
async function signedRequest(method, path, value) {
const privateKey = await loadPrivateKey();
const body = JSON.stringify(value);
const bodySha256 = createHash("sha256").update(body, "utf8").digest("hex");
const timestamp = now().toISOString();
const nonce = randomUUIDImpl();
const signature = sign(null, Buffer.from(signingPayload({
audience,
serviceId,
keyId,
method,
path,
timestamp,
nonce,
bodySha256,
}), "utf8"), privateKey).toString("base64url");
let response;
try {
response = await fetchImpl(new URL(path, `${baseUrl}/`), {
method,
headers: {
"content-type": "application/json",
accept: "application/json",
"x-nodedc-engine-service-id": serviceId,
"x-nodedc-engine-key-id": keyId,
"x-nodedc-request-audience": audience,
"x-nodedc-request-timestamp": timestamp,
"x-nodedc-request-nonce": nonce,
"x-nodedc-content-sha256": bodySha256,
"x-nodedc-request-signature": signature,
},
body,
signal: AbortSignal.timeout(10_000),
});
} catch {
throw provisionerError("foundry_reader_grant_provisioner_unavailable", 503);
}
const payload = await response.json().catch(() => null);
if (!response.ok) {
const code = String(payload?.error || payload?.code || "foundry_reader_grant_provisioner_rejected");
throw provisionerError(/^[a-z][a-z0-9_]{2,120}$/.test(code) ? code : "foundry_reader_grant_provisioner_rejected", response.status);
}
return payload;
}
async function plan(target) {
const identity = targetIdentity(target);
const payload = await signedRequest("POST", "/internal/data-plane/v1/consumer-reader-bindings/plan", {
allowedDataProductIds: [identity.dataProductId],
});
const product = Array.isArray(payload?.dataProducts)
? payload.dataProducts.find((candidate) => candidate?.id === identity.dataProductId)
: null;
if (!product || payload?.sourceScope !== "resolved-server-side") {
throw provisionerError("foundry_reader_grant_plan_response_invalid", 502);
}
return { product, sourceScope: "resolved-server-side" };
}
async function ensure(target) {
const identity = targetIdentity(target);
const digest = targetDigest(identity);
const token = await ensureReaderToken(join(resolve(tokenRoot), digest), {
production,
randomBytesImpl,
});
const bindingKey = `fndrc-${digest}`;
const payload = await signedRequest(
"PUT",
`/internal/data-plane/v1/consumer-reader-bindings/by-key/${encodeURIComponent(bindingKey)}`,
{
allowedDataProductIds: [identity.dataProductId],
expiresAt: null,
generation: 1,
capabilityDigest: createHash("sha256").update(token, "utf8").digest("hex"),
},
);
const binding = payload?.readerBinding;
if (binding?.bindingKey !== bindingKey || binding?.generation !== 1 || binding?.active !== true
|| binding?.expiresAt !== null || binding?.sourceScope !== "resolved-server-side"
|| !Array.isArray(binding?.allowedDataProductIds)
|| !binding.allowedDataProductIds.includes(identity.dataProductId)) {
throw provisionerError("foundry_reader_grant_ensure_response_invalid", 502);
}
return { ensured: true, idempotent: payload?.idempotent === true, generation: 1, sourceScope: "resolved-server-side" };
}
async function readToken(target) {
if (!tokenRoot) return null;
return readReaderToken(join(resolve(tokenRoot), targetDigest(target)), { production, missing: null });
}
return { configured, plan, ensure, readToken };
}
async function readPrivateKeySecurely(path, { production }) {
let handle;
try {
handle = await open(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0));
} catch {
throw provisionerError("foundry_reader_grant_private_key_unreadable", 503);
}
try {
const metadata = await handle.stat();
if (!metadata.isFile() || metadata.size < 80 || metadata.size > 8192 || (metadata.mode & 0o022) !== 0
|| (production && metadata.uid !== 0)) {
throw provisionerError("foundry_reader_grant_private_key_invalid", 503);
}
const pem = await handle.readFile("utf8");
const privateKey = createPrivateKey(pem);
if (privateKey.type !== "private" || privateKey.asymmetricKeyType !== "ed25519") {
throw provisionerError("foundry_reader_grant_private_key_invalid", 503);
}
return privateKey;
} catch (error) {
if (String(error?.message || "").startsWith("foundry_reader_grant_")) throw error;
throw provisionerError("foundry_reader_grant_private_key_invalid", 503);
} finally {
await handle.close();
}
}
async function ensureReaderToken(path, { production, randomBytesImpl }) {
const existing = await readReaderToken(path, { production, missing: null });
if (existing) return existing;
const directory = resolve(path, "..");
await mkdir(directory, { recursive: true, mode: 0o700 });
await chmod(directory, 0o700);
const directoryMetadata = await lstat(directory);
if (!directoryMetadata.isDirectory() || directoryMetadata.isSymbolicLink()
|| (directoryMetadata.mode & 0o077) !== 0 || (production && directoryMetadata.uid !== 0)) {
throw provisionerError("foundry_reader_grant_store_invalid", 503);
}
const token = `ndc_edprb_${randomBytesImpl(32).toString("base64url")}`;
let handle;
try {
handle = await open(
path,
fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | (fsConstants.O_NOFOLLOW || 0),
0o400,
);
await handle.writeFile(`${token}\n`, "utf8");
await handle.sync();
await handle.chmod(0o400);
return token;
} catch (error) {
if (error?.code === "EEXIST") return readConcurrentlyCreatedReaderToken(path, { production });
throw provisionerError("foundry_reader_grant_store_unavailable", 503);
} finally {
await handle?.close();
}
}
async function readConcurrentlyCreatedReaderToken(path, { production }) {
for (let attempt = 0; attempt < 10; attempt += 1) {
try {
const token = await readReaderToken(path, { production, missing: null });
if (token) return token;
} catch (error) {
if (error?.message !== "foundry_reader_grant_invalid") throw error;
}
await new Promise((resolveDelay) => setTimeout(resolveDelay, 10));
}
throw provisionerError("foundry_reader_grant_store_unavailable", 503);
}
async function readReaderToken(path, { production, missing }) {
let handle;
try {
handle = await open(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0));
} catch (error) {
if (error?.code === "ENOENT") return missing;
throw provisionerError("foundry_reader_grant_unavailable", 503);
}
try {
const metadata = await handle.stat();
if (!metadata.isFile() || metadata.size < 2 || metadata.size > 1024 || (metadata.mode & 0o222) !== 0
|| (production && metadata.uid !== 0)) {
throw provisionerError("foundry_reader_grant_invalid", 503);
}
const token = (await handle.readFile("utf8")).trim();
if (!TOKEN_PATTERN.test(token)) throw provisionerError("foundry_reader_grant_invalid", 503);
return token;
} finally {
await handle.close();
}
}

View File

@ -0,0 +1,127 @@
import assert from "node:assert/strict";
import { createHash, generateKeyPairSync, verify } from "node:crypto";
import { chmod, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { createFoundryReaderGrantProvisioner } from "./foundry-reader-grant-provisioner.mjs";
const signatureSchema = "nodedc.external-data-plane.managed-provisioner-request/v1";
const audience = "nodedc-external-data-plane.managed-provisioning.v1";
const serviceId = "nodedc-module-foundry";
const keyId = "foundry-edp-managed-provisioner-v1";
const target = {
application: { id: "11111111-1111-4111-8111-111111111111" },
page: { id: "map" },
binding: { id: "fleet-current", dataProductId: "fleet.positions.current.v1" },
};
test("managed Foundry grant provisioning signs digest-only requests and persists the token privately", async () => {
const root = await mkdtemp(join(tmpdir(), "foundry-reader-grant-"));
const privateKeyFile = join(root, "private-key.pem");
const grantsDir = join(root, "grants");
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
await writeFile(privateKeyFile, privateKey.export({ type: "pkcs8", format: "pem" }), { mode: 0o400 });
await chmod(privateKeyFile, 0o400);
const requestBodies = [];
let capabilityDigest = null;
const fetchImpl = async (input, options) => {
const url = new URL(input);
const body = String(options.body);
const parsed = JSON.parse(body);
requestBodies.push(body);
const bodySha256 = createHash("sha256").update(body, "utf8").digest("hex");
assert.equal(options.headers["x-nodedc-content-sha256"], bodySha256);
const signedPayload = JSON.stringify({
schemaVersion: signatureSchema,
audience,
serviceId,
keyId,
method: options.method,
path: url.pathname,
timestamp: options.headers["x-nodedc-request-timestamp"],
nonce: options.headers["x-nodedc-request-nonce"],
bodySha256,
});
assert.equal(verify(
null,
Buffer.from(signedPayload, "utf8"),
publicKey,
Buffer.from(options.headers["x-nodedc-request-signature"], "base64url"),
), true);
assert.equal(options.headers["x-nodedc-engine-service-id"], serviceId);
assert.equal(options.headers["x-nodedc-engine-key-id"], keyId);
assert.equal(options.headers["x-nodedc-request-audience"], audience);
assert.deepEqual(parsed.allowedDataProductIds, ["fleet.positions.current.v1"]);
for (const forbidden of ["provider", "providerId", "tenant", "tenantId", "connection", "connectionId", "token", "capability"]) {
assert.equal(Object.hasOwn(parsed, forbidden), false);
}
if (url.pathname.endsWith("/plan")) {
assert.deepEqual(Object.keys(parsed), ["allowedDataProductIds"]);
return Response.json({
ok: true,
sourceScope: "resolved-server-side",
dataProducts: [{
id: "fleet.positions.current.v1",
version: "1.0.0",
deliveryMode: "snapshot+patch",
semanticTypes: ["map.moving_object"],
active: true,
}],
});
}
assert.match(url.pathname, /\/consumer-reader-bindings\/by-key\/fndrc-[0-9a-f]{64}$/);
assert.deepEqual(Object.keys(parsed).sort(), ["allowedDataProductIds", "capabilityDigest", "expiresAt", "generation"].sort());
assert.match(parsed.capabilityDigest, /^[0-9a-f]{64}$/);
capabilityDigest ||= parsed.capabilityDigest;
assert.equal(parsed.capabilityDigest, capabilityDigest);
const bindingKey = decodeURIComponent(url.pathname.split("/").at(-1));
return Response.json({
ok: true,
idempotent: capabilityDigest === parsed.capabilityDigest,
readerBinding: {
bindingKey,
generation: 1,
active: true,
expiresAt: null,
sourceScope: "resolved-server-side",
allowedDataProductIds: parsed.allowedDataProductIds,
},
});
};
const provisioner = createFoundryReaderGrantProvisioner({
dataPlaneUrl: "http://edp.test",
privateKeyFile,
grantsDir,
serviceId,
keyId,
audience,
fetchImpl,
now: () => new Date("2026-07-19T14:00:00.000Z"),
randomBytesImpl: () => Buffer.alloc(32, 7),
randomUUIDImpl: () => "22222222-2222-4222-8222-222222222222",
production: false,
});
try {
assert.equal(provisioner.configured, true);
const planned = await provisioner.plan(target);
assert.equal(planned.product.id, "fleet.positions.current.v1");
const first = await provisioner.ensure(target);
const second = await provisioner.ensure(target);
assert.equal(first.ensured, true);
assert.equal(second.ensured, true);
const files = await readdir(grantsDir);
assert.equal(files.length, 1);
const tokenPath = join(grantsDir, files[0]);
const token = (await readFile(tokenPath, "utf8")).trim();
assert.match(token, /^ndc_edprb_/);
assert.equal((await stat(tokenPath)).mode & 0o777, 0o400);
assert.equal(createHash("sha256").update(token, "utf8").digest("hex"), capabilityDigest);
assert.equal(await provisioner.readToken(target), token);
assert.equal(requestBodies.some((body) => body.includes(token)), false);
assert.equal(JSON.stringify([planned, first, second]).includes(token), false);
assert.equal(requestBodies.some((body) => /provider|tenant|connection/i.test(body)), false);
} finally {
await rm(root, { recursive: true, force: true });
}
});