feat(foundry): add managed data consumers and agent settings
This commit is contained in:
parent
5f583caa05
commit
aac44d057f
|
|
@ -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;
|
# Private External Data Plane address. It is used only by the Foundry server;
|
||||||
# map pages use same-origin /api/applications/.../data-bindings/... routes.
|
# map pages use same-origin /api/applications/.../data-bindings/... routes.
|
||||||
NODEDC_EXTERNAL_DATA_PLANE_INTERNAL_URL=
|
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
|
# Absolute path to a runner-managed directory of opaque reader grants. Each
|
||||||
# filename is sha256("<applicationId>/<pageId>/<bindingId>"); file content is
|
# filename is sha256("<applicationId>/<pageId>/<bindingId>"); file content is
|
||||||
# exactly one ndc_edprb_ reader token. Files must be root-owned and read-only.
|
# 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_BASE_URL=http://127.0.0.1:5173
|
||||||
NODEDC_LAUNCHER_INTERNAL_URL=http://127.0.0.1:5173
|
NODEDC_LAUNCHER_INTERNAL_URL=http://127.0.0.1:5173
|
||||||
NODEDC_INTERNAL_ACCESS_TOKEN=
|
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_COOKIE=nodedc_foundry_session
|
||||||
NODEDC_FOUNDRY_SESSION_TTL_MS=43200000
|
NODEDC_FOUNDRY_SESSION_TTL_MS=43200000
|
||||||
# Production validation TTL is deliberately clamped to 15–30 seconds. A
|
# Production validation TTL is deliberately clamped to 15–30 seconds. A
|
||||||
|
|
|
||||||
|
|
@ -18,10 +18,8 @@ import {
|
||||||
FieldFrame,
|
FieldFrame,
|
||||||
GlassSurface,
|
GlassSurface,
|
||||||
GlassMaterialSurface,
|
GlassMaterialSurface,
|
||||||
HeaderAvatar,
|
|
||||||
HeaderNavigation,
|
HeaderNavigation,
|
||||||
HeaderProfile,
|
HeaderProfile,
|
||||||
HeaderProfileButton,
|
|
||||||
HeaderWorkspace,
|
HeaderWorkspace,
|
||||||
Icon,
|
Icon,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
|
@ -40,6 +38,7 @@ import {
|
||||||
TextAreaField,
|
TextAreaField,
|
||||||
TextField,
|
TextField,
|
||||||
Toolbar,
|
Toolbar,
|
||||||
|
UserProfileMenu,
|
||||||
useApplicationWorkspace,
|
useApplicationWorkspace,
|
||||||
Window,
|
Window,
|
||||||
WindowFooterActions,
|
WindowFooterActions,
|
||||||
|
|
@ -62,6 +61,7 @@ import {
|
||||||
type DesignProfileStatus,
|
type DesignProfileStatus,
|
||||||
} from "./applicationManifest.js";
|
} from "./applicationManifest.js";
|
||||||
import { MapFixturePreview, type MapFixturePreviewHandle, type MapPageLayout } from "./MapFixturePreview.js";
|
import { MapFixturePreview, type MapFixturePreviewHandle, type MapPageLayout } from "./MapFixturePreview.js";
|
||||||
|
import { FoundrySettingsModal } from "./FoundrySettingsModal.js";
|
||||||
|
|
||||||
type CatalogSection = "controls" | "media" | "glass" | "modals" | "icons";
|
type CatalogSection = "controls" | "media" | "glass" | "modals" | "icons";
|
||||||
type StudioContext = "visual" | "pages" | "applications";
|
type StudioContext = "visual" | "pages" | "applications";
|
||||||
|
|
@ -333,6 +333,7 @@ export function CatalogApp() {
|
||||||
const [applicationError, setApplicationError] = useState("");
|
const [applicationError, setApplicationError] = useState("");
|
||||||
const [sessionProfile, setSessionProfile] = useState<FoundrySessionProfile | null>(null);
|
const [sessionProfile, setSessionProfile] = useState<FoundrySessionProfile | null>(null);
|
||||||
const [platformSettingsOpen, setPlatformSettingsOpen] = useState(false);
|
const [platformSettingsOpen, setPlatformSettingsOpen] = useState(false);
|
||||||
|
const [foundrySettingsOpen, setFoundrySettingsOpen] = useState(false);
|
||||||
const [cesiumIonToken, setCesiumIonToken] = useState("");
|
const [cesiumIonToken, setCesiumIonToken] = useState("");
|
||||||
const [cesiumIonStatus, setCesiumIonStatus] = useState<CesiumIonSecretStatus | null>(null);
|
const [cesiumIonStatus, setCesiumIonStatus] = useState<CesiumIonSecretStatus | null>(null);
|
||||||
const [cesiumIonSaveState, setCesiumIonSaveState] = useState<"idle" | "loading" | "saving" | "saved" | "error">("idle");
|
const [cesiumIonSaveState, setCesiumIonSaveState] = useState<"idle" | "loading" | "saving" | "saved" | "error">("idle");
|
||||||
|
|
@ -1815,15 +1816,15 @@ export function CatalogApp() {
|
||||||
right={
|
right={
|
||||||
<HeaderProfile>
|
<HeaderProfile>
|
||||||
<IconButton label="Уведомления"><Icon name="inbox" size={20} strokeWidth={1.7} /></IconButton>
|
<IconButton label="Уведомления"><Icon name="inbox" size={20} strokeWidth={1.7} /></IconButton>
|
||||||
<HeaderProfileButton
|
<UserProfileMenu
|
||||||
title={sessionProfile?.user?.displayName || "Профиль NODE.DC"}
|
displayName={sessionProfile?.user?.displayName || "Профиль"}
|
||||||
onClick={() => { if (sessionProfile?.profileUrl) window.location.assign(sessionProfile.profileUrl); }}
|
subtitle={sessionProfile?.user?.email || "NODE.DC Foundry"}
|
||||||
>
|
avatarUrl={sessionProfile?.user?.avatarUrl || undefined}
|
||||||
{sessionProfile?.user?.displayName || "Профиль"}
|
actions={[
|
||||||
</HeaderProfileButton>
|
{ id: "profile", label: "Профиль", icon: "profile", href: sessionProfile?.profileUrl || undefined, disabled: !sessionProfile?.profileUrl },
|
||||||
<HeaderAvatar
|
{ id: "settings", label: "Настройки", icon: "settings", onSelect: () => setFoundrySettingsOpen(true) },
|
||||||
label={sessionProfile?.user?.displayName || sessionProfile?.user?.initials || "DC"}
|
{ id: "logout", label: "Выйти", icon: "external", href: "/auth/logout" },
|
||||||
imageUrl={sessionProfile?.user?.avatarUrl || undefined}
|
]}
|
||||||
/>
|
/>
|
||||||
</HeaderProfile>
|
</HeaderProfile>
|
||||||
}
|
}
|
||||||
|
|
@ -2144,6 +2145,8 @@ export function CatalogApp() {
|
||||||
</div>
|
</div>
|
||||||
</Window>
|
</Window>
|
||||||
|
|
||||||
|
<FoundrySettingsModal open={foundrySettingsOpen} onClose={() => setFoundrySettingsOpen(false)} />
|
||||||
|
|
||||||
<Window
|
<Window
|
||||||
open={createModalOpen}
|
open={createModalOpen}
|
||||||
title="Создать проект"
|
title="Создать проект"
|
||||||
|
|
|
||||||
|
|
@ -448,6 +448,10 @@ function runtimePointColor(fact: MapRuntimeFact) {
|
||||||
// This is a semantic default for the generic Map entity-stream adapter,
|
// This is a semantic default for the generic Map entity-stream adapter,
|
||||||
// not a provider style. A renderer-neutral style profile can refine it
|
// not a provider style. A renderer-neutral style profile can refine it
|
||||||
// later without changing a data product or its L2 workflow.
|
// 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;
|
return fact.semanticType === "map.moving_object" ? accent : violet;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -270,7 +270,7 @@ export const MapFixturePreview = forwardRef<MapFixturePreviewHandle, {
|
||||||
const attributes = fact.attributes;
|
const attributes = fact.attributes;
|
||||||
const label = [attributes.label, attributes.name, attributes.title, attributes.subject_id]
|
const label = [attributes.label, attributes.name, attributes.title, attributes.subject_id]
|
||||||
.find((value) => typeof value === "string" && value.trim());
|
.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 {
|
return {
|
||||||
id: mapRuntimeEntityId(binding.bindingId, fact),
|
id: mapRuntimeEntityId(binding.bindingId, fact),
|
||||||
title: typeof label === "string" ? label : fact.sourceId,
|
title: typeof label === "string" ? label : fact.sourceId,
|
||||||
|
|
|
||||||
|
|
@ -1073,6 +1073,120 @@ textarea {
|
||||||
gap: 0.75rem;
|
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 {
|
.catalog-modal-groups {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
|
|
@ -1363,10 +1477,17 @@ textarea {
|
||||||
}
|
}
|
||||||
|
|
||||||
.catalog-application-draft__fields,
|
.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;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.catalog-codex-agent-settings__actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.catalog-application-draft__fields .nodedc-field-frame:last-child {
|
.catalog-application-draft__fields .nodedc-field-frame:last-child {
|
||||||
grid-column: auto;
|
grid-column: auto;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ export type MapRuntimeFact = {
|
||||||
receivedAt: string;
|
receivedAt: string;
|
||||||
attributes: Record<string, unknown>;
|
attributes: Record<string, unknown>;
|
||||||
geometry: DataProductPoint | null;
|
geometry: DataProductPoint | null;
|
||||||
|
presentationStatus: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MapRuntimeBinding = {
|
export type MapRuntimeBinding = {
|
||||||
|
|
@ -42,7 +43,16 @@ type PatchEnvelope = {
|
||||||
cursor: string;
|
cursor: string;
|
||||||
previousCursor: string;
|
previousCursor: string;
|
||||||
emittedAt: 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 = {
|
type BindingState = {
|
||||||
|
|
@ -97,6 +107,9 @@ function asFact(value: unknown, binding: MapDataProductBinding): MapRuntimeFact
|
||||||
receivedAt: candidate.receivedAt,
|
receivedAt: candidate.receivedAt,
|
||||||
attributes,
|
attributes,
|
||||||
geometry: asPoint(candidate.geometry),
|
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;
|
const product = candidate.dataProduct;
|
||||||
if (candidate.schemaVersion !== "nodedc.data-product.patch/v1" || !product || typeof product !== "object" || Array.isArray(product)) return null;
|
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;
|
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) => {
|
const operations: PatchEnvelope["operations"] = [];
|
||||||
if (!operation || typeof operation !== "object" || Array.isArray(operation)) return [];
|
for (const operation of candidate.operations) {
|
||||||
const value = operation as { op?: unknown; fact?: unknown };
|
if (!operation || typeof operation !== "object" || Array.isArray(operation)) continue;
|
||||||
if (value.op !== "upsert") return [];
|
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);
|
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 {
|
return {
|
||||||
schemaVersion: "nodedc.data-product.patch/v1",
|
schemaVersion: "nodedc.data-product.patch/v1",
|
||||||
dataProduct: { id: binding.dataProductId, version: String((product as { version?: unknown }).version || "") },
|
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 {
|
function replaceSnapshot(current: BindingState, snapshot: SnapshotEnvelope): BindingState {
|
||||||
return {
|
return {
|
||||||
...current,
|
...current,
|
||||||
|
|
@ -153,10 +201,22 @@ function applyPatch(current: BindingState, patch: PatchEnvelope): BindingState |
|
||||||
// browser applies more updates.
|
// browser applies more updates.
|
||||||
if (current.cursor !== patch.previousCursor) return null;
|
if (current.cursor !== patch.previousCursor) return null;
|
||||||
const facts = { ...current.facts };
|
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" };
|
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 {
|
function stateFor(binding: MapDataProductBinding, state: MapRuntimeBinding["state"] = "idle"): BindingState {
|
||||||
return { binding, cursor: null, facts: {}, state };
|
return { binding, cursor: null, facts: {}, state };
|
||||||
}
|
}
|
||||||
|
|
@ -263,6 +323,16 @@ export function useMapDataProductRuntime({
|
||||||
source.addEventListener("nodedc.data-product.resync-required.v1", () => {
|
source.addEventListener("nodedc.data-product.resync-required.v1", () => {
|
||||||
void loadSnapshotAndStream(binding);
|
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 = () => {
|
source.onerror = () => {
|
||||||
// EventSource will retry transient transport failures itself. A
|
// EventSource will retry transient transport failures itself. A
|
||||||
// permanent upstream rejection closes the stream; loading a fresh
|
// permanent upstream rejection closes the stream; loading a fresh
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,29 @@ Pill navigation для верхней панели и компактного п
|
||||||
|
|
||||||
В каноническом `ApplicationShell` шапка фиксирована. Её три оси не двигаются при открытии navigation/content и не зависят от ширины предметного контента.
|
В каноническом `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
|
## ApplicationShell и ApplicationPanel
|
||||||
|
|
||||||
Общий шаблон приложения по механике Launcher/Hub: фиксированный `AppHeader`, центральный stage, левая navigation panel и отдельное правое content window.
|
Общий шаблон приложения по механике Launcher/Hub: фиксированный `AppHeader`, центральный stage, левая navigation panel и отдельное правое content window.
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
|
|
||||||
`NDC Foundry Binding` is a deploy/control-plane operation. Realtime facts do
|
`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
|
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
|
## 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
|
Before persisting a binding, Foundry verifies that the target-specific EDP
|
||||||
reader grant exists, is active, permits the selected product and exposes a
|
reader grant exists, is active, permits the selected product and exposes a
|
||||||
`snapshot+patch` product. The reader grant filename remains
|
`snapshot+patch` product. The workload API remains fail-closed: if the grant is
|
||||||
`sha256(applicationId/pageId/bindingId)`. If it is not ready, POST fails with
|
not ready, POST fails with `409 data_product_reader_grant_not_ready`; an
|
||||||
`409 data_product_reader_grant_not_ready`; an unusable visual binding is never
|
unusable visual binding is never saved.
|
||||||
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
|
The canonical Map entity-stream slot is `points`. Other templates may define
|
||||||
their own typed slots, but the workload grant must name them explicitly.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -86,13 +86,21 @@ semantic types, field projection и `slotId` (`points` для live point entitie
|
||||||
При открытии Application page browser делает same-origin запрос к Foundry:
|
При открытии Application page browser делает same-origin запрос к Foundry:
|
||||||
|
|
||||||
```text
|
```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 в закрытом
|
Foundry сопоставляет target с opaque reader grant в закрытом persistent
|
||||||
deployment directory. Browser, manifest и Cesium adapter не получают provider
|
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.
|
endpoint, tenant/connection scope, reader token или raw provider payload.
|
||||||
Сначала приходит snapshot, затем только patch events с cursor; при пропуске
|
Сначала server-owned Foundry consumer коммитит snapshot, затем применяет patch
|
||||||
cursor BFF требует resync snapshot. Renderer держит отдельный `CustomDataSource`
|
events с exact cursor и только после commit делает fan-out всем viewers. При
|
||||||
на binding и обновляет stable entity id без пересоздания viewer. History и
|
пропуске cursor требуется snapshot rebase; несколько вкладок одного binding не
|
||||||
sampling остаются политикой Data Plane, а не Map Template.
|
создают несколько 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.
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,10 @@ MCP не вводит новую оркестрацию. Доступ выдаё
|
||||||
| Просматривать Page Library и экземпляры Applications | Менять канонический Page Library или дизайн-компоненты |
|
| Просматривать Page Library и экземпляры Applications | Менять канонический Page Library или дизайн-компоненты |
|
||||||
| Создавать application instance | Удалять application instance через MCP |
|
| Создавать application instance | Удалять application instance через MCP |
|
||||||
| Изменять название, slug и описание instance | Создавать свободный canvas или произвольный React-интерфейс |
|
| Изменять название, 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 |
|
| Создавать/обновлять 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 |
|
| Связывать 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 и повторный вызов с тем же ключом и тем же входом вернёт исходный результат без дублирования. Повтор ключа с иным входом завершается конфликтом.
|
Каждая запись требует `idempotencyKey`. Операция сохраняется в persistent runtime volume и повторный вызов с тем же ключом и тем же входом вернёт исходный результат без дублирования. Повтор ключа с иным входом завершается конфликтом.
|
||||||
|
|
||||||
|
|
@ -28,15 +29,22 @@ MCP не вводит новую оркестрацию. Доступ выдаё
|
||||||
Обязательные HTTP-заголовки каждого вызова:
|
Обязательные HTTP-заголовки каждого вызова:
|
||||||
|
|
||||||
```http
|
```http
|
||||||
Authorization: Bearer <short-lived server-issued Foundry capability>
|
Authorization: Bearer <Foundry capability or Foundry Agent credential>
|
||||||
MCP-Protocol-Version: 2025-06-18
|
MCP-Protocol-Version: 2025-06-18
|
||||||
```
|
```
|
||||||
|
|
||||||
Capability выдаётся только сервером Foundry после штатной entitlement-проверки
|
Endpoint поддерживает два существующих контура входа, не смешивая их:
|
||||||
AI Workspace. Она подписана существующим внутренним service credential,
|
|
||||||
привязана к `actorId` и `ownerKey`, живёт не более 10 минут и не даёт worker
|
- AI Workspace получает короткоживущую Foundry capability после штатной
|
||||||
доступа к самому platform credential. Заголовки с actor/owner от worker не
|
entitlement-проверки. Она подписана существующим внутренним service
|
||||||
принимаются: контекст извлекается только из подписанной capability.
|
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_add_page_instance`
|
||||||
- `foundry_upsert_map_pin_binding`
|
- `foundry_upsert_map_pin_binding`
|
||||||
- `foundry_upsert_map_data_product_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 слоями.
|
`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 получает
|
token, credential и raw payload валидатор отклоняет. Page runtime получает
|
||||||
scoped snapshot/patch поток через Platform, но не через Foundry MCP.
|
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
|
## Runtime data-product boundary
|
||||||
|
|
||||||
Для Map Page Foundry предоставляет только same-origin runtime routes:
|
Для Map Page Foundry предоставляет только same-origin runtime routes:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
GET /api/applications/:applicationId/pages/:pageId/data-bindings/:bindingId/snapshot
|
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
|
GET /api/applications/:applicationId/pages/:pageId/data-bindings/:bindingId/stream?after=:cursor
|
||||||
```
|
```
|
||||||
|
|
||||||
Маршрут разрешает persisted binding, а затем серверно находит opaque EDP reader
|
Маршрут разрешает persisted binding, а затем server-owned consumer находит
|
||||||
grant по `sha256(applicationId/pageId/bindingId)`. Grant находится в
|
opaque EDP reader grant по `sha256(applicationId/pageId/bindingId)`. Нормальный
|
||||||
root-owned read-only directory, передаётся только как `Authorization` во
|
grant создаётся внутри persistent private runtime Foundry; runner монтирует
|
||||||
внутренний External Data Plane и никогда не попадает в browser, manifest,
|
отдельный Ed25519 private key только для подписи digest-only provisioner
|
||||||
MCP или лог. В browser отдаётся только canonical data-product envelope:
|
request, а EDP получает только public trust. Старый root-owned read-only grant
|
||||||
snapshot, safe `nodedc.data-product.patch/v1` upserts и cursor. `Last-Event-ID`
|
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.
|
авторитетнее старого `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
|
## Entitlement для AI Workspace
|
||||||
|
|
||||||
`POST /api/ai-workspace/entitlements`
|
`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
|
FOUNDRY_MCP_URL=https://<future-foundry-domain>/api/mcp
|
||||||
# Existing NODE.DC server-to-server credential; never send it to a browser or worker.
|
# Existing NODE.DC server-to-server credential; never send it to a browser or worker.
|
||||||
NODEDC_INTERNAL_ACCESS_TOKEN=<existing-platform-service-value>
|
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
|
FOUNDRY_MCP_CAPABILITY_TTL_MS=600000
|
||||||
# Optional exceptional identities, not the default dcctouch superadmin grant.
|
# Optional exceptional identities, not the default dcctouch superadmin grant.
|
||||||
FOUNDRY_ALLOWED_OWNER_IDS=<comma-separated-extra-ids>
|
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_MCP_ALLOWED_ORIGINS=https://<ai-workspace-domain>
|
||||||
```
|
```
|
||||||
|
|
||||||
Foundry не требует у пользователя новый secret. `NODEDC_INTERNAL_ACCESS_TOKEN` —
|
Foundry не требует у пользователя вводить platform secret.
|
||||||
уже существующий server-to-server credential платформы: он находится только в
|
`NODEDC_INTERNAL_ACCESS_TOKEN` — уже существующий server-to-server credential
|
||||||
server environment и используется и для Launcher/Authentiк handoff, и для
|
платформы: он находится только в server environment и используется для
|
||||||
внутренней entitlement-проверки. Worker получает лишь короткоживущую capability.
|
Launcher/Authentiк handoff, внутренней entitlement-проверки и server-side
|
||||||
|
Ontology Core proxy. AI Workspace worker получает лишь короткоживущую
|
||||||
|
capability; внешний Codex — отдельные Agent credentials, но не internal token.
|
||||||
|
|
||||||
После появления домена в deployment AI Workspace добавляется только штатная настройка adapter; новый worker, отдельная оркестрация или специальный bridge не нужны:
|
После появления домена в deployment AI Workspace добавляется только штатная настройка adapter; новый worker, отдельная оркестрация или специальный bridge не нужны:
|
||||||
|
|
||||||
|
|
@ -141,7 +232,7 @@ AI_WORKSPACE_ENTITLEMENT_ADAPTERS_JSON='{"module-foundry":{"url":"https://<futur
|
||||||
|
|
||||||
## Deployment package
|
## 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
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@ services:
|
||||||
NODEDC_LAUNCHER_BASE_URL: ${NODEDC_LAUNCHER_BASE_URL:?set NODEDC_LAUNCHER_BASE_URL in .env}
|
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_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}
|
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
|
# Runner-owned file mount. The secret signs private Gateway requests but
|
||||||
# never becomes a Foundry env value or browser-visible setting.
|
# never becomes a Foundry env value or browser-visible setting.
|
||||||
NODEDC_MAP_GATEWAY_ADMIN_SECRET_FILE: /run/nodedc-secrets/map-gateway-admin-secret
|
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.
|
# 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_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
|
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
|
# Long-lived L2 credentials are separate revocable workload grants. The
|
||||||
# directory contains only hashed grant records; opaque values stay in
|
# directory contains only hashed grant records; opaque values stay in
|
||||||
# Engine credentials and never enter Foundry env or application state.
|
# Engine credentials and never enter Foundry env or application state.
|
||||||
|
|
@ -52,6 +63,12 @@ services:
|
||||||
read_only: true
|
read_only: true
|
||||||
bind:
|
bind:
|
||||||
create_host_path: false
|
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:
|
networks:
|
||||||
- platform-engine
|
- platform-engine
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@
|
||||||
"validate:registry": "node scripts/validate-registry.mjs",
|
"validate:registry": "node scripts/validate-registry.mjs",
|
||||||
"test:platform-settings": "node scripts/smoke-platform-settings.mjs",
|
"test:platform-settings": "node scripts/smoke-platform-settings.mjs",
|
||||||
"test:data-product-runtime": "node scripts/smoke-data-product-runtime.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-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:map-cache-contract": "node --test scripts/map-cache-resource-contract.test.mjs",
|
||||||
"test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs"
|
"test:inspector-select": "node --test scripts/inspector-select-contract.test.mjs"
|
||||||
|
|
|
||||||
|
|
@ -1085,6 +1085,135 @@ textarea.nodedc-field__control {
|
||||||
object-fit: cover;
|
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 {
|
.nodedc-header__workspace {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
width: var(--nodedc-header-row-height);
|
width: var(--nodedc-header-row-height);
|
||||||
|
|
@ -1991,6 +2120,120 @@ textarea.nodedc-field__control {
|
||||||
width: min(58rem, calc(100vw - 2rem));
|
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"] {
|
.nodedc-window[data-placement="end"] {
|
||||||
width: min(var(--nodedc-inspector-width), calc(100vw - 3rem));
|
width: min(var(--nodedc-inspector-width), calc(100vw - 3rem));
|
||||||
max-height: calc(100vh - 7.25rem);
|
max-height: calc(100vh - 7.25rem);
|
||||||
|
|
@ -2947,6 +3190,24 @@ textarea.nodedc-field__control {
|
||||||
padding-inline: 0.75rem;
|
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 {
|
.nodedc-share-access__member {
|
||||||
grid-template-columns: 2.25rem minmax(0, 1fr) auto;
|
grid-template-columns: 2.25rem minmax(0, 1fr) auto;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ export * from "./ConfirmationModal.js";
|
||||||
export * from "./Dropdown.js";
|
export * from "./Dropdown.js";
|
||||||
export * from "./DragDrop.js";
|
export * from "./DragDrop.js";
|
||||||
export * from "./Field.js";
|
export * from "./Field.js";
|
||||||
|
export * from "./FeatureSettingsWindow.js";
|
||||||
export * from "./Glass.js";
|
export * from "./Glass.js";
|
||||||
export * from "./Inspector.js";
|
export * from "./Inspector.js";
|
||||||
export * from "./Icon.js";
|
export * from "./Icon.js";
|
||||||
|
|
@ -19,5 +20,6 @@ export * from "./StatusBadge.js";
|
||||||
export * from "./Settings.js";
|
export * from "./Settings.js";
|
||||||
export * from "./SharingModals.js";
|
export * from "./SharingModals.js";
|
||||||
export * from "./Toolbar.js";
|
export * from "./Toolbar.js";
|
||||||
|
export * from "./UserProfileMenu.js";
|
||||||
export * from "./Window.js";
|
export * from "./Window.js";
|
||||||
export * from "./WorkspaceWindow.js";
|
export * from "./WorkspaceWindow.js";
|
||||||
|
|
|
||||||
|
|
@ -207,6 +207,34 @@
|
||||||
"Consumers supply data and actions but cannot override preset geometry through local className or style props."
|
"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",
|
"id": "application-shell",
|
||||||
"status": "baseline",
|
"status": "baseline",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -28,8 +28,9 @@ const bindingGrantDir = await mkdtemp(join(tmpdir(), "nodedc-foundry-binding-gra
|
||||||
const bindingGrantToken = createFoundryBindingGrantToken();
|
const bindingGrantToken = createFoundryBindingGrantToken();
|
||||||
let foundry;
|
let foundry;
|
||||||
let edp;
|
let edp;
|
||||||
const pressure = { sent: 0, backpressured: false, closed: false };
|
const firstStream = { opened: false, closed: false };
|
||||||
const shutdownStream = { opened: false, closed: false };
|
const shutdownStream = { opened: false, closed: false };
|
||||||
|
const internalAccessToken = "foundry-runtime-smoke-internal-access";
|
||||||
|
|
||||||
function listen(server) {
|
function listen(server) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
|
|
@ -54,39 +55,64 @@ async function waitFor(url) {
|
||||||
async function waitUntil(predicate, label, timeoutMs = 5_000) {
|
async function waitUntil(predicate, label, timeoutMs = 5_000) {
|
||||||
const deadline = Date.now() + timeoutMs;
|
const deadline = Date.now() + timeoutMs;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
if (predicate()) return;
|
if (await predicate()) return;
|
||||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||||
}
|
}
|
||||||
throw new Error(`${label}_timeout`);
|
throw new Error(`${label}_timeout`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function waitForDrainOrClose(response) {
|
async function readStreamUntil(response, pattern, timeoutMs = 5_000) {
|
||||||
return new Promise((resolve) => {
|
assert.equal(response.status, 200);
|
||||||
let settled = false;
|
assert.ok(response.body);
|
||||||
const settle = (value) => {
|
const reader = response.body.getReader();
|
||||||
if (settled) return;
|
const decoder = new TextDecoder();
|
||||||
settled = true;
|
let text = "";
|
||||||
response.off("drain", onDrain);
|
const deadline = Date.now() + timeoutMs;
|
||||||
response.off("close", onClose);
|
try {
|
||||||
response.off("error", onError);
|
while (Date.now() < deadline) {
|
||||||
resolve(value);
|
const next = await Promise.race([
|
||||||
};
|
reader.read(),
|
||||||
const onDrain = () => settle(true);
|
new Promise((_, reject) => setTimeout(() => reject(new Error("stream_read_timeout")), 250)),
|
||||||
const onClose = () => settle(false);
|
]);
|
||||||
const onError = () => settle(false);
|
if (next.done) break;
|
||||||
response.once("drain", onDrain);
|
text += decoder.decode(next.value, { stream: true });
|
||||||
response.once("close", onClose);
|
if (pattern.test(text)) return text;
|
||||||
response.once("error", onError);
|
}
|
||||||
});
|
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) => {
|
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) => {
|
const request = httpRequest(url, { headers: { accept: "text/event-stream" } }, (response) => {
|
||||||
response.pause();
|
response.setEncoding("utf8");
|
||||||
resolve({ request, response });
|
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();
|
request.end();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -181,13 +207,37 @@ try {
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (request.url === "/internal/data-plane/v1/data-products/fleet.positions.current.v1/stream?after=1") {
|
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(409, { "content-type": "application/json" });
|
response.writeHead(200, { "content-type": "application/json" });
|
||||||
response.end(JSON.stringify({ ok: false, error: "resync_required" }));
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (request.url === "/internal/data-plane/v1/data-products/fleet.positions.current.v1/stream?after=7") {
|
if (request.url === "/internal/data-plane/v1/data-products/fleet.positions.current.v1/stream?after=7") {
|
||||||
response.writeHead(200, { "content-type": "text/event-stream" });
|
response.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("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(`data: ${JSON.stringify({ schemaVersion: "nodedc.data-product.ready/v1", dataProductId: "fleet.positions.current.v1", cursor: "7", emittedAt: "2026-07-15T12:00:01.000Z" })}\n\n`);
|
||||||
response.write("id: 8\n");
|
response.write("id: 8\n");
|
||||||
|
|
@ -200,44 +250,18 @@ try {
|
||||||
emittedAt: "2026-07-15T12:00:02.000Z",
|
emittedAt: "2026-07-15T12:00:02.000Z",
|
||||||
operations: [{ op: "upsert", fact: canonicalFact({ longitude: 37.62, name: "Vehicle 001 updated" }) }],
|
operations: [{ op: "upsert", fact: canonicalFact({ longitude: 37.62, name: "Vehicle 001 updated" }) }],
|
||||||
})}\n\n`);
|
})}\n\n`);
|
||||||
response.end();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (request.url === "/internal/data-plane/v1/data-products/fleet.positions.current.v1/stream?after=9") {
|
if (request.url === "/internal/data-plane/v1/data-products/fleet.positions.current.v1/stream?after=8") {
|
||||||
response.writeHead(200, { "content-type": "text/event-stream" });
|
|
||||||
response.once("close", () => { pressure.closed = true; });
|
|
||||||
void (async () => {
|
|
||||||
const limit = 2_048;
|
|
||||||
const largeName = "vehicle-position-".padEnd(16 * 1024, "x");
|
|
||||||
for (let index = 0; index < limit && !response.destroyed; index += 1) {
|
|
||||||
const cursor = String(10 + index);
|
|
||||||
const previousCursor = String(9 + index);
|
|
||||||
const frame = `id: ${cursor}\nevent: nodedc.data-product.patch.v1\ndata: ${JSON.stringify({
|
|
||||||
schemaVersion: "nodedc.data-product.patch/v1",
|
|
||||||
dataProduct: { id: "fleet.positions.current.v1", version: "1.0.0" },
|
|
||||||
cursor,
|
|
||||||
previousCursor,
|
|
||||||
emittedAt: "2026-07-15T12:00:02.000Z",
|
|
||||||
operations: [{ op: "upsert", fact: canonicalFact({ longitude: 37.62, name: largeName }) }],
|
|
||||||
})}\n\n`;
|
|
||||||
pressure.sent += 1;
|
|
||||||
if (!response.write(frame)) {
|
|
||||||
pressure.backpressured = true;
|
|
||||||
if (!(await waitForDrainOrClose(response))) return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!response.destroyed) response.end();
|
|
||||||
})();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (request.url === "/internal/data-plane/v1/data-products/fleet.positions.current.v1/stream?after=10") {
|
|
||||||
response.writeHead(200, { "content-type": "text/event-stream" });
|
response.writeHead(200, { "content-type": "text/event-stream" });
|
||||||
shutdownStream.opened = true;
|
shutdownStream.opened = true;
|
||||||
const heartbeat = setInterval(() => response.write(": keepalive\n\n"), 50);
|
const heartbeat = setInterval(() => response.write(": keepalive\n\n"), 50);
|
||||||
response.once("close", () => {
|
const closeShutdownStream = () => {
|
||||||
clearInterval(heartbeat);
|
clearInterval(heartbeat);
|
||||||
shutdownStream.closed = true;
|
shutdownStream.closed = true;
|
||||||
});
|
};
|
||||||
|
response.once("close", closeShutdownStream);
|
||||||
|
request.socket.once("close", closeShutdownStream);
|
||||||
response.write(": keepalive\n\n");
|
response.write(": keepalive\n\n");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -258,6 +282,8 @@ try {
|
||||||
PORT: String(foundryPort),
|
PORT: String(foundryPort),
|
||||||
FOUNDRY_RUNTIME_DIR: runtimeDir,
|
FOUNDRY_RUNTIME_DIR: runtimeDir,
|
||||||
NODEDC_FOUNDRY_AUTH_REQUIRED: "false",
|
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_INTERNAL_URL: `http://127.0.0.1:${edpPort}`,
|
||||||
NODEDC_EXTERNAL_DATA_PLANE_READER_GRANTS_DIR: grantDir,
|
NODEDC_EXTERNAL_DATA_PLANE_READER_GRANTS_DIR: grantDir,
|
||||||
NODEDC_FOUNDRY_BINDING_GRANTS_DIR: bindingGrantDir,
|
NODEDC_FOUNDRY_BINDING_GRANTS_DIR: bindingGrantDir,
|
||||||
|
|
@ -268,6 +294,40 @@ try {
|
||||||
foundry.stderr.on("data", (chunk) => { stderr += String(chunk); });
|
foundry.stderr.on("data", (chunk) => { stderr += String(chunk); });
|
||||||
await waitFor(`http://127.0.0.1:${foundryPort}/healthz`);
|
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 = {
|
const workloadHeaders = {
|
||||||
authorization: `Bearer ${bindingGrantToken}`,
|
authorization: `Bearer ${bindingGrantToken}`,
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
|
|
@ -308,6 +368,23 @@ try {
|
||||||
assert.equal(replayResponse.status, 200);
|
assert.equal(replayResponse.status, 200);
|
||||||
assert.equal((await replayResponse.json()).idempotency.replayed, true);
|
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 base = `http://127.0.0.1:${foundryPort}/api/applications/${applicationId}/pages/${pageId}/data-bindings/${bindingId}`;
|
||||||
const snapshotResponse = await fetch(`${base}/snapshot`);
|
const snapshotResponse = await fetch(`${base}/snapshot`);
|
||||||
assert.equal(snapshotResponse.status, 200);
|
assert.equal(snapshotResponse.status, 200);
|
||||||
|
|
@ -317,18 +394,55 @@ try {
|
||||||
assert.deepEqual(snapshot.facts[0].attributes, { name: "Vehicle 001" });
|
assert.deepEqual(snapshot.facts[0].attributes, { name: "Vehicle 001" });
|
||||||
assert.equal(JSON.stringify(snapshot).includes("must-not-reach-browser"), false);
|
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" } });
|
const resyncResponse = await fetch(`${base}/stream?after=1`, { headers: { accept: "text/event-stream" } });
|
||||||
assert.equal(resyncResponse.status, 200);
|
assert.equal(resyncResponse.status, 200);
|
||||||
assert.match(await resyncResponse.text(), /nodedc\.data-product\.resync-required\.v1/);
|
assert.match(await resyncResponse.text(), /nodedc\.data-product\.resync-required\.v1/);
|
||||||
|
|
||||||
const streamResponse = await fetch(`${base}/stream?after=7`, { headers: { accept: "text/event-stream" } });
|
const streamText = await readHttpStreamUntil(`${base}/stream?after=7`, /"cursor":"8"/);
|
||||||
assert.equal(streamResponse.status, 200);
|
|
||||||
const streamText = await streamResponse.text();
|
|
||||||
assert.match(streamText, /event: nodedc\.data-product\.ready\.v1/);
|
assert.match(streamText, /event: nodedc\.data-product\.ready\.v1/);
|
||||||
assert.match(streamText, /event: nodedc\.data-product\.patch\.v1/);
|
assert.match(streamText, /event: nodedc\.data-product\.patch\.v1/);
|
||||||
assert.match(streamText, /"cursor":"8"/);
|
assert.match(streamText, /"cursor":"8"/);
|
||||||
assert.match(streamText, /Vehicle 001 updated/);
|
assert.match(streamText, /Vehicle 001 updated/);
|
||||||
assert.equal(streamText.includes("must-not-reach-browser"), false);
|
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, "");
|
assert.equal(stderr, "");
|
||||||
|
|
||||||
await rm(join(grantDir, targetKey));
|
await rm(join(grantDir, targetKey));
|
||||||
|
|
@ -338,16 +452,8 @@ try {
|
||||||
|
|
||||||
await writeFile(join(grantDir, targetKey), `${readerToken}\n`, "utf8");
|
await writeFile(join(grantDir, targetKey), `${readerToken}\n`, "utf8");
|
||||||
await chmod(join(grantDir, targetKey), 0o400);
|
await chmod(join(grantDir, targetKey), 0o400);
|
||||||
const paused = await openPausedStream(`${base}/stream?after=9`);
|
const shutdownClient = await fetch(`${base}/stream?after=8`, { headers: { accept: "text/event-stream" } });
|
||||||
await waitUntil(() => pressure.backpressured, "upstream_backpressure");
|
assert.equal(shutdownClient.status, 200);
|
||||||
await waitUntil(() => pressure.sent > 0 && pressure.sent < 2_048, "bounded_upstream_read");
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
||||||
assert.ok(pressure.sent < 2_048, "Foundry must stop draining the upstream while the browser is backpressured");
|
|
||||||
paused.response.destroy();
|
|
||||||
paused.request.destroy();
|
|
||||||
await waitUntil(() => pressure.closed, "upstream_close_after_browser_disconnect");
|
|
||||||
|
|
||||||
const shutdownClient = await openPausedStream(`${base}/stream?after=10`);
|
|
||||||
await waitUntil(() => shutdownStream.opened, "shutdown_stream_open");
|
await waitUntil(() => shutdownStream.opened, "shutdown_stream_open");
|
||||||
const exit = once(foundry, "exit");
|
const exit = once(foundry, "exit");
|
||||||
foundry.kill("SIGTERM");
|
foundry.kill("SIGTERM");
|
||||||
|
|
@ -358,8 +464,7 @@ try {
|
||||||
assert.equal(exitCode, 0);
|
assert.equal(exitCode, 0);
|
||||||
assert.equal(exitSignal, null);
|
assert.equal(exitSignal, null);
|
||||||
await waitUntil(() => shutdownStream.closed, "upstream_close_after_shutdown");
|
await waitUntil(() => shutdownStream.closed, "upstream_close_after_shutdown");
|
||||||
shutdownClient.response.destroy();
|
await shutdownClient.body?.cancel().catch(() => undefined);
|
||||||
shutdownClient.request.destroy();
|
|
||||||
assert.equal(stderr, "");
|
assert.equal(stderr, "");
|
||||||
console.log("foundry data-product runtime BFF: ok");
|
console.log("foundry data-product runtime BFF: ok");
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
});
|
||||||
|
|
@ -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.
|
|
@ -1,6 +1,7 @@
|
||||||
import { constants as fsConstants, createReadStream, createWriteStream } from "node:fs";
|
import { constants as fsConstants, createReadStream, createWriteStream } from "node:fs";
|
||||||
import { lstat, mkdir, open, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
|
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 { createHash, createHmac, randomUUID } from "node:crypto";
|
||||||
import { basename, extname, join, normalize, resolve } from "node:path";
|
import { basename, extname, join, normalize, resolve } from "node:path";
|
||||||
import { Readable, Transform } from "node:stream";
|
import { Readable, Transform } from "node:stream";
|
||||||
|
|
@ -11,6 +12,10 @@ import {
|
||||||
FOUNDRY_BINDING_UPSERT_PATH,
|
FOUNDRY_BINDING_UPSERT_PATH,
|
||||||
handleFoundryBindingApiRequest,
|
handleFoundryBindingApiRequest,
|
||||||
} from "./foundry-binding-api.mjs";
|
} 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 { handleFoundryEntitlementRequest, handleFoundryMcpRequest } from "./foundry-mcp.mjs";
|
||||||
import { createFoundryAuth } from "./nodedc-auth.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 designProfileReleasesDir = join(runtimeDir, "design-profile-releases");
|
||||||
const pageLayoutsDir = join(runtimeDir, "page-layouts");
|
const pageLayoutsDir = join(runtimeDir, "page-layouts");
|
||||||
const foundryMcpOperationsDir = join(runtimeDir, "foundry-mcp-operations");
|
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 layoutPath = join(runtimeDir, "layout.json");
|
||||||
const pageRegistryPath = join(root, "registry", "pages.json");
|
const pageRegistryPath = join(root, "registry", "pages.json");
|
||||||
const port = Number(process.env.PORT || 3333);
|
const port = Number(process.env.PORT || 3333);
|
||||||
|
|
@ -35,7 +43,10 @@ await mkdir(designProfilesDir, { recursive: true });
|
||||||
await mkdir(designProfileReleasesDir, { recursive: true });
|
await mkdir(designProfileReleasesDir, { recursive: true });
|
||||||
await mkdir(pageLayoutsDir, { recursive: true });
|
await mkdir(pageLayoutsDir, { recursive: true });
|
||||||
await mkdir(foundryMcpOperationsDir, { 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 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 foundryAuth = createFoundryAuth();
|
||||||
const mapGatewayHeadersTimeoutMs = boundedMapGatewayTimeout(
|
const mapGatewayHeadersTimeoutMs = boundedMapGatewayTimeout(
|
||||||
process.env.NODEDC_MAP_GATEWAY_HEADERS_TIMEOUT_MS,
|
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.
|
// 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 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 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 foundryBindingGrantsDir = String(process.env.NODEDC_FOUNDRY_BINDING_GRANTS_DIR || "").trim();
|
||||||
const activeRuntimeStreams = new Set();
|
const activeRuntimeStreams = new Set();
|
||||||
// A dedicated server-only signing value shared with Map Gateway. In production
|
// 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.
|
// the runner supplies it as a read-only file, never an env value or browser API.
|
||||||
const mapGatewayAdminSecret = await readMapGatewayAdminSecret();
|
const mapGatewayAdminSecret = await readMapGatewayAdminSecret();
|
||||||
const foundryPublicUrl = normalizeFoundryPublicUrl(process.env.FOUNDRY_PUBLIC_URL);
|
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) {
|
function boundedMapGatewayTimeout(value, fallback) {
|
||||||
const parsed = Number.parseInt(String(value || ""), 10);
|
const parsed = Number.parseInt(String(value || ""), 10);
|
||||||
|
|
@ -826,12 +853,14 @@ async function resolveRuntimeMapDataProductBinding(applicationId, pageId, bindin
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveRuntimeDataProductReaderToken(applicationId, pageId, bindingId) {
|
async function resolveRuntimeDataProductReaderToken(applicationId, pageId, bindingId) {
|
||||||
if (!externalDataPlaneInternalUrl || !externalDataPlaneReaderGrantsDir) {
|
if (!externalDataPlaneInternalUrl || (!externalDataPlaneReaderGrantsDir && !foundryReaderGrantProvisioner.configured)) {
|
||||||
throw applicationError("data_product_runtime_not_configured", 503);
|
throw applicationError("data_product_runtime_not_configured", 503);
|
||||||
}
|
}
|
||||||
// The name is deterministic but opaque to the browser. The runner creates
|
const managedToken = await foundryReaderGrantProvisioner.readToken({ applicationId, pageId, bindingId });
|
||||||
// this root-owned, read-only file after it provisions an EDP reader grant.
|
if (managedToken) return managedToken;
|
||||||
// There is no fallback to one shared Platform token.
|
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));
|
const grantPath = join(resolve(externalDataPlaneReaderGrantsDir), runtimeTargetGrantKey(applicationId, pageId, bindingId));
|
||||||
let handle;
|
let handle;
|
||||||
try {
|
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;
|
if (["data_product_reader_grant_not_found", "data_product_reader_grant_invalid"].includes(error?.message)) return false;
|
||||||
throw error;
|
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;
|
let upstream;
|
||||||
try {
|
try {
|
||||||
upstream = await fetch(new URL("/internal/data-plane/v1/reader/data-products", `${externalDataPlaneInternalUrl}/`), {
|
upstream = await fetch(new URL("/internal/data-plane/v1/reader/data-products", `${externalDataPlaneInternalUrl}/`), {
|
||||||
|
|
@ -884,13 +922,28 @@ async function preflightFoundryBindingReaderGrant({ applicationId, pageId, bindi
|
||||||
} catch {
|
} catch {
|
||||||
throw applicationError("data_product_reader_grant_preflight_unavailable", 503);
|
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);
|
if (!upstream.ok) throw applicationError("data_product_reader_grant_preflight_unavailable", 503);
|
||||||
const payload = await upstream.json().catch(() => null);
|
const payload = await upstream.json().catch(() => null);
|
||||||
const product = Array.isArray(payload?.dataProducts)
|
const product = Array.isArray(payload?.dataProducts)
|
||||||
? payload.dataProducts.find((candidate) => candidate?.id === dataProductId)
|
? payload.dataProducts.find((candidate) => candidate?.id === dataProductId)
|
||||||
: null;
|
: 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) {
|
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) {
|
function sanitizeRuntimePatch(value, binding) {
|
||||||
if (!isObject(value) || value.schemaVersion !== "nodedc.data-product.patch/v1" || !isObject(value.dataProduct)) return null;
|
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;
|
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) => {
|
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);
|
const fact = sanitizeRuntimeFact(operation.fact, binding);
|
||||||
return fact ? [{ op: "upsert", fact }] : [];
|
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 {
|
return {
|
||||||
schemaVersion: "nodedc.data-product.patch/v1",
|
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) {
|
function runtimeUpstreamUrl(dataProductId, resource, after) {
|
||||||
const target = new URL(`/internal/data-plane/v1/data-products/${encodeURIComponent(dataProductId)}/${resource}`, `${externalDataPlaneInternalUrl}/`);
|
const target = new URL(`/internal/data-plane/v1/data-products/${encodeURIComponent(dataProductId)}/${resource}`, `${externalDataPlaneInternalUrl}/`);
|
||||||
if (after) target.searchParams.set("after", after);
|
if (after) target.searchParams.set("after", after);
|
||||||
|
|
@ -1019,25 +1156,127 @@ async function writeSse(response, { event, data, id }) {
|
||||||
return writeRuntimeStreamChunk(response, frame);
|
return writeRuntimeStreamChunk(response, frame);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSseBlock(block) {
|
function openRuntimeDataProductConsumerStream({ url, token, signal }) {
|
||||||
const fields = { event: "message", id: "", data: [] };
|
return new Promise((resolveStream, rejectStream) => {
|
||||||
for (const line of block.split("\n")) {
|
const transport = url.protocol === "https:" ? httpsRequest : url.protocol === "http:" ? httpRequest : null;
|
||||||
if (!line || line.startsWith(":")) continue;
|
if (!transport) return rejectStream(applicationError("data_product_runtime_url_invalid", 500));
|
||||||
const separator = line.indexOf(":");
|
let incoming = null;
|
||||||
const field = separator === -1 ? line : line.slice(0, separator);
|
let settled = false;
|
||||||
const value = separator === -1 ? "" : line.slice(separator + 1).replace(/^ /, "");
|
const request = transport(url, {
|
||||||
if (field === "event") fields.event = value;
|
method: "GET",
|
||||||
if (field === "id") fields.id = value;
|
headers: {
|
||||||
if (field === "data") fields.data.push(value);
|
authorization: `Bearer ${token}`,
|
||||||
}
|
accept: "text/event-stream",
|
||||||
return { event: fields.event, id: fields.id, data: fields.data.join("\n") };
|
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) {
|
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 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;
|
let upstream;
|
||||||
try {
|
try {
|
||||||
upstream = await fetch(runtimeUpstreamUrl(target.binding.dataProductId, "snapshot"), {
|
upstream = await fetch(upstreamUrl, {
|
||||||
headers: { authorization: `Bearer ${readerToken}`, accept: "application/json" },
|
headers: { authorization: `Bearer ${readerToken}`, accept: "application/json" },
|
||||||
signal: AbortSignal.timeout(10_000),
|
signal: AbortSignal.timeout(10_000),
|
||||||
});
|
});
|
||||||
|
|
@ -1045,12 +1284,11 @@ async function proxyRuntimeDataProductSnapshot(response, target) {
|
||||||
throw applicationError("data_product_runtime_unavailable", 503);
|
throw applicationError("data_product_runtime_unavailable", 503);
|
||||||
}
|
}
|
||||||
if (!upstream.ok) throw runtimeUpstreamFailure(upstream.status);
|
if (!upstream.ok) throw runtimeUpstreamFailure(upstream.status);
|
||||||
const payload = await upstream.json().catch(() => null);
|
const payload = await readRuntimeJsonResponse(upstream);
|
||||||
json(response, 200, sanitizeRuntimeSnapshot(payload, target.binding));
|
json(response, 200, sanitizeRuntimeHistory(payload, target.binding));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function proxyRuntimeDataProductStream(request, response, url, target) {
|
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 requestedAfter = String(url.searchParams.get("after") || "");
|
||||||
const lastEventId = String(request.headers["last-event-id"] || "");
|
const lastEventId = String(request.headers["last-event-id"] || "");
|
||||||
if (requestedAfter && !isRuntimeCursor(requestedAfter)) throw applicationError("stream_cursor_invalid");
|
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.
|
// cursor; using it avoids replaying a stale query-string cursor forever.
|
||||||
const after = lastEventId || requestedAfter;
|
const after = lastEventId || requestedAfter;
|
||||||
const controller = new AbortController();
|
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 };
|
const activeStream = { controller, response };
|
||||||
activeRuntimeStreams.add(activeStream);
|
activeRuntimeStreams.add(activeStream);
|
||||||
request.once("aborted", abort);
|
request.once("aborted", abort);
|
||||||
response.once("close", abort);
|
response.once("close", abort);
|
||||||
let upstream;
|
request.socket?.once("close", abort);
|
||||||
try {
|
try {
|
||||||
upstream = await fetch(runtimeUpstreamUrl(target.binding.dataProductId, "stream", after), {
|
lease = await dataProductConsumerManager.subscribe({ ...target, after }, enqueue);
|
||||||
headers: {
|
|
||||||
authorization: `Bearer ${readerToken}`,
|
|
||||||
accept: "text/event-stream",
|
|
||||||
...(lastEventId ? { "last-event-id": lastEventId } : {}),
|
|
||||||
},
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
if (upstream.status === 409) {
|
|
||||||
response.writeHead(200, {
|
response.writeHead(200, {
|
||||||
"content-type": "text/event-stream; charset=utf-8",
|
"content-type": "text/event-stream; charset=utf-8",
|
||||||
"cache-control": "no-store, no-transform",
|
"cache-control": "no-store, no-transform",
|
||||||
|
|
@ -1083,71 +1338,31 @@ async function proxyRuntimeDataProductStream(request, response, url, target) {
|
||||||
"x-accel-buffering": "no",
|
"x-accel-buffering": "no",
|
||||||
vary: "Cookie",
|
vary: "Cookie",
|
||||||
});
|
});
|
||||||
|
headersReady = true;
|
||||||
|
if (lease.resyncRequired) {
|
||||||
await writeSse(response, {
|
await writeSse(response, {
|
||||||
event: "nodedc.data-product.resync-required.v1",
|
event: "nodedc.data-product.resync-required.v1",
|
||||||
data: { schemaVersion: "nodedc.data-product.resync-required/v1", dataProductId: target.binding.dataProductId },
|
data: { schemaVersion: "nodedc.data-product.resync-required/v1", dataProductId: target.binding.dataProductId },
|
||||||
});
|
});
|
||||||
if (!response.destroyed && !response.writableEnded) response.end();
|
response.end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!upstream.ok || !upstream.body || !String(upstream.headers.get("content-type") || "").startsWith("text/event-stream")) {
|
for (const event of bufferedEvents.splice(0)) enqueue(event);
|
||||||
throw runtimeUpstreamFailure(upstream.status);
|
heartbeat = setInterval(() => {
|
||||||
}
|
writeChain = writeChain.then((open) => open && writeRuntimeStreamChunk(response, ": keepalive\n\n")).catch(() => false);
|
||||||
response.writeHead(200, {
|
}, 15_000);
|
||||||
"content-type": "text/event-stream; charset=utf-8",
|
heartbeat.unref?.();
|
||||||
"cache-control": "no-store, no-transform",
|
await new Promise((resolve) => {
|
||||||
connection: "keep-alive",
|
if (controller.signal.aborted || response.destroyed) return resolve();
|
||||||
"x-accel-buffering": "no",
|
controller.signal.addEventListener("abort", resolve, { once: true });
|
||||||
vary: "Cookie",
|
|
||||||
});
|
});
|
||||||
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 {
|
} finally {
|
||||||
|
if (heartbeat) clearInterval(heartbeat);
|
||||||
abort();
|
abort();
|
||||||
activeRuntimeStreams.delete(activeStream);
|
activeRuntimeStreams.delete(activeStream);
|
||||||
request.off("aborted", abort);
|
request.off("aborted", abort);
|
||||||
response.off("close", abort);
|
response.off("close", abort);
|
||||||
|
request.socket?.off("close", abort);
|
||||||
if (!response.writableEnded && !response.destroyed) response.end();
|
if (!response.writableEnded && !response.destroyed) response.end();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1334,14 +1549,25 @@ const foundryMcpOperations = {
|
||||||
const config = foundryMcpConfig();
|
const config = foundryMcpConfig();
|
||||||
return {
|
return {
|
||||||
module: "NDC Module Foundry",
|
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",
|
status: config.capabilitySecret && config.mcpUrl ? "ready" : "configuration_required",
|
||||||
canonicalPageLibrary: "read-only",
|
canonicalPageLibrary: "read-only",
|
||||||
applicationInstances: "read-write",
|
applicationInstances: "read-write",
|
||||||
destructiveApplicationDelete: false,
|
destructiveApplicationDelete: false,
|
||||||
supportedPageTemplates: pageRegistry.templates.map((template) => ({ id: template.id, version: template.version, title: template.title })),
|
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"],
|
supportedActions: [
|
||||||
persistence: { runtimeDir: "persistent runtime volume required", idempotentWrites: true },
|
"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() {
|
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) {
|
async function readJsonBody(request, maxBytes = 2 * 1024 * 1024) {
|
||||||
|
|
@ -1762,6 +2013,9 @@ const server = createServer(async (request, response) => {
|
||||||
try {
|
try {
|
||||||
const url = new URL(request.url || "/", `http://${request.headers.host || "127.0.0.1"}`);
|
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") {
|
if (url.pathname === "/auth/nodedc/handoff" && request.method === "GET") {
|
||||||
await foundryAuth.handleLauncherHandoff(request, response, url);
|
await foundryAuth.handleLauncherHandoff(request, response, url);
|
||||||
return;
|
return;
|
||||||
|
|
@ -1781,10 +2035,16 @@ const server = createServer(async (request, response) => {
|
||||||
configured: Boolean(config.capabilitySecret && config.mcpUrl),
|
configured: Boolean(config.capabilitySecret && config.mcpUrl),
|
||||||
entitlementConfigured: Boolean(config.internalAccessToken),
|
entitlementConfigured: Boolean(config.internalAccessToken),
|
||||||
},
|
},
|
||||||
|
codexAgent: foundryAgentGateway.health(),
|
||||||
dataProductBindingApi: {
|
dataProductBindingApi: {
|
||||||
configured: Boolean(foundryBindingGrantsDir && externalDataPlaneInternalUrl && externalDataPlaneReaderGrantsDir),
|
configured: Boolean(foundryBindingGrantsDir && externalDataPlaneInternalUrl && externalDataPlaneReaderGrantsDir),
|
||||||
auth: "scoped-workload-grant",
|
auth: "scoped-workload-grant",
|
||||||
},
|
},
|
||||||
|
dataProductConsumerProvisioner: {
|
||||||
|
configured: foundryReaderGrantProvisioner.configured,
|
||||||
|
auth: "dedicated-ed25519-service-identity",
|
||||||
|
sourceScope: "external-data-plane-resolved",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1793,6 +2053,7 @@ const server = createServer(async (request, response) => {
|
||||||
await handleFoundryMcpRequest(request, response, {
|
await handleFoundryMcpRequest(request, response, {
|
||||||
getConfig: foundryMcpConfig,
|
getConfig: foundryMcpConfig,
|
||||||
operations: foundryMcpOperations,
|
operations: foundryMcpOperations,
|
||||||
|
authenticateAgentToken: foundryAgentGateway.authenticateFoundryToken,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1816,6 +2077,16 @@ const server = createServer(async (request, response) => {
|
||||||
return;
|
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") {
|
if (url.pathname === "/api/session/profile" && request.method === "GET") {
|
||||||
const access = foundryAuth.currentUserAccess(request);
|
const access = foundryAuth.currentUserAccess(request);
|
||||||
json(response, 200, { user: foundryAuth.currentUserProfile(request), profileUrl: foundryAuth.profileUrl, access: { role: access.role } });
|
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;
|
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") {
|
if (runtimeDataProductMatch && request.method === "GET") {
|
||||||
const [, applicationId, pageId, bindingId, resource] = runtimeDataProductMatch;
|
const [, applicationId, pageId, bindingId, resource] = runtimeDataProductMatch;
|
||||||
const target = await resolveRuntimeMapDataProductBinding(applicationId, pageId, bindingId);
|
const target = await resolveRuntimeMapDataProductBinding(applicationId, pageId, bindingId);
|
||||||
if (resource === "snapshot") await proxyRuntimeDataProductSnapshot(response, target);
|
if (resource === "snapshot") await proxyRuntimeDataProductSnapshot(response, target);
|
||||||
|
else if (resource === "history") await proxyRuntimeDataProductHistory(response, url, target);
|
||||||
else await proxyRuntimeDataProductStream(request, response, url, target);
|
else await proxyRuntimeDataProductStream(request, response, url, target);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -2125,11 +2397,13 @@ server.on("connection", (socket) => {
|
||||||
let shutdownPromise = null;
|
let shutdownPromise = null;
|
||||||
function shutdownServer(signal) {
|
function shutdownServer(signal) {
|
||||||
if (shutdownPromise) return shutdownPromise;
|
if (shutdownPromise) return shutdownPromise;
|
||||||
shutdownPromise = new Promise((resolveShutdown) => {
|
shutdownPromise = (async () => {
|
||||||
for (const activeStream of activeRuntimeStreams) {
|
for (const activeStream of activeRuntimeStreams) {
|
||||||
activeStream.controller.abort();
|
activeStream.controller.abort();
|
||||||
activeStream.response.destroy();
|
activeStream.response.destroy();
|
||||||
}
|
}
|
||||||
|
await dataProductConsumerManager.shutdown();
|
||||||
|
await new Promise((resolveShutdown) => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
for (const socket of serverSockets) socket.destroy();
|
for (const socket of serverSockets) socket.destroy();
|
||||||
}, 5_000);
|
}, 5_000);
|
||||||
|
|
@ -2142,11 +2416,20 @@ function shutdownServer(signal) {
|
||||||
resolveShutdown();
|
resolveShutdown();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
})();
|
||||||
return shutdownPromise;
|
return shutdownPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
process.once("SIGTERM", () => { void shutdownServer("SIGTERM"); });
|
async function shutdownAndExit(signal) {
|
||||||
process.once("SIGINT", () => { void shutdownServer("SIGINT"); });
|
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, () => {
|
server.listen(port, host, () => {
|
||||||
console.log(`NDC Module Foundry: http://${host}:${port}`);
|
console.log(`NDC Module Foundry: http://${host}:${port}`);
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -114,6 +114,9 @@ function toolText(payload, isError = false) {
|
||||||
function normalizeMcpError(error) {
|
function normalizeMcpError(error) {
|
||||||
const code = String(error?.message || "foundry_operation_failed");
|
const code = String(error?.message || "foundry_operation_failed");
|
||||||
if (code === "application_not_found") return { code, status: 404 };
|
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("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 };
|
if (code.startsWith("duplicate_") || code.includes("conflict") || code.includes("mismatch")) return { code, status: 409 };
|
||||||
return { code: "foundry_operation_failed", status: 500 };
|
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) {
|
function toolMap(operations) {
|
||||||
|
|
@ -280,6 +364,11 @@ function toolMap(operations) {
|
||||||
foundry_add_page_instance: (input, actor) => operations.addPageInstance(input, actor),
|
foundry_add_page_instance: (input, actor) => operations.addPageInstance(input, actor),
|
||||||
foundry_upsert_map_pin_binding: (input, actor) => operations.upsertMapPinBinding(input, actor),
|
foundry_upsert_map_pin_binding: (input, actor) => operations.upsertMapPinBinding(input, actor),
|
||||||
foundry_upsert_map_data_product_binding: (input, actor) => operations.upsertMapDataProductBinding(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;
|
return;
|
||||||
}
|
}
|
||||||
if (!originAllowed(request, allowedOrigins)) return sendJson(response, 403, { error: "mcp_origin_not_allowed" });
|
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 token = bearerToken(request);
|
||||||
const actor = actorFromMcpCapability(bearerToken(request), config);
|
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" });
|
if (!actor) return sendJson(response, 401, { error: "mcp_unauthorized" });
|
||||||
|
|
||||||
let message;
|
let message;
|
||||||
|
|
@ -310,8 +405,8 @@ export async function handleFoundryMcpRequest(request, response, options) {
|
||||||
return sendJson(response, 200, mcpResult(id, {
|
return sendJson(response, 200, mcpResult(id, {
|
||||||
protocolVersion: MCP_PROTOCOL_VERSION,
|
protocolVersion: MCP_PROTOCOL_VERSION,
|
||||||
capabilities: { tools: { listChanged: false } },
|
capabilities: { tools: { listChanged: false } },
|
||||||
serverInfo: { name: "nodedc_module_foundry", version: "0.1.0" },
|
serverInfo: { name: "nodedc_module_foundry", version: "0.2.0" },
|
||||||
instructions: "NDC Module Foundry edits application instances only. Page Library is read-only. Application deletion is unavailable.",
|
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, {});
|
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.page-instance.create",
|
||||||
"foundry.map-pin.upsert",
|
"foundry.map-pin.upsert",
|
||||||
"foundry.map-data-product.upsert",
|
"foundry.map-data-product.upsert",
|
||||||
|
"foundry.map-data-product-consumer.read",
|
||||||
|
"foundry.map-data-product-consumer.lifecycle",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
if (!accessAllowed) {
|
if (!accessAllowed) {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue